269 lines
13 KiB
Python
269 lines
13 KiB
Python
"""The router's authoritative name server, PowerDNS with its gpgsql backend, for vpn-router-setup and the web service.
|
|
|
|
The name server answers for one zone, dns.zone, on the addresses of the interfaces of dns.listen. Its 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 the zone: its SOA, its NS, dns.nameserver, A and AAAA records of
|
|
dns.addresses for the zone's apex, for the name server when it is in the zone, and for every host name of an enabled
|
|
site in the zone, and the records an administrator added (manual records), but those of a name and type the records
|
|
before them have. The serial of the SOA grows only when the records change.
|
|
|
|
Every failure of the database raises DatabaseError; a record that cannot be added raises Failure.
|
|
"""
|
|
|
|
import contextlib
|
|
import ipaddress
|
|
import pathlib
|
|
import re
|
|
|
|
import psycopg
|
|
from psycopg.rows import dict_row
|
|
|
|
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 manual records.
|
|
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 record that cannot be added or removed; status is 2 invalid, 3 no such record."""
|
|
|
|
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 manual():
|
|
"""The manual records."""
|
|
with database() as connection:
|
|
return connection.execute('SELECT * FROM dns_manual_list()').fetchall()
|
|
|
|
|
|
def add(cfg, record):
|
|
"""Add the manual record, or set the TTL of the same one; the record."""
|
|
zone = cfg['dns']['zone'].strip()
|
|
if not zone:
|
|
raise Failure('dns.zone is not set')
|
|
record = check_record(zone, record)
|
|
record['ttl'] = record['ttl'] or cfg['dns']['ttl']
|
|
with database() as connection:
|
|
records = connection.execute('SELECT * FROM dns_manual_list()').fetchall()
|
|
same = [row for row in records if (row['name'], row['type']) == (record['name'], record['type'])]
|
|
others = [row for row in records if row['name'] == record['name'] and row['type'] != record['type']]
|
|
if len(records) >= MANUAL_MAX and record not in records:
|
|
raise Failure(f'the zone has {MANUAL_MAX} manual records')
|
|
if record['type'] == 'CNAME' and (others or [row for row in same if row['content'] != record['content']]):
|
|
raise Failure(f'{record["name"]} has other records; a CNAME record must be its only record')
|
|
if any(row['type'] == 'CNAME' for row in others):
|
|
raise Failure(f'{record["name"]} has a CNAME record, which must be its only record')
|
|
connection.execute('CALL dns_manual_put(%s, %s, %s, %s)',
|
|
(record['name'], record['type'], record['content'], record['ttl']))
|
|
return record
|
|
|
|
|
|
def remove(cfg, name, kind, content):
|
|
"""Remove the manual records of the name and type, only the one of the content when it is given."""
|
|
zone = cfg['dns']['zone'].strip()
|
|
full = absolute(zone, name)
|
|
with database() as connection:
|
|
found = [row for row in connection.execute('SELECT * FROM dns_manual_list()').fetchall()
|
|
if (row['name'], row['type']) == (full, kind) and content in (None, row['content'])]
|
|
if not found:
|
|
raise Failure(f'no {kind} record of {full}', 3)
|
|
connection.execute('CALL dns_manual_remove(%s, %s, %s)', (full, kind, content))
|
|
return {'name': full, 'type': kind, 'removed': len(found)}
|
|
|
|
|
|
def served(zone):
|
|
"""The records of the zone as the name server answers them, [] when it serves none."""
|
|
with database() as connection:
|
|
return connection.execute('SELECT * FROM dns_served(%s)', (zone,)).fetchall()
|
|
|
|
|
|
def records(cfg, hosts, manual_records):
|
|
"""The records of the zone besides its SOA, as (name, type, content, ttl): its NS, the A and AAAA records of
|
|
dns.addresses for the apex, the name server and each host name in the zone, and the manual records."""
|
|
dns = cfg['dns']
|
|
zone, nameserver, ttl = dns['zone'].strip(), dns['nameserver'].strip(), dns['ttl']
|
|
addresses = [ipaddress.ip_address(item.strip()) for item in dns['addresses'] if item.strip()]
|
|
found = [(zone, 'NS', nameserver, ttl)]
|
|
for name in dict.fromkeys([zone, *([nameserver] if in_zone(zone, nameserver) else []),
|
|
*(host for host in hosts if in_zone(zone, host))]):
|
|
found += [(name, 'A' if address.version == 4 else 'AAAA', str(address), ttl) for address in addresses]
|
|
taken = {(name, kind) for name, kind, _, _ in found}
|
|
found += [(row['name'], row['type'], row['content'], row['ttl']) for row in manual_records
|
|
if (row['name'], row['type']) not in taken]
|
|
return list(dict.fromkeys(found))
|
|
|
|
|
|
def apply(cfg, hosts):
|
|
"""Make the name server serve dns.zone with records(), or no zone while dns.enabled is false."""
|
|
with database() as connection:
|
|
if not cfg['dns']['enabled']:
|
|
connection.execute('CALL dns_clear()')
|
|
return
|
|
dns = cfg['dns']
|
|
zone = dns['zone'].strip()
|
|
rows = records(cfg, hosts, connection.execute('SELECT * FROM dns_manual_list()').fetchall())
|
|
soa = f'{dns["nameserver"].strip()} hostmaster.{zone} {{serial}} {SOA_TIMES} {dns["ttl"]}'
|
|
connection.execute('CALL dns_apply(%s, %s, %s, %s, %s, %s, %s)',
|
|
(zone, soa, dns['ttl'], [row[0] for row in rows], [row[1] for row in rows],
|
|
[row[2] for row in rows], [row[3] for row in rows]))
|
|
|
|
|
|
def config_text(addresses):
|
|
"""The settings of PowerDNS: the gpgsql backend on the database of the name server, 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}',
|
|
f'local-address={", ".join(addresses)}',
|
|
'local-port=53',
|
|
'',
|
|
])
|