• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Aldaviva / GandiDynamicDns / 13939009884

19 Mar 2025 04:39AM UTC coverage: 91.603% (-5.8%) from 97.391%
13939009884

push

github

Aldaviva
Update dependencies

39 of 56 branches covered (69.64%)

120 of 131 relevant lines covered (91.6%)

7.63 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

84.51
/GandiDynamicDns/DynamicDnsService.cs
1
using Gandi;
2
using Gandi.Dns;
3
using Microsoft.Extensions.Options;
4
using System.Diagnostics;
5
using System.Net;
6
using ThrottleDebounce;
7
using Unfucked;
8
using Unfucked.HTTP.Exceptions;
9
using Unfucked.STUN;
10

11
namespace GandiDynamicDns;
12

13
public interface DynamicDnsService: IDisposable {
14

15
    IPAddress? selfWanAddress { get; }
16

17
}
18

19
public class DynamicDnsServiceImpl(ILiveDns liveDns, ISelfWanAddressClient stun, IOptions<Configuration> configuration, ILogger<DynamicDnsServiceImpl> logger, IHostApplicationLifetime lifetime)
15✔
20
    : BackgroundService, DynamicDnsService {
21

22
    private static readonly TimeSpan ONE_SHOT_MODE = TimeSpan.Zero;
1✔
23

24
    public IPAddress? selfWanAddress { get; private set; }
45✔
25

26
    private readonly EventLog? eventLog =
27
#if WINDOWS
28
        new("Application") { Source = "GandiDynamicDns" };
29
#else
30
        null;
31
#endif
32

33
    protected override async Task ExecuteAsync(CancellationToken ct) {
34
        try {
35
            await Retrier.Attempt(
9✔
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
9✔
37
                    if ((await liveDns.Get(RecordType.A, configuration.Value.subdomain, ct))?.Values.First() is { } existingIpAddress) {
9✔
38
                        try {
9✔
39
                            selfWanAddress = IPAddress.Parse(existingIpAddress);
8✔
40
                        } catch (FormatException) { }
9✔
41
                    }
9✔
42
                }, maxAttempts: null, delay: _ => TimeSpan.FromSeconds(3), ex => ex is not (OutOfMemoryException or GandiException { InnerException: ClientErrorException }),
9✔
43
                beforeRetry: async (i, e) =>
9✔
44
                    logger.LogWarning("Failed to fetch existing DNS record from Gandi HTTP API server, retrying (attempt {attempt}): {message}", i + 2, e.MessageChain()), ct);
9✔
45

46
            logger.LogInformation("On startup, the {fqdn} DNS A record is pointing to {address}", configuration.Value.fqdn, selfWanAddress?.ToString() ?? "(nothing)");
9✔
47

48
            if (configuration.Value.updateInterval > ONE_SHOT_MODE) {
9✔
49
                logger.LogInformation("Checking for public IP address changes every {period}", configuration.Value.updateInterval);
1✔
50
            }
51

52
            while (!ct.IsCancellationRequested) {
11✔
53
                await updateDnsRecordIfNecessary(ct);
11✔
54

55
                if (configuration.Value.updateInterval > ONE_SHOT_MODE) {
11✔
56
                    await Tasks.Delay(configuration.Value.updateInterval, ct);
3✔
57
                } else {
58
                    logger.LogInformation(
8✔
59
                        $"Exiting after one public IP address check. To continue running and checking for IP address changes repeatedly, set {nameof(Configuration.updateInterval)} to a {nameof(TimeSpan)} longer than {{zero}} in appsettings.json.",
8✔
60
                        ONE_SHOT_MODE);
8✔
61
                    lifetime.StopApplication();
8✔
62
                    break;
8✔
63
                }
64
            }
65
        } catch (GandiException e) when (e is GandiException.AuthException or { InnerException: ForbiddenException or NotAuthorizedException }) {
8✔
66
            logger.LogError(
×
67
                $"Auth error from Gandi. Please check the value of {nameof(Configuration.gandiAuthToken)} in appsettings.json and https://admin.gandi.net/organizations/account/pat to see if the Personal Access Token has expired.");
×
68
            throw;
×
69
        } catch (GandiException e) {
×
70
            logger.LogError(e, "Error while communicating with the Gandi LiveDNS API.");
×
71
            throw;
×
72
        }
73
    }
8✔
74

75
    private async Task updateDnsRecordIfNecessary(CancellationToken ct = default) {
76
        SelfWanAddressResponse originalResponse = await stun.GetSelfWanAddress(ct);
11✔
77
        if (originalResponse.SelfWanAddress != null && !originalResponse.SelfWanAddress.Equals(selfWanAddress)) {
11✔
78
            int unanimity = (int) Math.Max(1, configuration.Value.unanimity);
8✔
79
            if (await getUnanimousAgreement(originalResponse, unanimity, ct)) {
8✔
80
                logger.LogInformation(
7✔
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",
7✔
82
                    selfWanAddress, originalResponse.SelfWanAddress, originalResponse.Server.Host, originalResponse.ServerAddress.ToString(), unanimity - 1, configuration.Value.fqdn);
7✔
83
#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);
87
#endif
88

89
                selfWanAddress = originalResponse.SelfWanAddress;
7✔
90
                await updateDnsRecord(originalResponse.SelfWanAddress, ct);
7✔
91
            } 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}",
1✔
93
                    configuration.Value.fqdn, unanimity, selfWanAddress);
1✔
94
            }
95
        } else {
96
            logger.LogDebug("Not updating DNS A record for {fqdn} because it is already set to {value}", configuration.Value.fqdn, selfWanAddress);
3✔
97
        }
98
    }
11✔
99

100
    private async Task<bool> getUnanimousAgreement(SelfWanAddressResponse originalResponse, int unanimity = 1, CancellationToken ct = default) {
101
        IList<SelfWanAddressResponse> extraResponses  = (await Task.WhenAll(Enumerable.Range(1, unanimity - 1).Select(_ => stun.GetSelfWanAddress(ct)))).ToList();
14✔
102
        ISet<string>                  serverHostnames = extraResponses.Append(originalResponse).Select(response => response.Server.Host).ToHashSet();
22✔
103

104
        while (serverHostnames.Count < unanimity) {
9✔
105
            SelfWanAddressResponse distinctResponse = await stun.GetSelfWanAddress(ct);
1✔
106
            extraResponses.Add(distinctResponse);
1✔
107
            serverHostnames.Add(distinctResponse.Server.Host);
1✔
108
        }
109

110
        return extraResponses.All(extra => originalResponse.SelfWanAddress!.Equals(extra.SelfWanAddress));
15✔
111
    }
8✔
112

113
    private async Task updateDnsRecord(IPAddress currentIPAddress, CancellationToken ct = default) {
114
        if (!configuration.Value.dryRun) {
7✔
115
            try {
116
                await liveDns.Set(new DnsRecord(RecordType.A, configuration.Value.subdomain, configuration.Value.dnsRecordTimeToLive, [currentIPAddress.ToString()]), ct);
6✔
117
            } catch (GandiException e) {
6✔
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;
×
121
                }
122
            }
×
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,
1✔
125
                currentIPAddress, nameof(Configuration.dryRun));
1✔
126
        }
127
    }
7✔
128

129
    protected virtual void Dispose(bool disposing) {
130
        if (disposing) {
1!
131
            eventLog?.Dispose();
1!
132
        }
133
    }
×
134

135
    public sealed override void Dispose() {
136
        Dispose(true);
1✔
137
        base.Dispose();
1✔
138
        GC.SuppressFinalize(this);
1✔
139
    }
1✔
140

141
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc