publish DDNS
This commit is contained in:
parent
31eec2d3f3
commit
95aa8aa3bc
57
net/dns/linode/README
Normal file
57
net/dns/linode/README
Normal 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
299
net/dns/linode/ddns.py
Executable 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()
|
19
net/dns/linode/example.ddns.xml
Normal file
19
net/dns/linode/example.ddns.xml
Normal 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>
|
Loading…
Reference in New Issue
Block a user