In Prod

← Back to Posts

A Dotnet Core DNS Updater

April 21, 2020

TL;DR

I created a DNS Updater using Dotnet Core that lives on my CentOS VM on my QNAP which gets my external IP address from my Google Wi-Fi router, updates my GoDaddy DNS and sends me a push notification if changed...all controlled by a cron job.

It's a pretty bespoke solution that solves my issue of having a dynamic IP address, and was quite a fun little project.


Background

I, like I presume most software developers, have a very specific personal server setup.

It currently consists of:

As I currently have a dynamic IP from my service provider it makes self-hosting a server for my basic personal sites a pain to maintain. This is because every time my IP address changes I need to update my DNS settings with my domain registrar.

There are services that allow you to keep your DNS up to date by updating for you (dyn, no-ip, afraid etc), however I found that my need to run multiple subdomains, hosted locally, meant that they weren't the right fit for me (due to cost, domain restrictions, hosting, or other limitations).

To be honest, I didn't really look into it that hard as I wanted to build something myself anyway. And so, to get around my limitations, I built a DNS Updater for my specific use case.

For it to work, I needed to be able to supply my DNS provider with my current external IP address, when it changes, to update my A-Records (i.e. sub-domains/servers). Luckily, GoDaddy has an accessible API that allows just that.

I went through a couple of iterations of this project before landing on the final implementation:

  1. Using services like icanhazip to get my external IP address (seemed to fail more often than my DNS)
  2. Using Firebase Firestore real-time updates to trigger manual update on the server as an API workaround (ran into some memory and timeout issues)
  3. Using a console app with a timer job that re-called itself at an interval (memory/timing issues)

In my current implementation I have done away with the manual update requirement as I never really used it. I decided to go with a Web API as I can call an endpoint to trigger the sync. I also found that Google Wi-Fi has its own 'hidden' API that allows you to retrieve information about the network, including external IP address.

So now we have the tools needed to create a Dotnet Core Web API that we can host on a Linux virtual which we can trigger using a crontab job. This will retrieve the current external IP from out local router, check if it has changed, and then update our DNS with the current, if needed. As an added bonus I added in a call to a service call Pushover which will send us a notification when the DNS gets updated, or errors occur.

If, by any chance, this is similar to your setup, the following may be of use.


DNS Updater

Create a new Dotnet Core Web API.

Update appsettings.json to hold our service keys and endpoints for easy access:

appsettings.json

_24
{
_24
"Logging": {
_24
"LogLevel": {
_24
"Default": "Information",
_24
"Microsoft": "Warning",
_24
"Microsoft.Hosting.Lifetime": "Information"
_24
}
_24
},
_24
"AllowedHosts": "*",
_24
"GoDaddy": {
_24
"Key": "<GODADDY_KEY>",
_24
"Secret": "<GODADDY_SECRET>"
_24
},
_24
"Wan": {
_24
"IP": "192.168.86.1"
_24
},
_24
"AdminSafeList": "127.0.0.1;192.168.86.101;::1",
_24
"Domains": [ "domain.com" ],
_24
"Pushover": {
_24
"Url": "https://api.pushover.net/1/",
_24
"Token": "<PUSHOVER_APP_TOKEN>",
_24
"User": "<PUSHOVER_USER_KEY>"
_24
}
_24
}

I added an authorization filter that only allows access to from localhost for some basic auth:

AuthorizeIPFilter.cs

_42
public class AuthorizeIPFilter : ActionFilterAttribute
_42
{
_42
private readonly string safelist;
_42
_42
// AdminSafeList param passed in from Startup.cs consisting of allowed local ips
_42
public AuthorizeIPFilter(string _safelist)
_42
{
_42
safelist = _safelist;
_42
}
_42
_42
public override void OnActionExecuting(ActionExecutingContext context)
_42
{
_42
// get the caller IP
_42
var remoteIp = context.HttpContext.Connection.RemoteIpAddress;
_42
string[] ip = safelist.Split(';');
_42
_42
var badIp = true;
_42
foreach (var address in ip)
_42
{
_42
if (remoteIp.IsIPv4MappedToIPv6)
_42
{
_42
remoteIp = remoteIp.MapToIPv4();
_42
}
_42
var testIp = IPAddress.Parse(address);
_42
// if we match an IP, we are local
_42
if (testIp.Equals(remoteIp))
_42
{
_42
badIp = false;
_42
break;
_42
}
_42
}
_42
_42
// throw forbidden if no match to local
_42
if (badIp)
_42
{
_42
context.Result = new StatusCodeResult(StatusCodes.Status403Forbidden);
_42
return;
_42
}
_42
_42
base.OnActionExecuting(context);
_42
}
_42
}

Add the following to ConfigureServices in Startup.cs (this passes our allowed values to the auth filter):

Startup.cs

_7
public void ConfigureServices(IServiceCollection services)
_7
{
_7
services.AddScoped(container =>
_7
{
_7
return new AuthorizeIPFilter(Configuration["AdminSafeList"]);
_7
});
_7
}

And ensure we are using authorization in Configure:

Startup.cs

_4
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
_4
{
_4
app.UseAuthorization();
_4
}

The Web API consists of a single endpoint that is called by our cron job (authorized by our filter):

SyncController.cs

_20
// auth filter implementation from above
_20
[ServiceFilter(typeof(AuthorizeIPFilter))]
_20
[ApiController]
_20
[Route("api/[controller]")]
_20
public class SyncController : ControllerBase
_20
{
_20
// configure dependency injection of our sync service
_20
private readonly ISyncService service;
_20
public SyncController(ISyncService _service)
_20
{
_20
service = _service;
_20
}
_20
_20
// GET https://localhost/api/sync
_20
public IActionResult Index()
_20
{
_20
var result = service.Sync();
_20
return Ok(result);
_20
}
_20
}

The sync service consists of a single public method that runs through our order of operations. On call, it retrieves the current IP from the router, gets any previously saved IP from storage, compares the two and updates GoDaddy if they are different. If a change is made, it serializes the new IP to disk for next time and sends us an update notification through Pushover. If any errors occur during this process, it tries to send us a notification about it if possible.

SyncService.cs

_83
public class SyncService : ISyncService
_83
{
_83
// configure dependency injection of our services
_83
private readonly string FileName = "ip";
_83
private readonly IGoDaddyService goDaddy;
_83
private readonly IGoogleWiFiService wan;
_83
private readonly IPushoverService pushover;
_83
_83
public SyncService(IGoogleWiFiService _wan, IGoDaddyService _goDaddy, IPushoverService _pushover)
_83
{
_83
wan = _wan;
_83
goDaddy = _goDaddy;
_83
pushover = _pushover;
_83
}
_83
_83
// our sync method that returns a basic object of previous and current IP strings
_83
public IPResult Sync()
_83
{
_83
try
_83
{
_83
var ipResult = new IPResult();
_83
// from router
_83
ipResult.Current = GetCurrentIP();
_83
// from storage
_83
ipResult.Previous = GetPreviousIP();
_83
_83
// if true we have a changed IP or no saved value (first run)
_83
if (ipResult.Current != ipResult.Previous ||
_83
string.IsNullOrWhiteSpace(ipResult.Previous))
_83
{
_83
// update the dns
_83
goDaddy.UpdateDomains(ipResult.Current);
_83
// serialize new value to disk
_83
SaveIP(ipResult.Current);
_83
// notify of change
_83
pushover.SendPush("DNS Updated: " + ipResult.Current);
_83
}
_83
return ipResult;
_83
}
_83
catch (Exception e)
_83
{
_83
// try to notify on any errors
_83
pushover.SendPush(e.Message);
_83
throw e;
_83
}
_83
}
_83
_83
private string GetCurrentIP()
_83
{
_83
var currentIP = wan.GetIP().Result;
_83
// we should always get a value back, otherwise something is wrong
_83
if (currentIP == null)
_83
{
_83
// most likely won't get a notification on this, as it probably
_83
// means that the internet is down
_83
throw new Exception("No IP returned from router");
_83
}
_83
return currentIP;
_83
}
_83
_83
// retrieve previous ip from a serialized binary file saved to disk
_83
private string GetPreviousIP()
_83
{
_83
var previousIP = "";
_83
if (File.Exists(FileName))
_83
{
_83
var openFileStream = File.OpenRead(FileName);
_83
var deserializer = new BinaryFormatter();
_83
previousIP = (string)deserializer.Deserialize(openFileStream);
_83
openFileStream.Close();
_83
}
_83
return previousIP;
_83
}
_83
_83
// serialize the string value and save to file on disk for next time
_83
private void SaveIP(string currentIP)
_83
{
_83
Stream SaveFileStream = File.Create(FileName);
_83
BinaryFormatter serializer = new BinaryFormatter();
_83
serializer.Serialize(SaveFileStream, currentIP);
_83
SaveFileStream.Close();
_83
}
_83
}

Our GoogleWiFiService makes a simple GET request to the router to retrieve the current external IP value:

GoogleWiFiService.cs

_26
public class GoogleWiFiService : IGoogleWiFiService
_26
{
_26
// configure dependency injection for service
_26
private readonly IConfiguration config;
_26
public GoogleWiFiService(IConfiguration _config)
_26
{
_26
config = _config;
_26
}
_26
_26
// make a GET request to router
_26
public async Task<string> GetIP()
_26
{
_26
var status = new GoogleWiFi();
_26
var client = new HttpClient();
_26
client.BaseAddress = new Uri("http://" + config["Wan:IP"] + "/api/v1/");
_26
var request = new HttpRequestMessage(HttpMethod.Get, "status?type=wan");
_26
var response = await client.SendAsync(request);
_26
response.EnsureSuccessStatusCode();
_26
_26
var json = await response.Content.ReadAsStringAsync();
_26
status = JsonConvert.DeserializeObject<GoogleWiFi>(json);
_26
_26
// return the IP value only
_26
return status.wan.localIpAddress;
_26
}
_26
}

The GoogleWificlass has many values, but we can just deserialize for the localIpAddress value:

GoogleWiFi.cs

_9
public class GoogleWiFi
_9
{
_9
public Wan wan { get; set; }
_9
}
_9
public class Wan
_9
{
_9
// even though it says local, it is the external IP
_9
public string localIpAddress { get; set; }
_9
}

The GoDaddyService makes the required calls to update the DNS for each value within the Domains string array in appsettings.json:

GoDaddyService.cs

_86
public class GoDaddyService : IGoDaddyService
_86
{
_86
private readonly string url = "https://api.godaddy.com/v1/domains/";
_86
private readonly string endpoint = "/records/A";
_86
// configure dependency injection for service
_86
private readonly IConfiguration _config;
_86
public GoDaddyService(IConfiguration config)
_86
{
_86
_config = config;
_86
}
_86
_86
public ARecord GetDomainARecord(string domain)
_86
{
_86
var result = new ARecord();
_86
var client = new HttpClient();
_86
client.BaseAddress = new Uri(url);
_86
var request = new HttpRequestMessage(HttpMethod.Get, domain + endpoint);
_86
request.Headers.Add(
_86
"Authorization", "sso-key " + _config["GoDaddy:Key"] + ":" +
_86
_config["GoDaddy:Secret"]
_86
);
_86
request.Headers.Add("Accept", "application/json");
_86
var response = client.SendAsync(request).Result;
_86
response.EnsureSuccessStatusCode();
_86
_86
var content = response.Content.ReadAsStringAsync().Result;
_86
// get the first A Record returned for the domain
_86
// depending on configuration you may need to change this to reflect
_86
// your needs
_86
if (JsonConvert.DeserializeObject<List<ARecord>>(content).Count > 0)
_86
{
_86
result = JsonConvert.DeserializeObject<List<ARecord>>(content)[0];
_86
}
_86
else
_86
{
_86
result.type = "A";
_86
result.name = "@";
_86
result.ttl = 3600;
_86
}
_86
_86
return result;
_86
}
_86
_86
private void UpdateARecord(string domain, ARecord record, string ip)
_86
{
_86
// dont needlessly update the record
_86
if (record.data == ip)
_86
{
_86
return;
_86
}
_86
record.data = ip;
_86
_86
ARecord[] recordArray = { record };
_86
_86
var client = new HttpClient();
_86
client.BaseAddress = new Uri(url);
_86
client.DefaultRequestHeaders.Accept.Clear();
_86
client.DefaultRequestHeaders.Accept.Add(
_86
new MediaTypeWithQualityHeaderValue("application/json")
_86
);
_86
client.DefaultRequestHeaders.Add(
_86
"Authorization", "sso-key " + _config["GoDaddy:Key"] + ":" +
_86
_config["GoDaddy:Secret"]
_86
);
_86
_86
var body = JsonConvert.SerializeObject(recordArray);
_86
var response = client.PutAsync(
_86
domain + endpoint,
_86
new StringContent(body, Encoding.UTF8, "application/json")
_86
).Result;
_86
response.EnsureSuccessStatusCode();
_86
}
_86
_86
public void UpdateDomains(string ip)
_86
{
_86
// get our domains as a list
_86
var domains = _config.GetSection("Domains").Get<List<string>>();
_86
foreach (var domain in domains)
_86
{
_86
// get each domain A Record
_86
var aRecord = GetDomainARecord(domain);
_86
// and update with new IP value
_86
UpdateARecord(domain, aRecord, ip);
_86
}
_86
}
_86
}

The ARecord class has some required values that we populate above:

ARecord.cs

_11
public class ARecord
_11
{
_11
// "A"
_11
public string type { get; set; }
_11
// i use "@" as my A Record name to point my CNAME values to
_11
public string name { get; set; }
_11
// the IP
_11
public string data { get; set; }
_11
// 3600 (godaddy default: 60 * 60 = 1 hour)
_11
public int ttl { get; set; }
_11
}

At this point our service has updated the A Record, now we can send ourselves a notification about it, so we know it has been changed:

PushoverService.cs

_22
public class PushoverService : IPushoverService
_22
{
_22
// configure dependency injection for service
_22
private readonly IConfiguration config;
_22
public PushoverService(IConfiguration _config)
_22
{
_22
config = _config;
_22
}
_22
_22
// pass a message string to be sent to the server (ip or error message in our case)
_22
public void SendPush(string message)
_22
{
_22
var client = new HttpClient();
_22
var url = config["Pushover:Url"];
_22
var token = config["Pushover:Token"];
_22
var user = config["Pushover:User"];
_22
client.BaseAddress = new Uri(url);
_22
_22
var response = client.PostAsync($"messages.json?message={message}&token={token}&user={user}", null).Result;
_22
response.EnsureSuccessStatusCode();
_22
}
_22
}


Publishing

We can now publish the API and deploy it to the Linux server.

For this we publish as a framework-dependant deployment (dotnet core 3.1), as I know I only want it on my linux-x64 box. To deploy, I simply used WinSCP to SSH my CentOS server and dropped the files in my home directory.

Make sure you have the latest runtimes installed.

To make sure the server maintained uptime, I used a package called Supervisor to make sure it gets restarted if it falls over.

/etc/supervisord.conf

_10
[program:<PROJECT_NAME>]
_10
command=/usr/bin/dotnet <FULL_PATH_TO_DLL>.dll
_10
directory=<DIRECTORY_OF_FILES>
_10
autostart=true
_10
autorestart=true
_10
stderr_logfile=/var/log/<PROJECT_NAME>.err.log
_10
stdout_logfile=/var/log/<PROJECT_NAME>.out.log
_10
environment=ASPNETCORE__ENVIRONMENT=Production
_10
user=<SU_USER>
_10
stopsignal=INT

Crontab

To kick off the sync process I added a cron job to my root user that cURL's into the API endpoint every 5 minutes.

/var/spool/cron/root

_1
*/5 * * * * /home/andrew/checkdns.sh

It runs a shell script that just calls a cURL command:

checkdns.sh

_2
#!/bin/bash
_2
curl http://localhost:5000/api/sync

The result is a GET request to our endpoint from localhost, which satisfies our authorization filter, and kicks off the sync process to check for a change in IP address.


Andrew McMahon
These are a few of my insignificant productions
by Andrew McMahon.