Files
linux-cloud-router/debian-package/src/usr/lib/vpn-router/vpnrouter_dns.py
T

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',
'',
])