Skip to content

Instantly share code, notes, and snippets.

@NiklasGollenstede
Last active December 15, 2020 16:42
Show Gist options
  • Save NiklasGollenstede/24e0af6ce138c6a60f4936fc96f9bfc5 to your computer and use it in GitHub Desktop.
Save NiklasGollenstede/24e0af6ce138c6a60f4936fc96f9bfc5 to your computer and use it in GitHub Desktop.
DynDNS Update Proxy

This relay allows to reliably update multiple DynDNS targets at once.

Please read the README for more information.

accounts.js
docker-compose.override.yml

DynDNS Proxy -- update many domains

Especially in Europe, it is very common for ISPs to dynamically assign short lived IPv4 addresses to private customers. While IP addresses that change up to every 24h have some advantages in terms of privacy, they are problematic when one wants to host publicly accessible services over a home Internet connection.

To work around this problem, a number of registrars and dedicated services provide Dynamic DNS: A-Records that can be adjusted via HTTP request. Many DSL modems support this as well. Whenever they are assigned a new public IP, they will automatically and immediately inform the DynDNS provider about the new address.

One problem that remains is that many routers only allow to specify a single domain to be updated. Since CNAMEs can not be used with public-suffix level domain names (and only some providers have workarounds for this), this server can be used as an intermediate that forwards any IP address changes to multiple DynDNS accounts. And besides, adjusting any settings though the web UI of most routers is just plain awful.

Currently this server supports the registrars Namecheap.com and Strato.de. More should be easy to add to ./providers.js. Contributions are welcome.

Setup

Download and configure

mkdir -p dyn-dns-proxy && cd dyn-dns-proxy
git clone https://gist.github.com/NiklasGollenstede/24e0af6ce138c6a60f4936fc96f9bfc5 .
cp accounts.js accounts.sample.js
chmod 600 accounts.js
editor accounts.js # configure your DnyDNS domains/accounts

Option 1: run with pm2 (as normal user)

pm2 start pm2.yaml # start the server and keep it running across reboots etc. (assumes a global installation oft he `pm2` npm module)

TODO: This saves the passwords in plain. It would probably be better to create a new user for this (but that would also need the pm2 service).

Option 2: run with Docker (as root or docker user)

To start directly with Docker, download and configure the repo, cd into it, then run:

docker build -t $(hostname)-$(whoami)/dyn-dns-proxy .
docker rm --force dyn-dns-proxy; docker run --restart=unless-stopped --publish=127.0.0.1:58080:58080/tcp --hostname=dyn-dns-proxy --name=dyn-dns-proxy -d $(hostname)-$(whoami)/dyn-dns-proxy

You may want to adjust the --publish listen address. Since the server needs outbound traffic, make sure the network setup works (set e.g. docker run --dns=). Check the status with docker ps and docker logs dyn-dns-proxy. After updating the repo or configuration, run the same commands again.

Option 3: run with docker-compose (as root or docker user)

To start with docker-compose, download and configure the repo, cd into it, optionally create a docker-compose.override.yml, then run:

docker-compose up -d

Since the server needs outbound traffic, make sure the network setup works (e.g. set dns in the override config). Check the status with docker ps and docker logs dyn-dns-proxy. After an update to docker-compose*.yml, run docker-compose up -d again. After other updates, run docker-compose restart.

nginx proxy (as root)

It is probably impractical if this server only runs at http://localhost:58080/ (behind your firewall). The following instructions add a nginx configuration that forwards http://<server-IP/name>/dyndns-update from local IP addresses to this server and closes anything else. You can of cause modify the allowed IP ranges, the hostname and/or path or add some kind of authentication and HTTPS.

cat ./nginx.conf > /etc/nginx/sites-available/dyn-dns-update.conf
ln -s /etc/nginx/sites-available/dyn-dns-update.conf /etc/nginx/sites-enabled/dyn-dns-update.conf
chmod 0644 /etc/nginx/sites-available/dyn-dns-update.conf && chmod 0644 /etc/nginx/sites-enabled/dyn-dns-update.conf
editor /etc/nginx/sites-available/dyn-dns-update.conf # set the <server_name> to the servers local IP
service nginx restart

License

The MIT License (MIT)

Copyright (c) 2018 Niklas Gollenstede

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

'use strict';
const { namecheap, strato, utils: { encodedUrl, }, } = require('./providers.js');
/**
* Copy this as ./accounts.js and edit it to match your domains.
* Remove all example entries.
*/
module.exports = [
{ // namecheap
domain: 'example.com', host: '@',
provider: namecheap, password: '<32charHex>',
}, {
domain: 'somename.me', host: 'home',
provider: namecheap, password: '<32charHex>',
},
{ // strato
domain: 'example.de', host: '@',
provider: strato, password: '<20alphaNum>',
},
{ // others
domain: 'other.host', host: 'www',
// In the likely case that your registrar is neither namecheap nor strato,
// define your custom provider like this. Feel free to request it to be added.
provider: { url({ domain, host, password, }) {
return encodedUrl`https://my-registrar.net/host=${host}&domain=${domain}&password=${password}`;
}, },
password: '???',
},
];
version: '2.1'
services:
node:
image: node:12-alpine
container_name: dyn-dns-proxy
environment:
- PORT=${PORT:-58080}
volumes:
- .:/usr/src/app:ro
entrypoint: su node -c 'node /usr/src/app/index.js'
ports:
- "127.0.0.1:${PORT:-58080}:${PORT:-58080}"
restart: unless-stopped
FROM node:12-alpine
WORKDIR /usr/src/app
COPY . .
RUN chown -R node: .
User node
EXPOSE 58080
CMD [ "node", "index.js" ]
#!/usr/bin/env node
'use strict'; /* globals Buffer, */
const PORT = process.env.PORT || 58080;
const LISTEN_ADDRESS = process.env.LISTEN_ADDRESS || '0.0.0.0';
const accounts = require('./accounts.js');
const https = require('https'), { parse, } = require('url');
require('http').createServer(async (req, res) => { try {
const reply = { }; (await Promise.all(accounts.map(async ({ password, domain, host, provider, }) => {
const { status, type, body, } = (await fetch(provider.url({ password, domain, host, })));
return { domain, host, url: provider.url({ password: '(pw)', domain, host, }), status, type, body, };
}))).forEach(({ domain, host, ...props }) => (reply[domain +':'+ host] = props));
const status = Object.values(reply).every(_=>_.status === 200) ? 200
: +(Object.values(reply).find(_=>_.status > 400) || { status: 400, }).status || 500;
res.writeHead(status, { 'Content-Type': 'application/json', });
res.end(JSON.stringify(reply, null, '\t'));
} catch (error) {
res.writeHead(500, { 'Content-Type': 'text/plain', });
res.end(error.stack);
} }).listen(PORT, LISTEN_ADDRESS, () => {
console.log(`Listening on ${LISTEN_ADDRESS}:${PORT}`);
});
async function fetch(url) { return new Promise((resolve, reject) => {
const request = typeof url === 'string' ? parse(url) : url;
!request.headers && (request.headers = { });
!request.headers['user-agent'] && (request.headers['user-agent'] = 'DynDNS Proxy/1.0.0 (bot; https://gist.github.com/24e0af6ce138c6a60f4936fc96f9bfc5)');
https.get(request, response => { try {
const status = +response.statusCode, type = response.headers['content-type'], length = +response.headers['content-length'];
const match = (/charset="?([^";]+)/).exec(type), encoding = match ? Buffer.isEncoding(match[1]) ? match[1] : 'ascii' : 'utf-8';
if (!encoding || length < 0 || length > 1024 || isNaN(length)) { abort(); return; }
response.setEncoding(encoding); let body = ''; function read(chunk) { body += chunk; if (body.length > length) { abort(); response.off('data', read); } }
response.on('data', read); response.on('end', () => resolve({ status, type, body, })); response.on('error', reject);
function abort() { response.resume(); resolve({ status: 502, type: 'text/plain', body: 'Invalid HTTP response', }); }
} catch (error) { reject(error); } }).on('error', reject);
}); }
## Exposes the local `dyn-dns-proxy` service.
## Edit this _after_ copying it to `/etc/nginx/sites-available/`.
server { # DynDNS proxy
server_name <ip_address>; # TODO: set host name
listen 80; listen [::]:80;
# if Internet access is desired, listen to 443, add certs and the redirect below,
# and replace the `allow`s below with some kind of access control
#if ($scheme != "https") { return 301 https://$host$request_uri; }
location /dyndns-update {
allow 10.0.0.0/8; allow 172.16.0.0/12; allow 192.168.0.0/16; # allow local-net access only
deny all; error_page 403 =444 /; # and otherwise play dead
proxy_set_header X-Forwarded-For $remote_addr;
proxy_pass http://127.0.0.1:58080$request_uri;
}
# play dead (close the connection) for all other requests
error_page 403 404 =444 /; location / { return 444; }
}
{
"name": "dyn-dns-proxy",
"version": "1.0.0",
"description": "Tiny server to to reliably update multiple DynDNS targets at once",
"keywords": [ "DynDNS", "proxy", "DSL" ],
"author": "NiklasGollenstede",
"license": "MIT",
"repo": "gist:24e0af6ce138c6a60f4936fc96f9bfc5",
"homepage": "https://gist.github.com/NiklasGollenstede/24e0af6ce138c6a60f4936fc96f9bfc5#file-readme-md",
"main": "index.js", "scripts": { "start": "node index.js" }
}
name: dyn-dns-proxy
script: ./index.js
source_map_support: false
env:
NODE_ENV: production
PORT: 58080
'use strict';
/**
* Properties specific individual DynDNS providers:
* Each provider currently has a single method that maps `{ domain, host, password, }` to the URL that will be GETed.
* Where `domain` is the registered domain, i.e. the chosen name plus the TLD without any subdomin,
* `host` is the subdomain label without surrounding '.'s or '@',
* and `password` is the password, which will be included in the actual request but not in the status reply.
* Do **not** hard code any passwords or they might be returned in clear.
*/
module.exports = {
namecheap: { url({ domain, host, password, }) {
return 'https://dynamicdns.park-your-domain.com/update?'
+ encodedUrl`host=${ host || '@' }&domain=${domain}&password=${password}`;
}, },
strato: { url({ domain, host, password, }) {
const hostname = !host || host === '@' ? domain : host.endsWith('.'+ domain) ? host : host +'.'+ domain;
return encodedUrl`https://${domain}:${password}@dyndns.strato.com/nic/update?hostname=${ hostname }`;
}, },
utils: { encodedUrl, },
};
const QS = require('querystring');
//! Template string tag function that URL-encodes all substitutions.
function encodedUrl() {
for (let i = 1, l = arguments.length; i < l; ++i) {
arguments[i] = QS.escape(arguments[i]);
} return String.raw.apply(String, arguments);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment