publish DDNS

This commit is contained in:
brent s. 2020-04-21 00:56:28 -04:00
parent 31eec2d3f3
commit 95aa8aa3bc
Signed by: bts
GPG Key ID: 8C004C2F93481F6B
3 changed files with 375 additions and 0 deletions

57
net/dns/linode/README Normal file
View File

@ -0,0 +1,57 @@
This script requires a configuration file (by default, ~/.config/ddns.xml). Please refer to example.ddns.xml for an example.

The path to the configuration file can be changed with the -c/--config argument.

!!! NOTE !!!
This script as a precautionary measure does NOT create new domain names! It may create or remove A/AAAA records depending
on whether your client has a IPv4 and/or IPv6 WAN route respectively, however.

Because network DNS settings are unpredictable and we need to ensure we don't get split-brain or bogus DNS responses,
this script uses Verisign's public DNS resolvers hardcoded in. These resolvers are recommended for privacy, speed, and
RFC compliance. The exact resolvers used are:

* 64.6.64.6
* 64.6.65.6

If you do not consent to this, do not use this script.
!!!!!!!!!!!!

!!! NOTE !!!
This script, by *necessity*, connects to (tries to connect to) the following URLs:

* https://ipv4.clientinfo.square-r00t.net/?raw=1
* https://ipv6.clientinfo.square-r00t.net/?raw=1

This is a necessity because otherwise we do not have a method of fetching the WAN IP if the client is e.g. behind NAT
(or is using ULA addresses with a routed gateway/RFC 6296 in IPv6 networks, etc.).

This is a service that the author himself has written (https://git.square-r00t.net/OpTools/tree/net/addr) and deployed.
No personal information is sold, etc. and it only returns the headers and connection information the client sends in a
standard HTTP(S) request.

If you do not consent to this, either change the URL in Updater._getMyIP() (it is compatible with https://ipinfo.io/,
but this service does not return split IPv4 and IPv6 records so further modifications would be required) or do not use
this script.
!!!!!!!!!!!!

SETUP:

1.)a.) Create the domain(s) you wish to use in the Linode Domains manager (https://cloud.linode.com/domains).
b.) Create the API token (https://cloud.linode.com/profile/tokens).
* It MUST have "Read/Write" access to the "Domains" scope. All other scopes can be "None".
* It is *HIGHLY recommended* that you generate a *unique* token for each and every client machine rather than
sharing a token across them.
1.) Create a configuration file. Refer to the accompanying "example.ddns.xml" file.
2.) Make sure the script is executable and you have all required python modules installed:
https://pypi.org/project/dnspython/
https://pypi.org/project/requests/
https://pypi.org/project/lxml/
https://pypi.org/project/systemd/ (optional; for logging to the journal)
3.) You're ready to go! It is recommended that you either:
a.) Set up a cronjob (https://crontab.guru/), or
b.) Create a systemd timer (https://wiki.archlinux.org/index.php/Systemd/Timers) (if you're on a system with systemd).

LOGGING:
Logging is done to ~/.cache/ddns.log. Messages will also be logged to the systemd journal (if available and the systemd module is installed).

Suggestions for improvement are welcome (r00t [at] square-r00t.net).

299
net/dns/linode/ddns.py Executable file
View File

@ -0,0 +1,299 @@
#!/usr/bin/env python3

import argparse
import json
import logging
import logging.handlers
import os
import re
import sys
import warnings
##
import dns.exception
import dns.resolver
import requests
##
from lxml import etree
try:
# https://www.freedesktop.org/software/systemd/python-systemd/journal.html#journalhandler-class
from systemd import journal
_has_journald = True
except ImportError:
_has_journald = False


logfile = '~/.cache/ddns.log'

# Prep the log file.
logfile = os.path.abspath(os.path.expanduser(logfile))
os.makedirs(os.path.dirname(logfile), exist_ok = True, mode = 0o0700)
if not os.path.isfile(logfile):
with open(logfile, 'w') as fh:
fh.write('')
os.chmod(logfile, 0o0600)

# And set up logging.
_cfg_args = {'handlers': [],
'level': logging.DEBUG}
if _has_journald:
# There were some weird changes somewhere along the line.
try:
# But it's *probably* this one.
h = journal.JournalHandler()
except AttributeError:
h = journal.JournaldLogHandler()
# Systemd includes times, so we don't need to.
h.setFormatter(logging.Formatter(style = '{',
fmt = ('{name}:{levelname}:{name}:{filename}:'
'{funcName}:{lineno}: {message}')))
_cfg_args['handlers'].append(h)
h = logging.handlers.RotatingFileHandler(logfile,
encoding = 'utf8',
# Disable rotating for now.
# maxBytes = 50000000000,
# backupCount = 30
)
h.setFormatter(logging.Formatter(style = '{',
fmt = ('{asctime}:'
'{levelname}:{name}:{filename}:'
'{funcName}:{lineno}: {message}')))
_cfg_args['handlers'].append(h)
logging.basicConfig(**_cfg_args)
logger = logging.getLogger('DDNS')
logger.info('Logging initialized.')

is_tty = sys.stdin.isatty()
if not is_tty:
logger.debug('Not running in an interactive invocation; disabling printing warnings')
else:
logger.debug('Running in an interactive invocation; enabling printing warnings')


class Updater(object):
tree = None
records = {}
api_base = None
session = None
token = None
my_ips = {4: None, 6: None}
resolver = dns.resolver.Resolver(configure = False)
resolver.nameservers = ['64.6.64.6', '64.6.65.6']

def __init__(self, cfg_path = '~/.config/ddns.xml', *args, **kwargs):
self.xml = os.path.abspath(os.path.expanduser(cfg_path))
logger.debug('Updater initialized with config {0}'.format(self.xml))
self._getConf()
self._getMyIP()
self._getSession()

def _getConf(self):
try:
with open(self.xml, 'rb') as fh:
self.xml = etree.fromstring(fh.read())
except FileNotFoundError as e:
logger.error('Configuration file does not exist; please create it')
raise e
self.tree = self.xml.getroottree()
self.token = self.xml.attrib['token']
self.api_base = re.sub(r'/$', '', self.xml.attrib['base'])
dom_xml = self.xml.findall('domain')
num_doms = len(dom_xml)
logger.debug('Found {0} domains in config'.format(num_doms))
for idx, d in enumerate(dom_xml):
domain = d.attrib['name']
logger.debug('Iterating domain {0} ({1}/{2})'.format(domain, (idx + 1), num_doms))
if domain not in self.records.keys():
self.records[domain] = []
sub_xml = d.findall('sub')
num_subs = len(sub_xml)
logger.debug('Found {0} records for domain {1}'.format(num_subs, domain))
for idx2, s in enumerate(sub_xml):
logger.debug('Adding record {0}.{1} to index ({2}/{3})'.format(s.text, domain, (idx2 + 1), num_subs))
self.records[domain].append(s.text)
return()

def _getDNS(self, record):
records = {}
for t in ('A', 'AAAA'):
logger.debug('Resolving {0} ({1})'.format(record, t))
try:
q = self.resolver.query(record, t)
for a in q:
if t not in records.keys():
records[t] = []
ip = a.to_text()
logger.debug('Found IP {0} for record {1} ({2})'.format(ip, record, t))
records[t].append(ip)
except dns.exception.Timeout as e:
logger.error('Got a timeout when resolving {0} ({1}): {2}'.format(record, t, e))
continue
except dns.resolver.NXDOMAIN as e:
# This is a debug instead of an error because that record type may not exist.
logger.debug('Record {0} ({1}) does not exist: {2}'.format(record, t, e))
continue
except dns.resolver.YXDOMAIN as e:
logger.error('Record {0} ({1}) is too long: {2}'.format(record, t, e))
continue
except dns.resolver.NoAnswer as e:
logger.error('Record {0} ({1}) exists but has no content: {2}'.format(record, t, e))
continue
except dns.resolver.NoNameservers as e:
logger.error(('Could not failover to a non-broken resolver when resolving {0} ({1}): '
'{2}').format(record, t, e))
continue
return(records)

def _getMyIP(self):
for v in self.my_ips.keys():
try:
logger.debug('Getting the client\'s WAN address for IPv{0}'.format(v))
r = requests.get('https://ipv{0}.clientinfo.square-r00t.net/?raw=1'.format(v))
if not r.ok:
logger.error('Got a non-OK response from WAN IPv{0} fetch.'.format(v))
raise RuntimeError('Could not get the IPv{0} address'.format(v))
ip = r.json()['ip']
logger.debug('Got WAN IP address {0} for IPv{1}'.format(ip, v))
self.my_ips[v] = ip
except requests.exceptions.ConnectionError:
logger.debug('Could not get WAN address for IPv{0}; likely not supported on this network'.format(v))
return()

def _getSession(self):
self.session = requests.Session()
self.session.headers.update({'Authorization': 'Bearer {0}'.format(self.token)})
return()

def update(self):
for d in self.records.keys():
d_f = json.dumps({'domain': d})
doms_url = '{0}/domains'.format(self.api_base)
logger.debug('Getting list of domains from {0} (filtered to {1})'.format(doms_url, d))
d_r = self.session.get(doms_url,
headers = {'X-Filter': d_f})
if not d_r.ok:
e = 'Could not get list of domains when attempting to check {0}; skipping'.format(d)
if is_tty:
warnings.warn(e)
logger.warning(e)
continue
try:
d_id = d_r.json()['data'][0]['id']
except (IndexError, KeyError):
e = 'Could not find domain {0} in the returned domains list; skipping'.format(d)
if is_tty:
warnings.warn(e)
logger.warning(e)
continue
for s in self.records[d]:
fqdn = '{0}.{1}'.format(s, d)
logger.debug('Processing {0}'.format(fqdn))
records = self._getDNS(fqdn)
for v, t in ((4, 'A'), (6, 'AAAA')):
ip = self.my_ips.get(v)
rrset = records.get(t)
if not ip:
e = 'IPv{0} disabled; skipping'.format(v)
warnings.warn(e)
if is_tty:
logger.warning(e)
continue
if rrset and ip in rrset:
e = 'Skipping adding {0} for {1}; already exists in DNS'.format(ip, fqdn)
logger.info(e)
if is_tty:
print(e)
continue
s_f = json.dumps({'name': s,
'type': t})
records_url = '{0}/domains/{1}/records'.format(self.api_base, d_id)
logger.debug(('Getting list of records from {0} '
'(filtered to name {1} and type {2})').format(records_url, s, t))
s_r = self.session.get(records_url,
headers = {'X-Filter': s_f})
if not s_r.ok:
e = 'Could not get list of records when attempting to check {0} ({1}); skipping'.format(fqdn, t)
if is_tty:
warnings.warn(e)
logger.warning(e)
continue
r_ids = set()
# If r_exists is:
# None, then the record exists but the current WAN IP is missing (all records replaced).
# False, then the record does not exist (record will be added).
# True, then the record exists and is current (nothing will be done).
r_exists = None
try:
api_records = s_r.json().pop('data')
for idx, r in enumerate(api_records):
r_ids.add(r['id'])
r_ip = r['target']
if r_ip == ip:
r_exists = True
except (IndexError, KeyError):
e = ('Could not find record {0} ({1}) in the returned records list; '
'creating new record').format(fqdn, t)
if is_tty:
print(e)
logger.info(e)
r_exists = False
if r_exists:
# Do nothing.
e = 'Skipping adding {0} for {1}; already exists in API'.format(ip, fqdn)
logger.info(e)
if is_tty:
print(e)
continue
elif r_exists is None:
# Remove all records and then add (at the end).
# We COULD do an update:
# https://developers.linode.com/api/v4/domains-domain-id-records-record-id/#put
# BUT then we break future updating since we don't know which record is the "right" one to
# update.
logger.debug('Record {0} ({1}) exists but does not contain {2}; replacing'.format(fqdn, t, ip))
for r_id in r_ids:
del_url = '{0}/domains/{1}/records/{1}'.format(self.api_base, d_id, r_id)
logger.debug(('Deleting record ID {0} for {1} ({2})').format(r_id, fqdn, t))
del_r = self.session.delete(records_url)
if not del_r.ok:
e = 'Could not delete record ID {0} for {1} ({2}); skipping'.format(r_id, fqdn, t)
if is_tty:
warnings.warn(e)
logger.warning(e)
continue
else:
# Create the record.
logger.debug('Record {0} ({1}) does not exist; creating'.format(fqdn, ip))
record = {'name': s,
'type': t,
'target': ip,
'ttl_sec': 300}
create_url = '{0}/domains/{1}/records'.format(self.api_base, d_id)
create_r = self.session.put(create_url,
json = record)
if not create_r.ok:
e = 'Could not create record {0} ({1}); skipping'.format(fqdn, t)
if is_tty:
warnings.warn(e)
logger.warning(e)
continue
return()


def parseArgs():
args = argparse.ArgumentParser(description = ('Automatically update Linode DNS via their API'))
args.add_argument('-c', '--config',
dest = 'cfg_path',
default = '~/.config/ddns.xml',
help = ('The path to the configuration file. Default: ~/.config/ddns.xml'))
return(args)


def main():
args = parseArgs().parse_args()
u = Updater(**vars(args))
u.update()
return(None)


if __name__ == '__main__':
main()

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!-- You very much most likely will want to leave "base" ALONE. Make sure you change "token" to your Linode API token,
though. -->
<api base="https://api.linode.com/v4/"
token="YOUR_TOKEN_HERE">
<!-- Domains MUST be created first in the Linode Domains manager! -->
<domain name="domain1.com">
<!-- This would be for the A/AAAA record "foo.domain1.com". -->
<sub>foo</sub>
<!-- And obviously, this for "bar.domain1.com". -->
<sub>bar</sub>
</domain>
<domain name="domain2.net">
<!-- baz.domain2.net -->
<sub>baz</sub>
<!-- quux.domain2.net -->
<sub>quux</sub>
</domain>
</api>