fix: the name server is the primary of any number of domains, each with secondary name servers and a switch for the records of the sites.
Release / release (push) Successful in 13s
Release / release (push) Successful in 13s
This commit is contained in:
1 parent
57e8bbcffe
commit
1697e1aa38
9 files changed
+412
-150
No files matched your search
@@ -821,12 +821,12 @@ def manage_webdav(ctx):
|
||||
|
||||
|
||||
def manage_dns(cfg, ctx):
|
||||
"""Run PowerDNS, the name server of dns.zone, on the addresses of dns.listen while DNS is enabled, and stop it when
|
||||
not; its packages are installed when they are missing.
|
||||
"""Run PowerDNS, the primary name server of the domains of vpnrouter_dns, on the addresses of dns.listen while DNS
|
||||
is enabled, and stop it when not; its packages are installed when they are missing.
|
||||
|
||||
The settings, vpnrouter_dns.PDNS_CONF, are written before the packages are installed, so the server their
|
||||
installation starts answers on those addresses only, beside systemd-resolved's stub on 127.0.0.53. The zone gets
|
||||
the host names of the enabled sites, and keeps those it has while the sites cannot be read.
|
||||
installation starts answers on those addresses only, beside systemd-resolved's stub on 127.0.0.53. The domains
|
||||
with site records get the host names of the enabled sites, and keep those they have while the sites cannot be read.
|
||||
"""
|
||||
if not vpnrouter.dns_ready(ctx):
|
||||
if unit_is_active(vpnrouter_dns.UNIT):
|
||||
|
||||
@@ -69,7 +69,7 @@ SCHEMA = {
|
||||
'https': {'enabled': True, 'email': '', 'web_port': 7444, 'manager_port': 7443, 'caddy_source': 'ubuntu'},
|
||||
'oidc': {'enabled': False},
|
||||
'sites': {'enabled': False, 'upload_max_mb': 512, 'webdav': False, 'webdav_port': 7445},
|
||||
'dns': {'enabled': False, 'zone': '', 'nameserver': '', 'addresses': [], 'listen': 'external', 'ttl': 300},
|
||||
'dns': {'enabled': False, 'nameserver': '', 'addresses': [], 'listen': 'external', 'ttl': 300},
|
||||
'pki': {'root_name': 'VPN Router Root CA', 'root_days': 7300, 'ca_days': 3650, 'key': 'rsa-4096',
|
||||
'crl_days': 7, 'root_crl_days': 180, 'publish': ['http'], 'p2s_name': 'VPN Router P2S CA',
|
||||
's2s_name': 'VPN Router Site-to-Site CA', 'issuing_cas': {}, 'templates': {},
|
||||
@@ -146,7 +146,7 @@ UPLOAD_MAX_MB = 10240
|
||||
#: The shortest and the longest dns.ttl, in seconds.
|
||||
DNS_TTL_RANGE = (60, 86400)
|
||||
|
||||
#: A DNS name without a wildcard, in lowercase, of dns.zone and dns.nameserver.
|
||||
#: A DNS name without a wildcard, in lowercase, of dns.nameserver and the domains of the name server.
|
||||
ZONE_NAME = 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])?)+$')
|
||||
|
||||
#: A domain of p2s.dns_domains, one label or more, and the most domains.
|
||||
@@ -579,11 +579,10 @@ def validate(cfg):
|
||||
|
||||
dns = cfg['dns']
|
||||
if dns['enabled']:
|
||||
for option in ('zone', 'nameserver'):
|
||||
if not ZONE_NAME.match(dns[option].strip()):
|
||||
raise ConfigError(f'dns.{option}: {dns[option]!r} is not a DNS name in lowercase')
|
||||
if not ZONE_NAME.match(dns['nameserver'].strip()):
|
||||
raise ConfigError(f'dns.nameserver: {dns["nameserver"]!r} is not a DNS name in lowercase')
|
||||
if not _get_list(cfg, 'dns', 'addresses'):
|
||||
raise ConfigError('dns.enabled needs dns.addresses, the public addresses of the zone')
|
||||
raise ConfigError('dns.enabled needs dns.addresses, the public addresses of the name server and the sites')
|
||||
for item in _get_list(cfg, 'dns', 'addresses'):
|
||||
_check_address(item, 'dns.addresses')
|
||||
if dns['listen'] not in LISTEN_CHOICES:
|
||||
@@ -1093,7 +1092,7 @@ def webdav_ready(ctx):
|
||||
|
||||
|
||||
def dns_ready(ctx):
|
||||
"""The name server answers for dns.zone while dns.enabled is true."""
|
||||
"""The name server answers for its domains while dns.enabled is true."""
|
||||
return bool(ctx['dns_enabled'])
|
||||
|
||||
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
"""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 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 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.
|
||||
connection of a process loads it.
|
||||
|
||||
Every failure of the database raises DatabaseError; a record that cannot be added raises Failure.
|
||||
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
|
||||
@@ -19,6 +24,7 @@ import re
|
||||
import psycopg
|
||||
from psycopg.rows import dict_row
|
||||
|
||||
import vpnrouter
|
||||
import vpnrouter_auth
|
||||
|
||||
DATABASE = 'vpn-router-dns'
|
||||
@@ -38,7 +44,9 @@ GROUP = 'pdns'
|
||||
#: The types of the manual records.
|
||||
TYPES = ('A', 'AAAA', 'CNAME', 'TXT', 'MX', 'SRV', 'CAA')
|
||||
|
||||
#: The most manual records.
|
||||
#: 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.
|
||||
@@ -65,7 +73,8 @@ class DatabaseError(Exception):
|
||||
|
||||
|
||||
class Failure(Exception):
|
||||
"""A record that cannot be added or removed; status is 2 invalid, 3 no such record."""
|
||||
"""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)
|
||||
@@ -173,87 +182,210 @@ def check_record(zone, record):
|
||||
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 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 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']
|
||||
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:
|
||||
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']]):
|
||||
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(row['type'] == 'CNAME' for row in others):
|
||||
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_manual_put(%s, %s, %s, %s)',
|
||||
(record['name'], record['type'], record['content'], record['ttl']))
|
||||
connection.execute('CALL dns_record_put(%s, %s, %s, %s, %s)',
|
||||
(name, 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)
|
||||
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:
|
||||
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'])]
|
||||
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_manual_remove(%s, %s, %s)', (full, kind, content))
|
||||
connection.execute('CALL dns_record_remove(%s, %s, %s, %s)', (name, 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."""
|
||||
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 = dns['zone'].strip(), dns['nameserver'].strip(), dns['ttl']
|
||||
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()]
|
||||
found = [(zone, 'NS', nameserver, ttl)]
|
||||
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 in_zone(zone, host))]):
|
||||
*(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 += [(row['name'], row['type'], row['content'], row['ttl']) for row in manual_records
|
||||
if (row['name'], row['type']) not in taken]
|
||||
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 serve dns.zone with records(), or no zone while dns.enabled is false."""
|
||||
"""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_clear()')
|
||||
connection.execute('CALL dns_retain(%s::text[])', ([],))
|
||||
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]))
|
||||
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, and the addresses it answers
|
||||
on."""
|
||||
"""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.',
|
||||
@@ -262,6 +394,7 @@ def config_text(addresses):
|
||||
f'gpgsql-dbname={DATABASE}',
|
||||
f'gpgsql-user={DATABASE}',
|
||||
f'gpgsql-password={password}',
|
||||
'primary=yes',
|
||||
f'local-address={", ".join(addresses)}',
|
||||
'local-port=53',
|
||||
'',
|
||||
|
||||
@@ -52,8 +52,8 @@ Pages:
|
||||
/sites the web sites Caddy serves: the settings of sites, list, add, change and remove (see sites()),
|
||||
and the files of each: list, download, upload, extract a zip archive, create a folder, rename and
|
||||
remove (see Handler.site_files()); a member of a site's editors group may use the site's files
|
||||
/dns the name server of dns.zone: its settings, the records it serves, and the manual records: add and
|
||||
remove (see dns())
|
||||
/dns the primary name server of the router's domains: its settings, and the domains: add, change and
|
||||
remove, each with its secondaries, the records it serves, and its manual records (see dns())
|
||||
/postgresql
|
||||
the databases of each PostgreSQL cluster on the router with their sizes, as JSON (see
|
||||
postgresql_databases()); /config has the clusters' settings
|
||||
@@ -1264,44 +1264,69 @@ def site_file_action(name, target, body):
|
||||
def dns(method, path, query, body):
|
||||
"""Answer a /dns request with vpnrouter_dns: the HTTP status and the JSON document.
|
||||
|
||||
GET /dns {"settings", "installed", "running", "records", "manual", "problem"}: the dns section of the
|
||||
configuration, whether PowerDNS is installed and runs, the records the name server serves,
|
||||
the manual records, and why the records cannot be read, null when they can
|
||||
GET /dns {"settings", "installed", "running", "domains", "problem"}: the dns section of the
|
||||
configuration, whether PowerDNS is installed and runs, the domains, each {"name", "sites",
|
||||
"ttl", "secondaries", "manual", "records"} with its secondaries {"nameserver", "address"},
|
||||
its manual records and the records the name server serves, and why the domains cannot be
|
||||
read, null when they can
|
||||
PUT /dns change any of the settings of the dns section
|
||||
POST /dns/records add a manual record: {"name", "type", "content", "ttl"?}; name is relative to dns.zone, @
|
||||
for the zone itself, or absolute with a final dot; ttl is dns.ttl without it
|
||||
DELETE /dns/records remove the manual records of name= and type=, with content= only that one
|
||||
POST /dns/domains add a domain: {"name", "sites"?, "ttl"?}; sites gives the host names of the enabled sites
|
||||
in it records; ttl is dns.ttl when null or absent; an existing name is 409
|
||||
PUT /dns/domains/<domain>
|
||||
change "sites" or "ttl" of the domain
|
||||
DELETE /dns/domains/<domain>
|
||||
remove the domain with its secondaries and manual records
|
||||
POST /dns/domains/<domain>/secondaries
|
||||
add a secondary name server of the domain, or change its address: {"nameserver", "address"}
|
||||
DELETE /dns/domains/<domain>/secondaries/<nameserver>
|
||||
remove the secondary name server
|
||||
POST /dns/domains/<domain>/records
|
||||
add a manual record: {"name", "type", "content", "ttl"?}; name is relative to the domain,
|
||||
@ for the domain itself, or absolute with a final dot; ttl is the domain's without it
|
||||
DELETE /dns/domains/<domain>/records
|
||||
remove the manual records of name= and type=, with content= only that one
|
||||
|
||||
A change is applied by restarting vpn-router-setup without waiting for it.
|
||||
"""
|
||||
if body == 'invalid' or (body is not None and not isinstance(body, dict)):
|
||||
return 400, {'error': 'the body must be a JSON object'}
|
||||
body = body or {}
|
||||
parts = path.strip('/').split('/')
|
||||
route = (method, *parts[:2], *parts[3:4])
|
||||
try:
|
||||
cfg = vpnrouter.load_config()
|
||||
if (method, path) == ('GET', '/dns'):
|
||||
served, manual, problem = [], [], None
|
||||
if route == ('GET', 'dns'):
|
||||
domains, problem = [], None
|
||||
try:
|
||||
manual = vpnrouter_dns.manual()
|
||||
served = vpnrouter_dns.served(cfg['dns']['zone'].strip())
|
||||
domains = vpnrouter_dns.listing()
|
||||
except vpnrouter_dns.DatabaseError as exc:
|
||||
problem = str(exc)
|
||||
return 200, {'settings': cfg['dns'], 'installed': vpnrouter_dns.PDNS_SERVER.exists(),
|
||||
'running': unit_state(vpnrouter_dns.UNIT) == 'active', 'records': served, 'manual': manual,
|
||||
'running': unit_state(vpnrouter_dns.UNIT) == 'active', 'domains': domains,
|
||||
'problem': problem}
|
||||
if (method, path) == ('PUT', '/dns'):
|
||||
if route == ('PUT', 'dns'):
|
||||
changed = section_change('dns', body)
|
||||
return changed if isinstance(changed, tuple) else (200, {'settings': changed})
|
||||
if (method, path) == ('POST', '/dns/records'):
|
||||
document, code = vpnrouter_dns.add(cfg, body), 201
|
||||
elif (method, path) == ('DELETE', '/dns/records'):
|
||||
if route == ('POST', 'dns', 'domains') and len(parts) == 2:
|
||||
document, code = vpnrouter_dns.domain_add(body), 201
|
||||
elif route == ('PUT', 'dns', 'domains') and len(parts) == 3:
|
||||
document, code = vpnrouter_dns.domain_change(parts[2], body), 200
|
||||
elif route == ('DELETE', 'dns', 'domains') and len(parts) == 3:
|
||||
document, code = vpnrouter_dns.domain_remove(parts[2]), 200
|
||||
elif route == ('POST', 'dns', 'domains', 'secondaries') and len(parts) == 4:
|
||||
document, code = vpnrouter_dns.secondary_add(parts[2], body), 201
|
||||
elif route == ('DELETE', 'dns', 'domains', 'secondaries') and len(parts) == 5:
|
||||
document, code = vpnrouter_dns.secondary_remove(parts[2], parts[4]), 200
|
||||
elif route == ('POST', 'dns', 'domains', 'records') and len(parts) == 4:
|
||||
document, code = vpnrouter_dns.add(cfg, parts[2], body), 201
|
||||
elif route == ('DELETE', 'dns', 'domains', 'records') and len(parts) == 4:
|
||||
content = query.get('content', [None])[0]
|
||||
document = vpnrouter_dns.remove(cfg, query.get('name', [''])[0], query.get('type', [''])[0], content)
|
||||
document = vpnrouter_dns.remove(parts[2], query.get('name', [''])[0], query.get('type', [''])[0], content)
|
||||
code = 200
|
||||
else:
|
||||
return 404, {'error': f'no route {method} {path}'}
|
||||
except vpnrouter_dns.Failure as failure:
|
||||
return (404 if failure.status == 3 else 400), {'error': str(failure)}
|
||||
return {3: 404, 4: 409}.get(failure.status, 400), {'error': str(failure)}
|
||||
except vpnrouter_dns.DatabaseError as exc:
|
||||
return 500, {'error': str(exc)}
|
||||
except SystemExit as exc:
|
||||
|
||||
@@ -89,7 +89,6 @@
|
||||
},
|
||||
"dns": {
|
||||
"enabled": false,
|
||||
"zone": "",
|
||||
"nameserver": "",
|
||||
"addresses": [],
|
||||
"listen": "external",
|
||||
|
||||
@@ -1,48 +1,106 @@
|
||||
-- The zone the router's name server (PowerDNS, gpgsql backend) answers for (vpnrouter_dns), in the vpn-router-dns
|
||||
-- database, whose domains and records tables are PowerDNS's own schema.
|
||||
-- The domains the router's name server (PowerDNS, gpgsql backend) is the primary of (vpnrouter_dns), in the
|
||||
-- vpn-router-dns database, whose domains, records and domainmetadata tables are PowerDNS's own schema.
|
||||
-- The whole file runs as one transaction, serialized by an advisory lock, so that two processes can load it at once.
|
||||
|
||||
SELECT pg_advisory_xact_lock(hashtext('vpn-router dns schema'));
|
||||
|
||||
-- A record an administrator added to the zone, besides those of the zone itself and of the sites.
|
||||
CREATE TABLE IF NOT EXISTS dns_manual_records (
|
||||
DROP TABLE IF EXISTS dns_manual_records;
|
||||
|
||||
-- A domain the router serves as its primary name server; sites says whether the host names of the enabled sites in
|
||||
-- it get records, ttl is the TTL of its records, null for dns.ttl.
|
||||
CREATE TABLE IF NOT EXISTS dns_domains (
|
||||
name text PRIMARY KEY,
|
||||
sites boolean NOT NULL,
|
||||
ttl integer
|
||||
);
|
||||
|
||||
-- A secondary name server of a domain: its NS name and the address it transfers the domain from.
|
||||
CREATE TABLE IF NOT EXISTS dns_secondaries (
|
||||
domain text NOT NULL REFERENCES dns_domains (name) ON DELETE CASCADE,
|
||||
nameserver text NOT NULL,
|
||||
address inet NOT NULL,
|
||||
PRIMARY KEY (domain, nameserver)
|
||||
);
|
||||
|
||||
-- A record an administrator added to a domain, besides those of the domain itself and of the sites.
|
||||
CREATE TABLE IF NOT EXISTS dns_records (
|
||||
domain text NOT NULL REFERENCES dns_domains (name) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
type text NOT NULL,
|
||||
content text NOT NULL,
|
||||
ttl integer NOT NULL,
|
||||
PRIMARY KEY (name, type, content)
|
||||
PRIMARY KEY (domain, name, type, content)
|
||||
);
|
||||
|
||||
-- The digest of the records last written to the zone and the serial of its SOA.
|
||||
-- The digest of the records last written to a zone and the serial of its SOA.
|
||||
CREATE TABLE IF NOT EXISTS dns_state (
|
||||
zone text PRIMARY KEY,
|
||||
digest text NOT NULL,
|
||||
serial bigint NOT NULL
|
||||
);
|
||||
|
||||
CREATE OR REPLACE FUNCTION dns_manual_list()
|
||||
RETURNS TABLE (name text, type text, content text, ttl integer)
|
||||
CREATE OR REPLACE FUNCTION dns_domains_list()
|
||||
RETURNS TABLE (name text, sites boolean, ttl integer)
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT name, type, content, ttl FROM dns_manual_records ORDER BY name, type, content;
|
||||
SELECT name, sites, ttl FROM dns_domains ORDER BY name;
|
||||
$$;
|
||||
|
||||
-- Add the record, or set its TTL.
|
||||
CREATE OR REPLACE PROCEDURE dns_manual_put(p_name text, p_type text, p_content text, p_ttl integer)
|
||||
-- Add the domain, or set its sites and TTL.
|
||||
CREATE OR REPLACE PROCEDURE dns_domain_put(p_name text, p_sites boolean, p_ttl integer)
|
||||
LANGUAGE sql AS $$
|
||||
INSERT INTO dns_manual_records VALUES (p_name, p_type, p_content, p_ttl)
|
||||
ON CONFLICT (name, type, content) DO UPDATE SET ttl = p_ttl;
|
||||
INSERT INTO dns_domains VALUES (p_name, p_sites, p_ttl)
|
||||
ON CONFLICT (name) DO UPDATE SET sites = p_sites, ttl = p_ttl;
|
||||
$$;
|
||||
|
||||
-- Remove the records of the name and type, all of them without content.
|
||||
CREATE OR REPLACE PROCEDURE dns_manual_remove(p_name text, p_type text, p_content text)
|
||||
-- Remove the domain with its secondaries and records.
|
||||
CREATE OR REPLACE PROCEDURE dns_domain_remove(p_name text)
|
||||
LANGUAGE sql AS $$
|
||||
DELETE FROM dns_manual_records WHERE name = p_name AND type = p_type AND (p_content IS NULL OR content = p_content);
|
||||
DELETE FROM dns_domains WHERE name = p_name;
|
||||
$$;
|
||||
|
||||
-- Make the zone the only one served and give it the records, its SOA first, whose content has {serial} for the serial.
|
||||
-- The serial grows, to at least the current Unix time, only when the records differ from those last written.
|
||||
CREATE OR REPLACE FUNCTION dns_secondaries_list()
|
||||
RETURNS TABLE (domain text, nameserver text, address text)
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT domain, nameserver, host(address) FROM dns_secondaries ORDER BY domain, nameserver;
|
||||
$$;
|
||||
|
||||
-- Add the secondary of the domain, or set its address.
|
||||
CREATE OR REPLACE PROCEDURE dns_secondary_put(p_domain text, p_nameserver text, p_address inet)
|
||||
LANGUAGE sql AS $$
|
||||
INSERT INTO dns_secondaries VALUES (p_domain, p_nameserver, p_address)
|
||||
ON CONFLICT (domain, nameserver) DO UPDATE SET address = p_address;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE PROCEDURE dns_secondary_remove(p_domain text, p_nameserver text)
|
||||
LANGUAGE sql AS $$
|
||||
DELETE FROM dns_secondaries WHERE domain = p_domain AND nameserver = p_nameserver;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION dns_records_list()
|
||||
RETURNS TABLE (domain text, name text, type text, content text, ttl integer)
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT domain, name, type, content, ttl FROM dns_records ORDER BY domain, name, type, content;
|
||||
$$;
|
||||
|
||||
-- Add the record to the domain, or set its TTL.
|
||||
CREATE OR REPLACE PROCEDURE dns_record_put(p_domain text, p_name text, p_type text, p_content text, p_ttl integer)
|
||||
LANGUAGE sql AS $$
|
||||
INSERT INTO dns_records VALUES (p_domain, p_name, p_type, p_content, p_ttl)
|
||||
ON CONFLICT (domain, name, type, content) DO UPDATE SET ttl = p_ttl;
|
||||
$$;
|
||||
|
||||
-- Remove the records of the name and type in the domain, all of them without content.
|
||||
CREATE OR REPLACE PROCEDURE dns_record_remove(p_domain text, p_name text, p_type text, p_content text)
|
||||
LANGUAGE sql AS $$
|
||||
DELETE FROM dns_records
|
||||
WHERE domain = p_domain AND name = p_name AND type = p_type AND (p_content IS NULL OR content = p_content);
|
||||
$$;
|
||||
|
||||
-- Serve the zone as its primary and give it the records, its SOA first, whose content has {serial} for the serial,
|
||||
-- and the addresses of its secondaries, which are notified of its changes and may transfer it. The serial grows, to
|
||||
-- at least the current Unix time, only when the records differ from those last written.
|
||||
CREATE OR REPLACE PROCEDURE dns_apply(p_zone text, p_soa text, p_ttl integer, p_names text[], p_types text[],
|
||||
p_contents text[], p_ttls integer[])
|
||||
p_contents text[], p_ttls integer[], p_secondaries text[])
|
||||
LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
v_domain integer;
|
||||
@@ -50,12 +108,17 @@ DECLARE
|
||||
v_digest text := md5(concat_ws('|', p_soa, p_ttl, array_to_string(p_names, ','), array_to_string(p_types, ','),
|
||||
array_to_string(p_contents, ','), array_to_string(p_ttls, ',')));
|
||||
BEGIN
|
||||
DELETE FROM domains WHERE name <> p_zone;
|
||||
DELETE FROM dns_state WHERE zone <> p_zone;
|
||||
SELECT id INTO v_domain FROM domains WHERE name = p_zone;
|
||||
IF v_domain IS NULL THEN
|
||||
INSERT INTO domains (name, type) VALUES (p_zone, 'NATIVE') RETURNING id INTO v_domain;
|
||||
ELSIF EXISTS (SELECT 1 FROM dns_state WHERE zone = p_zone AND digest = v_digest) THEN
|
||||
INSERT INTO domains (name, type) VALUES (p_zone, 'MASTER') RETURNING id INTO v_domain;
|
||||
ELSE
|
||||
UPDATE domains SET type = 'MASTER' WHERE id = v_domain AND type <> 'MASTER';
|
||||
END IF;
|
||||
DELETE FROM domainmetadata WHERE domain_id = v_domain AND kind IN ('ALSO-NOTIFY', 'ALLOW-AXFR-FROM');
|
||||
INSERT INTO domainmetadata (domain_id, kind, content)
|
||||
SELECT v_domain, kind, address FROM unnest(p_secondaries) AS address,
|
||||
unnest(ARRAY['ALSO-NOTIFY', 'ALLOW-AXFR-FROM']) AS kind;
|
||||
IF EXISTS (SELECT 1 FROM dns_state WHERE zone = p_zone AND digest = v_digest) THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
SELECT greatest(coalesce(max(serial) + 1, 1), extract(epoch FROM now())::bigint) INTO v_serial
|
||||
@@ -71,11 +134,11 @@ BEGIN
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Serve no zone.
|
||||
CREATE OR REPLACE PROCEDURE dns_clear()
|
||||
-- Serve only the zones of the list, none for an empty one.
|
||||
CREATE OR REPLACE PROCEDURE dns_retain(p_zones text[])
|
||||
LANGUAGE sql AS $$
|
||||
DELETE FROM domains;
|
||||
DELETE FROM dns_state;
|
||||
DELETE FROM domains WHERE name <> ALL (p_zones);
|
||||
DELETE FROM dns_state WHERE zone <> ALL (p_zones);
|
||||
$$;
|
||||
|
||||
-- The records of the zone as they are served.
|
||||
|
||||
Reference in new issue
Block a user