From 4d9138b8283eced0c5b47865e2023c63a55558c2 Mon Sep 17 00:00:00 2001 From: Christian Pointner Date: Fri, 11 Nov 2022 22:34:56 +0100 Subject: ch-pan: deploy new and improved dyndns/server --- chaos-at-home/ch-pan.yml | 1 + inventory/host_vars/ch-pan-legacy.yml | 12 --- inventory/host_vars/ch-pan.yml | 31 ++++++- roles/dyndns/client/tasks/main.yml | 4 +- roles/dyndns/server/tasks/main.yml | 39 ++++++-- roles/dyndns/server/templates/dyndns-regen.py.j2 | 109 +++++++++++++++++++++++ roles/dyndns/server/templates/dyndns.py.j2 | 67 ++++++-------- roles/dyndns/server/templates/header.tmpl.j2 | 11 +++ roles/network/bind/templates/master-zones.j2 | 4 + 9 files changed, 216 insertions(+), 62 deletions(-) delete mode 100644 inventory/host_vars/ch-pan-legacy.yml create mode 100644 roles/dyndns/server/templates/dyndns-regen.py.j2 create mode 100644 roles/dyndns/server/templates/header.tmpl.j2 diff --git a/chaos-at-home/ch-pan.yml b/chaos-at-home/ch-pan.yml index 4c65b77f..8e9466cd 100644 --- a/chaos-at-home/ch-pan.yml +++ b/chaos-at-home/ch-pan.yml @@ -12,6 +12,7 @@ hosts: ch-pan roles: - role: network/bind + - role: dyndns/server - role: apt-repo/spreadspace - role: nginx/base - role: monitoring/prometheus/exporter diff --git a/inventory/host_vars/ch-pan-legacy.yml b/inventory/host_vars/ch-pan-legacy.yml deleted file mode 100644 index 560cf90b..00000000 --- a/inventory/host_vars/ch-pan-legacy.yml +++ /dev/null @@ -1,12 +0,0 @@ ---- -dyndns: - domain: schaaas.at - zone_file: /etc/bind/db.schaaas.at - clients: - ele-media: elemedia - r3-cccamp19-gw: r3-cccamp19-gw - r3-cccamp19-flora: r3-cccamp19-flora - r3-cccamp19-verr: r3-cccamp19-verr - r3-cccamp19-av: r3-cccamp19-av - r3-cccamp19-dione: r3-cccamp19-dione - r3-cccamp19-helene: r3-cccamp19-helene diff --git a/inventory/host_vars/ch-pan.yml b/inventory/host_vars/ch-pan.yml index 0f370fb9..3a02f935 100644 --- a/inventory/host_vars/ch-pan.yml +++ b/inventory/host_vars/ch-pan.yml @@ -35,9 +35,36 @@ spreadspace_apt_repo_components: - prometheus +sshd_allowusers_host: "{{ admin_users_host + ['dyndns'] }}" + + ntp_variant: systemd-timesyncd +dyndns: + domain: schaaas.at + soa: + ttl: 7200 + mname: ns0.chaos-at-home.org + rname: hostmaster.schaaas.at + refresh: 1200 + retry: 900 + expire: 2592000 + default_ttl: 60 + static_records: + - "schaaas.at. 7200 IN NS ns0.chaos-at-home.org." + - "schaaas.at. 7200 IN NS ns1.chaos-at-home.org." + - "schaaas.at. 7200 IN MX 10 mx0.chaos-at-home.org." + - "schaaas.at. 7200 IN MX 10 mx1.chaos-at-home.org." + - "dyn.schaaas.at. 7200 IN A 89.106.215.19" + - "dyn.schaaas.at. 7200 IN AAAA 2a02:3e0:407::19" + - "captive.schaaas.at. 7200 IN CNAME dyn.schaaas.at." + clients: + mz-router: mzl + ch-equinox-t450s: equinox + ele-media: elemedia + + bind_option_empty_zones_enable: no bind_option_notify: explicit @@ -77,8 +104,8 @@ bind_master_zones: file: "{{ global_files_dir }}/chaos-at-home/bind-zones/db.java-sucks.com" xn--gh-via.org: file: "{{ global_files_dir }}/chaos-at-home/bind-zones/db.gäh.org" - # schaaas.at: - # file: ... + schaaas.at: + remote_file: /var/lib/dyndns/db.schaaas.at gimpf.org: file: "{{ global_files_dir }}/chaos-at-home/bind-zones/db.gimpf.org" movetogether.at: diff --git a/roles/dyndns/client/tasks/main.yml b/roles/dyndns/client/tasks/main.yml index 6f2ff021..d1faba2c 100644 --- a/roles/dyndns/client/tasks/main.yml +++ b/roles/dyndns/client/tasks/main.yml @@ -46,8 +46,8 @@ - name: install systemd units loop: - - service - - timer + - service + - timer template: src: "dyndns.{{ item }}.j2" dest: "/etc/systemd/system/dyndns.{{ item }}" diff --git a/roles/dyndns/server/tasks/main.yml b/roles/dyndns/server/tasks/main.yml index 06ef6c47..c29d7edd 100644 --- a/roles/dyndns/server/tasks/main.yml +++ b/roles/dyndns/server/tasks/main.yml @@ -10,17 +10,42 @@ - name: create .ssh directory file: path: /var/lib/dyndns/.ssh + state: directory mode: 0700 owner: dyndns group: dyndns -- name: install zone update script +- name: install zone update scripts + loop: + - dyndns.py + - dyndns-regen.py template: - src: dyndns.py.j2 - dest: /usr/local/bin/dyndns.py + src: "{{ item }}.j2" + dest: "/usr/local/bin/{{ item }}" mode: 0755 -- name: install script dependency - apt: - name: python-easyzone - state: present +- name: test if serial file exists + stat: + path: "/var/lib/dyndns/serial.{{ dyndns.domain }}" + register: dyndns_serial_file_info + +- name: autogenerate new serial + when: not dyndns_serial_file_info.stat.exists + copy: + content: "{{ ansible_date_time.year }}{{ ansible_date_time.month }}{{ ansible_date_time.day }}00\n" + dest: "/var/lib/dyndns/serial.{{ dyndns.domain }}" + owner: dyndns + group: dyndns + +- name: generate header template + template: + src: header.tmpl.j2 + dest: "/var/lib/dyndns/header.{{ dyndns.domain }}" + +- name: regenerate dns zone file if changed + become: yes + become_method: su + become_user: dyndns + command: /usr/local/bin/dyndns-regen.py "{{ dyndns.domain }}" + register: dyndns_regen + changed_when: "'OK: already up to date.' not in dyndns_regen.stdout" diff --git a/roles/dyndns/server/templates/dyndns-regen.py.j2 b/roles/dyndns/server/templates/dyndns-regen.py.j2 new file mode 100644 index 00000000..3611618d --- /dev/null +++ b/roles/dyndns/server/templates/dyndns-regen.py.j2 @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +# +# dyndns.py +# +# a simple ssh based dyndns updater for bind zone files +# +# Copyright (C) 2013 Christian Pointner +# +# This file is part of dyndns.py. +# +# dyndns.py is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# any later version. +# +# dyndns.py is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with dyndns.py. If not, see . +# + +import subprocess +import fcntl +import shutil +import hashlib +import tempfile +import sys +import os + +if len(sys.argv) != 2: + print("ERROR: the base-domain-name must be the sole parameter") + sys.exit(1) + + +domain = sys.argv[1] +base_d = '/var/lib/dyndns' +rndc = '/usr/sbin/rndc' + +digest_fn = os.path.join(base_d, 'digest.'+domain) +header_fn = os.path.join(base_d, 'header.'+domain) +records_fn = os.path.join(base_d, 'records.'+domain) +tmpl_fn = os.path.join(base_d, 'tmpl.'+domain) +serial_fn = os.path.join(base_d, 'serial.'+domain) +db_fn = os.path.join(base_d, 'db.'+domain) + +digest_fd = open(digest_fn, 'a+') +fcntl.flock(digest_fd, fcntl.LOCK_EX) +digest_fd.seek(0) + +h = hashlib.blake2b() +with open(tmpl_fn, 'wb+') as tmpl_fd: + with open(header_fn, 'rb') as header_fd: + shutil.copyfileobj(header_fd, tmpl_fd) + try: + with open(records_fn, 'rb') as records_fd: + shutil.copyfileobj(records_fd, tmpl_fd) + except FileNotFoundError: + pass + + tmpl_fd.seek(0) + while True: + data = tmpl_fd.read(64*1024) + if not data: + break + h.update(data) + +old_digest = digest_fd.readline().strip() +new_digest = h.hexdigest() +if old_digest == new_digest: + print("OK: already up to date.") + sys.exit(0) + +serial = 0 +with open(serial_fn, 'a+') as serial_fd: + serial_fd.seek(0) + serial = int(serial_fd.readline()) + 1 + serial_fd.truncate(0) + serial_fd.write(str(serial)+'\n') + +with tempfile.NamedTemporaryFile(dir=base_d, delete=False, mode='w') as out_fd: + with open(tmpl_fn, 'r') as tmpl_fd: + while True: + line = tmpl_fd.readline() + if not line: + break + line = line.replace('__SERIAL__', str(serial)) + out_fd.write(line) + os.chmod(out_fd.name, 0o644) + os.rename(out_fd.name, db_fn) + + +cmd = [rndc, 'reload', domain] +p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) +rndc_output = '' +for line in p.stdout.readlines(): + rndc_output = rndc_output + line.decode('utf-8') + +r = p.wait() +if r != 0: + print("ERROR: rndc returned %d\n\n%s" % (r, rndc_output)) + sys.exit(1) + +digest_fd.truncate(0) +digest_fd.write(new_digest + '\n') + +print("OK: " + rndc_output) diff --git a/roles/dyndns/server/templates/dyndns.py.j2 b/roles/dyndns/server/templates/dyndns.py.j2 index eb5e7ab5..d6d396ce 100755 --- a/roles/dyndns/server/templates/dyndns.py.j2 +++ b/roles/dyndns/server/templates/dyndns.py.j2 @@ -1,4 +1,4 @@ -#!/usr/bin/python +#!/usr/bin/env python3 # # dyndns.py # @@ -22,17 +22,16 @@ # along with dyndns.py. If not, see . # -from easyzone import easyzone -import subprocess - +import fcntl +import tempfile import sys import os import re # -domain = '{{ dyndns.domain }}.' -zonefile = '{{ dyndns.zone_file }}' -rndc = '/usr/sbin/rndc' +domain = '{{ dyndns.domain }}' +base_d = '/var/lib/dyndns' +records_fn = os.path.join(base_d, 'records.'+domain) # # this is from: https://gist.github.com/dfee/6ed3a4b05cfe7a6faf40a2102408d5d8 @@ -51,7 +50,7 @@ IPV6GROUPS = ( r':(?:(?::' + IPV6SEG + r'){1,7}|:)', # ::2:3:4:5:6:7:8 ::2:3:4:5:6:7:8 ::8 :: r'fe80:(?::' + IPV6SEG + r'){0,4}%[0-9a-zA-Z]{1,}', # fe80::7:8%eth0 fe80::7:8%1 (link-local IPv6 addresses with zone index) r'::(?:ffff(?::0{1,4}){0,1}:){0,1}[^\s:]' + IPV4ADDR, - # ::255.255.255.255 ::ffff:255.255.255.255 ::ffff:0:255.255.255.255 (IPv4-mapped IPv6 addresses and IPv4-translated addresses) + # ::255.255.255.255 ::ffff:255.255.255.255 ::ffff:0:255.255.255.255 (IPv4-mapped IPv6 addresses and IPv4-translated addresses) r'(?:' + IPV6SEG + r':){1,4}:[^\s:]' + IPV4ADDR, # 2001:db8:3:4::192.0.2.33 64:ff9b::192.0.2.33 (IPv4-Embedded IPv6 Address) ) IPV6ADDR = '|'.join(['(?:{})'.format(g) for g in IPV6GROUPS[::-1]]) # Reverse rows for greedy match @@ -69,12 +68,12 @@ if ('SSH_CLIENT' in os.environ) and ('SSH_ORIGINAL_COMMAND' in os.environ): sshclient = os.environ['SSH_CLIENT'] sshcmd = os.environ['SSH_ORIGINAL_COMMAND'] else: - sys.stdout.write("Error: not an SSH connection\n") + print("ERROR: not an SSH connection or no name given") sys.exit(1) -name = sshcmd.split()[0] -if name not in set(sys.argv[1:]): - sys.stdout.write("Error: you are not allowed to update '%s'\n" % name) +record_name = sshcmd.split()[0] +if record_name not in set(sys.argv[1:]): + print("ERROR: you are not allowed to update '%s'" % record_name) sys.exit(1) ip = sshclient.split()[0] @@ -82,35 +81,25 @@ record_type = '' if re.match(IPV4ADDR, ip): record_type = 'A' if re.match(IPV6ADDR, ip): -# record_type = 'AAAA' - sys.stdout.write("Error: Sorry no IPv6 support yet!\n") - sys.exit(1) + record_type = 'AAAA' if record_type == '': - sys.stdout.write("Error: not a valid IPv4/IPv6 address\n") + print("ERROR: not a valid IPv4/IPv6 address") sys.exit(1) -name = name + '.' + domain -zone = easyzone.zone_from_file(domain, zonefile) -zone.add_name(name) -records = zone.names[name].records(record_type, create=True, ttl=60) -for item in records.get_items(): - if item == ip: - sys.stdout.write("Ok: nothing changed\n") - sys.exit(0) - - records.delete(item) +new_record = f'{record_name:<20} {{ dyndns.soa.default_ttl }} IN {record_type:<5} {ip}' -records.add(ip) -zone.save(autoserial=True) +records_fd = open(records_fn, 'a+') +fcntl.flock(records_fd, fcntl.LOCK_EX) +records_fd.seek(0) +records = records_fd.readlines() +records_fd.truncate(0) +for record in records: + tmp = record.split() + if tmp[0] != record_name or tmp[3] != record_type: + records_fd.write(record) + else: + records_fd.write(new_record + '\n') -cmd = [rndc, 'reload', domain] -p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) -output = '' -for line in p.stdout.readlines(): - output = output + line - -r = p.wait() -if r != 0: - sys.stdout.write("Error: rndc returned %d\n\n%s" % (r, output)) -else: - sys.stdout.write("Ok: " + output) +records_fd.flush() +os.fsync(records_fd) +os.execl('/usr/local/bin/dyndns-regen.py', '/usr/local/bin/dyndns-regen.py', domain) diff --git a/roles/dyndns/server/templates/header.tmpl.j2 b/roles/dyndns/server/templates/header.tmpl.j2 new file mode 100644 index 00000000..e1d557d1 --- /dev/null +++ b/roles/dyndns/server/templates/header.tmpl.j2 @@ -0,0 +1,11 @@ +$origin {{ dyndns.domain }}. +@ {{ dyndns.soa.ttl }} IN SOA {{ dyndns.soa.mname }}. {{ dyndns.soa.rname }}. __SERIAL__ {{ dyndns.soa.refresh }} {{ dyndns.soa.retry }} {{ dyndns.soa.expire }} {{ dyndns.soa.default_ttl }} + +; *************************** +; static records +{% for record in dyndns.static_records %} +{{ record }} +{% endfor %} + +; *************************** +; dynamic records diff --git a/roles/network/bind/templates/master-zones.j2 b/roles/network/bind/templates/master-zones.j2 index 2e400711..737b5e84 100644 --- a/roles/network/bind/templates/master-zones.j2 +++ b/roles/network/bind/templates/master-zones.j2 @@ -3,6 +3,10 @@ zone "{{ zone }}" { type master; +{% if 'remote_file' in bind_master_zones[zone] %} + file "{{ bind_master_zones[zone].remote_file }}"; +{% else %} file "/etc/bind/db.{{ zone }}"; +{% endif %} }; {% endfor %} -- cgit v1.2.3