feature: the name server's domains have wan.local_fqdn and dns.secondary as their name servers, and a site with dns router gets records of the router's public, internal or external address.
Release / release (push) Successful in 35s

This commit is contained in:
slawek committed 2026-11-06 19:06:48 +01:00
1 parent c2c33cfca0
commit 7f85a1a2e5
16 files changed
+497 -304

No files matched your search

+23 -3
View File
@@ -820,13 +820,33 @@ def manage_webdav(ctx):
run(['systemctl', 'enable', '--now', WEBDAV_UNIT], check=False)
def site_addresses(cfg, ctx):
"""The pairs of a host name and the router's address of dns_address of each enabled site with dns router: public,
wan.public_address; internal, the internal interface's; external, the external interface's first. A site whose
address is not known gets none, with a warning."""
addresses = {'public': cfg['wan']['public_address'].strip(), 'internal': ctx['int_addr'],
'external': next(iter(ctx['local_addr_list']), '')}
pairs = []
for site in ctx['sites']:
if site['dns'] != 'router':
continue
address = addresses[site['dns_address']]
if not address:
warn(f'site {site["name"]}: the router has no {site["dns_address"]} address, so its host names get no '
'records')
continue
pairs += [(host, address) for host in site['hosts']]
return pairs
def manage_dns(cfg, ctx):
"""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 domains
with site records get the host names of the enabled sites, and keep those they have while the sites cannot be read.
installation starts answers on those addresses only, beside systemd-resolved's stub on 127.0.0.53. The host names
of the enabled sites with dns router get records of the router's address they choose (site_addresses), and the
domains keep those they have while the sites cannot be read.
"""
if not vpnrouter.dns_ready(ctx):
if unit_is_active(vpnrouter_dns.UNIT):
@@ -854,7 +874,7 @@ def manage_dns(cfg, ctx):
os.chown(vpnrouter_dns.PDNS_CONF, 0, grp.getgrnam(vpnrouter_dns.GROUP).gr_gid)
vpnrouter_dns.provision()
if ctx['sites'] is not None:
vpnrouter_dns.apply(cfg, [host for site in ctx['sites'] for host in site['hosts']])
vpnrouter_dns.apply(cfg, site_addresses(cfg, ctx))
except (OSError, KeyError, vpnrouter_dns.DatabaseError) as exc:
warn(f'the name server cannot be set up: {exc}')
return
@@ -55,7 +55,7 @@ OPENVPN_LDAP = pathlib.Path('/etc/openvpn/server/vpn-router-ldap.conf')
SCHEMA = {
'general': {'platform': 'none', 'mode': 'manual'},
'interfaces': {'external': '', 'internal': ''},
'wan': {'local_fqdn': ''},
'wan': {'local_fqdn': '', 'public_address': ''},
'local': {'cidrs': [], 'int_addr': '', 'int_gateway_ip': ''},
'site_to_site': {},
'p2s': {'enabled': False, 'address_pool': '', 'dns_listen': 'internal', 'dns_domains': []},
@@ -70,7 +70,7 @@ SCHEMA = {
'acme_staging': False},
'oidc': {'enabled': False},
'sites': {'enabled': False, 'upload_max_mb': 512, 'webdav': False, 'webdav_port': 7445},
'dns': {'enabled': False, 'nameserver': '', 'addresses': [], 'listen': 'external', 'ttl': 300},
'dns': {'enabled': False, 'secondary': '', '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': {},
@@ -144,10 +144,14 @@ WEBDAV_LOOPBACK_PORT = 7446
#: The largest sites.upload_max_mb, 10 GiB.
UPLOAD_MAX_MB = 10240
#: Settings that no longer exist, section -> options, left out when the file is read so that a file of an earlier
#: version loads: the name server is wan.local_fqdn and a site chooses the address of its records.
RETIRED = {'dns': ('nameserver', 'addresses')}
#: The shortest and the longest dns.ttl, in seconds.
DNS_TTL_RANGE = (60, 86400)
#: A DNS name without a wildcard, in lowercase, of dns.nameserver and the domains of the name server.
#: A DNS name without a wildcard, in lowercase, of the name servers 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.
@@ -300,6 +304,9 @@ def read_settings(path=None):
for section, options in data.items():
if not isinstance(options, dict):
raise ConfigError(f'cannot parse {path}: {section} is not an object')
for section, options in RETIRED.items():
for option in options:
data.get(section, {}).pop(option, None)
return data
@@ -581,14 +588,17 @@ def validate(cfg):
raise ConfigError('sites.webdav: WebDAV is served by Caddy at https://<wan.local_fqdn>:<sites.webdav_port>, '
'which needs https.enabled and wan.local_fqdn')
_check_address(_get(cfg, 'wan', 'public_address'), 'wan.public_address')
dns = cfg['dns']
if dns['enabled']:
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 name server and the sites')
for item in _get_list(cfg, 'dns', 'addresses'):
_check_address(item, 'dns.addresses')
if dns['enabled'] and not (ZONE_NAME.match(_get(cfg, 'wan', 'local_fqdn'))
and not re.search(r'\.[0-9]+$', _get(cfg, 'wan', 'local_fqdn'))):
raise ConfigError('dns.enabled needs wan.local_fqdn, a DNS name in lowercase: the primary name server of the '
'domains')
secondary = _get(cfg, 'dns', 'secondary')
if secondary and not ZONE_NAME.match(secondary):
raise ConfigError(f'dns.secondary: {secondary!r} is not a DNS name in lowercase')
if secondary and secondary == _get(cfg, 'wan', 'local_fqdn'):
raise ConfigError('dns.secondary: the secondary name server is not wan.local_fqdn, the primary')
if dns['listen'] not in LISTEN_CHOICES:
raise ConfigError(f'dns.listen: {dns["listen"]!r} is not one of {", ".join(LISTEN_CHOICES)}')
if not DNS_TTL_RANGE[0] <= dns['ttl'] <= DNS_TTL_RANGE[1]:
@@ -1,17 +1,17 @@
"""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.
domains 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.
apply() writes every domain as a primary zone: its SOA with wan.local_fqdn as the primary name server, its NS,
wan.local_fqdn and dns.secondary, the NS of the domains directly below it, an A or AAAA record for every host name of
an enabled site with dns router whose closest domain it is, with the router's address the site chooses, and the manual
records, but those of a name and type the records before them have. wan.local_fqdn gets no record in the zones: its
record is kept where its own domain is. The addresses dns.secondary resolves to are notified of every change
(ALSO-NOTIFY) and may transfer the zones (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.
"""
@@ -20,6 +20,7 @@ import contextlib
import ipaddress
import pathlib
import re
import socket
import psycopg
from psycopg.rows import dict_row
@@ -44,9 +45,8 @@ 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.
#: The most domains, 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.
@@ -73,8 +73,7 @@ class DatabaseError(Exception):
class Failure(Exception):
"""A request that cannot be done; status is 2 invalid, 3 no such domain, secondary or record, 4 the domain
exists."""
"""A request that cannot be done; status is 2 invalid, 3 no such domain or record, 4 the domain exists."""
def __init__(self, message, status=2):
super().__init__(message)
@@ -211,13 +210,10 @@ def domain(connection, name):
def listing():
"""Every domain with its secondaries, its manual records and the records the name server answers for it."""
"""Every domain with 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()}
@@ -225,71 +221,52 @@ def listing():
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'))
"""Add the domain {name, ttl?}; the domain."""
if not isinstance(body, dict) or not set(body) <= {'name', 'ttl'}:
raise Failure('a domain is an object of name and ttl')
name, ttl = check_domain(body.get('name')), 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}
connection.execute('CALL dns_domain_put(%s, %s)', (name, ttl))
return {'name': name, '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')
"""Change the ttl of the domain; the domain."""
if not isinstance(body, dict) or not set(body) <= {'ttl'}:
raise Failure('a change of a domain is an object of 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}
ttl = check_ttl(body.get('ttl', row['ttl']))
connection.execute('CALL dns_domain_put(%s, %s)', (name, ttl))
return {'name': name, 'ttl': ttl}
def domain_remove(name):
"""Remove the domain with its secondaries and manual records."""
"""Remove the domain with its 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('.')
def secondary_addresses(cfg):
"""The addresses dns.secondary resolves to, which are notified of the zones' changes and may transfer them; none,
with a warning, while it is not set or does not resolve."""
secondary = cfg['dns']['secondary'].strip()
if not secondary:
vpnrouter.warn('dns.secondary is not set: the domains have no secondary name server')
return []
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}
found = socket.getaddrinfo(secondary, 53, proto=socket.IPPROTO_UDP)
except OSError as exc:
vpnrouter.warn(f'dns.secondary {secondary} does not resolve, so it may not transfer the domains: {exc}')
return []
return list(dict.fromkeys(str(ipaddress.ip_address(item[4][0])) for item in found))
def add(cfg, name, record):
@@ -332,25 +309,20 @@ def remove(name, record_name, kind, content):
return {'name': full, 'type': kind, 'removed': len(found)}
def records(cfg, row, zones, secondaries, hosts, manual_records):
def records(cfg, row, zones, 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."""
domains directly below it, wan.local_fqdn and dns.secondary, the A or AAAA record of each host name of hosts, pairs
of a host name and an address, whose closest domain it is, and its manual records."""
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()]
zone, ttl = row['name'], row['ttl'] or dns['ttl']
servers = [cfg['wan']['local_fqdn'].strip(), *([dns['secondary'].strip()] if dns['secondary'].strip() else [])]
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))
found = [(name, 'NS', server, ttl) for name in [zone, *children] for server in servers]
for host, address in hosts:
if closest(zones, host) == zone:
address = ipaddress.ip_address(address)
found.append((host, '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]
@@ -358,7 +330,8 @@ def records(cfg, row, zones, secondaries, hosts, manual_records):
def apply(cfg, hosts):
"""Make the name server the primary of every domain with records(), and of none while dns.enabled is false."""
"""Make the name server the primary of every domain with records(), hosts being the pairs of a host name and an
address of the sites with dns router, and of none while dns.enabled is false."""
with database() as connection:
if not cfg['dns']['enabled']:
connection.execute('CALL dns_retain(%s::text[])', ([],))
@@ -366,20 +339,21 @@ def apply(cfg, hosts):
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 host, _ in hosts:
if closest(zones, host) is None:
vpnrouter.warn(f'{host} is in none of the domains, so the name server has no record of it')
manual = {}
for item in connection.execute('SELECT * FROM dns_records_list()').fetchall():
manual.setdefault(item['domain'], []).append(item)
transfers = secondary_addresses(cfg) if domains else []
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}'
rows = records(cfg, row, zones, hosts, manual.get(zone, []))
soa = f'{cfg["wan"]["local_fqdn"].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, [])]))
[item[2] for item in rows], [item[3] for item in rows], transfers))
connection.execute('CALL dns_retain(%s::text[])', (zones,))
@@ -7,7 +7,11 @@ Caddy answers on its port (hosts, the names TLS clients send as SNI; none answer
firewall opens the port (listen), and TLS: none (plain HTTP), acme (a certificate of Let's Encrypt), ca (certificates
Caddy issues with one of pki.issuing_cas) or file (a certificate and key given with the site). Caddy serves the files of
the folder, index_file for a folder, and with spa index_file of the site for any path without a file. editors names
the group whose members change the site's files besides the admins, through the web service and WebDAV.
the group whose members change the site's files besides the admins, through the web service and WebDAV. dns says who
serves the records of its host names: user, a DNS provider outside the router, or router, the router's name server,
which gives each of them in the closest of its domains an A or AAAA record of the router's address of dns_address:
public (wan.public_address), internal or external. A site may use wan.local_fqdn, on a port of its own; its record
stays where the domain of wan.local_fqdn is.
The sites are served while sites.enabled is true; vpn-router-setup writes Caddy's configuration and opens the ports. A
site is changed only through this module, which checks it (check), and its callers then restart vpn-router-setup.
@@ -55,6 +59,11 @@ INDEX = re.compile(r'^[A-Za-z0-9_-][A-Za-z0-9._-]{0,63}$')
LISTEN = vpnrouter.LISTEN_CHOICES
TLS = ('none', 'acme', 'ca', 'file')
#: Who serves the records of a site's host names: user, elsewhere, or router, the router's name server, with the
#: router's address of DNS_ADDRESSES.
DNS = ('user', 'router')
DNS_ADDRESSES = ('public', 'internal', 'external')
#: The most host names of one site.
HOSTS_MAX = 16
@@ -66,7 +75,8 @@ CHUNK = 65536
#: The settings of a site and their defaults; the type of a default is the required type.
SETTINGS = {'enabled': True, 'hosts': [], 'port': 443, 'listen': 'external', 'tls': 'acme', 'ca': '', 'certificate': '',
'private_key': '', 'index_file': 'index.html', 'spa': False, 'editors': ''}
'private_key': '', 'index_file': 'index.html', 'spa': False, 'editors': '', 'dns': 'user',
'dns_address': 'public'}
#: Whether this process has loaded SITES_SQL.
_loaded = False
@@ -215,8 +225,17 @@ def check(cfg, sites, site):
for host in hosts:
if not (vpnrouter_pki.DNS_NAME.match(host) or vpnrouter_pki.is_address(host)):
raise Failure(f'hosts: {host!r} is not a host name in lowercase or an IP address', 2)
if host == cfg['wan']['local_fqdn'].strip():
raise Failure(f'hosts: {host} is wan.local_fqdn, the name of the router\'s own services', 2)
if site['dns'] not in DNS:
raise Failure(f'dns: {site["dns"]!r} is not one of {", ".join(DNS)}', 2)
if site['dns_address'] not in DNS_ADDRESSES:
raise Failure(f'dns_address: {site["dns_address"]!r} is not one of {", ".join(DNS_ADDRESSES)}', 2)
if site['dns'] == 'router':
local_fqdn = cfg['wan']['local_fqdn'].strip()
if any(vpnrouter_pki.is_address(host) or host.startswith('*.') or host == local_fqdn for host in hosts):
raise Failure('dns router takes host names only, without wildcards and other than wan.local_fqdn, whose '
'record is kept where its domain is', 2)
if site['dns_address'] == 'public' and not cfg['wan']['public_address'].strip():
raise Failure('dns_address public needs wan.public_address', 2)
port = site['port']
if not 1 <= port <= 65535:
raise Failure(f'port: {port} is not a port', 2)
@@ -285,7 +304,7 @@ def save(cfg, name, values, create):
if site['tls'] != 'ca':
site['ca'] = ''
check(cfg, rows, site)
connection.execute('CALL site_put(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)',
connection.execute(f'CALL site_put({", ".join(["%s"] * (len(SETTINGS) + 1))})',
tuple(site[key] for key in ('name', *SETTINGS)))
ensure_folders([name])
return shown(get(connection, name))
@@ -55,7 +55,7 @@ Pages:
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 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())
remove, each with 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
@@ -10,22 +10,20 @@ from .common import section_change, unit_state
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", "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/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
GET /dns {"settings", "nameserver", "public_address", "installed", "running", "domains",
"problem"}: the dns section of the configuration, wan.local_fqdn, the primary name server
of the domains, wan.public_address, the address of the records of the sites with
dns_address public, whether PowerDNS is installed and runs, the domains, each {"name",
"ttl", "manual", "records"} with 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, and with "public_address"
wan.public_address; {"settings", "public_address"}
POST /dns/domains add a domain: {"name", "ttl"?}; ttl is dns.ttl when null or absent; an existing name is
409
PUT /dns/domains/<domain>
change "sites" or "ttl" of the domain
change "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
remove the domain with its manual records
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
@@ -47,22 +45,27 @@ def dns(method, path, query, body):
domains = vpnrouter_dns.listing()
except vpnrouter_dns.DatabaseError as exc:
problem = str(exc)
return 200, {'settings': cfg['dns'], 'installed': vpnrouter_dns.PDNS_SERVER.exists(),
return 200, {'settings': cfg['dns'], 'nameserver': cfg['wan']['local_fqdn'].strip(),
'public_address': cfg['wan']['public_address'].strip(),
'installed': vpnrouter_dns.PDNS_SERVER.exists(),
'running': unit_state(vpnrouter_dns.UNIT) == 'active', 'domains': domains,
'problem': problem}
if route == ('PUT', 'dns'):
wan = cfg['wan']
if 'public_address' in body:
wan = section_change('wan', {'public_address': body.pop('public_address')})
if isinstance(wan, tuple):
return wan
changed = section_change('dns', body)
return changed if isinstance(changed, tuple) else (200, {'settings': changed})
if isinstance(changed, tuple):
return changed
return 200, {'settings': changed, 'public_address': wan['public_address'].strip()}
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:
@@ -25,7 +25,8 @@ def sites(method, path, query, body):
settings but private_key, with private_key_set, modified and its folder
PUT /sites change any of the settings of the sites section
POST /sites add a site: {"name", and any of "enabled", "hosts", "port", "listen", "tls", "ca",
"certificate", "private_key", "index_file", "spa", "editors"}; an existing name is 409
"certificate", "private_key", "index_file", "spa", "editors", "dns",
"dns_address"}; an existing name is 409
GET /sites/<name> the site
PUT /sites/<name> change any of the settings of POST but name; an empty or absent private_key keeps the key
DELETE /sites/<name> remove the site, with content=true also its folder
+13 -5
View File
@@ -3,7 +3,9 @@
A site has a name, which also names its folder in /var/lib/vpn-router/sites, host names, a port, the interfaces whose
firewall opens it, TLS (none, acme, ca with --ca, or file with --certificate and --key), the file served for a folder,
whether any path without a file is served that file of the site (--spa), and the group whose members change its files.
whether any path without a file is served that file of the site (--spa), the group whose members change its files, and
who serves the records of its host names (--dns user, elsewhere, or router, the router's name server, with the router's
address of --dns-address).
A change is applied by restarting vpn-router-setup without waiting for it. The answer is text for people on standard
output, and a failure is a message on standard error. With --json the answer is JSON on standard output, and a failure
is {"error": ...} there too. The exit status says what failed: 2 the request is invalid, 3 the site does not exist, 4
@@ -17,7 +19,8 @@ the site exists, 1 anything else. -h or --help shows the commands and their opti
options: [--host H ...] [--no-hosts] [--port P] [--listen internal|external|both] [--tls none|acme|ca|file]
[--ca NAME] [--certificate FILE] [--key FILE] [--index FILE] [--spa | --no-spa]
[--enabled | --no-enabled] [--editors GROUP]
[--enabled | --no-enabled] [--editors GROUP] [--dns user|router]
[--dns-address public|internal|external]
"""
import argparse
@@ -73,6 +76,9 @@ def parse(argv):
'path without a file')
sub.add_argument('--enabled', action=argparse.BooleanOptionalAction, help='serve the site')
sub.add_argument('--editors', metavar='GROUP', help="the group whose members change the files; '' for none")
sub.add_argument('--dns', choices=vpnrouter_sites.DNS, help='who serves the records of the host names')
sub.add_argument('--dns-address', choices=vpnrouter_sites.DNS_ADDRESSES,
help="the router's address in the records of dns router")
if command == 'remove':
sub.add_argument('--content', action='store_true', help='remove the site\'s folder too')
return parser.parse_args(argv)
@@ -82,7 +88,8 @@ def values_of(options):
"""The settings the options give."""
values = {key: getattr(options, option) for key, option in (('port', 'port'), ('listen', 'listen'), ('tls', 'tls'),
('ca', 'ca'), ('index_file', 'index'), ('spa', 'spa'),
('enabled', 'enabled'), ('editors', 'editors'))
('enabled', 'enabled'), ('editors', 'editors'),
('dns', 'dns'), ('dns_address', 'dns_address'))
if getattr(options, option) is not None}
if options.no_hosts:
values['hosts'] = []
@@ -120,9 +127,10 @@ def text_of(answer):
sites = answer['sites'] if 'sites' in answer else [answer]
if not sites:
return 'no sites'
head = ('SITE', 'ENABLED', 'HOSTS', 'PORT', 'LISTEN', 'TLS', 'EDITORS')
head = ('SITE', 'ENABLED', 'HOSTS', 'PORT', 'LISTEN', 'TLS', 'EDITORS', 'DNS')
rows = [(site['name'], 'yes' if site['enabled'] else 'no', ', '.join(site['hosts']) or '*', str(site['port']),
site['listen'], site['tls'] + (f' {site["ca"]}' if site['tls'] == 'ca' else ''), site['editors'] or '-')
site['listen'], site['tls'] + (f' {site["ca"]}' if site['tls'] == 'ca' else ''), site['editors'] or '-',
site['dns'] + (f' {site["dns_address"]}' if site['dns'] == 'router' else ''))
for site in sites]
widths = [max(len(row[column]) for row in (head, *rows)) for column in range(len(head))]
return '\n'.join(' '.join(cell.ljust(width) for cell, width in zip(row, widths)).rstrip() for row in (head, *rows))
@@ -64,6 +64,14 @@ _vpn_router_site()
COMPREPLY=($(compgen -W "$(_vpn_router_site_groups)" -- "$cur"))
return
;;
--dns)
COMPREPLY=($(compgen -W 'user router' -- "$cur"))
return
;;
--dns-address)
COMPREPLY=($(compgen -W 'public internal external' -- "$cur"))
return
;;
--certificate | --key)
_filedir
return
@@ -78,7 +86,7 @@ _vpn_router_site()
local options='--help'
case $command in
add | update) options+=' --host --no-hosts --port --listen --tls --ca --certificate --key --index --spa
--no-spa --enabled --no-enabled --editors' ;;
--no-spa --enabled --no-enabled --editors --dns --dns-address' ;;
remove) options+=' --content' ;;
esac
COMPREPLY=($(compgen -W "$options" -- "$cur"))
@@ -8,7 +8,8 @@
"internal": "eth1"
},
"wan": {
"local_fqdn": "router.example.com"
"local_fqdn": "router.example.com",
"public_address": ""
},
"local": {
"cidrs": ["10.0.0.0/24"],
@@ -89,8 +90,7 @@
},
"dns": {
"enabled": false,
"nameserver": "",
"addresses": [],
"secondary": "",
"listen": "external",
"ttl": 300
},
@@ -6,21 +6,21 @@ SELECT pg_advisory_xact_lock(hashtext('vpn-router dns schema'));
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.
-- The secondaries of each domain and the domain's switch of site records: the secondary is dns.secondary, the same
-- for every domain, and each site says whether the router serves its records.
DROP PROCEDURE IF EXISTS dns_secondary_put(text, text, inet);
DROP PROCEDURE IF EXISTS dns_secondary_remove(text, text);
DROP FUNCTION IF EXISTS dns_secondaries_list();
DROP TABLE IF EXISTS dns_secondaries;
DROP FUNCTION IF EXISTS dns_domains_list();
DROP PROCEDURE IF EXISTS dns_domain_put(text, boolean, integer);
-- A domain the router serves as its primary name server; 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)
);
ALTER TABLE dns_domains DROP COLUMN IF EXISTS sites;
-- 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 (
@@ -39,43 +39,25 @@ CREATE TABLE IF NOT EXISTS dns_state (
serial bigint NOT NULL
);
CREATE OR REPLACE FUNCTION dns_domains_list()
RETURNS TABLE (name text, sites boolean, ttl integer)
CREATE FUNCTION dns_domains_list()
RETURNS TABLE (name text, ttl integer)
LANGUAGE sql STABLE AS $$
SELECT name, sites, ttl FROM dns_domains ORDER BY name;
SELECT name, ttl FROM dns_domains ORDER BY name;
$$;
-- 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)
-- Add the domain, or set its TTL.
CREATE PROCEDURE dns_domain_put(p_name text, p_ttl integer)
LANGUAGE sql AS $$
INSERT INTO dns_domains VALUES (p_name, p_sites, p_ttl)
ON CONFLICT (name) DO UPDATE SET sites = p_sites, ttl = p_ttl;
INSERT INTO dns_domains (name, ttl) VALUES (p_name, p_ttl)
ON CONFLICT (name) DO UPDATE SET ttl = p_ttl;
$$;
-- Remove the domain with its secondaries and records.
-- Remove the domain with its records.
CREATE OR REPLACE PROCEDURE dns_domain_remove(p_name text)
LANGUAGE sql AS $$
DELETE FROM dns_domains WHERE name = p_name;
$$;
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 $$
@@ -97,7 +79,7 @@ LANGUAGE sql AS $$
$$;
-- 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
-- and the addresses of its secondary, 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_secondaries text[])
@@ -3,7 +3,9 @@
SELECT pg_advisory_xact_lock(hashtext('vpn-router sites schema'));
-- A static web site: its host names, port, interfaces and TLS, and the group whose members change its files.
-- A static web site: its host names, port, interfaces and TLS, the group whose members change its files, and its
-- DNS: user, the router serves no records of its host names, or router, its name server serves A or AAAA records of
-- them with the router's public, internal or external address (dns_address).
CREATE TABLE IF NOT EXISTS sites (
name text PRIMARY KEY,
enabled boolean NOT NULL,
@@ -19,44 +21,60 @@ CREATE TABLE IF NOT EXISTS sites (
editors text NOT NULL,
modified timestamptz NOT NULL
);
ALTER TABLE sites ADD COLUMN IF NOT EXISTS dns text NOT NULL DEFAULT 'user' CHECK (dns IN ('user', 'router'));
ALTER TABLE sites ADD COLUMN IF NOT EXISTS dns_address text NOT NULL DEFAULT 'public'
CHECK (dns_address IN ('public', 'internal', 'external'));
-- The functions whose columns and the procedure whose arguments changed with dns and dns_address.
DROP FUNCTION IF EXISTS sites_list();
DROP FUNCTION IF EXISTS site_get(text);
DROP FUNCTION IF EXISTS sites_served();
DROP PROCEDURE IF EXISTS site_put(text, boolean, text[], integer, text, text, text, text, text, text, boolean, text);
-- Every site, by name; the private key is left out.
CREATE OR REPLACE FUNCTION sites_list()
CREATE FUNCTION sites_list()
RETURNS TABLE (name text, enabled boolean, hosts text[], port integer, listen text, tls text, ca text,
certificate text, index_file text, spa boolean, editors text, modified timestamptz)
certificate text, index_file text, spa boolean, editors text, dns text, dns_address text,
modified timestamptz)
LANGUAGE sql STABLE AS $$
SELECT name, enabled, hosts, port, listen, tls, ca, certificate, index_file, spa, editors, modified
SELECT name, enabled, hosts, port, listen, tls, ca, certificate, index_file, spa, editors, dns, dns_address, modified
FROM sites ORDER BY name;
$$;
-- The site with its private key.
CREATE OR REPLACE FUNCTION site_get(p_name text)
CREATE FUNCTION site_get(p_name text)
RETURNS TABLE (name text, enabled boolean, hosts text[], port integer, listen text, tls text, ca text,
certificate text, private_key text, index_file text, spa boolean, editors text, modified timestamptz)
certificate text, private_key text, index_file text, spa boolean, editors text, dns text,
dns_address text, modified timestamptz)
LANGUAGE sql STABLE AS $$
SELECT name, enabled, hosts, port, listen, tls, ca, certificate, private_key, index_file, spa, editors, modified
SELECT name, enabled, hosts, port, listen, tls, ca, certificate, private_key, index_file, spa, editors, dns,
dns_address, modified
FROM sites WHERE name = p_name;
$$;
-- The enabled sites with their private keys, by name, for Caddy's configuration and certificate files.
CREATE OR REPLACE FUNCTION sites_served()
CREATE FUNCTION sites_served()
RETURNS TABLE (name text, enabled boolean, hosts text[], port integer, listen text, tls text, ca text,
certificate text, private_key text, index_file text, spa boolean, editors text, modified timestamptz)
certificate text, private_key text, index_file text, spa boolean, editors text, dns text,
dns_address text, modified timestamptz)
LANGUAGE sql STABLE AS $$
SELECT name, enabled, hosts, port, listen, tls, ca, certificate, private_key, index_file, spa, editors, modified
SELECT name, enabled, hosts, port, listen, tls, ca, certificate, private_key, index_file, spa, editors, dns,
dns_address, modified
FROM sites WHERE enabled ORDER BY name;
$$;
-- Add the site, or replace all its settings.
CREATE OR REPLACE PROCEDURE site_put(p_name text, p_enabled boolean, p_hosts text[], p_port integer, p_listen text,
p_tls text, p_ca text, p_certificate text, p_private_key text, p_index_file text,
p_spa boolean, p_editors text)
CREATE PROCEDURE site_put(p_name text, p_enabled boolean, p_hosts text[], p_port integer, p_listen text, p_tls text,
p_ca text, p_certificate text, p_private_key text, p_index_file text, p_spa boolean,
p_editors text, p_dns text, p_dns_address text)
LANGUAGE sql AS $$
INSERT INTO sites VALUES (p_name, p_enabled, p_hosts, p_port, p_listen, p_tls, p_ca, p_certificate, p_private_key,
p_index_file, p_spa, p_editors, now())
INSERT INTO sites (name, enabled, hosts, port, listen, tls, ca, certificate, private_key, index_file, spa, editors,
dns, dns_address, modified)
VALUES (p_name, p_enabled, p_hosts, p_port, p_listen, p_tls, p_ca, p_certificate, p_private_key, p_index_file,
p_spa, p_editors, p_dns, p_dns_address, now())
ON CONFLICT (name) DO UPDATE SET enabled = p_enabled, hosts = p_hosts, port = p_port, listen = p_listen,
tls = p_tls, ca = p_ca, certificate = p_certificate, private_key = p_private_key, index_file = p_index_file,
spa = p_spa, editors = p_editors, modified = now();
spa = p_spa, editors = p_editors, dns = p_dns, dns_address = p_dns_address, modified = now();
$$;
CREATE OR REPLACE PROCEDURE site_remove(p_name text)