diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..a83ca2b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,13 @@
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+*.code-workspace
+
+# Local History for Visual Studio Code
+.history/
+**/bin/
+**/obj/
+**/dist
+**/.DS_Store
diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 0000000..cf9fb90
--- /dev/null
+++ b/.vscode/launch.json
@@ -0,0 +1,29 @@
+{
+ // Use IntelliSense to learn about possible attributes.
+ // Hover to view descriptions of existing attributes.
+ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "name": ".NET Core Launch (console)",
+ "type": "coreclr",
+ "request": "launch",
+ "preLaunchTask": "build",
+ "program": "${workspaceFolder}/src/bin/Debug/net5.0/TinyDdns.dll",
+ "args": [
+ "--DdnsOptions:Provider", "Namecheap",
+ "--DdnsOptions:Domains", "lab.barakimam.me,*.lab.barakimam.me",
+ "--NamecheapOptions:Domain", "barakimam.me",
+ "--NamecheapOptions:Password", "hello"
+ ],
+ "cwd": "${workspaceFolder}/src",
+ "console": "internalConsole",
+ "stopAtEntry": false
+ },
+ {
+ "name": ".NET Core Attach",
+ "type": "coreclr",
+ "request": "attach"
+ }
+ ]
+}
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
new file mode 100644
index 0000000..33f4e3d
--- /dev/null
+++ b/.vscode/tasks.json
@@ -0,0 +1,42 @@
+{
+ "version": "2.0.0",
+ "tasks": [
+ {
+ "label": "build",
+ "command": "dotnet",
+ "type": "process",
+ "args": [
+ "build",
+ "${workspaceFolder}/src/TinyDdns.csproj",
+ "/property:GenerateFullPaths=true",
+ "/consoleloggerparameters:NoSummary"
+ ],
+ "problemMatcher": "$msCompile"
+ },
+ {
+ "label": "publish",
+ "command": "dotnet",
+ "type": "process",
+ "args": [
+ "publish",
+ "${workspaceFolder}/src/TinyDdns.csproj",
+ "/property:GenerateFullPaths=true",
+ "/consoleloggerparameters:NoSummary"
+ ],
+ "problemMatcher": "$msCompile"
+ },
+ {
+ "label": "watch",
+ "command": "dotnet",
+ "type": "process",
+ "args": [
+ "watch",
+ "run",
+ "${workspaceFolder}/src/TinyDdns.csproj",
+ "/property:GenerateFullPaths=true",
+ "/consoleloggerparameters:NoSummary"
+ ],
+ "problemMatcher": "$msCompile"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/TinyDdns.sln b/TinyDdns.sln
new file mode 100644
index 0000000..8bb531e
--- /dev/null
+++ b/TinyDdns.sln
@@ -0,0 +1,18 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 16
+VisualStudioVersion = 16.0.30114.105
+MinimumVisualStudioVersion = 10.0.40219.1
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Debug|x64 = Debug|x64
+ Debug|x86 = Debug|x86
+ Release|Any CPU = Release|Any CPU
+ Release|x64 = Release|x64
+ Release|x86 = Release|x86
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+EndGlobal
diff --git a/src/Contracts/BaseDdnsClient.cs b/src/Contracts/BaseDdnsClient.cs
new file mode 100644
index 0000000..4bb1554
--- /dev/null
+++ b/src/Contracts/BaseDdnsClient.cs
@@ -0,0 +1,74 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace TinyDdns.Contracts
+{
+ ///
+ /// base DDNS client
+ ///
+ public abstract class BaseDdnsClient : IDdnsClient
+ {
+ private readonly DdnsOptions _options;
+ protected readonly ILogger Logger;
+
+ ///
+ /// create base client
+ ///
+ ///
+ protected BaseDdnsClient(IOptions options, ILogger logger)
+ {
+ _options = options.Value;
+ Logger = logger;
+ }
+
+ ///
+ public async Task UpdateDdns(string ip, CancellationToken cancellationToken = default)
+ {
+ if (!string.IsNullOrWhiteSpace(_options.Domains))
+ {
+ var hosts = _options.Domains.Split(',');
+ var updated = 0;
+ if (hosts.Any())
+ {
+ await foreach(var host in UpdateDdns(hosts, ip, cancellationToken))
+ {
+ Logger.LogInformation("Updated records for host: {0}", host);
+ updated++;
+ }
+ }
+ return updated == hosts.Length;
+ }
+
+ return true;
+ }
+
+ private async IAsyncEnumerable UpdateDdns(string[] hosts, string ip,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ foreach(var host in hosts)
+ {
+ var done = false;
+ try
+ {
+ await UpdateDdns(host, ip, cancellationToken);
+ done = true;
+ }
+ catch (Exception ex)
+ {
+ Logger.LogError(ex, "failed to update record for host: {0}", host);
+ }
+
+ if (done) yield return host;
+ }
+ }
+
+ ///
+ public abstract Task UpdateDdns(string host, string ip, CancellationToken cancellationToken = default);
+ }
+}
diff --git a/src/Contracts/IDdnsClient.cs b/src/Contracts/IDdnsClient.cs
new file mode 100644
index 0000000..c827aae
--- /dev/null
+++ b/src/Contracts/IDdnsClient.cs
@@ -0,0 +1,18 @@
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace TinyDdns.Contracts
+{
+ ///
+ /// DDNS client interface
+ ///
+ public interface IDdnsClient
+ {
+ ///
+ /// update ddns record
+ ///
+ ///
+ ///
+ Task UpdateDdns(string ip, CancellationToken cancellationToken = default);
+ }
+}
diff --git a/src/DdnsOptions.cs b/src/DdnsOptions.cs
new file mode 100644
index 0000000..eeef03f
--- /dev/null
+++ b/src/DdnsOptions.cs
@@ -0,0 +1,33 @@
+
+namespace TinyDdns
+{
+ ///
+ /// DDNS options
+ ///
+ public class DdnsOptions
+ {
+ ///
+ /// default check interval
+ ///
+ public readonly int DefaultCheckInterval = 30;
+ private int? _checkInterval;
+
+ ///
+ /// configured check interval
+ ///
+ public int CheckInterval {
+ get => _checkInterval ?? DefaultCheckInterval;
+ set => _checkInterval = value;
+ }
+
+ ///
+ /// configured DDNS provider
+ ///
+ public DdnsProviders Provider { get; set; }
+
+ ///
+ /// comma-seperated list of domains
+ ///
+ public string Domains { get; set; }
+ }
+}
diff --git a/src/DdnsProviders.cs b/src/DdnsProviders.cs
new file mode 100644
index 0000000..3598451
--- /dev/null
+++ b/src/DdnsProviders.cs
@@ -0,0 +1,14 @@
+
+namespace TinyDdns
+{
+ ///
+ /// list of supported DdnsProviders
+ ///
+ public enum DdnsProviders
+ {
+ ///
+ /// namecheap is a free online Ddns providers
+ ///
+ NameCheap = 1,
+ }
+}
diff --git a/src/DdnsWorker.cs b/src/DdnsWorker.cs
new file mode 100644
index 0000000..3a468f8
--- /dev/null
+++ b/src/DdnsWorker.cs
@@ -0,0 +1,92 @@
+using System;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using TinyDdns.Contracts;
+
+namespace TinyDdns
+{
+ ///
+ /// DDNS update worker
+ ///
+ public class DdnsWorker : BackgroundService
+ {
+ private readonly ILogger _logger;
+ private readonly IDdnsClient _ddnsClient;
+ private readonly DdnsOptions _options;
+ private readonly HttpClient _httpClient;
+ private string _lastIpAddress;
+
+ public DdnsWorker(IDdnsClient ddnsClient, IOptions options,
+ HttpClient httpClient, ILogger logger)
+ {
+ _ddnsClient = ddnsClient;
+ _options = options.Value;
+ _httpClient = httpClient;
+ _logger = logger;
+ }
+
+ ///
+ /// run update continuously
+ ///
+ ///
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ while (!stoppingToken.IsCancellationRequested
+ && !string.IsNullOrEmpty(_options.Domains))
+ {
+ _logger.LogInformation("DDNS Update Client running at: {time}", DateTimeOffset.Now);
+ var currentIp = await GetCurrentIp();
+ if (!string.IsNullOrEmpty(currentIp) && currentIp != _lastIpAddress)
+ {
+ _logger.LogInformation("Updating DNS records for {0}", _options.Provider);
+ await UpdateDnsRecord(currentIp);
+ _lastIpAddress = currentIp;
+ }
+ await Task.Delay(1000 * _options.CheckInterval, stoppingToken);
+ }
+ }
+
+ ///
+ /// get current router public IP address
+ ///
+ ///
+ private async Task GetCurrentIp()
+ {
+ const string ICANHAZIPURL = "https://ipv4.icanhazip.com";
+ const string IPIFYURL = "https://api.ipify.org";
+ var ip = string.Empty;
+ try
+ {
+ ip = await _httpClient.GetStringAsync(ICANHAZIPURL);
+ } catch (Exception ex)
+ {
+ _logger.LogError(ex, "failed to get IP from ICANHAZIP");
+ try
+ {
+ ip = await _httpClient.GetStringAsync(IPIFYURL);
+ }
+ catch (Exception exx)
+ {
+ _logger.LogError(exx, "faied to get IP from IPIFY");
+ }
+ }
+
+ return ip.Trim();
+ }
+
+ ///
+ /// update DNS record with new ip
+ ///
+ ///
+ private async Task UpdateDnsRecord(string ip)
+ {
+ _logger.LogInformation("updating DDNS records for {0}", _options.Provider);
+ var result = await _ddnsClient.UpdateDdns(ip);
+ if (result) _logger.LogInformation("DDNS records updated for {0}", _options.Provider);
+ }
+ }
+}
diff --git a/src/Implementations/Namecheap/NamecheapClient.cs b/src/Implementations/Namecheap/NamecheapClient.cs
new file mode 100644
index 0000000..0367bd7
--- /dev/null
+++ b/src/Implementations/Namecheap/NamecheapClient.cs
@@ -0,0 +1,48 @@
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using TinyDdns.Contracts;
+
+namespace TinyDdns.Implementations.Namecheap
+{
+ ///
+ /// default implement for namecheap DDNS service
+ ///
+ public class NamecheapClient : BaseDdnsClient
+ {
+ private readonly NamecheapOptions _options;
+ private readonly HttpClient _httpClient;
+ private const string URL = "https://dynamicdns.park-your-domain.com/update?host={0}&domain={1}&password={2}&ip={3}";
+
+ ///
+ /// create an instance of
+ ///
+ ///
+ public NamecheapClient(HttpClient httpClient, IOptions options,
+ IOptions ddnsOptions, ILogger logger)
+ : base(ddnsOptions, logger)
+ {
+ _options = options.Value;
+ _httpClient = httpClient;
+ }
+
+ ///
+ /// update dns for given host with ip
+ ///
+ ///
+ ///
+ ///
+ public async override Task UpdateDdns(string host, string ip,
+ CancellationToken cancellationToken = default)
+ {
+ var cleanHost = CleanHost(host, _options.Domain);
+ var url = string.Format(URL, cleanHost, _options.Domain, _options.Password, ip);
+ await _httpClient.GetStringAsync(url, cancellationToken);
+ }
+
+ private static string CleanHost(string host, string domain) =>
+ host.Replace($".{domain}", string.Empty);
+ }
+}
diff --git a/src/Implementations/Namecheap/NamecheapOptions.cs b/src/Implementations/Namecheap/NamecheapOptions.cs
new file mode 100644
index 0000000..f150522
--- /dev/null
+++ b/src/Implementations/Namecheap/NamecheapOptions.cs
@@ -0,0 +1,19 @@
+
+namespace TinyDdns.Implementations.Namecheap
+{
+ ///
+ /// namecheap configuration options
+ ///
+ public class NamecheapOptions
+ {
+ ///
+ /// TLD domain
+ ///
+ public string Domain { get; set; }
+
+ ///
+ /// name cheap DDNS password
+ ///
+ public string Password { get; set; }
+ }
+}
diff --git a/src/Implementations/Namecheap/ServiceCollectionExtensions.cs b/src/Implementations/Namecheap/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000..76d1dc5
--- /dev/null
+++ b/src/Implementations/Namecheap/ServiceCollectionExtensions.cs
@@ -0,0 +1,25 @@
+
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace TinyDdns.Implementations.Namecheap
+{
+ ///
+ /// service collection extensions for namecheap support
+ ///
+ public static class ServiceCollectionExtensions
+ {
+ ///
+ /// add Namecheap DDNS options
+ ///
+ ///
+ ///
+ ///
+ public static IServiceCollection AddNamecheapDdns(this IServiceCollection services, IConfiguration configuration)
+ {
+ services.Configure(configuration.GetSection("NamecheapOptions"));
+ services.AddHttpClient();
+ return services;
+ }
+ }
+}
diff --git a/src/Program.cs b/src/Program.cs
new file mode 100644
index 0000000..963bae2
--- /dev/null
+++ b/src/Program.cs
@@ -0,0 +1,38 @@
+using System;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using TinyDdns.Contracts;
+using TinyDdns.Implementations;
+using TinyDdns.Implementations.Namecheap;
+
+namespace TinyDdns
+{
+ ///
+ /// main entry point
+ ///
+ public static class Program
+ {
+ public static void Main(string[] args)
+ {
+ CreateHostBuilder(args).Build().Run();
+ }
+
+ public static IHostBuilder CreateHostBuilder(string[] args) =>
+ Host.CreateDefaultBuilder(args)
+ .ConfigureServices((hostContext, services) =>
+ {
+ services.Configure(hostContext.Configuration.GetSection("DdnsOptions"));
+ var ddnsProviderType = GetProvider(hostContext.Configuration.GetValue("DdnsOptions:Provider"));
+ services.AddSingleton(typeof(IDdnsClient), ddnsProviderType);
+ services.AddNamecheapDdns(hostContext.Configuration);
+ services.AddHostedService();
+ });
+
+ public static Type GetProvider(DdnsProviders provider) =>
+ provider switch {
+ DdnsProviders.NameCheap => typeof(NamecheapClient),
+ _ => throw new NotSupportedException($"provider type {provider} not supported")
+ };
+ }
+}
diff --git a/src/TinyDdns.csproj b/src/TinyDdns.csproj
new file mode 100644
index 0000000..b21c143
--- /dev/null
+++ b/src/TinyDdns.csproj
@@ -0,0 +1,18 @@
+
+
+
+ net5.0
+ true
+ true
+ true
+ true
+ Link
+ true
+ embedded
+
+
+
+
+
+
+
diff --git a/tests/TinyDdns.Tests.csproj b/tests/TinyDdns.Tests.csproj
new file mode 100644
index 0000000..2c12453
--- /dev/null
+++ b/tests/TinyDdns.Tests.csproj
@@ -0,0 +1,22 @@
+
+
+
+ net5.0
+
+ false
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
diff --git a/tests/UnitTest1.cs b/tests/UnitTest1.cs
new file mode 100644
index 0000000..fc411e5
--- /dev/null
+++ b/tests/UnitTest1.cs
@@ -0,0 +1,14 @@
+using System;
+using Xunit;
+
+namespace TinyDdns.Tests
+{
+ public class UnitTest1
+ {
+ [Fact]
+ public void Test1()
+ {
+
+ }
+ }
+}