summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorbrent s <bts@square-r00t.net>2020-04-21 00:56:28 -0400
committerbrent s <bts@square-r00t.net>2020-04-21 00:56:28 -0400
commit95aa8aa3bc1bf972803bed7e19c2b0011ac72c58 (patch)
treed604e6effe60ece30cd9b27a9ae3effb3b919efd
parent31eec2d3f343508176f9c8fdcdf00e97b3fd8c6b (diff)
downloadOpTools-95aa8aa3bc1bf972803bed7e19c2b0011ac72c58.tar.xz
publish DDNS
-rw-r--r--net/dns/linode/README57
-rwxr-xr-xnet/dns/linode/ddns.py299
-rw-r--r--net/dns/linode/example.ddns.xml19
3 files changed, 375 insertions, 0 deletions
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 @@
+<?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>