From 95aa8aa3bc1bf972803bed7e19c2b0011ac72c58 Mon Sep 17 00:00:00 2001 From: brent s Date: Tue, 21 Apr 2020 00:56:28 -0400 Subject: [PATCH] publish DDNS --- net/dns/linode/README | 57 ++++++ net/dns/linode/ddns.py | 299 ++++++++++++++++++++++++++++++++ net/dns/linode/example.ddns.xml | 19 ++ 3 files changed, 375 insertions(+) create mode 100644 net/dns/linode/README create mode 100755 net/dns/linode/ddns.py create mode 100644 net/dns/linode/example.ddns.xml diff --git a/net/dns/linode/README b/net/dns/linode/README new file mode 100644 index 0000000..66728da --- /dev/null +++ b/net/dns/linode/README @@ -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). diff --git a/net/dns/linode/ddns.py b/net/dns/linode/ddns.py new file mode 100755 index 0000000..bfaa385 --- /dev/null +++ b/net/dns/linode/ddns.py @@ -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() diff --git a/net/dns/linode/example.ddns.xml b/net/dns/linode/example.ddns.xml new file mode 100644 index 0000000..59f3760 --- /dev/null +++ b/net/dns/linode/example.ddns.xml @@ -0,0 +1,19 @@ + + + + + + + foo + + bar + + + + baz + + quux + +