Skip to content

Commit bd09aee

Browse files
committed
Update multiple subdomains, useful for IPv6 where there is no NAT so each individual server publicly exposes their address instead of the router's, and you can't CNAME when another record (like AAAA) exists, so you end up with lots of duplicate A records. New configuration property string[] subdomains, backwards compatible with configuration using string subdomain. Added RuntimeUpgradeNotifier to restart the service if the .NET Runtime gets upgraded out from under the process before it crashes.
1 parent 834f7ff commit bd09aee

File tree

7 files changed

+674
-80
lines changed

7 files changed

+674
-80
lines changed

GandiDynamicDns/Configuration.cs

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,16 +32,26 @@ public string? gandiApiKey {
3232

3333
public uint unanimity { get; init; } = 1;
3434

35-
private readonly string _subdomain = DOMAIN_ROOT;
36-
public string subdomain {
37-
get => _subdomain;
38-
init => _subdomain = value.TrimEnd('.') is { Length: not 0 } s ? s : DOMAIN_ROOT;
35+
public IList<string> subdomains { get; } = [];
36+
37+
[Obsolete($"Use {nameof(subdomains)} instead")]
38+
public string? subdomain {
39+
get => null;
40+
set {
41+
if (value is not null) {
42+
subdomains.Add(value);
43+
}
44+
}
3945
}
4046

41-
public string fqdn => subdomain == DOMAIN_ROOT ? domain : $"{subdomain}.{domain}";
42-
4347
public static bool isAuthTokenValid([NotNullWhen(true)] string? authToken) => authToken != null && validAuthTokenPattern().IsMatch(authToken);
4448

49+
public void sanitize() {
50+
for (int i = 0; i < subdomains.Count; i++) {
51+
subdomains[i] = subdomains[i].TrimEnd('.') is { Length: not 0 } s ? s : DOMAIN_ROOT;
52+
}
53+
}
54+
4555
[GeneratedRegex("^[A-Za-z0-9]{24}|[a-f0-9]{40}$")]
4656
private static partial Regex validAuthTokenPattern();
4757

GandiDynamicDns/DynamicDnsService.cs

Lines changed: 55 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ public class DynamicDnsServiceImpl(ILiveDns liveDns, ISelfWanAddressClient stun,
2323

2424
public IPAddress? selfWanAddress { get; private set; }
2525

26+
private readonly IDictionary<string, IPAddress?> initialRecordValues = new Dictionary<string, IPAddress?>(configuration.Value.subdomains.Count);
27+
2628
private readonly EventLog? eventLog =
2729
#if WINDOWS
2830
new("Application") { Source = "GandiDynamicDns" };
@@ -32,18 +34,23 @@ public class DynamicDnsServiceImpl(ILiveDns liveDns, ISelfWanAddressClient stun,
3234

3335
protected override async Task ExecuteAsync(CancellationToken ct) {
3436
try {
35-
await Retrier.Attempt(
36-
async _ => { // this retry is to handle the case where the service starts before the computer connects to the network on bootup, not where Gandi's API servers are down
37-
if ((await liveDns.Get(RecordType.A, configuration.Value.subdomain, ct))?.Values.First() is { } existingIpAddress) {
38-
try {
39-
selfWanAddress = IPAddress.Parse(existingIpAddress);
40-
} catch (FormatException) { }
41-
}
42-
}, maxAttempts: null, delay: _ => TimeSpan.FromSeconds(3), ex => ex is not (OutOfMemoryException or GandiException { InnerException: ClientErrorException }),
43-
beforeRetry: async (i, e) =>
44-
logger.LogWarning("Failed to fetch existing DNS record from Gandi HTTP API server, retrying (attempt {attempt}): {message}", i + 2, e.MessageChain()), ct);
45-
46-
logger.LogInformation("On startup, the {fqdn} DNS A record is pointing to {address}", configuration.Value.fqdn, selfWanAddress?.ToString() ?? "(nothing)");
37+
foreach (string subdomain in configuration.Value.subdomains) {
38+
// this retry is to handle the case where the service starts before the computer connects to the network on bootup, not where Gandi's API servers are down
39+
await Retrier.Attempt(async _ => {
40+
IPAddress? initialRecordValue = null;
41+
if ((await liveDns.Get(RecordType.A, subdomain, ct))?.Values.First() is { } existingIpAddress) {
42+
try {
43+
initialRecordValue = IPAddress.Parse(existingIpAddress);
44+
} catch (FormatException) { }
45+
}
46+
initialRecordValues[subdomain] = initialRecordValue;
47+
}, maxAttempts: null, delay: Retrier.Delays.Constant(TimeSpan.FromSeconds(3)), ex => ex is not (OutOfMemoryException or GandiException { InnerException: ClientErrorException }),
48+
beforeRetry: async (i, e) =>
49+
logger.LogWarning("Failed to fetch existing DNS record from Gandi HTTP API server, retrying (attempt {attempt}): {message}", i + 2, e.MessageChain()), ct);
50+
51+
logger.LogInformation("On startup, the {subdomain}.{domain} DNS A record is pointing to {address}", subdomain, configuration.Value.domain,
52+
initialRecordValues[subdomain]?.ToString() ?? "(nothing)");
53+
}
4754

4855
if (configuration.Value.updateInterval > ONE_SHOT_MODE) {
4956
logger.LogInformation("Checking for public IP address changes every {period}", configuration.Value.updateInterval);
@@ -74,26 +81,35 @@ await Retrier.Attempt(
7481

7582
private async Task updateDnsRecordIfNecessary(CancellationToken ct = default) {
7683
SelfWanAddressResponse originalResponse = await stun.GetSelfWanAddress(ct);
77-
if (originalResponse.SelfWanAddress != null && !originalResponse.SelfWanAddress.Equals(selfWanAddress)) {
78-
int unanimity = (int) Math.Max(1, configuration.Value.unanimity);
79-
if (await getUnanimousAgreement(originalResponse, unanimity, ct)) {
80-
logger.LogInformation(
81-
"This computer's public IP address changed from {old} to {new} according to {server} ({serverAddr}) and {extraServerCount:N0} other STUN servers, updating {fqdn} A record in DNS server",
82-
selfWanAddress, originalResponse.SelfWanAddress, originalResponse.Server.Host, originalResponse.ServerAddress.ToString(), unanimity - 1, configuration.Value.fqdn);
84+
if (originalResponse.SelfWanAddress != null) {
85+
bool updateRequired = selfWanAddress == null
86+
? initialRecordValues.Any(pair => !originalResponse.SelfWanAddress.Equals(pair.Value))
87+
: !originalResponse.SelfWanAddress.Equals(selfWanAddress);
88+
if (updateRequired) {
89+
int unanimity = (int) Math.Max(1, configuration.Value.unanimity);
90+
if (await getUnanimousAgreement(originalResponse, unanimity, ct)) {
91+
logger.LogInformation(
92+
"This computer's public IP address changed from {old} to {new} according to {server} ({serverAddr}) and {extraServerCount:N0} other STUN servers, updating {recordCount} A records in DNS server",
93+
selfWanAddress, originalResponse.SelfWanAddress, originalResponse.Server.Host, originalResponse.ServerAddress.ToString(), unanimity - 1, configuration.Value.subdomains.Count);
8394
#if WINDOWS
84-
eventLog?.WriteEntry(
85-
$"This computer's public IP address changed from {selfWanAddress} to {originalResponse.SelfWanAddress}, according to {originalResponse.Server.Host} ({originalResponse.ServerAddress}) and {unanimity - 1:N0} others, updating {configuration.Value.fqdn} A record in DNS server",
86-
EventLogEntryType.Information, 1);
95+
eventLog?.WriteEntry(
96+
$"This computer's public IP address changed from {selfWanAddress} to {originalResponse.SelfWanAddress}, according to {originalResponse.Server.Host} ({originalResponse.ServerAddress}) and {unanimity - 1:N0} others, updating {configuration.Value.subdomains.Count} A records in DNS server",
97+
EventLogEntryType.Information, 1);
8798
#endif
8899

89-
selfWanAddress = originalResponse.SelfWanAddress;
90-
await updateDnsRecord(originalResponse.SelfWanAddress, ct);
100+
selfWanAddress = originalResponse.SelfWanAddress;
101+
await updateDnsRecords(originalResponse.SelfWanAddress!, ct);
102+
} else {
103+
logger.LogWarning("Not updating DNS A record because there was a disagreement among {serverCount:N0} STUN servers about our public IP address, leaving it set to {value}",
104+
unanimity, selfWanAddress);
105+
}
91106
} else {
92-
logger.LogWarning("Not updating DNS A record for {fqdn} because there was a disagreement among {serverCount:N0} STUN servers about our public IP address, leaving it set to {value}",
93-
configuration.Value.fqdn, unanimity, selfWanAddress);
107+
selfWanAddress ??= originalResponse.SelfWanAddress;
108+
logger.LogDebug("Not updating DNS A records because they are all already set to {value}", selfWanAddress);
94109
}
95110
} else {
96-
logger.LogDebug("Not updating DNS A record for {fqdn} because it is already set to {value}", configuration.Value.fqdn, selfWanAddress);
111+
logger.LogDebug("STUN request to {server} ({serverAddr}) did not return a public WAN IP address, will try again with a different server next time", originalResponse.Server.Host,
112+
originalResponse.ServerAddress.ToString());
97113
}
98114
}
99115

@@ -110,19 +126,21 @@ private async Task<bool> getUnanimousAgreement(SelfWanAddressResponse originalRe
110126
return extraResponses.All(extra => originalResponse.SelfWanAddress!.Equals(extra.SelfWanAddress));
111127
}
112128

113-
private async Task updateDnsRecord(IPAddress currentIPAddress, CancellationToken ct = default) {
114-
if (!configuration.Value.dryRun) {
115-
try {
116-
await liveDns.Set(new DnsRecord(RecordType.A, configuration.Value.subdomain, configuration.Value.dnsRecordTimeToLive, [currentIPAddress.ToString()]), ct);
117-
} catch (GandiException e) {
118-
logger.LogError(e, "Failed to update DNS record for {fqdn} to {newAddr} due to DNS API server error", configuration.Value.fqdn, currentIPAddress);
119-
if (e is GandiException.AuthException or { InnerException: ForbiddenException or NotAuthorizedException }) {
120-
throw;
129+
private async Task updateDnsRecords(IPAddress currentIPAddress, CancellationToken ct = default) {
130+
foreach (string subdomain in configuration.Value.subdomains) {
131+
if (!configuration.Value.dryRun) {
132+
try {
133+
await liveDns.Set(new DnsRecord(RecordType.A, subdomain, configuration.Value.dnsRecordTimeToLive, [currentIPAddress.ToString()]), ct);
134+
} catch (GandiException e) {
135+
logger.LogError(e, "Failed to update DNS record for {subdomain}.{domain} to {newAddr} due to DNS API server error", subdomain, configuration.Value.domain, currentIPAddress);
136+
if (e is GandiException.AuthException or { InnerException: ForbiddenException or NotAuthorizedException }) {
137+
throw;
138+
}
121139
}
140+
} else {
141+
logger.LogInformation("Dry run mode, not updating {subdomain}.{domain} to {newAddr}. To actually make DNS changes, change {dryRun} from true to false in appsettings.json.", subdomain,
142+
configuration.Value.domain, currentIPAddress, nameof(Configuration.dryRun));
122143
}
123-
} else {
124-
logger.LogInformation("Dry run mode, not updating {fqdn} to {newAddr}. To actually make DNS changes, change {dryRun} from true to false in appsettings.json.", configuration.Value.fqdn,
125-
currentIPAddress, nameof(Configuration.dryRun));
126144
}
127145
}
128146

GandiDynamicDns/GandiDynamicDns.csproj

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@
22

33
<PropertyGroup>
44
<OutputType>Exe</OutputType>
5-
<TargetFrameworks>net8.0;net8.0-windows</TargetFrameworks> <!-- Windows TFM is for Event Log -->
5+
<TargetFrameworks>net8.0-windows;net8.0</TargetFrameworks> <!-- Windows TFM is for Event Log -->
66
<RuntimeIdentifiers>win-x64;win-arm64;linux-x64;linux-arm;linux-arm64</RuntimeIdentifiers>
77
<ImplicitUsings>enable</ImplicitUsings>
88
<Nullable>enable</Nullable>
99
<RollForward>latestMajor</RollForward>
1010
<NoWarn>$(NoWarn);8524;VSTHRD200</NoWarn>
11-
<Version>0.4.1</Version>
11+
<Version>0.5.0</Version>
1212
<Authors>Ben Hutchison</Authors>
1313
<Copyright>© 2025 $(Authors)</Copyright>
1414
<Company>$(Authors)</Company>
@@ -18,6 +18,7 @@
1818
<ApplicationManifest>app.manifest</ApplicationManifest>
1919
<!-- <PublishSingleFile>true</PublishSingleFile> -->
2020
<SelfContained>false</SelfContained>
21+
<DebugType>embedded</DebugType>
2122
</PropertyGroup>
2223

2324
<ItemGroup>
@@ -35,6 +36,7 @@
3536
<PackageReference Include="Unfucked" Version="0.0.1-beta.1" />
3637
<PackageReference Include="Unfucked.DI" Version="0.0.1-beta.1" />
3738
<PackageReference Include="Unfucked.STUN" Version="0.0.1-beta.1" />
39+
<PackageReference Include="RuntimeUpgradeNotifier" Version="1.0.0-beta3" />
3840
</ItemGroup>
3941

4042
<ItemGroup Condition="$(RuntimeIdentifier.StartsWith('win'))">

GandiDynamicDns/Program.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
using GandiDynamicDns;
44
using GandiDynamicDns.Util;
55
using Microsoft.Extensions.Options;
6+
using RuntimeUpgrade.Notifier;
7+
using RuntimeUpgrade.Notifier.Data;
68
using System.Net.Http.Headers;
79
using System.Reflection;
810
using Unfucked;
@@ -29,6 +31,7 @@
2931
.AddSystemd()
3032
.AddWindowsService(WindowsService.configure)
3133
.Configure<Configuration>(appConfig.Configuration)
34+
.PostConfigure<Configuration>(configuration => configuration.sanitize())
3235
.AddSingleton<HttpClient>(_ => new UnfuckedHttpClient {
3336
DefaultRequestHeaders = {
3437
UserAgent = {
@@ -44,4 +47,9 @@
4447

4548
using IHost app = appConfig.Build();
4649

50+
using RuntimeUpgradeNotifier upgradeNotifier = new() {
51+
LoggerFactory = app.Services.GetRequiredService<ILoggerFactory>(),
52+
RestartStrategy = RestartStrategy.AutoRestartService
53+
};
54+
4755
await app.RunAsync();

GandiDynamicDns/appsettings.json

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"gandiAuthToken": "<Generate a Personal Access Token on https://admin.gandi.net/organizations/account, or use an API Key>",
33
"domain": "example.com",
4-
"subdomain": "www",
4+
"subdomains": ["www"],
55
"updateInterval": "0.0:05:00",
66
"dnsRecordTimeToLive": "0.0:05:00",
77
"dryRun": false,
@@ -10,5 +10,13 @@
1010
"stun.bergophor.de",
1111
"stun.usfamily.net",
1212
"stun.finsterwalder.com"
13-
]
13+
],
14+
"logging": {
15+
"LogLevel": {
16+
"Default": "Information",
17+
"Program": "Information",
18+
"GandiDynamicDns": "Information",
19+
"RuntimeUpgrade.Notifier": "Debug"
20+
}
21+
}
1422
}

0 commit comments

Comments
 (0)