402 lines
20 KiB
Python
402 lines
20 KiB
Python
"""The router's authoritative name server, PowerDNS with its gpgsql backend, for vpn-router-setup and the web service.
|
|
|
|
The name server is the primary of its domains and answers on the addresses of the interfaces of dns.listen. The
|
|
domains, their secondary name servers and the records an administrator added (manual records) are in the
|
|
vpn-router-dns database, which provision() creates with its role and the password in DATABASE_SECRET, and fills with
|
|
the schema of pdns-backend-pgsql, PDNS_SCHEMA; DNS_SQL defines the router's own tables and procedures, and the first
|
|
connection of a process loads it.
|
|
|
|
apply() writes every domain as a primary zone: its SOA with dns.nameserver, its NS, dns.nameserver and the name
|
|
servers of its secondaries, the NS of the domains directly below it, A and AAAA records of dns.addresses for its apex,
|
|
for those name servers inside it that are the router, and, when the domain has site records, for every host name of an
|
|
enabled site whose closest domain it is, and the manual records, but those of a name and type the records before them
|
|
have. A secondary's address is notified of every change (ALSO-NOTIFY) and may transfer the zone (ALLOW-AXFR-FROM); the
|
|
serial of the SOA grows only when the records change.
|
|
|
|
Every failure of the database raises DatabaseError; a request that cannot be done raises Failure.
|
|
"""
|
|
|
|
import contextlib
|
|
import ipaddress
|
|
import pathlib
|
|
import re
|
|
|
|
import psycopg
|
|
from psycopg.rows import dict_row
|
|
|
|
import vpnrouter
|
|
import vpnrouter_auth
|
|
|
|
DATABASE = 'vpn-router-dns'
|
|
DATABASE_SECRET = pathlib.Path('/etc/vpn-router/dns-database.secret')
|
|
DNS_SQL = pathlib.Path('/usr/share/vpn-router/sql/dns.sql')
|
|
|
|
#: The schema of PowerDNS's gpgsql backend, which pdns-backend-pgsql installs.
|
|
PDNS_SCHEMA = pathlib.Path('/usr/share/pdns-backend-pgsql/schema/schema.pgsql.sql')
|
|
|
|
#: The settings of PowerDNS that vpn-router-setup writes, read after /etc/powerdns/pdns.conf; the server, its unit and
|
|
#: the group it runs as.
|
|
PDNS_CONF = pathlib.Path('/etc/powerdns/pdns.d/vpn-router.conf')
|
|
PDNS_SERVER = pathlib.Path('/usr/sbin/pdns_server')
|
|
UNIT = 'pdns.service'
|
|
GROUP = 'pdns'
|
|
|
|
#: The types of the manual records.
|
|
TYPES = ('A', 'AAAA', 'CNAME', 'TXT', 'MX', 'SRV', 'CAA')
|
|
|
|
#: The most domains, secondaries of a domain, and manual records of a domain.
|
|
DOMAINS_MAX = 100
|
|
SECONDARIES_MAX = 8
|
|
MANUAL_MAX = 1000
|
|
|
|
#: The refresh, retry and expire times of the SOA, in seconds.
|
|
SOA_TIMES = '10800 3600 604800'
|
|
|
|
#: A label of a record's name: letters, digits, hyphens and underscores, or * as the first label.
|
|
LABEL = re.compile(r'^[a-z0-9_]([a-z0-9_-]{0,61}[a-z0-9_])?$')
|
|
|
|
#: The content of an MX, SRV and CAA record, and a DNS name a record points to.
|
|
MX = re.compile(r'^(\d{1,5}) (\S+)$')
|
|
SRV = re.compile(r'^(\d{1,5}) (\d{1,5}) (\d{1,5}) (\S+)$')
|
|
CAA = re.compile(r'^(0|128) (issue|issuewild|iodef) "[^"\\]*"$')
|
|
TARGET = re.compile(r'^[a-z0-9_]([a-z0-9_-]{0,61}[a-z0-9_])?(\.[a-z0-9_]([a-z0-9_-]{0,61}[a-z0-9_])?)*$')
|
|
|
|
#: The longest content of a TXT record, in characters.
|
|
TXT_MAX = 2048
|
|
|
|
#: Whether this process has loaded DNS_SQL.
|
|
_loaded = False
|
|
|
|
|
|
class DatabaseError(Exception):
|
|
"""The database of the name server failed."""
|
|
|
|
|
|
class Failure(Exception):
|
|
"""A request that cannot be done; status is 2 invalid, 3 no such domain, secondary or record, 4 the domain
|
|
exists."""
|
|
|
|
def __init__(self, message, status=2):
|
|
super().__init__(message)
|
|
self.status = status
|
|
|
|
|
|
def provision():
|
|
"""Create the role, its password file and the database of the name server when they are missing, and PowerDNS's
|
|
tables in it; DatabaseError while pdns-backend-pgsql is not installed."""
|
|
vpnrouter_auth.provision_database(DATABASE, DATABASE_SECRET)
|
|
try:
|
|
with psycopg.connect(vpnrouter_auth.local_connection(DATABASE, DATABASE_SECRET), autocommit=True) as connection:
|
|
if connection.execute("SELECT to_regclass('public.domains')").fetchone()[0] is None:
|
|
connection.execute(PDNS_SCHEMA.read_text(encoding='utf-8'))
|
|
except OSError as exc:
|
|
raise DatabaseError(f'the schema of PowerDNS cannot be read: {exc}') from exc
|
|
except psycopg.Error as exc:
|
|
raise DatabaseError(f'the database of the name server failed: {exc}') from exc
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def database():
|
|
"""A connection to the database of the name server, with the router's tables and procedures, closed at the end;
|
|
DatabaseError on any failure, also before provision() made it."""
|
|
global _loaded
|
|
try:
|
|
schema = None if _loaded else DNS_SQL.read_text(encoding='utf-8')
|
|
target = vpnrouter_auth.local_connection(DATABASE, DATABASE_SECRET)
|
|
except OSError as exc:
|
|
raise DatabaseError(f'the database of the name server is not ready: {exc}') from exc
|
|
try:
|
|
with psycopg.connect(target, autocommit=True, row_factory=dict_row) as connection:
|
|
if schema is not None:
|
|
connection.execute(schema)
|
|
_loaded = True
|
|
yield connection
|
|
except psycopg.Error as exc:
|
|
raise DatabaseError(f'the database of the name server failed: {exc}') from exc
|
|
|
|
|
|
def in_zone(zone, name):
|
|
return name == zone or name.endswith(f'.{zone}')
|
|
|
|
|
|
def absolute(zone, name):
|
|
"""The record name in the zone: @ or '' is the zone itself, a name ending in a dot is taken as it is, any other is
|
|
relative to the zone; Failure for a name outside the zone or not a DNS name."""
|
|
if not isinstance(name, str):
|
|
raise Failure('name must be a string')
|
|
name = name.strip().lower()
|
|
if name in ('', '@'):
|
|
return zone
|
|
full = name[:-1] if name.endswith('.') else f'{name}.{zone}'
|
|
labels = full.split('.')
|
|
if not in_zone(zone, full) or not all(LABEL.match(label) or (index == 0 and label == '*')
|
|
for index, label in enumerate(labels)):
|
|
raise Failure(f'{name!r} is not a name in {zone}')
|
|
return full
|
|
|
|
|
|
def check_record(zone, record):
|
|
"""The manual record {name, type, content, ttl?} with its name made absolute and its content checked; Failure when
|
|
it is not valid. A TXT content not in quotes is quoted."""
|
|
if not isinstance(record, dict):
|
|
raise Failure('a record is an object of name, type, content and ttl')
|
|
name = absolute(zone, record.get('name'))
|
|
kind = record.get('type')
|
|
content = record.get('content')
|
|
ttl = record.get('ttl')
|
|
if kind not in TYPES:
|
|
raise Failure(f'type must be one of {", ".join(TYPES)}')
|
|
if not isinstance(content, str) or not content.strip():
|
|
raise Failure('content must be a string')
|
|
if ttl is not None and (isinstance(ttl, bool) or not isinstance(ttl, int) or not 60 <= ttl <= 86400):
|
|
raise Failure('ttl must be 60 to 86400 seconds')
|
|
content = content.strip()
|
|
if kind in ('A', 'AAAA'):
|
|
try:
|
|
address = ipaddress.ip_address(content)
|
|
except ValueError:
|
|
address = None
|
|
if address is None or address.version != (4 if kind == 'A' else 6):
|
|
raise Failure(f'{content!r} is not an IPv{4 if kind == "A" else 6} address')
|
|
content = str(address)
|
|
elif kind == 'CNAME':
|
|
if name == zone:
|
|
raise Failure('the zone itself cannot have a CNAME record')
|
|
content = content.lower().rstrip('.')
|
|
if not TARGET.match(content):
|
|
raise Failure(f'{content!r} is not a DNS name')
|
|
elif kind == 'TXT':
|
|
if not (content.startswith('"') and content.endswith('"') and len(content) > 1):
|
|
content = '"' + content.replace('\\', '\\\\').replace('"', '\\"') + '"'
|
|
if len(content) > TXT_MAX or not content.isprintable():
|
|
raise Failure(f'the text must have at most {TXT_MAX} printable characters')
|
|
elif kind in ('MX', 'SRV'):
|
|
match = (MX if kind == 'MX' else SRV).match(content.lower())
|
|
if not match or any(int(value) > 65535 for value in match.groups()[:-1]) or \
|
|
not TARGET.match(match.groups()[-1].rstrip('.')):
|
|
raise Failure(f'{kind} content is ' + ('"<preference> <host>"' if kind == 'MX'
|
|
else '"<priority> <weight> <port> <host>"'))
|
|
content = ' '.join([*match.groups()[:-1], match.groups()[-1].rstrip('.')])
|
|
elif not CAA.match(content):
|
|
raise Failure('CAA content is "<0 or 128> <issue, issuewild or iodef> \\"<value>\\""')
|
|
return {'name': name, 'type': kind, 'content': content, 'ttl': ttl}
|
|
|
|
|
|
def closest(zones, name):
|
|
"""The longest of the zones the name is in, None when it is in none."""
|
|
return max((zone for zone in zones if in_zone(zone, name)), key=len, default=None)
|
|
|
|
|
|
def check_domain(name):
|
|
"""The domain name in lowercase without a final dot; Failure when it is not a DNS name."""
|
|
if not isinstance(name, str) or not vpnrouter.ZONE_NAME.match(name.strip().lower().rstrip('.')):
|
|
raise Failure(f'{name!r} is not a domain name')
|
|
return name.strip().lower().rstrip('.')
|
|
|
|
|
|
def check_ttl(ttl):
|
|
if ttl is not None and (isinstance(ttl, bool) or not isinstance(ttl, int) or
|
|
not vpnrouter.DNS_TTL_RANGE[0] <= ttl <= vpnrouter.DNS_TTL_RANGE[1]):
|
|
raise Failure(f'ttl must be {vpnrouter.DNS_TTL_RANGE[0]} to {vpnrouter.DNS_TTL_RANGE[1]} seconds')
|
|
return ttl
|
|
|
|
|
|
def domain(connection, name):
|
|
"""The domain; Failure with status 3 when there is none."""
|
|
row = next((row for row in connection.execute('SELECT * FROM dns_domains_list()').fetchall()
|
|
if row['name'] == name), None)
|
|
if row is None:
|
|
raise Failure(f'no domain {name}', 3)
|
|
return row
|
|
|
|
|
|
def listing():
|
|
"""Every domain with its secondaries, its manual records and the records the name server answers for it."""
|
|
with database() as connection:
|
|
secondaries = connection.execute('SELECT * FROM dns_secondaries_list()').fetchall()
|
|
manual = connection.execute('SELECT * FROM dns_records_list()').fetchall()
|
|
return [{**row,
|
|
'secondaries': [{key: item[key] for key in ('nameserver', 'address')}
|
|
for item in secondaries if item['domain'] == row['name']],
|
|
'manual': [{key: item[key] for key in ('name', 'type', 'content', 'ttl')}
|
|
for item in manual if item['domain'] == row['name']],
|
|
'records': connection.execute('SELECT * FROM dns_served(%s)', (row['name'],)).fetchall()}
|
|
for row in connection.execute('SELECT * FROM dns_domains_list()').fetchall()]
|
|
|
|
|
|
def domain_add(body):
|
|
"""Add the domain {name, sites, ttl?}; the domain."""
|
|
if not isinstance(body, dict) or not isinstance(body.get('sites', False), bool):
|
|
raise Failure('a domain is an object of name, sites and ttl')
|
|
name, sites, ttl = check_domain(body.get('name')), body.get('sites', False), check_ttl(body.get('ttl'))
|
|
with database() as connection:
|
|
names = [row['name'] for row in connection.execute('SELECT * FROM dns_domains_list()').fetchall()]
|
|
if name in names:
|
|
raise Failure(f'the domain {name} exists', 4)
|
|
if len(names) >= DOMAINS_MAX:
|
|
raise Failure(f'the name server has {DOMAINS_MAX} domains')
|
|
connection.execute('CALL dns_domain_put(%s, %s, %s)', (name, sites, ttl))
|
|
return {'name': name, 'sites': sites, 'ttl': ttl}
|
|
|
|
|
|
def domain_change(name, body):
|
|
"""Change the sites and ttl of the domain; the domain."""
|
|
if not isinstance(body, dict) or not set(body) <= {'sites', 'ttl'} or \
|
|
not isinstance(body.get('sites', False), bool):
|
|
raise Failure('a change of a domain is an object of sites and ttl')
|
|
with database() as connection:
|
|
row = domain(connection, name)
|
|
sites, ttl = body.get('sites', row['sites']), check_ttl(body.get('ttl', row['ttl']))
|
|
connection.execute('CALL dns_domain_put(%s, %s, %s)', (name, sites, ttl))
|
|
return {'name': name, 'sites': sites, 'ttl': ttl}
|
|
|
|
|
|
def domain_remove(name):
|
|
"""Remove the domain with its secondaries and manual records."""
|
|
with database() as connection:
|
|
domain(connection, name)
|
|
connection.execute('CALL dns_domain_remove(%s)', (name,))
|
|
return {'name': name}
|
|
|
|
|
|
def secondary_add(name, body):
|
|
"""Add the secondary name server {nameserver, address} of the domain, or set its address; the secondary."""
|
|
if not isinstance(body, dict):
|
|
raise Failure('a secondary is an object of nameserver and address')
|
|
nameserver = body.get('nameserver')
|
|
if not isinstance(nameserver, str) or not vpnrouter.ZONE_NAME.match(nameserver.strip().lower().rstrip('.')):
|
|
raise Failure(f'{nameserver!r} is not a name server name')
|
|
nameserver = nameserver.strip().lower().rstrip('.')
|
|
try:
|
|
address = str(ipaddress.ip_address(str(body.get('address', '')).strip()))
|
|
except ValueError as exc:
|
|
raise Failure(f'{body.get("address")!r} is not an IP address') from exc
|
|
with database() as connection:
|
|
domain(connection, name)
|
|
others = [row for row in connection.execute('SELECT * FROM dns_secondaries_list()').fetchall()
|
|
if row['domain'] == name and row['nameserver'] != nameserver]
|
|
if len(others) >= SECONDARIES_MAX:
|
|
raise Failure(f'the domain {name} has {SECONDARIES_MAX} secondaries')
|
|
connection.execute('CALL dns_secondary_put(%s, %s, %s)', (name, nameserver, address))
|
|
return {'domain': name, 'nameserver': nameserver, 'address': address}
|
|
|
|
|
|
def secondary_remove(name, nameserver):
|
|
"""Remove the secondary name server of the domain."""
|
|
with database() as connection:
|
|
domain(connection, name)
|
|
if not any(row['domain'] == name and row['nameserver'] == nameserver
|
|
for row in connection.execute('SELECT * FROM dns_secondaries_list()').fetchall()):
|
|
raise Failure(f'no secondary {nameserver} of {name}', 3)
|
|
connection.execute('CALL dns_secondary_remove(%s, %s)', (name, nameserver))
|
|
return {'domain': name, 'nameserver': nameserver}
|
|
|
|
|
|
def add(cfg, name, record):
|
|
"""Add the manual record to the domain, or set the TTL of the same one; the record. A name in a domain below it
|
|
belongs to that domain."""
|
|
with database() as connection:
|
|
row = domain(connection, name)
|
|
record = check_record(name, record)
|
|
record['ttl'] = record['ttl'] or row['ttl'] or cfg['dns']['ttl']
|
|
zones = [item['name'] for item in connection.execute('SELECT * FROM dns_domains_list()').fetchall()]
|
|
owner = closest(zones, record['name'])
|
|
if owner != name:
|
|
raise Failure(f'{record["name"]} is in the domain {owner}')
|
|
records = [item for item in connection.execute('SELECT * FROM dns_records_list()').fetchall()
|
|
if item['domain'] == name]
|
|
same = [item for item in records if (item['name'], item['type']) == (record['name'], record['type'])]
|
|
others = [item for item in records if item['name'] == record['name'] and item['type'] != record['type']]
|
|
if len(records) >= MANUAL_MAX and not same:
|
|
raise Failure(f'the domain {name} has {MANUAL_MAX} manual records')
|
|
if record['type'] == 'CNAME' and (others or [item for item in same if item['content'] != record['content']]):
|
|
raise Failure(f'{record["name"]} has other records; a CNAME record must be its only record')
|
|
if any(item['type'] == 'CNAME' for item in others):
|
|
raise Failure(f'{record["name"]} has a CNAME record, which must be its only record')
|
|
connection.execute('CALL dns_record_put(%s, %s, %s, %s, %s)',
|
|
(name, record['name'], record['type'], record['content'], record['ttl']))
|
|
return record
|
|
|
|
|
|
def remove(name, record_name, kind, content):
|
|
"""Remove the manual records of the name and type in the domain, only the one of the content when it is given."""
|
|
full = absolute(name, record_name)
|
|
with database() as connection:
|
|
domain(connection, name)
|
|
found = [row for row in connection.execute('SELECT * FROM dns_records_list()').fetchall()
|
|
if (row['domain'], row['name'], row['type']) == (name, full, kind)
|
|
and content in (None, row['content'])]
|
|
if not found:
|
|
raise Failure(f'no {kind} record of {full}', 3)
|
|
connection.execute('CALL dns_record_remove(%s, %s, %s, %s)', (name, full, kind, content))
|
|
return {'name': full, 'type': kind, 'removed': len(found)}
|
|
|
|
|
|
def records(cfg, row, zones, secondaries, hosts, manual_records):
|
|
"""The records of the domain row besides its SOA, as (name, type, content, ttl): the NS of the domain and of the
|
|
domains directly below it, the A and AAAA records of dns.addresses for its apex, dns.nameserver when it is inside
|
|
and, with site records, each host name whose closest domain it is, the glue of the secondaries inside it, and its
|
|
manual records. secondaries maps a domain to its secondaries."""
|
|
dns = cfg['dns']
|
|
zone, nameserver, ttl = row['name'], dns['nameserver'].strip(), row['ttl'] or dns['ttl']
|
|
addresses = [ipaddress.ip_address(item.strip()) for item in dns['addresses'] if item.strip()]
|
|
children = [name for name in zones
|
|
if name != zone and closest([item for item in zones if item != name], name) == zone]
|
|
found = [(name, 'NS', server, ttl) for name in [zone, *children]
|
|
for server in [nameserver, *(item['nameserver'] for item in secondaries.get(name, []))]]
|
|
for name in dict.fromkeys([zone, *([nameserver] if in_zone(zone, nameserver) else []),
|
|
*(host for host in hosts if row['sites'] and closest(zones, host) == zone)]):
|
|
found += [(name, 'A' if address.version == 4 else 'AAAA', str(address), ttl) for address in addresses]
|
|
for item in (item for name in [zone, *children] for item in secondaries.get(name, [])):
|
|
if in_zone(zone, item['nameserver']) and item['nameserver'] != nameserver:
|
|
address = ipaddress.ip_address(item['address'])
|
|
found.append((item['nameserver'], 'A' if address.version == 4 else 'AAAA', str(address), ttl))
|
|
taken = {(name, kind) for name, kind, _, _ in found}
|
|
found += [(item['name'], item['type'], item['content'], item['ttl']) for item in manual_records
|
|
if (item['name'], item['type']) not in taken]
|
|
return list(dict.fromkeys(found))
|
|
|
|
|
|
def apply(cfg, hosts):
|
|
"""Make the name server the primary of every domain with records(), and of none while dns.enabled is false."""
|
|
with database() as connection:
|
|
if not cfg['dns']['enabled']:
|
|
connection.execute('CALL dns_retain(%s::text[])', ([],))
|
|
return
|
|
dns = cfg['dns']
|
|
domains = connection.execute('SELECT * FROM dns_domains_list()').fetchall()
|
|
zones = [row['name'] for row in domains]
|
|
secondaries, manual = {}, {}
|
|
for item in connection.execute('SELECT * FROM dns_secondaries_list()').fetchall():
|
|
secondaries.setdefault(item['domain'], []).append(item)
|
|
for item in connection.execute('SELECT * FROM dns_records_list()').fetchall():
|
|
manual.setdefault(item['domain'], []).append(item)
|
|
for row in domains:
|
|
zone, ttl = row['name'], row['ttl'] or dns['ttl']
|
|
rows = records(cfg, row, zones, secondaries, hosts, manual.get(zone, []))
|
|
soa = f'{dns["nameserver"].strip()} hostmaster.{zone} {{serial}} {SOA_TIMES} {ttl}'
|
|
connection.execute('CALL dns_apply(%s, %s, %s, %s::text[], %s::text[], %s::text[], %s::integer[], '
|
|
'%s::text[])',
|
|
(zone, soa, ttl, [item[0] for item in rows], [item[1] for item in rows],
|
|
[item[2] for item in rows], [item[3] for item in rows],
|
|
[item['address'] for item in secondaries.get(zone, [])]))
|
|
connection.execute('CALL dns_retain(%s::text[])', (zones,))
|
|
|
|
|
|
def config_text(addresses):
|
|
"""The settings of PowerDNS: the gpgsql backend on the database of the name server, primary operation, and the
|
|
addresses it answers on."""
|
|
password = DATABASE_SECRET.read_text(encoding='utf-8').strip()
|
|
return '\n'.join([
|
|
'# Written by vpn-router-setup from /etc/vpn-router/vpn-router.json; edits are overwritten.',
|
|
'launch=gpgsql',
|
|
'gpgsql-host=localhost',
|
|
f'gpgsql-dbname={DATABASE}',
|
|
f'gpgsql-user={DATABASE}',
|
|
f'gpgsql-password={password}',
|
|
'primary=yes',
|
|
f'local-address={", ".join(addresses)}',
|
|
'local-port=53',
|
|
'',
|
|
])
|