feature: site_to_site holds any number of named site-to-site connections, which /site-to-site lists, adds, changes and removes.
Release / release (push) Successful in 12s
Release / release (push) Successful in 12s
This commit is contained in:
1 parent
a79510a02f
commit
4a5b15a559
22 files changed
+843
-351
No files matched your search
@@ -1,3 +1,43 @@
|
||||
vpn-router (0.18.0-1) unstable; urgency=medium
|
||||
|
||||
feature: site_to_site holds any number of named site-to-site connections, which /site-to-site lists, adds, changes and removes.
|
||||
|
||||
* site_to_site maps a connection name to addrs, id, cidrs, local_cidrs,
|
||||
local_id_mode, psk and psk_file; it replaces remote and
|
||||
wan.local_id_mode, and a configuration with them is refused.
|
||||
* The install answers fill the connection remote-site; the local subnets
|
||||
fill both its local_cidrs and local.cidrs.
|
||||
* Every ready connection is written to
|
||||
/etc/swanctl/conf.d/site-to-site.conf as the swanctl connection and
|
||||
child of its name with the secret ike-psk-<name>; remote-site.conf is
|
||||
no longer written.
|
||||
* Traffic from each connection's local_cidrs to its cidrs is not
|
||||
masqueraded.
|
||||
* /site-to-site, for members of admins and admin tokens, lists the
|
||||
connections without their keys, adds, changes and removes them, and
|
||||
restarts vpn-router-setup to apply the change.
|
||||
* vpn-config-edit sets site_to_site.NAME.option, creating the
|
||||
connection, and removes site_to_site.NAME; it and the web service
|
||||
write vpn-router.json under one lock.
|
||||
* The status shows site_to_site.connections, whether each connection is
|
||||
established.
|
||||
* The metrics service keeps, per site-to-site connection, whether it is
|
||||
up, the bytes and packets per second received and sent, and the
|
||||
seconds its IKE SA has been up, in metrics_site_to_site and its
|
||||
summaries; /metrics/history has them in site_to_site of each point.
|
||||
* /metrics serves vpn_router_site_to_site_up,
|
||||
vpn_router_site_to_site_uptime_seconds,
|
||||
vpn_router_site_to_site_io_bytes_per_second and
|
||||
vpn_router_site_to_site_packets_per_second by connection, and the
|
||||
OTLP push the vpn_router.site_to_site gauges; vpn_router_clients and
|
||||
metrics_vpn no longer have the kind site_to_site.
|
||||
* /config shows each connection in network.site_to_site, and no psk at
|
||||
any level of the configuration.
|
||||
* Removing the package terminates every site-to-site connection; purging
|
||||
removes site-to-site.conf.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sun, 25 Oct 2026 23:00:00 +0100
|
||||
|
||||
vpn-router (0.17.0-1) unstable; urgency=medium
|
||||
|
||||
feature: /config shows the router's PostgreSQL clusters and their settings, and /postgresql their databases with their sizes.
|
||||
|
||||
@@ -43,7 +43,7 @@ case "$1" in
|
||||
ucfr --purge vpn-router /etc/vpn-router/vpn-router.json
|
||||
fi
|
||||
|
||||
rm -f /etc/swanctl/conf.d/remote-site.conf \
|
||||
rm -f /etc/swanctl/conf.d/site-to-site.conf \
|
||||
/etc/swanctl/conf.d/road-warrior.conf \
|
||||
/etc/systemd/resolved.conf.d/p2s-forwarder.conf \
|
||||
/etc/wireguard/wg0.conf \
|
||||
|
||||
@@ -6,8 +6,13 @@ case "$1" in
|
||||
# Stop offering the tunnels, but leave strongSwan itself enabled: it is
|
||||
# a shared service and may be in use by something other than this
|
||||
# package. The connection files are removed on purge.
|
||||
# The site-to-site connections are the names of site_to_site in vpn-router.json.
|
||||
if [ -d /run/systemd/system ]; then
|
||||
swanctl --terminate --ike remote-site >/dev/null 2>&1 || true
|
||||
connections="$(python3 -c 'import json, sys; print(" ".join(json.load(open(sys.argv[1])).get("site_to_site", {})))' \
|
||||
/etc/vpn-router/vpn-router.json 2>/dev/null || true)"
|
||||
for connection in $connections; do
|
||||
swanctl --terminate --ike "$connection" >/dev/null 2>&1 || true
|
||||
done
|
||||
swanctl --terminate --ike road-warrior >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
|
||||
@@ -66,7 +66,8 @@ Type: select
|
||||
Choices: fqdn, public_ip, internal_ip
|
||||
Default: fqdn
|
||||
Description: IKE local identity mode:
|
||||
How to derive the IKE identity advertised to the remote site:
|
||||
How to derive the IKE identity the first site-to-site connection
|
||||
advertises to the remote site:
|
||||
.
|
||||
fqdn - use the FQDN, which must match what the peer expects.
|
||||
.
|
||||
@@ -78,8 +79,9 @@ Template: vpn-router/local_cidrs
|
||||
Type: string
|
||||
Default:
|
||||
Description: Local subnet CIDR(s):
|
||||
Comma-separated list of local subnet CIDRs to advertise into the
|
||||
site-to-site tunnel (for example 10.0.0.0/24 or 10.0.0.0/24,10.0.1.0/24).
|
||||
Comma-separated list of local subnet CIDRs routed by this router and
|
||||
advertised into the first site-to-site connection (for example
|
||||
10.0.0.0/24 or 10.0.0.0/24,10.0.1.0/24).
|
||||
|
||||
Template: vpn-router/int_gateway_ip
|
||||
Type: string
|
||||
@@ -94,25 +96,26 @@ Type: string
|
||||
Default:
|
||||
Description: Remote site WAN IP address(es):
|
||||
Comma-separated list of remote gateway addresses or FQDNs for the
|
||||
site-to-site IPSec tunnel.
|
||||
first site-to-site connection.
|
||||
|
||||
Template: vpn-router/remote_id
|
||||
Type: string
|
||||
Default:
|
||||
Description: Remote site IKE identity:
|
||||
IKE identity of the remote peer, without a leading @.
|
||||
IKE identity of the remote peer of the first site-to-site connection,
|
||||
without a leading @.
|
||||
|
||||
Template: vpn-router/remote_cidrs
|
||||
Type: string
|
||||
Default:
|
||||
Description: Remote subnet CIDR(s):
|
||||
Comma-separated list of remote subnet CIDRs reachable through the
|
||||
site-to-site tunnel (for example 192.168.0.0/24).
|
||||
first site-to-site connection (for example 192.168.0.0/24).
|
||||
|
||||
Template: vpn-router/psk
|
||||
Type: password
|
||||
Description: Pre-shared key (PSK):
|
||||
Pre-shared key for the site-to-site IKEv2 tunnel. Must match the value
|
||||
Pre-shared key of the first site-to-site connection. Must match the value
|
||||
configured on the remote peer. Stored in the configuration file and cleared
|
||||
from the debconf database after installation.
|
||||
|
||||
|
||||
@@ -18,7 +18,9 @@ on the next dpkg-reconfigure.
|
||||
|
||||
Answers arrive as VPN_ROUTER_* environment variables and are converted to the
|
||||
type of the setting they fill: comma-separated lists become arrays, "true" and
|
||||
"false" become booleans and digits become integers.
|
||||
"false" become booleans and digits become integers. The site-to-site answers
|
||||
fill the connection vpnrouter.FIRST_CONNECTION of site_to_site; the local
|
||||
subnets fill both its local_cidrs and local.cidrs.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -29,27 +31,29 @@ sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
|
||||
|
||||
import vpnrouter
|
||||
|
||||
# environment variable -> (section, option)
|
||||
FIRST = ('site_to_site', vpnrouter.FIRST_CONNECTION)
|
||||
|
||||
# environment variable -> the settings it fills, each (section, option) or (site_to_site, name, option)
|
||||
ANSWERS = {
|
||||
'VPN_ROUTER_PLATFORM': ('general', 'platform'),
|
||||
'VPN_ROUTER_MODE': ('general', 'mode'),
|
||||
'VPN_ROUTER_INT_ADDR': ('local', 'int_addr'),
|
||||
'VPN_ROUTER_EXTERNAL_INTERFACE': ('interfaces', 'external'),
|
||||
'VPN_ROUTER_INTERNAL_INTERFACE': ('interfaces', 'internal'),
|
||||
'VPN_ROUTER_LOCAL_FQDN': ('wan', 'local_fqdn'),
|
||||
'VPN_ROUTER_LOCAL_ID_MODE': ('wan', 'local_id_mode'),
|
||||
'VPN_ROUTER_LOCAL_CIDRS': ('local', 'cidrs'),
|
||||
'VPN_ROUTER_INT_GATEWAY_IP': ('local', 'int_gateway_ip'),
|
||||
'VPN_ROUTER_REMOTE_ADDRS': ('remote', 'addrs'),
|
||||
'VPN_ROUTER_REMOTE_ID': ('remote', 'id'),
|
||||
'VPN_ROUTER_REMOTE_CIDRS': ('remote', 'cidrs'),
|
||||
'VPN_ROUTER_PSK': ('remote', 'psk'),
|
||||
'VPN_ROUTER_P2S_ENABLED': ('p2s', 'enabled'),
|
||||
'VPN_ROUTER_P2S_ADDRESS_POOL': ('p2s', 'address_pool'),
|
||||
'VPN_ROUTER_P2S_CA_NAME': ('p2s', 'ca_name'),
|
||||
'VPN_ROUTER_WG_ENABLED': ('wireguard', 'enabled'),
|
||||
'VPN_ROUTER_WG_ADDRESS': ('wireguard', 'address'),
|
||||
'VPN_ROUTER_WG_LISTEN_PORT': ('wireguard', 'listen_port'),
|
||||
'VPN_ROUTER_PLATFORM': (('general', 'platform'),),
|
||||
'VPN_ROUTER_MODE': (('general', 'mode'),),
|
||||
'VPN_ROUTER_INT_ADDR': (('local', 'int_addr'),),
|
||||
'VPN_ROUTER_EXTERNAL_INTERFACE': (('interfaces', 'external'),),
|
||||
'VPN_ROUTER_INTERNAL_INTERFACE': (('interfaces', 'internal'),),
|
||||
'VPN_ROUTER_LOCAL_FQDN': (('wan', 'local_fqdn'),),
|
||||
'VPN_ROUTER_LOCAL_ID_MODE': ((*FIRST, 'local_id_mode'),),
|
||||
'VPN_ROUTER_LOCAL_CIDRS': (('local', 'cidrs'), (*FIRST, 'local_cidrs')),
|
||||
'VPN_ROUTER_INT_GATEWAY_IP': (('local', 'int_gateway_ip'),),
|
||||
'VPN_ROUTER_REMOTE_ADDRS': ((*FIRST, 'addrs'),),
|
||||
'VPN_ROUTER_REMOTE_ID': ((*FIRST, 'id'),),
|
||||
'VPN_ROUTER_REMOTE_CIDRS': ((*FIRST, 'cidrs'),),
|
||||
'VPN_ROUTER_PSK': ((*FIRST, 'psk'),),
|
||||
'VPN_ROUTER_P2S_ENABLED': (('p2s', 'enabled'),),
|
||||
'VPN_ROUTER_P2S_ADDRESS_POOL': (('p2s', 'address_pool'),),
|
||||
'VPN_ROUTER_P2S_CA_NAME': (('p2s', 'ca_name'),),
|
||||
'VPN_ROUTER_WG_ENABLED': (('wireguard', 'enabled'),),
|
||||
'VPN_ROUTER_WG_ADDRESS': (('wireguard', 'address'),),
|
||||
'VPN_ROUTER_WG_LISTEN_PORT': (('wireguard', 'listen_port'),),
|
||||
}
|
||||
|
||||
|
||||
@@ -74,23 +78,28 @@ def main():
|
||||
vpnrouter.fail(f'cannot parse {vpnrouter.CONFIG_FILE}: the top level is not an object')
|
||||
|
||||
answered = False
|
||||
for variable, (section, option) in ANSWERS.items():
|
||||
value = os.environ.get(variable)
|
||||
if not value:
|
||||
for variable, paths in ANSWERS.items():
|
||||
answer = os.environ.get(variable)
|
||||
if not answer:
|
||||
continue
|
||||
# A value that does not convert is kept as a string for validate() to report.
|
||||
default = vpnrouter.SCHEMA[section][option]
|
||||
if isinstance(default, bool):
|
||||
value = value == 'true'
|
||||
elif isinstance(default, int):
|
||||
value = int(value) if value.isdigit() else value
|
||||
elif isinstance(default, list):
|
||||
value = [item.strip() for item in value.split(',') if item.strip()]
|
||||
if not isinstance(cfg.get(section, {}), dict):
|
||||
vpnrouter.fail(f'cannot parse {vpnrouter.CONFIG_FILE}: {section} is not an object')
|
||||
if cfg.setdefault(section, {}).get(option) != value:
|
||||
cfg[section][option] = value
|
||||
answered = True
|
||||
for path in paths:
|
||||
# A value that does not convert is kept as a string for validate() to report.
|
||||
default = vpnrouter.CONNECTION[path[2]] if path[0] == 'site_to_site' else vpnrouter.SCHEMA[path[0]][path[1]]
|
||||
value = answer
|
||||
if isinstance(default, bool):
|
||||
value = value == 'true'
|
||||
elif isinstance(default, int):
|
||||
value = int(value) if value.isdigit() else value
|
||||
elif isinstance(default, list):
|
||||
value = [item.strip() for item in value.split(',') if item.strip()]
|
||||
target = cfg
|
||||
for key in path[:-1]:
|
||||
if not isinstance(target.setdefault(key, {}), dict):
|
||||
vpnrouter.fail(f'cannot parse {vpnrouter.CONFIG_FILE}: {".".join(path[:-1])} is not an object')
|
||||
target = target[key]
|
||||
if target.get(path[-1]) != value:
|
||||
target[path[-1]] = value
|
||||
answered = True
|
||||
|
||||
# Without a change the candidate is the current file byte for byte, as ucf compares checksums.
|
||||
with open(output, 'w', encoding='utf-8') as handle:
|
||||
|
||||
@@ -8,9 +8,10 @@ Every SAMPLE_SECONDS, on the minute, a sample is taken with psutil: the CPU util
|
||||
previous sample, the memory used and in total, the free and the total space of /, the bytes
|
||||
received and sent per second on the external and the internal interface, and the load averages;
|
||||
and the VPN clients of each enabled VPN system (vpnrouter_status): road-warrior (p2s) and
|
||||
OpenVPN clients, WireGuard peers with a recent handshake, and the site-to-site tunnel, 1 while it
|
||||
is up. The interfaces and the enabled systems are those vpn-router-setup resolved, read from
|
||||
/run/vpn-router/web.json.
|
||||
OpenVPN clients and WireGuard peers with a recent handshake; and of each site-to-site connection
|
||||
that is set up, whether it is up, the bytes and packets per second through its child SAs since
|
||||
the previous sample, and how long its IKE SA has been up. The interfaces, the enabled systems and
|
||||
the connections are those vpn-router-setup resolved, read from /run/vpn-router/web.json.
|
||||
|
||||
The sample is written to the detail tables of vpnrouter_metrics. When the clock has passed into
|
||||
a new hour, day, month or year, the complete periods are summarized, and once a day, after the
|
||||
@@ -105,9 +106,6 @@ def clients(state):
|
||||
counts['openvpn'] = vpnrouter_status.openvpn_clients()
|
||||
if state.get('wireguard'):
|
||||
counts['wireguard'] = vpnrouter_status.wireguard_connected()
|
||||
if state.get('site_to_site'):
|
||||
count = vpnrouter_status.established('remote-site')
|
||||
counts['site_to_site'] = None if count is None else int(count > 0)
|
||||
return counts
|
||||
|
||||
|
||||
@@ -121,6 +119,8 @@ def sample(previous):
|
||||
memory = psutil.virtual_memory()
|
||||
disk = psutil.disk_usage(MOUNTPOINT)
|
||||
load1, load5, load15 = os.getloadavg()
|
||||
connections = state.get('site_to_site')
|
||||
tunnels = vpnrouter_status.site_to_site_tunnels(connections) if isinstance(connections, list) and connections else {}
|
||||
now = time.time()
|
||||
current = {
|
||||
'time': now,
|
||||
@@ -138,13 +138,26 @@ def sample(previous):
|
||||
'counters': {name: (counters[name].bytes_recv, counters[name].bytes_sent) for name in names if name in counters},
|
||||
'clients': clients(state),
|
||||
'rates': {},
|
||||
'tunnels': tunnels or {},
|
||||
'site_to_site': {},
|
||||
}
|
||||
if previous is not None:
|
||||
elapsed = current['monotonic'] - previous['monotonic']
|
||||
elapsed = current['monotonic'] - previous['monotonic'] if previous is not None else 0
|
||||
if elapsed > 0:
|
||||
for name, (received, sent) in current['counters'].items():
|
||||
if name in previous['counters'] and elapsed > 0:
|
||||
if name in previous['counters']:
|
||||
before_received, before_sent = previous['counters'][name]
|
||||
current['rates'][name] = (max(received - before_received, 0) / elapsed, max(sent - before_sent, 0) / elapsed)
|
||||
# A connection's rates need its counters of the previous sample; a counter that went down was rekeyed and counts from zero.
|
||||
for name, tunnel in current['tunnels'].items():
|
||||
before = previous['tunnels'].get(name) if elapsed > 0 else None
|
||||
values = {'up': tunnel['up'], 'uptime': tunnel['uptime']}
|
||||
for value, counter in (('rx', 'bytes_in'), ('tx', 'bytes_out'), ('packets_rx', 'packets_in'), ('packets_tx', 'packets_out')):
|
||||
if before is None:
|
||||
values[value] = None
|
||||
else:
|
||||
count = tunnel[counter]
|
||||
values[value] = (count - before[counter] if count >= before[counter] else count) / elapsed
|
||||
current['site_to_site'][name] = values
|
||||
return current
|
||||
|
||||
|
||||
@@ -162,7 +175,7 @@ def store(cfg, current, done):
|
||||
"""
|
||||
now = periods(datetime.datetime.now(datetime.timezone.utc))
|
||||
with vpnrouter_metrics.database(cfg) as connection:
|
||||
vpnrouter_metrics.write(connection, current['system'], current['rates'], current['clients'])
|
||||
vpnrouter_metrics.write(connection, current['system'], current['rates'], current['clients'], current['site_to_site'])
|
||||
for level, _, _ in vpnrouter_metrics.LEVELS:
|
||||
if done.get(level) != now[level]:
|
||||
vpnrouter_metrics.summarize(connection, level)
|
||||
@@ -200,6 +213,17 @@ def otlp_request(current, started):
|
||||
gauge('system.cpu.load_average.5m', '{thread}', [(system['load5'], {})]),
|
||||
gauge('system.cpu.load_average.15m', '{thread}', [(system['load15'], {})]),
|
||||
gauge('vpn_router.clients', '{client}', [(count, {'kind': kind}) for kind, count in current['clients'].items()]),
|
||||
gauge('vpn_router.site_to_site.up', '1', [(values['up'], {'connection': name})
|
||||
for name, values in current['site_to_site'].items()]),
|
||||
gauge('vpn_router.site_to_site.uptime', 's', [(values['uptime'], {'connection': name})
|
||||
for name, values in current['site_to_site'].items()]),
|
||||
gauge('vpn_router.site_to_site.io', 'By/s', [(values[value], {'connection': name, 'direction': direction})
|
||||
for name, values in current['site_to_site'].items()
|
||||
for value, direction in (('rx', 'receive'), ('tx', 'transmit'))]),
|
||||
gauge('vpn_router.site_to_site.packets', '{packet}/s',
|
||||
[(values[value], {'connection': name, 'direction': direction})
|
||||
for name, values in current['site_to_site'].items()
|
||||
for value, direction in (('packets_rx', 'receive'), ('packets_tx', 'transmit'))]),
|
||||
{'name': 'system.network.io', 'unit': 'By', 'sum': {
|
||||
'aggregationTemporality': 2, 'isMonotonic': True, 'dataPoints': [
|
||||
{'startTimeUnixNano': start, 'timeUnixNano': stamp, 'asInt': str(value),
|
||||
@@ -295,12 +319,30 @@ class Sample:
|
||||
network.add_metric([name, 'receive'], received)
|
||||
network.add_metric([name, 'transmit'], sent)
|
||||
yield network
|
||||
vpn = GaugeMetricFamily('vpn_router_clients', 'Connected VPN clients; site_to_site is 1 while the tunnel is up',
|
||||
labels=['kind'])
|
||||
vpn = GaugeMetricFamily('vpn_router_clients', 'Connected VPN clients', labels=['kind'])
|
||||
for kind, count in self.current['clients'].items():
|
||||
if count is not None:
|
||||
vpn.add_metric([kind], count)
|
||||
yield vpn
|
||||
tunnels = self.current['site_to_site']
|
||||
up = GaugeMetricFamily('vpn_router_site_to_site_up', 'Whether the site-to-site connection is established, 1 or 0',
|
||||
labels=['connection'])
|
||||
uptime = GaugeMetricFamily('vpn_router_site_to_site_uptime_seconds', 'Seconds the IKE SA of the site-to-site connection has been up',
|
||||
labels=['connection'])
|
||||
io = GaugeMetricFamily('vpn_router_site_to_site_io_bytes_per_second',
|
||||
'Bytes per second received and sent through the site-to-site connection', labels=['connection', 'direction'])
|
||||
packets = GaugeMetricFamily('vpn_router_site_to_site_packets_per_second',
|
||||
'Packets per second received and sent through the site-to-site connection',
|
||||
labels=['connection', 'direction'])
|
||||
for name, values in tunnels.items():
|
||||
up.add_metric([name], values['up'])
|
||||
if values['uptime'] is not None:
|
||||
uptime.add_metric([name], values['uptime'])
|
||||
for family, received, sent in ((io, 'rx', 'tx'), (packets, 'packets_rx', 'packets_tx')):
|
||||
for value, direction in ((received, 'receive'), (sent, 'transmit')):
|
||||
if values[value] is not None:
|
||||
family.add_metric([name, direction], values[value])
|
||||
yield from (up, uptime, io, packets)
|
||||
|
||||
|
||||
def authorized(header, account):
|
||||
|
||||
@@ -294,7 +294,6 @@ def nat_and_mangle_block(ctx):
|
||||
|
||||
if vpnrouter.routing_ready(ctx):
|
||||
subnets = ctx['local_cidr_list']
|
||||
remote = ctx['remote_cidr_list']
|
||||
lines += [
|
||||
'',
|
||||
'# ROUTER NAT RULES START',
|
||||
@@ -302,11 +301,14 @@ def nat_and_mangle_block(ctx):
|
||||
':POSTROUTING ACCEPT [0:0]',
|
||||
'-F POSTROUTING',
|
||||
]
|
||||
# Every RETURN precedes every MASQUERADE, so tunnel-bound traffic
|
||||
# escapes NAT whichever local subnet it came from.
|
||||
for subnet in subnets:
|
||||
for cidr in remote:
|
||||
lines.append(f'-A POSTROUTING -s {subnet} -d {cidr} -j RETURN')
|
||||
# Every RETURN precedes every MASQUERADE, so traffic bound for a tunnel
|
||||
# escapes NAT; each ready connection pairs its local and remote subnets.
|
||||
for connection in ctx['connections']:
|
||||
if not connection['ready']:
|
||||
continue
|
||||
for subnet in connection['local_cidr_list']:
|
||||
for cidr in connection['remote_cidr_list']:
|
||||
lines.append(f'-A POSTROUTING -s {subnet} -d {cidr} -j RETURN')
|
||||
for subnet in subnets:
|
||||
lines.append(
|
||||
f'-A POSTROUTING -s {subnet} -o {ctx["wan_iface"]} -j MASQUERADE')
|
||||
@@ -591,31 +593,37 @@ def refresh_web(cfg, ctx):
|
||||
the configuration without its secret options and the facts resolved from
|
||||
the system, which it cannot work out with the standard library alone. The
|
||||
file is readable by root only. The service follows a changed address or
|
||||
port itself.
|
||||
port itself. Secret options are left out at every level, so also from each
|
||||
site-to-site connection.
|
||||
"""
|
||||
public = {
|
||||
section: {option: value for option, value in options.items() if not SECRET_OPTION.search(option)}
|
||||
for section, options in cfg.items()
|
||||
}
|
||||
def public(options):
|
||||
return {option: public(value) if isinstance(value, dict) else value
|
||||
for option, value in options.items()
|
||||
if isinstance(value, dict) or not SECRET_OPTION.search(option)}
|
||||
|
||||
WEB_STATE.parent.mkdir(parents=True, exist_ok=True)
|
||||
WEB_STATE.touch(mode=0o600)
|
||||
WEB_STATE.chmod(0o600)
|
||||
WEB_STATE.write_text(json.dumps({
|
||||
'site_to_site': bool(vpnrouter.s2s_ready(ctx)),
|
||||
'site_to_site': [connection['name'] for connection in ctx['connections'] if connection['ready']],
|
||||
'p2s': bool(vpnrouter.p2s_ready(ctx)),
|
||||
'wireguard': bool(vpnrouter.wg_ready(ctx)),
|
||||
'openvpn': bool(vpnrouter.openvpn_ready(ctx)),
|
||||
'configuration': public,
|
||||
'configuration': public(cfg),
|
||||
'network': {
|
||||
'external_interface': ctx['wan_iface'],
|
||||
'internal_interface': ctx['int_iface'],
|
||||
'external_addresses': ctx['local_addr_list'],
|
||||
'internal_address': ctx['int_addr'],
|
||||
'internal_gateway': ctx['int_gateway_ip'],
|
||||
'local_identity': ctx['local_id'],
|
||||
'remote_identity': ctx['remote_id'],
|
||||
'local_cidrs': ctx['local_cidr_list'],
|
||||
'remote_cidrs': ctx['remote_cidr_list'],
|
||||
'site_to_site': [{
|
||||
'name': connection['name'],
|
||||
'local_identity': connection['local_id'],
|
||||
'remote_identity': connection['remote_id'],
|
||||
'local_cidrs': connection['local_cidr_list'],
|
||||
'remote_cidrs': connection['remote_cidr_list'],
|
||||
} for connection in ctx['connections']],
|
||||
},
|
||||
}) + '\n', encoding='utf-8')
|
||||
|
||||
@@ -666,10 +674,10 @@ def main():
|
||||
if vpnrouter.routing_ready(ctx):
|
||||
apply_routes(ctx)
|
||||
|
||||
if ctx['local_id_mode'] == 'public_ip' and ctx['local_fqdn']:
|
||||
if ctx['local_fqdn'] and any(connection['local_id_mode'] == 'public_ip' for connection in ctx['connections']):
|
||||
progress('running', 'public address')
|
||||
address = resolve_public_ip(ctx['local_fqdn'])
|
||||
ctx, more = vpnrouter.configure(cfg, local_id_override=address)
|
||||
ctx, more = vpnrouter.configure(cfg, public_ip=address)
|
||||
changed |= more
|
||||
|
||||
progress('running', 'WireGuard keys')
|
||||
|
||||
@@ -4,6 +4,8 @@ Everything derivable from the configuration file alone, with no running
|
||||
network required. Used by both 'configure' and 'setup'.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import fcntl
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
@@ -12,6 +14,7 @@ import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.parse
|
||||
|
||||
import jinja2
|
||||
@@ -25,12 +28,13 @@ import vpnrouter_platforms
|
||||
from vpnrouter_net import DEFAULT_LISTEN, LISTEN_CHOICES, LOOPBACK, ConfigError, detect_interfaces, interface_addresses, ip_output
|
||||
|
||||
CONFIG_FILE = pathlib.Path('/etc/vpn-router/vpn-router.json')
|
||||
CONFIG_LOCK = pathlib.Path('/run/vpn-router/config.lock')
|
||||
TEMPLATE_DIR = pathlib.Path('/usr/share/vpn-router/templates')
|
||||
PKI_DIR = pathlib.Path('/etc/vpn-router/pki')
|
||||
SIMPLE_CA = LIB_DIR / 'simple-ca'
|
||||
|
||||
SWANCTL_CONF_D = pathlib.Path('/etc/swanctl/conf.d')
|
||||
REMOTE_SITE = SWANCTL_CONF_D / 'remote-site.conf'
|
||||
SITE_TO_SITE = SWANCTL_CONF_D / 'site-to-site.conf'
|
||||
ROAD_WARRIOR = SWANCTL_CONF_D / 'road-warrior.conf'
|
||||
P2S_FORWARDER = pathlib.Path('/etc/systemd/resolved.conf.d/p2s-forwarder.conf')
|
||||
WG_CONF = pathlib.Path('/etc/wireguard/wg0.conf')
|
||||
@@ -48,9 +52,9 @@ TRUST_ANCHOR = pathlib.Path('/usr/local/share/ca-certificates/vpn-router-ca.cr
|
||||
SCHEMA = {
|
||||
'general': {'platform': 'none', 'mode': 'manual'},
|
||||
'interfaces': {'external': '', 'internal': ''},
|
||||
'wan': {'local_fqdn': '', 'local_id_mode': 'fqdn'},
|
||||
'wan': {'local_fqdn': ''},
|
||||
'local': {'cidrs': [], 'int_addr': '', 'int_gateway_ip': ''},
|
||||
'remote': {'addrs': [], 'id': '', 'cidrs': [], 'psk': '', 'psk_file': ''},
|
||||
'site_to_site': {},
|
||||
'p2s': {'enabled': False, 'address_pool': '', 'ca_name': 'VPN Router CA'},
|
||||
'wireguard': {'enabled': False, 'address': '', 'listen_port': 51820},
|
||||
'openvpn': {'enabled': False, 'address_pool': '', 'port': 1194, 'protocol': 'udp'},
|
||||
@@ -64,6 +68,15 @@ SCHEMA = {
|
||||
'keep_monthly': '1 year', 'keep_yearly': '10 years'},
|
||||
}
|
||||
|
||||
#: site_to_site maps a connection name to its settings: option -> default, the type of the default being the required type.
|
||||
CONNECTION = {'addrs': [], 'id': '', 'cidrs': [], 'local_cidrs': [], 'local_id_mode': 'fqdn', 'psk': '', 'psk_file': ''}
|
||||
|
||||
#: The name of a site_to_site connection, which also names its swanctl connection, child and secret.
|
||||
CONNECTION_NAME = re.compile(r'^[a-z][a-z0-9-]{0,30}$')
|
||||
|
||||
#: The site_to_site connection the install answers fill.
|
||||
FIRST_CONNECTION = 'remote-site'
|
||||
|
||||
#: The retention settings of the metrics, from the details to the yearly summaries.
|
||||
METRICS_KEEP = ('keep_details', 'keep_hourly', 'keep_daily', 'keep_monthly', 'keep_yearly')
|
||||
|
||||
@@ -93,7 +106,7 @@ DOCKER_PORT = re.compile(r'^\d{1,5}(:\d{1,5})?$')
|
||||
DOCKER_PORT_LIMIT = 15
|
||||
|
||||
TYPE_NAMES = {str: 'a string', list: 'a list of strings', bool: 'a boolean',
|
||||
int: 'an integer'}
|
||||
int: 'an integer', dict: 'an object'}
|
||||
|
||||
ID_MODES = ('fqdn', 'public_ip', 'internal_ip')
|
||||
|
||||
@@ -118,29 +131,76 @@ def fail(message):
|
||||
sys.exit(f'vpn-router: error: {message}')
|
||||
|
||||
|
||||
def load_config(path=None):
|
||||
"""Read the configuration, filling in defaults for anything absent.
|
||||
def read_settings(path=None):
|
||||
"""The settings in the configuration file, without the defaults; {} when the file does not exist.
|
||||
|
||||
The result maps section -> option -> value. Sections and options not in
|
||||
SCHEMA are kept so that validate() can report them.
|
||||
Raises ConfigError when the file is not JSON or not an object of sections.
|
||||
"""
|
||||
path = pathlib.Path(path) if path else CONFIG_FILE
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding='utf-8'))
|
||||
except FileNotFoundError:
|
||||
return {}
|
||||
except ValueError as exc:
|
||||
raise ConfigError(f'cannot parse {path}: {exc}')
|
||||
if not isinstance(data, dict):
|
||||
raise ConfigError(f'cannot parse {path}: the top level is not an object')
|
||||
for section, options in data.items():
|
||||
if not isinstance(options, dict):
|
||||
raise ConfigError(f'cannot parse {path}: {section} is not an object')
|
||||
return data
|
||||
|
||||
|
||||
def with_defaults(data):
|
||||
"""The configuration of the settings of read_settings, with the defaults for anything absent.
|
||||
|
||||
The result maps section -> option -> value, and site_to_site maps each connection name to
|
||||
its settings filled from CONNECTION. Sections, options and connections that are not
|
||||
objects are kept so that validate() can report them.
|
||||
"""
|
||||
cfg = {section: dict(options) for section, options in SCHEMA.items()}
|
||||
for section, options in data.items():
|
||||
cfg.setdefault(section, {}).update(options)
|
||||
cfg['site_to_site'] = {name: {**CONNECTION, **options} if isinstance(options, dict) else options
|
||||
for name, options in cfg['site_to_site'].items()}
|
||||
return cfg
|
||||
|
||||
|
||||
def load_config(path=None):
|
||||
"""Read the configuration, filling in defaults for anything absent (see with_defaults).
|
||||
|
||||
The path is resolved when called, not when this function is defined, so
|
||||
CONFIG_FILE stays overridable.
|
||||
"""
|
||||
path = pathlib.Path(path) if path else CONFIG_FILE
|
||||
cfg = {section: dict(options) for section, options in SCHEMA.items()}
|
||||
if path.exists():
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding='utf-8'))
|
||||
except ValueError as exc:
|
||||
fail(f'cannot parse {path}: {exc}')
|
||||
if not isinstance(data, dict):
|
||||
fail(f'cannot parse {path}: the top level is not an object')
|
||||
for section, options in data.items():
|
||||
if not isinstance(options, dict):
|
||||
fail(f'cannot parse {path}: {section} is not an object')
|
||||
cfg.setdefault(section, {}).update(options)
|
||||
return cfg
|
||||
try:
|
||||
return with_defaults(read_settings(path))
|
||||
except ConfigError as exc:
|
||||
fail(str(exc))
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def config_lock():
|
||||
"""Hold the lock of the configuration file from reading it to writing it with save_config."""
|
||||
CONFIG_LOCK.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(CONFIG_LOCK, 'a', encoding='utf-8') as handle:
|
||||
fcntl.flock(handle, fcntl.LOCK_EX)
|
||||
yield
|
||||
|
||||
|
||||
def save_config(data):
|
||||
"""Write the settings to the configuration file, indented, with mode 0600, replacing it in one step.
|
||||
|
||||
The caller holds config_lock.
|
||||
"""
|
||||
descriptor, name = tempfile.mkstemp(dir=CONFIG_FILE.parent, prefix='.vpn-router.json.')
|
||||
try:
|
||||
with os.fdopen(descriptor, 'w', encoding='utf-8') as handle:
|
||||
handle.write(json.dumps(data, indent=2) + '\n')
|
||||
os.chmod(name, 0o600)
|
||||
os.replace(name, CONFIG_FILE)
|
||||
except OSError:
|
||||
os.unlink(name)
|
||||
raise
|
||||
|
||||
|
||||
def _split_list(value):
|
||||
@@ -152,23 +212,37 @@ def _get(cfg, section, option):
|
||||
return value.strip() if isinstance(value, str) else value
|
||||
|
||||
|
||||
def _clean(values):
|
||||
return [item.strip() for item in values if item.strip()]
|
||||
|
||||
|
||||
def _get_list(cfg, section, option):
|
||||
return [item.strip() for item in cfg[section][option] if item.strip()]
|
||||
return _clean(cfg[section][option])
|
||||
|
||||
|
||||
def _check_options(options, defaults, prefix):
|
||||
for option, value in options.items():
|
||||
if option not in defaults:
|
||||
raise ConfigError(f'{prefix}.{option}: unknown setting')
|
||||
expected = type(defaults[option])
|
||||
if type(value) is not expected or (
|
||||
expected is list and
|
||||
not all(isinstance(item, str) for item in value)):
|
||||
raise ConfigError(
|
||||
f'{prefix}.{option} must be {TYPE_NAMES[expected]}')
|
||||
|
||||
|
||||
def _check_types(cfg):
|
||||
for section, options in cfg.items():
|
||||
if section not in SCHEMA:
|
||||
raise ConfigError(f'{section}: unknown section')
|
||||
for option, value in options.items():
|
||||
if option not in SCHEMA[section]:
|
||||
raise ConfigError(f'{section}.{option}: unknown setting')
|
||||
expected = type(SCHEMA[section][option])
|
||||
if type(value) is not expected or (
|
||||
expected is list and
|
||||
not all(isinstance(item, str) for item in value)):
|
||||
raise ConfigError(
|
||||
f'{section}.{option} must be {TYPE_NAMES[expected]}')
|
||||
if section != 'site_to_site':
|
||||
_check_options(options, SCHEMA[section], section)
|
||||
continue
|
||||
for name, connection in options.items():
|
||||
if not isinstance(connection, dict):
|
||||
raise ConfigError(f'site_to_site.{name} must be {TYPE_NAMES[dict]}')
|
||||
_check_options(connection, CONNECTION, f'site_to_site.{name}')
|
||||
|
||||
|
||||
def _check_networks(values, name):
|
||||
@@ -220,19 +294,28 @@ def validate(cfg):
|
||||
raise ConfigError(
|
||||
f'{section}.{option} is required in mode {mode or "manual"}')
|
||||
|
||||
for section, option in (('wan', 'local_fqdn'), ('remote', 'id')):
|
||||
if str(_get(cfg, section, option)).startswith('@'):
|
||||
identities = [('wan.local_fqdn', _get(cfg, 'wan', 'local_fqdn'))]
|
||||
for name, connection in cfg['site_to_site'].items():
|
||||
prefix = f'site_to_site.{name}'
|
||||
if not CONNECTION_NAME.match(name):
|
||||
raise ConfigError(
|
||||
f'{section}.{option}: remove the leading @; strongSwan reads an '
|
||||
f'{prefix}: a connection name starts with a lowercase letter and has at most 31 '
|
||||
'lowercase letters, digits and hyphens')
|
||||
identities.append((f'{prefix}.id', connection['id'].strip()))
|
||||
id_mode = connection['local_id_mode'].strip()
|
||||
if id_mode and id_mode not in ID_MODES:
|
||||
raise ConfigError(
|
||||
f'{prefix}.local_id_mode: {id_mode!r} is not one of {", ".join(ID_MODES)}')
|
||||
_check_networks(_clean(connection['cidrs']), f'{prefix}.cidrs')
|
||||
_check_networks(_clean(connection['local_cidrs']), f'{prefix}.local_cidrs')
|
||||
|
||||
for option, value in identities:
|
||||
if value.startswith('@'):
|
||||
raise ConfigError(
|
||||
f'{option}: remove the leading @; strongSwan reads an '
|
||||
'address as an IP identity and a name as an FQDN')
|
||||
|
||||
id_mode = _get(cfg, 'wan', 'local_id_mode')
|
||||
if id_mode and id_mode not in ID_MODES:
|
||||
raise ConfigError(
|
||||
f'wan.local_id_mode: {id_mode!r} is not one of {", ".join(ID_MODES)}')
|
||||
|
||||
_check_networks(_get_list(cfg, 'local', 'cidrs'), 'local.cidrs')
|
||||
_check_networks(_get_list(cfg, 'remote', 'cidrs'), 'remote.cidrs')
|
||||
pool = _get(cfg, 'p2s', 'address_pool')
|
||||
_check_networks([pool] if pool else [], 'p2s.address_pool')
|
||||
openvpn_pool = _get(cfg, 'openvpn', 'address_pool')
|
||||
@@ -355,15 +438,15 @@ def validate(cfg):
|
||||
'a range counting as two')
|
||||
|
||||
|
||||
def resolve_psk(cfg):
|
||||
"""Return the pre-shared key, or '' when none is configured."""
|
||||
psk_file = _get(cfg, 'remote', 'psk_file')
|
||||
def resolve_psk(connection, name):
|
||||
"""Return the pre-shared key of the site_to_site connection: psk_file wins, else psk; '' when none is configured."""
|
||||
psk_file = connection['psk_file'].strip()
|
||||
if psk_file:
|
||||
path = pathlib.Path(psk_file)
|
||||
if not path.is_file():
|
||||
raise ConfigError(f'remote.psk_file: {psk_file} does not exist')
|
||||
raise ConfigError(f'site_to_site.{name}.psk_file: {psk_file} does not exist')
|
||||
return path.read_text(encoding='utf-8').strip()
|
||||
return cfg['remote']['psk']
|
||||
return connection['psk']
|
||||
|
||||
|
||||
def platform_module(cfg):
|
||||
@@ -447,29 +530,43 @@ def resolve_interfaces(cfg):
|
||||
return ext_iface, int_iface, local_addrs, int_addr, gateway
|
||||
|
||||
|
||||
def build_context(cfg, local_id_override=None):
|
||||
"""Assemble the template context from the configuration."""
|
||||
def build_context(cfg, public_ip=None):
|
||||
"""Assemble the template context from the configuration.
|
||||
|
||||
public_ip is the address wan.local_fqdn resolves to, the local identity of the
|
||||
site_to_site connections in mode public_ip; setup resolves it once the network is up and
|
||||
renders again, and without it such a connection is not ready.
|
||||
"""
|
||||
extras = vpnrouter_platforms.context(platform_module(cfg))
|
||||
|
||||
ext_iface, int_iface, local_addrs, int_addr, gateway = resolve_interfaces(cfg)
|
||||
|
||||
local_fqdn = _get(cfg, 'wan', 'local_fqdn')
|
||||
id_mode = _get(cfg, 'wan', 'local_id_mode') or 'fqdn'
|
||||
local_cidrs = _get_list(cfg, 'local', 'cidrs')
|
||||
remote_cidrs = _get_list(cfg, 'remote', 'cidrs')
|
||||
openvpn_pool = _get(cfg, 'openvpn', 'address_pool')
|
||||
openvpn_network = ipaddress.ip_network(openvpn_pool) if openvpn_pool else None
|
||||
identities = {'fqdn': local_fqdn, 'public_ip': public_ip or '', 'internal_ip': int_addr}
|
||||
|
||||
if local_id_override:
|
||||
local_id = local_id_override
|
||||
elif id_mode == 'fqdn':
|
||||
local_id = local_fqdn
|
||||
elif id_mode == 'internal_ip':
|
||||
local_id = int_addr
|
||||
else:
|
||||
# public_ip is resolved once the network is up, and the caller then
|
||||
# re-renders with local_id_override set.
|
||||
local_id = ''
|
||||
connections = []
|
||||
for name, connection in cfg['site_to_site'].items():
|
||||
id_mode = connection['local_id_mode'].strip() or 'fqdn'
|
||||
remote_cidrs = _clean(connection['cidrs'])
|
||||
connection_cidrs = _clean(connection['local_cidrs'])
|
||||
entry = {
|
||||
'name': name,
|
||||
'remote_addrs': ', '.join(_clean(connection['addrs'])),
|
||||
'remote_id': connection['id'].strip(),
|
||||
'remote_cidrs': ', '.join(remote_cidrs),
|
||||
'remote_cidr_list': remote_cidrs,
|
||||
'local_ts': _join(connection_cidrs, _split_list(extras['EXTRA_LOCAL_TS'])),
|
||||
'local_cidr_list': connection_cidrs,
|
||||
'local_id_mode': id_mode,
|
||||
'local_id': identities.get(id_mode, ''),
|
||||
'psk': resolve_psk(connection, name),
|
||||
}
|
||||
entry['ready'] = bool(local_addrs and connection_cidrs and all(
|
||||
entry[key] for key in ('remote_addrs', 'remote_id', 'remote_cidrs', 'local_id', 'psk')))
|
||||
connections.append(entry)
|
||||
|
||||
return {
|
||||
'platform': _get(cfg, 'general', 'platform') or PLATFORM_NONE,
|
||||
@@ -479,18 +576,12 @@ def build_context(cfg, local_id_override=None):
|
||||
'local_addrs': ', '.join(local_addrs),
|
||||
'local_addr_list': local_addrs,
|
||||
'local_fqdn': local_fqdn,
|
||||
'local_id_mode': id_mode,
|
||||
'local_id': local_id,
|
||||
'local_cidrs': ', '.join(local_cidrs),
|
||||
'local_cidr_list': local_cidrs,
|
||||
'local_ts': _join(local_cidrs, _split_list(extras['EXTRA_LOCAL_TS'])),
|
||||
'int_addr': int_addr,
|
||||
'int_gateway_ip': gateway,
|
||||
'remote_addrs': ', '.join(_get_list(cfg, 'remote', 'addrs')),
|
||||
'remote_id': _get(cfg, 'remote', 'id'),
|
||||
'remote_cidrs': ', '.join(remote_cidrs),
|
||||
'remote_cidr_list': remote_cidrs,
|
||||
'psk': resolve_psk(cfg),
|
||||
'connections': connections,
|
||||
'p2s_enabled': _get(cfg, 'p2s', 'enabled'),
|
||||
'p2s_address_pool': _get(cfg, 'p2s', 'address_pool'),
|
||||
'p2s_ca_name': _get(cfg, 'p2s', 'ca_name'),
|
||||
@@ -530,11 +621,8 @@ def build_context(cfg, local_id_override=None):
|
||||
|
||||
|
||||
def s2s_ready(ctx):
|
||||
return all([
|
||||
ctx['local_addrs'], ctx['local_fqdn'], ctx['local_cidrs'],
|
||||
ctx['remote_addrs'], ctx['remote_id'], ctx['remote_cidrs'],
|
||||
ctx['psk'], ctx['local_id'],
|
||||
])
|
||||
"""True when any site_to_site connection is ready."""
|
||||
return any(connection['ready'] for connection in ctx['connections'])
|
||||
|
||||
|
||||
def routing_ready(ctx):
|
||||
@@ -699,7 +787,7 @@ def distribute_pki(ctx):
|
||||
return changed
|
||||
|
||||
|
||||
def configure(cfg=None, local_id_override=None):
|
||||
def configure(cfg=None, public_ip=None):
|
||||
"""Render configuration files and provision PKI.
|
||||
|
||||
Returns (ctx, changed) where changed is a set naming what needs reloading:
|
||||
@@ -715,7 +803,7 @@ def configure(cfg=None, local_id_override=None):
|
||||
|
||||
try:
|
||||
validate(cfg)
|
||||
ctx = build_context(cfg, local_id_override=local_id_override)
|
||||
ctx = build_context(cfg, public_ip=public_ip)
|
||||
except ConfigError as exc:
|
||||
fail(str(exc))
|
||||
|
||||
@@ -728,9 +816,9 @@ def configure(cfg=None, local_id_override=None):
|
||||
env = _jinja_env()
|
||||
|
||||
if s2s_ready(ctx):
|
||||
if render(env, ctx, 'remote-site.conf.j2', REMOTE_SITE, 0o600):
|
||||
if render(env, ctx, 'site-to-site.conf.j2', SITE_TO_SITE, 0o600):
|
||||
changed.add('swanctl')
|
||||
elif remove(REMOTE_SITE):
|
||||
elif remove(SITE_TO_SITE):
|
||||
changed.add('swanctl')
|
||||
|
||||
if p2s_ready(ctx):
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"""The metrics of the router in PostgreSQL, for the metrics service and the web service.
|
||||
|
||||
The metrics service writes one detail row per minute to the three detail tables, of TABLES:
|
||||
The metrics service writes one detail row per minute to the four detail tables, of TABLES:
|
||||
metrics_system (CPU, memory, disk and load averages), metrics_network (bytes per second
|
||||
received and sent per interface) and metrics_vpn (clients per kind). Each detail table has four
|
||||
received and sent per interface), metrics_vpn (clients per kind) and metrics_site_to_site (per
|
||||
site-to-site connection: up, 1 or 0, bytes and packets per second received and sent, and the
|
||||
seconds its IKE SA has been up). Each detail table has four
|
||||
summary tables, of LEVELS: <table>_hourly from the details, <table>_daily from the hourly,
|
||||
<table>_monthly from the daily and <table>_yearly from the monthly rows. A summary row has the
|
||||
start of its period, in UTC, and the average and the maximum of each value over the period: of
|
||||
@@ -28,6 +30,7 @@ TABLES = {
|
||||
'metrics_system': ((), ('cpu', 'memory_used', 'memory_total', 'disk_free', 'disk_total', 'load1', 'load5', 'load15')),
|
||||
'metrics_network': (('interface',), ('rx', 'tx')),
|
||||
'metrics_vpn': (('kind',), ('clients',)),
|
||||
'metrics_site_to_site': (('connection',), ('up', 'rx', 'tx', 'packets_rx', 'packets_tx', 'uptime')),
|
||||
}
|
||||
|
||||
#: The summary levels, from the finest: the suffix of the tables, the period of date_trunc and the retention setting.
|
||||
@@ -38,8 +41,8 @@ LEVELS = (
|
||||
('yearly', 'year', 'keep_yearly'),
|
||||
)
|
||||
|
||||
#: The kinds of VPN clients of metrics_vpn; site_to_site is 1 while the tunnel is up.
|
||||
VPN_KINDS = ('p2s', 'openvpn', 'wireguard', 'site_to_site')
|
||||
#: The kinds of VPN clients of metrics_vpn.
|
||||
VPN_KINDS = ('p2s', 'openvpn', 'wireguard')
|
||||
|
||||
#: The views of history(): the summary level read (None: the details), the bucket and the span.
|
||||
RANGES = {
|
||||
@@ -82,11 +85,12 @@ def database(cfg):
|
||||
raise DatabaseError(f'the metrics database failed: {exc}') from exc
|
||||
|
||||
|
||||
def write(connection, system, network, vpn):
|
||||
def write(connection, system, network, vpn, site_to_site):
|
||||
"""Write a sample as the detail rows of the current minute.
|
||||
|
||||
system maps the values of metrics_system to numbers; network maps an interface to its rx and
|
||||
tx; vpn maps a kind of VPN_KINDS to its clients. A value that is None is written as NULL.
|
||||
tx; vpn maps a kind of VPN_KINDS to its clients; site_to_site maps a connection to the values
|
||||
of metrics_site_to_site. A value that is None is written as NULL.
|
||||
"""
|
||||
with connection.transaction():
|
||||
minute = connection.execute("SELECT date_trunc('minute', now(), 'UTC')").fetchone()[0]
|
||||
@@ -100,6 +104,11 @@ def write(connection, system, network, vpn):
|
||||
for kind, clients in vpn.items():
|
||||
connection.execute('INSERT INTO metrics_vpn (time, kind, clients) VALUES (%s, %s, %s) ON CONFLICT DO NOTHING',
|
||||
(minute, kind, clients))
|
||||
names = TABLES['metrics_site_to_site'][1]
|
||||
for name, values in site_to_site.items():
|
||||
connection.execute(f"INSERT INTO metrics_site_to_site (time, connection, {', '.join(names)}) "
|
||||
f"VALUES (%s, %s{', %s' * len(names)}) ON CONFLICT DO NOTHING",
|
||||
(minute, name, *(values.get(value) for value in names)))
|
||||
|
||||
|
||||
def summarize(connection, level):
|
||||
@@ -136,8 +145,9 @@ def history(connection, view):
|
||||
"""The points of a view of RANGES, oldest first, each with the start of its bucket in Unix seconds.
|
||||
|
||||
A point has the average and the maximum of each value of metrics_system, network, which maps
|
||||
each interface to its rx and tx, and clients, which maps each kind of VPN client to its
|
||||
clients; a value without data is None.
|
||||
each interface to its rx and tx, clients, which maps each kind of VPN client to its clients,
|
||||
and site_to_site, which maps each site-to-site connection to the values of
|
||||
metrics_site_to_site; a value without data is None.
|
||||
"""
|
||||
level, bucket, span = RANGES[view]
|
||||
|
||||
@@ -161,7 +171,7 @@ def history(connection, view):
|
||||
points = {}
|
||||
|
||||
def point(start):
|
||||
return points.setdefault(start, {'time': start, 'network': {}, 'clients': {}})
|
||||
return points.setdefault(start, {'time': start, 'network': {}, 'clients': {}, 'site_to_site': {}})
|
||||
|
||||
for row in rows('metrics_system'):
|
||||
point(row[0]).update(pairs(row[1:], TABLES['metrics_system'][1]))
|
||||
@@ -169,5 +179,7 @@ def history(connection, view):
|
||||
point(row[0])['network'][row[1]] = pairs(row[2:], TABLES['metrics_network'][1])
|
||||
for row in rows('metrics_vpn'):
|
||||
point(row[0])['clients'][row[1]] = pairs(row[2:], TABLES['metrics_vpn'][1])['clients']
|
||||
for row in rows('metrics_site_to_site'):
|
||||
point(row[0])['site_to_site'][row[1]] = pairs(row[2:], TABLES['metrics_site_to_site'][1])
|
||||
empty = {value: {'avg': None, 'max': None} for value in TABLES['metrics_system'][1]}
|
||||
return {'range': view, 'bucket': bucket, 'points': [{**empty, **points[start]} for start in sorted(points)]}
|
||||
@@ -1,7 +1,8 @@
|
||||
"""What the router's VPN systems report, for the web service and the metrics service.
|
||||
|
||||
Each reader asks the running system once and answers None when it cannot be asked: swanctl for
|
||||
the IKE SAs, wg for the WireGuard peers and the OpenVPN server's status file for its clients.
|
||||
the IKE SAs and the state and traffic of the site-to-site connections, wg for the WireGuard
|
||||
peers and the OpenVPN server's status file for its clients.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
@@ -65,12 +66,45 @@ def swanctl(command):
|
||||
return [dict(body, name=name) for event in events for name, body in event.items()]
|
||||
|
||||
|
||||
def established(connection):
|
||||
"""The number of established IKE SAs of the connection, or None when swanctl cannot be asked."""
|
||||
def established_each(connections):
|
||||
"""The number of established IKE SAs of each of the connections by name, None for each when swanctl cannot be asked."""
|
||||
sas = swanctl('--list-sas')
|
||||
return {name: None if sas is None else sum(1 for sa in sas if sa['name'] == name and sa.get('state') == 'ESTABLISHED')
|
||||
for name in connections}
|
||||
|
||||
|
||||
def _count(text):
|
||||
"""A counter swanctl prints as a string, as an integer; 0 when it is not a whole number."""
|
||||
return int(text) if isinstance(text, str) and text.isdigit() else 0
|
||||
|
||||
|
||||
def site_to_site_tunnels(connections):
|
||||
"""The state of each of the connections by name, or None when swanctl cannot be asked.
|
||||
|
||||
Each has up, 1 while it has an established IKE SA and 0 otherwise; uptime, the seconds since
|
||||
its oldest established IKE SA was established, None while it is down; and bytes_in,
|
||||
bytes_out, packets_in and packets_out, the sums over the child SAs of its established IKE
|
||||
SAs. The sums start again from zero when a child SA is rekeyed.
|
||||
"""
|
||||
sas = swanctl('--list-sas')
|
||||
if sas is None:
|
||||
return None
|
||||
return sum(1 for sa in sas if sa['name'] == connection and sa.get('state') == 'ESTABLISHED')
|
||||
tunnels = {}
|
||||
for name in connections:
|
||||
up = [sa for sa in sas if sa['name'] == name and sa.get('state') == 'ESTABLISHED']
|
||||
children = [child for sa in up for child in (sa.get('child-sas') or {}).values() if isinstance(child, dict)]
|
||||
tunnels[name] = {
|
||||
'up': 1 if up else 0,
|
||||
'uptime': max((_count(sa.get('established')) for sa in up), default=None),
|
||||
**{key.replace('-', '_'): sum(_count(child.get(key)) for child in children)
|
||||
for key in ('bytes-in', 'bytes-out', 'packets-in', 'packets-out')},
|
||||
}
|
||||
return tunnels
|
||||
|
||||
|
||||
def established(connection):
|
||||
"""The number of established IKE SAs of the connection, or None when swanctl cannot be asked."""
|
||||
return established_each([connection])[connection]
|
||||
|
||||
|
||||
def wireguard_peers():
|
||||
|
||||
@@ -17,6 +17,9 @@ Pages:
|
||||
add and take out members (see groups())
|
||||
/account the account of the signed-in user: its details, its password and its TOTP
|
||||
enrolment (see account())
|
||||
/site-to-site
|
||||
the site-to-site connections of the configuration: list, add, change and remove,
|
||||
applied by vpn-router-setup (see site_to_site())
|
||||
/auth sign-in with a user's password and TOTP code, refresh and sign-out (see auth())
|
||||
/metrics/history
|
||||
the metrics the metrics service keeps, for a view: hour, day, month or year (see
|
||||
@@ -25,7 +28,7 @@ Pages:
|
||||
the databases of each PostgreSQL cluster on the router with their sizes, as JSON (see
|
||||
postgresql_databases()); /config has the clusters' settings
|
||||
|
||||
/config, /metrics/history, /postgresql, /users, /groups and /account need an HS256 JWT in an Authorization: Bearer header signed
|
||||
/config, /metrics/history, /postgresql, /users, /groups, /site-to-site and /account need an HS256 JWT in an Authorization: Bearer header signed
|
||||
with vpnrouter_auth.WEB_KEY (see vpnrouter_auth.verify). A token of /auth names a user in sub;
|
||||
/account serves that user, and the other paths need a member of vpnrouter_users.ADMIN_GROUP. A
|
||||
token of vpn-router-token has the admin claim and names no user; it may call every path but
|
||||
@@ -39,7 +42,7 @@ default the address the call came in on, is the last entry of its X-Forwarded-Fo
|
||||
default, the overall status, the time and the client's address (its address as for the rate limit
|
||||
below); standard also the package version, the installed version of vpn-router-manager
|
||||
(manager_version, null when it is not installed) and the number of active and other services; full
|
||||
whether each service runs, whether Docker is installed, and the state of site-to-site, road-warrior
|
||||
whether each service runs, whether Docker is installed, and the state of each site-to-site connection, road-warrior
|
||||
(IPsec P2S), OpenVPN and WireGuard. No configuration data or key is shown, and no address but the
|
||||
client's.
|
||||
|
||||
@@ -80,7 +83,7 @@ import vpnrouter_metrics
|
||||
import vpnrouter_net
|
||||
import vpnrouter_serve
|
||||
import vpnrouter_users
|
||||
from vpnrouter_status import established, openvpn_clients, run, swanctl, wireguard_peers
|
||||
from vpnrouter_status import established, established_each, openvpn_clients, run, swanctl, wireguard_peers
|
||||
|
||||
STATE_FILE = '/run/vpn-router/web.json'
|
||||
CONFIG_FILE = '/etc/vpn-router/vpn-router.json'
|
||||
@@ -111,6 +114,9 @@ POSTGRESQL_SETTINGS = ('server_version', 'listen_addresses', 'port', 'max_connec
|
||||
#: The largest request body accepted, in bytes.
|
||||
BODY_LIMIT = 4096
|
||||
|
||||
#: The settings of a site_to_site connection that /site-to-site adds and changes.
|
||||
CONNECTION_SETTINGS = ('addrs', 'id', 'cidrs', 'local_cidrs', 'local_id_mode', 'psk')
|
||||
|
||||
#: The HTTP status for each status of a vpnrouter_users.Failure; any other is 500.
|
||||
USER_STATUS = {2: 400, 3: 404, 4: 409, 5: 401}
|
||||
|
||||
@@ -202,9 +208,10 @@ def collect(state):
|
||||
'units': units,
|
||||
'docker': docker_status(),
|
||||
}
|
||||
if state.get('site_to_site'):
|
||||
count = established('remote-site')
|
||||
status['site_to_site'] = {'established': None if count is None else count > 0}
|
||||
names = state.get('site_to_site')
|
||||
if isinstance(names, list) and names:
|
||||
status['site_to_site'] = {'connections': {name: None if count is None else count > 0
|
||||
for name, count in established_each(names).items()}}
|
||||
if state.get('p2s'):
|
||||
status['p2s'] = {'clients': established('road-warrior')}
|
||||
if state.get('wireguard'):
|
||||
@@ -365,9 +372,8 @@ def text_of(status):
|
||||
docker = status['docker']
|
||||
lines.append('Docker: not installed' if not docker['installed']
|
||||
else f'Docker: installed, service {docker["service"]}, rules {docker["rules"]}')
|
||||
if 'site_to_site' in status:
|
||||
value = status['site_to_site']['established']
|
||||
lines.append(f'Site to site: {"unknown" if value is None else "established" if value else "not established"}')
|
||||
for name, value in status.get('site_to_site', {}).get('connections', {}).items():
|
||||
lines.append(f'Site to site {name}: {"unknown" if value is None else "established" if value else "not established"}')
|
||||
if 'p2s' in status:
|
||||
clients = status['p2s']['clients']
|
||||
lines.append(f'Road-warrior (IPsec P2S): {"unknown" if clients is None else f"{clients} connected"}')
|
||||
@@ -527,6 +533,81 @@ def groups(state, method, path, body):
|
||||
return (201 if route == ('POST', 1, None) else 200), document
|
||||
|
||||
|
||||
def connection_of(name, connection, count):
|
||||
"""A site_to_site connection as /site-to-site shows it: its settings but psk, psk_set and established.
|
||||
|
||||
count is the number of its established IKE SAs, None when it is not known.
|
||||
"""
|
||||
return {'name': name, **{option: value for option, value in connection.items() if option != 'psk'},
|
||||
'psk_set': bool(connection['psk']), 'established': None if count is None else count > 0}
|
||||
|
||||
|
||||
def site_to_site(method, path, body):
|
||||
"""Answer a /site-to-site request: the HTTP status and the JSON document.
|
||||
|
||||
GET /site-to-site the connections, {"connections": [...]}: each with its settings but psk, with
|
||||
psk_set, whether psk holds a key, and established, whether its tunnel is up,
|
||||
null when swanctl cannot be asked
|
||||
POST /site-to-site add a connection: {"name", "addrs", "id", "cidrs", "local_cidrs", "local_id_mode",
|
||||
"psk"}; psk is required, and an existing name is 409
|
||||
PUT /site-to-site/<name> change any of the settings of POST but name; an empty or absent psk keeps the
|
||||
current key, and a psk given replaces psk_file
|
||||
DELETE /site-to-site/<name> remove the connection
|
||||
|
||||
A change is made holding vpnrouter.config_lock, checked as vpn-router-setup checks the configuration (400 with
|
||||
the reason), written with vpnrouter.save_config, and 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('/')
|
||||
name = parts[1] if len(parts) == 2 else None
|
||||
if (method, len(parts)) not in (('GET', 1), ('POST', 1), ('PUT', 2), ('DELETE', 2)):
|
||||
return 404, {'error': f'no route {method} {path}'}
|
||||
try:
|
||||
if method == 'GET':
|
||||
connections = vpnrouter.load_config()['site_to_site']
|
||||
counts = established_each(connections)
|
||||
return 200, {'connections': [connection_of(key, value, counts[key]) for key, value in connections.items()]}
|
||||
settings = {option: body[option] for option in CONNECTION_SETTINGS if option in body}
|
||||
with vpnrouter.config_lock():
|
||||
data = vpnrouter.read_settings()
|
||||
connections = data.setdefault('site_to_site', {})
|
||||
if method == 'POST':
|
||||
name = body.get('name')
|
||||
if not isinstance(name, str) or not name:
|
||||
return 400, {'error': 'name is required'}
|
||||
if name in connections:
|
||||
return 409, {'error': f'connection {name} exists'}
|
||||
if not isinstance(settings.get('psk'), str) or not settings['psk']:
|
||||
return 400, {'error': 'psk is required'}
|
||||
connections[name] = settings
|
||||
elif name not in connections:
|
||||
return 404, {'error': f'no connection {name}'}
|
||||
elif method == 'PUT':
|
||||
connection = dict(connections[name]) if isinstance(connections[name], dict) else {}
|
||||
if settings.get('psk'):
|
||||
connection.pop('psk_file', None)
|
||||
else:
|
||||
settings.pop('psk', None)
|
||||
connections[name] = {**connection, **settings}
|
||||
else:
|
||||
del connections[name]
|
||||
cfg = vpnrouter.with_defaults(data)
|
||||
vpnrouter.validate(cfg)
|
||||
vpnrouter.save_config(data)
|
||||
except vpnrouter.ConfigError as exc:
|
||||
return 400, {'error': str(exc)}
|
||||
except OSError as exc:
|
||||
return 500, {'error': f'the configuration cannot be written: {exc}'}
|
||||
except SystemExit as exc:
|
||||
return 500, {'error': str(exc.code)}
|
||||
run('systemctl', 'restart', '--no-block', 'vpn-router-setup.service')
|
||||
if method == 'DELETE':
|
||||
return 200, {'name': name, 'removed': True}
|
||||
return (201 if method == 'POST' else 200), connection_of(name, cfg['site_to_site'][name], None)
|
||||
|
||||
|
||||
def account(name, method, path, body):
|
||||
"""Answer an /account request of the user named by the token with vpnrouter_users: the HTTP status and the JSON document.
|
||||
|
||||
@@ -714,7 +795,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
# A body that is not read must not be taken for the next request.
|
||||
if self.command not in ('GET', 'HEAD'):
|
||||
self.close_connection = True
|
||||
directory = next((name for name in ('users', 'groups', 'account') if path == f'/{name}' or path.startswith(f'/{name}/')), None)
|
||||
directory = next((name for name in ('users', 'groups', 'account', 'site-to-site')
|
||||
if path == f'/{name}' or path.startswith(f'/{name}/')), None)
|
||||
protected = path in ('/config', '/metrics/history', '/postgresql') or directory is not None
|
||||
signing = path in ('/auth', '/auth/refresh', '/auth/logout')
|
||||
state = current() if protected or signing else {}
|
||||
@@ -751,6 +833,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
elif directory == 'account':
|
||||
code, document = account(claims['sub'], self.command, path, payload)
|
||||
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
||||
elif directory == 'site-to-site':
|
||||
code, document = site_to_site(self.command, path, payload)
|
||||
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
||||
elif directory is not None:
|
||||
code, document = (users if directory == 'users' else groups)(state, self.command, path, payload)
|
||||
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
||||
|
||||
@@ -2,23 +2,26 @@
|
||||
"""Show and change the router's configuration, /etc/vpn-router/vpn-router.json.
|
||||
|
||||
show prints the file as indented JSON; the file is not changed. set takes a setting as
|
||||
section.option, such as web.port, and a value, which it converts to the type the package's
|
||||
schema (vpnrouter.SCHEMA) gives the setting: a whole number, true or false, a list of strings
|
||||
separated by commas (an empty value is an empty list), or a string. The configuration with the
|
||||
new value is checked as vpn-router-setup checks it, and only then written, indented, with mode
|
||||
0600; vpn-router-setup applies it at its next run, which set reminds of. A failure is a message
|
||||
on standard error; the exit status is 2 for an invalid request and 1 for any other failure.
|
||||
-h or --help shows the options.
|
||||
section.option, such as web.port, or as site_to_site.NAME.option for a site-to-site connection,
|
||||
which it creates with the defaults (vpnrouter.CONNECTION) when it does not exist, and a value,
|
||||
which it converts to the type the package's schema (vpnrouter.SCHEMA) gives the setting: a whole
|
||||
number, true or false, a list of strings separated by commas (an empty value is an empty list),
|
||||
or a string. remove takes site_to_site.NAME and removes that connection. The configuration with
|
||||
the change is checked as vpn-router-setup checks it, and only then written with
|
||||
vpnrouter.save_config, holding vpnrouter.config_lock; vpn-router-setup applies it at its next
|
||||
run, which set and remove remind of. A failure is a message on standard error; the exit status
|
||||
is 2 for an invalid request and 1 for any other failure. -h or --help shows the options.
|
||||
|
||||
vpn-config-edit show
|
||||
vpn-config-edit set SECTION.OPTION VALUE
|
||||
vpn-config-edit set site_to_site.NAME.OPTION VALUE
|
||||
vpn-config-edit remove site_to_site.NAME
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
sys.path.insert(0, '/usr/lib/vpn-router')
|
||||
|
||||
@@ -43,48 +46,33 @@ class Parser(argparse.ArgumentParser):
|
||||
def read():
|
||||
"""The settings in the configuration file, without the defaults; {} when the file does not exist."""
|
||||
try:
|
||||
data = json.loads(vpnrouter.CONFIG_FILE.read_text(encoding='utf-8'))
|
||||
except FileNotFoundError:
|
||||
return {}
|
||||
except ValueError as exc:
|
||||
raise Failure(f'cannot parse {vpnrouter.CONFIG_FILE}: {exc}')
|
||||
if not isinstance(data, dict) or not all(isinstance(options, dict) for options in data.values()):
|
||||
raise Failure(f'cannot parse {vpnrouter.CONFIG_FILE}: it is not an object of sections')
|
||||
return data
|
||||
return vpnrouter.read_settings()
|
||||
except vpnrouter.ConfigError as exc:
|
||||
raise Failure(str(exc))
|
||||
|
||||
|
||||
def converted(key, text):
|
||||
"""The value of text for the setting key, section.option, of the type the schema gives it."""
|
||||
section, _, option = key.partition('.')
|
||||
if section not in vpnrouter.SCHEMA or option not in vpnrouter.SCHEMA[section]:
|
||||
raise Failure(f'{key}: unknown setting; settings are section.option, such as web.port', 2)
|
||||
kind = type(vpnrouter.SCHEMA[section][option])
|
||||
"""The path of the setting key, section.option or site_to_site.NAME.option, and text as the type the schema gives it."""
|
||||
path = key.split('.')
|
||||
if path[0] == 'site_to_site' and len(path) == 3 and path[2] in vpnrouter.CONNECTION:
|
||||
kind = type(vpnrouter.CONNECTION[path[2]])
|
||||
elif path[0] != 'site_to_site' and len(path) == 2 and path[0] in vpnrouter.SCHEMA and path[1] in vpnrouter.SCHEMA[path[0]]:
|
||||
kind = type(vpnrouter.SCHEMA[path[0]][path[1]])
|
||||
else:
|
||||
raise Failure(f'{key}: unknown setting; settings are section.option, such as web.port, '
|
||||
'or site_to_site.NAME.option', 2)
|
||||
if kind is bool:
|
||||
if text not in ('true', 'false'):
|
||||
raise Failure(f'{key} takes true or false', 2)
|
||||
return text == 'true'
|
||||
return path, text == 'true'
|
||||
if kind is int:
|
||||
try:
|
||||
return int(text)
|
||||
return path, int(text)
|
||||
except ValueError:
|
||||
raise Failure(f'{key} takes a whole number', 2)
|
||||
if kind is list:
|
||||
return [item.strip() for item in text.split(',') if item.strip()]
|
||||
return text
|
||||
|
||||
|
||||
def write(data):
|
||||
"""Write the settings, indented, with mode 0600, replacing the file in one step."""
|
||||
directory = vpnrouter.CONFIG_FILE.parent
|
||||
descriptor, name = tempfile.mkstemp(dir=directory, prefix='.vpn-router.json.')
|
||||
try:
|
||||
with os.fdopen(descriptor, 'w', encoding='utf-8') as handle:
|
||||
handle.write(json.dumps(data, indent=2) + '\n')
|
||||
os.chmod(name, 0o600)
|
||||
os.replace(name, vpnrouter.CONFIG_FILE)
|
||||
except OSError:
|
||||
os.unlink(name)
|
||||
raise
|
||||
return path, [item.strip() for item in text.split(',') if item.strip()]
|
||||
return path, text
|
||||
|
||||
|
||||
def main(argv):
|
||||
@@ -93,34 +81,49 @@ def main(argv):
|
||||
commands = parser.add_subparsers(dest='command', required=True, parser_class=Parser)
|
||||
commands.add_parser('show', help='print the configuration as indented JSON')
|
||||
setter = commands.add_parser('set', help='set a setting and write the configuration')
|
||||
setter.add_argument('key', metavar='SECTION.OPTION', help='the setting, such as web.port')
|
||||
setter.add_argument('key', metavar='SECTION.OPTION',
|
||||
help='the setting, such as web.port or site_to_site.NAME.option')
|
||||
setter.add_argument('value', metavar='VALUE', help='its value; a list separated by commas')
|
||||
remover = commands.add_parser('remove', help='remove a site-to-site connection and write the configuration')
|
||||
remover.add_argument('key', metavar='site_to_site.NAME', help='the connection')
|
||||
options = parser.parse_args(argv)
|
||||
|
||||
data = read()
|
||||
if options.command == 'show':
|
||||
print(json.dumps(data, indent=2))
|
||||
print(json.dumps(read(), indent=2))
|
||||
return 0
|
||||
|
||||
section, _, option = options.key.partition('.')
|
||||
value = converted(options.key, options.value)
|
||||
data.setdefault(section, {})[option] = value
|
||||
# The configuration as vpn-router-setup reads it: the defaults with the file's settings.
|
||||
candidate = {name: dict(defaults) for name, defaults in vpnrouter.SCHEMA.items()}
|
||||
for name, settings in data.items():
|
||||
candidate.setdefault(name, {}).update(settings)
|
||||
try:
|
||||
vpnrouter.validate(candidate)
|
||||
except vpnrouter.ConfigError as exc:
|
||||
raise Failure(str(exc), 2)
|
||||
write(data)
|
||||
with vpnrouter.config_lock():
|
||||
data = read()
|
||||
if options.command == 'set':
|
||||
path, value = converted(options.key, options.value)
|
||||
if path[0] == 'site_to_site':
|
||||
connections = data.setdefault('site_to_site', {})
|
||||
connection = connections.setdefault(path[1], copy.deepcopy(vpnrouter.CONNECTION))
|
||||
if not isinstance(connection, dict):
|
||||
raise Failure(f'site_to_site.{path[1]} is not an object')
|
||||
connection[path[2]] = value
|
||||
else:
|
||||
data.setdefault(path[0], {})[path[1]] = value
|
||||
else:
|
||||
section, _, name = options.key.partition('.')
|
||||
if section != 'site_to_site' or not name or name not in data.get('site_to_site', {}):
|
||||
raise Failure(f'{options.key}: no such site-to-site connection', 2)
|
||||
del data['site_to_site'][name]
|
||||
try:
|
||||
vpnrouter.validate(vpnrouter.with_defaults(data))
|
||||
except vpnrouter.ConfigError as exc:
|
||||
raise Failure(str(exc), 2)
|
||||
vpnrouter.save_config(data)
|
||||
except Failure as failure:
|
||||
print(f'vpn-config-edit: {failure}', file=sys.stderr)
|
||||
return failure.status
|
||||
except OSError as exc:
|
||||
print(f'vpn-config-edit: {exc}', file=sys.stderr)
|
||||
return 1
|
||||
print(f'{options.key} set to {json.dumps(value)} in {vpnrouter.CONFIG_FILE}.')
|
||||
if options.command == 'set':
|
||||
print(f'{options.key} set to {json.dumps(value)} in {vpnrouter.CONFIG_FILE}.')
|
||||
else:
|
||||
print(f'{options.key} removed from {vpnrouter.CONFIG_FILE}.')
|
||||
print('Apply it with: systemctl restart vpn-router-setup')
|
||||
return 0
|
||||
|
||||
|
||||
@@ -8,20 +8,23 @@
|
||||
"internal": "eth1"
|
||||
},
|
||||
"wan": {
|
||||
"local_fqdn": "router.example.com",
|
||||
"local_id_mode": "fqdn"
|
||||
"local_fqdn": "router.example.com"
|
||||
},
|
||||
"local": {
|
||||
"cidrs": ["10.0.0.0/24"],
|
||||
"int_addr": "10.1.1.4",
|
||||
"int_gateway_ip": "10.1.1.1"
|
||||
},
|
||||
"remote": {
|
||||
"addrs": ["peer.example.net"],
|
||||
"id": "peer.example.net",
|
||||
"cidrs": ["192.168.0.0/24"],
|
||||
"psk": "change-me",
|
||||
"psk_file": ""
|
||||
"site_to_site": {
|
||||
"remote-site": {
|
||||
"addrs": ["peer.example.net"],
|
||||
"id": "peer.example.net",
|
||||
"cidrs": ["192.168.0.0/24"],
|
||||
"local_cidrs": ["10.0.0.0/24"],
|
||||
"local_id_mode": "fqdn",
|
||||
"psk": "change-me",
|
||||
"psk_file": ""
|
||||
}
|
||||
},
|
||||
"p2s": {
|
||||
"enabled": false,
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
connections {
|
||||
remote-site {
|
||||
version = 2
|
||||
proposals = aes256-sha256-modp2048,aes256-sha1-modp1024
|
||||
reauth_time = 28800
|
||||
unique = replace
|
||||
|
||||
local_addrs = {{ local_addrs }}
|
||||
remote_addrs = {{ remote_addrs }}
|
||||
|
||||
local {
|
||||
id = {{ local_id }}
|
||||
auth = psk
|
||||
}
|
||||
|
||||
remote {
|
||||
id = {{ remote_id }}
|
||||
auth = psk
|
||||
}
|
||||
|
||||
children {
|
||||
site2site {
|
||||
mode = tunnel
|
||||
local_ts = {{ local_ts }}
|
||||
remote_ts = {{ remote_cidrs }}
|
||||
esp_proposals = aes256-sha256,aes256-sha1
|
||||
life_time = 3600
|
||||
dpd_action = restart
|
||||
start_action = start
|
||||
close_action = none
|
||||
}
|
||||
}
|
||||
|
||||
rekey_time = 10800
|
||||
dpd_delay = 10
|
||||
dpd_timeout = 120
|
||||
}
|
||||
}
|
||||
|
||||
secrets {
|
||||
ike-psk {
|
||||
id-1 = {{ local_id }}
|
||||
id-2 = {{ remote_id }}
|
||||
secret = "{{ psk }}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
connections {
|
||||
{%- for connection in connections if connection.ready %}
|
||||
{{ connection.name }} {
|
||||
version = 2
|
||||
proposals = aes256-sha256-modp2048,aes256-sha1-modp1024
|
||||
reauth_time = 28800
|
||||
unique = replace
|
||||
|
||||
local_addrs = {{ local_addrs }}
|
||||
remote_addrs = {{ connection.remote_addrs }}
|
||||
|
||||
local {
|
||||
id = {{ connection.local_id }}
|
||||
auth = psk
|
||||
}
|
||||
|
||||
remote {
|
||||
id = {{ connection.remote_id }}
|
||||
auth = psk
|
||||
}
|
||||
|
||||
children {
|
||||
{{ connection.name }} {
|
||||
mode = tunnel
|
||||
local_ts = {{ connection.local_ts }}
|
||||
remote_ts = {{ connection.remote_cidrs }}
|
||||
esp_proposals = aes256-sha256,aes256-sha1
|
||||
life_time = 3600
|
||||
dpd_action = restart
|
||||
start_action = start
|
||||
close_action = none
|
||||
}
|
||||
}
|
||||
|
||||
rekey_time = 10800
|
||||
dpd_delay = 10
|
||||
dpd_timeout = 120
|
||||
}
|
||||
{%- endfor %}
|
||||
}
|
||||
|
||||
secrets {
|
||||
{%- for connection in connections if connection.ready %}
|
||||
ike-psk-{{ connection.name }} {
|
||||
id-1 = {{ connection.local_id }}
|
||||
id-2 = {{ connection.remote_id }}
|
||||
secret = "{{ connection.psk }}"
|
||||
}
|
||||
{%- endfor %}
|
||||
}
|
||||
Reference in new issue
Block a user