This commit is contained in:
1 parent
cc6748a622
commit
a4cffb67e4
10 files changed
+369
-138
No files matched your search
@@ -29,7 +29,7 @@ restores the rules. The rules cover IPv4 only.
|
|||||||
|
|
||||||
## ADR-2: A status web service of the standard library, bound to the internal address
|
## ADR-2: A status web service of the standard library, bound to the internal address
|
||||||
|
|
||||||
Status: Accepted
|
Status: Accepted; amended by ADR-7
|
||||||
|
|
||||||
Context: The router has no way to see its state without a shell. A check from the protected
|
Context: The router has no way to see its state without a shell. A check from the protected
|
||||||
network, by a person or by a monitor, needs a page or an endpoint. The router is an
|
network, by a person or by a monitor, needs a page or an endpoint. The router is an
|
||||||
@@ -55,7 +55,7 @@ address is picked up at the next run of `vpn-router-setup`.
|
|||||||
|
|
||||||
## ADR-3: /config is authenticated with a short-lived JWT signed with a shared secret
|
## ADR-3: /config is authenticated with a short-lived JWT signed with a shared secret
|
||||||
|
|
||||||
Status: Accepted
|
Status: Accepted; amended by ADR-7
|
||||||
|
|
||||||
Context: A client such as manage-ui needs the configuration and the resolved state of the
|
Context: A client such as manage-ui needs the configuration and the resolved state of the
|
||||||
router, which `/` and `/status` must not show. The service has only the standard library, listens
|
router, which `/` and `/status` must not show. The service has only the standard library, listens
|
||||||
@@ -148,3 +148,29 @@ Consequences: The web service still imports nothing outside the standard library
|
|||||||
program serves the command line and the API. Each request starts a process, which is fine for
|
program serves the command line and the API. Each request starts a process, which is fine for
|
||||||
user administration. The API is protected by the token of `/config`, so whoever has the secret
|
user administration. The API is protected by the token of `/config`, so whoever has the secret
|
||||||
can manage users.
|
can manage users.
|
||||||
|
|
||||||
|
## ADR-7: The web service starts on its own and reads its inputs at each request
|
||||||
|
|
||||||
|
Status: Accepted
|
||||||
|
|
||||||
|
Context: The web service took the address to listen on, the port, the token secret and the
|
||||||
|
features from `/run/vpn-router/web.json`, written by `vpn-router-setup`, and ended with a failure
|
||||||
|
while the file or the internal address was missing. It was ordered after `vpn-router-setup`, so a
|
||||||
|
problem in the setup kept the status page, the tool to see that problem, from running.
|
||||||
|
|
||||||
|
Decision: The service depends on nothing to start and does not end on its own. A discovery
|
||||||
|
thread works out, every 30 seconds, the internal address from `vpn-router.json` as
|
||||||
|
`general.mode` directs, with the discovery code in `vpnrouter_net.py`, which uses only the
|
||||||
|
standard library and which `vpnrouter.py` uses too, and `web.port` and `web.enabled` from the
|
||||||
|
same file. It runs the HTTP server in a thread of its own at that address and port, stops it and
|
||||||
|
starts a new one when they change, and runs none while the service is disabled, the address is
|
||||||
|
not known or it cannot be bound. Each request reads the token secret from `vpn-router.json` and
|
||||||
|
the features and resolved facts from `web.json`, which setup still writes, because they need the
|
||||||
|
configuration code; while that file is missing the router shows no features. The unit is not
|
||||||
|
ordered after `vpn-router-setup`, and setup does not restart it.
|
||||||
|
|
||||||
|
Consequences: The status page answers as soon as the internal address exists, whatever state
|
||||||
|
setup is in, and follows a changed address, port or `web.enabled` within 30 seconds without a
|
||||||
|
restart. `web.json` no longer holds the address, the port or the secret, so the secret is only in
|
||||||
|
`vpn-router.json`. A change of the secret applies to the next request. The interfaces are read
|
||||||
|
every 30 seconds, which costs a few `ip` commands.
|
||||||
@@ -493,11 +493,14 @@ curl -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
|||||||
-d '{"name": "alice", "password": "s3cr3t-pass"}' http://10.0.0.4:8080/users
|
-d '{"name": "alice", "password": "s3cr3t-pass"}' http://10.0.0.4:8080/users
|
||||||
```
|
```
|
||||||
|
|
||||||
`vpn-router-setup` opens `web.port` on the internal interface in the firewall, writes the address,
|
The service starts on its own, without waiting for `vpn-router-setup`, and keeps running. Every
|
||||||
the port, the secret and the facts for `/config` to `/run/vpn-router/web.json` (readable by root
|
30 seconds it works out the internal address from `vpn-router.json` as `general.mode` directs,
|
||||||
only) and restarts the service, so a changed `web` section
|
and reads `web.port` and `web.enabled`; it listens there, moves when the address or the port
|
||||||
applies with `systemctl restart vpn-router-setup`. The service ends without an error when
|
changes, and stops listening while `web.enabled` is `false` or the address is not known. Each
|
||||||
`web.enabled` is `false`, and keeps trying to start until the router has an internal address.
|
request reads the token secret from `vpn-router.json`, and the features and the facts for
|
||||||
|
`/config` from `/run/vpn-router/web.json` (readable by root only), which `vpn-router-setup` writes;
|
||||||
|
before that file exists the page shows no features. `vpn-router-setup` opens `web.port` on the
|
||||||
|
internal interface in the firewall.
|
||||||
|
|
||||||
## Removal
|
## Removal
|
||||||
|
|
||||||
|
|||||||
Executable
+87
@@ -0,0 +1,87 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Commit everything, tag the version of the newest changelog entry and push main with the tags.
|
||||||
|
|
||||||
|
The tag is the upstream version of the first entry of debian-package/debian/changelog,
|
||||||
|
without the Debian revision: vpn-router (0.5.1-1) is tagged 0.5.1, which starts the
|
||||||
|
release workflow.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import pathlib
|
||||||
|
import shlex
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
ROOT = pathlib.Path(__file__).resolve().parent
|
||||||
|
CHANGELOG = ROOT / 'debian-package' / 'debian' / 'changelog'
|
||||||
|
|
||||||
|
|
||||||
|
#: Print the git commands instead of running them; set by -n/--dry-run.
|
||||||
|
DRY_RUN = False
|
||||||
|
|
||||||
|
|
||||||
|
def git(*args):
|
||||||
|
"""Run git in the repository, or print the command on a dry run; stop with git's exit status when it fails."""
|
||||||
|
command = ['git', *args]
|
||||||
|
if DRY_RUN:
|
||||||
|
print(shlex.join(command))
|
||||||
|
return
|
||||||
|
result = subprocess.run(command, cwd=ROOT)
|
||||||
|
if result.returncode != 0:
|
||||||
|
sys.exit(result.returncode)
|
||||||
|
|
||||||
|
|
||||||
|
def summary():
|
||||||
|
"""The summary line of the first changelog entry, or None when it has none.
|
||||||
|
|
||||||
|
The summary is the entry's first text line: indented by two spaces, not a "*" item,
|
||||||
|
with a blank line between it and the header and a blank line after it.
|
||||||
|
"""
|
||||||
|
lines = CHANGELOG.read_text(encoding='utf-8').splitlines()
|
||||||
|
if len(lines) < 4 or lines[1].strip() or lines[3].strip():
|
||||||
|
return None
|
||||||
|
line = lines[2]
|
||||||
|
text = line.strip()
|
||||||
|
if not line.startswith(' ') or line.startswith(' ') or not text or text.startswith('*') or line.startswith(' -- '):
|
||||||
|
return None
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def version():
|
||||||
|
"""The upstream version of the first changelog entry."""
|
||||||
|
first = CHANGELOG.read_text(encoding='utf-8').splitlines()[0]
|
||||||
|
start, end = first.find('('), first.find(')')
|
||||||
|
if start == -1 or end < start:
|
||||||
|
sys.exit(f'{CHANGELOG}: the first line has no version: {first}')
|
||||||
|
full = first[start + 1:end]
|
||||||
|
return full.rsplit('-', 1)[0] if '-' in full else full
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
global DRY_RUN
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||||
|
parser.add_argument('-m', '--message', help='commit message; default: the summary line of the newest changelog entry, a line without "*" between blank lines right after its header')
|
||||||
|
parser.add_argument('-n', '--dry-run', action='store_true', help='print the git commands instead of running them')
|
||||||
|
parser.add_argument('--no-push', action='store_true', help='commit and tag, but do not push')
|
||||||
|
args = parser.parse_args()
|
||||||
|
DRY_RUN = args.dry_run
|
||||||
|
|
||||||
|
message = args.message or summary()
|
||||||
|
if not message:
|
||||||
|
sys.exit(f'no commit message: pass -m, or start the newest entry of {CHANGELOG} with a summary line '
|
||||||
|
'without "*", indented by two spaces, with a blank line above and below it')
|
||||||
|
tag = version()
|
||||||
|
if subprocess.run(['git', 'rev-parse', '-q', '--verify', f'refs/tags/{tag}'], cwd=ROOT,
|
||||||
|
capture_output=True).returncode == 0:
|
||||||
|
sys.exit(f'tag {tag} exists; add a new changelog entry first')
|
||||||
|
|
||||||
|
git('add', '-A')
|
||||||
|
git('commit', '-m', message)
|
||||||
|
git('tag', tag)
|
||||||
|
if not args.no_push:
|
||||||
|
git('push', 'origin', 'main', '--tags')
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.exit(main())
|
||||||
@@ -1,3 +1,21 @@
|
|||||||
|
vpn-router (0.5.1-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
fix: the web service works in every state of the router.
|
||||||
|
|
||||||
|
* vpn-router-web works whether /etc/vpn-router/pki and
|
||||||
|
/etc/swanctl/x509crl exist or not; they exist only while OpenVPN or
|
||||||
|
road-warrior access is configured. The paths the unit may write are
|
||||||
|
used when they exist and ignored when they do not.
|
||||||
|
* vpn-router-web starts on its own and keeps running. Every 30 seconds a
|
||||||
|
discovery thread works out the internal address from vpn-router.json as
|
||||||
|
general.mode directs, with web.port and web.enabled, and starts, moves
|
||||||
|
or stops the server thread to match. Each request reads the token
|
||||||
|
secret, the features and the facts it shows. The service is no longer
|
||||||
|
ordered after vpn-router-setup, which no longer restarts it, and
|
||||||
|
web.json no longer holds the address, the port or the secret.
|
||||||
|
|
||||||
|
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sat, 24 Oct 2026 10:00:00 +0200
|
||||||
|
|
||||||
vpn-router (0.5.0-1) unstable; urgency=medium
|
vpn-router (0.5.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
* OpenVPN P2S access, set in the openvpn section (enabled, address_pool,
|
* OpenVPN P2S access, set in the openvpn section (enabled, address_pool,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
src/etc/sysctl.d/99-vpn-router.conf etc/sysctl.d/
|
src/etc/sysctl.d/99-vpn-router.conf etc/sysctl.d/
|
||||||
src/usr/lib/vpn-router/vpnrouter.py usr/lib/vpn-router/
|
src/usr/lib/vpn-router/vpnrouter.py usr/lib/vpn-router/
|
||||||
src/usr/lib/vpn-router/vpnrouter_ldap.py usr/lib/vpn-router/
|
src/usr/lib/vpn-router/vpnrouter_ldap.py usr/lib/vpn-router/
|
||||||
|
src/usr/lib/vpn-router/vpnrouter_net.py usr/lib/vpn-router/
|
||||||
src/usr/lib/vpn-router/openvpn-same-user usr/lib/vpn-router/
|
src/usr/lib/vpn-router/openvpn-same-user usr/lib/vpn-router/
|
||||||
src/usr/sbin/vpn-router-user usr/sbin/
|
src/usr/sbin/vpn-router-user usr/sbin/
|
||||||
src/usr/lib/vpn-router/simple-ca usr/lib/vpn-router/
|
src/usr/lib/vpn-router/simple-ca usr/lib/vpn-router/
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=VPN Router status web service
|
Description=VPN Router status web service
|
||||||
Documentation=file:/usr/share/doc/vpn-router/README.md
|
Documentation=file:/usr/share/doc/vpn-router/README.md
|
||||||
After=network-online.target vpn-router-setup.service
|
After=network-online.target
|
||||||
Wants=network-online.target
|
Wants=network-online.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
@@ -11,7 +11,7 @@ Restart=on-failure
|
|||||||
RestartSec=15
|
RestartSec=15
|
||||||
NoNewPrivileges=yes
|
NoNewPrivileges=yes
|
||||||
ProtectSystem=strict
|
ProtectSystem=strict
|
||||||
ReadWritePaths=/run/vpn-router /etc/vpn-router/pki /etc/swanctl/x509crl
|
ReadWritePaths=-/run/vpn-router -/etc/vpn-router/pki -/etc/swanctl/x509crl
|
||||||
ProtectHome=yes
|
ProtectHome=yes
|
||||||
PrivateTmp=yes
|
PrivateTmp=yes
|
||||||
|
|
||||||
|
|||||||
@@ -378,15 +378,13 @@ def refresh_docker_rules():
|
|||||||
|
|
||||||
|
|
||||||
def refresh_web(cfg, ctx):
|
def refresh_web(cfg, ctx):
|
||||||
"""Hand the web service what it needs and restart it.
|
"""Hand the web service the features and facts it shows.
|
||||||
|
|
||||||
The service takes the address to bind to, the port, the features of the
|
The service reads the file at each request: the features of the router,
|
||||||
router, the secret that signs its tokens, the configuration with its
|
the configuration with its secrets masked and the facts resolved from the
|
||||||
secrets masked and the facts resolved from the system from a file here, so
|
system, which it cannot work out with the standard library alone. The file
|
||||||
that it needs nothing but the standard library and the configuration code.
|
is readable by root only. The service follows a changed address or port
|
||||||
The file is readable by root only. The service ends quietly when
|
itself.
|
||||||
web.enabled is false. The restart is queued, not waited for, because the
|
|
||||||
unit is ordered after vpn-router-setup.
|
|
||||||
"""
|
"""
|
||||||
masked = {
|
masked = {
|
||||||
section: {option: '********' if SECRET_OPTION.search(option) and value else value
|
section: {option: '********' if SECRET_OPTION.search(option) and value else value
|
||||||
@@ -397,10 +395,6 @@ def refresh_web(cfg, ctx):
|
|||||||
WEB_STATE.touch(mode=0o600)
|
WEB_STATE.touch(mode=0o600)
|
||||||
WEB_STATE.chmod(0o600)
|
WEB_STATE.chmod(0o600)
|
||||||
WEB_STATE.write_text(json.dumps({
|
WEB_STATE.write_text(json.dumps({
|
||||||
'enabled': bool(ctx['web_enabled']),
|
|
||||||
'bind': ctx['int_addr'],
|
|
||||||
'port': ctx['web_port'],
|
|
||||||
'jwt_secret': cfg['web']['jwt_secret'],
|
|
||||||
'site_to_site': bool(vpnrouter.s2s_ready(ctx)),
|
'site_to_site': bool(vpnrouter.s2s_ready(ctx)),
|
||||||
'p2s': bool(vpnrouter.p2s_ready(ctx)),
|
'p2s': bool(vpnrouter.p2s_ready(ctx)),
|
||||||
'wireguard': bool(vpnrouter.wg_ready(ctx)),
|
'wireguard': bool(vpnrouter.wg_ready(ctx)),
|
||||||
@@ -419,7 +413,6 @@ def refresh_web(cfg, ctx):
|
|||||||
'remote_cidrs': ctx['remote_cidr_list'],
|
'remote_cidrs': ctx['remote_cidr_list'],
|
||||||
},
|
},
|
||||||
}) + '\n', encoding='utf-8')
|
}) + '\n', encoding='utf-8')
|
||||||
run(['systemctl', 'restart', '--no-block', 'vpn-router-web.service'], check=False)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ if str(LIB_DIR) not in sys.path:
|
|||||||
|
|
||||||
import vpnrouter_ldap
|
import vpnrouter_ldap
|
||||||
import vpnrouter_platforms
|
import vpnrouter_platforms
|
||||||
|
from vpnrouter_net import ConfigError, detect_interfaces, interface_addresses, ip_output
|
||||||
|
|
||||||
CONFIG_FILE = pathlib.Path('/etc/vpn-router/vpn-router.json')
|
CONFIG_FILE = pathlib.Path('/etc/vpn-router/vpn-router.json')
|
||||||
TEMPLATE_DIR = pathlib.Path('/usr/share/vpn-router/templates')
|
TEMPLATE_DIR = pathlib.Path('/usr/share/vpn-router/templates')
|
||||||
@@ -92,10 +93,6 @@ MODES = ('manual', 'interfaces', 'auto')
|
|||||||
PLATFORM_NONE = 'none'
|
PLATFORM_NONE = 'none'
|
||||||
|
|
||||||
|
|
||||||
class ConfigError(Exception):
|
|
||||||
"""A setting is present but malformed."""
|
|
||||||
|
|
||||||
|
|
||||||
def warn(message):
|
def warn(message):
|
||||||
print(f'vpn-router: warning: {message}', file=sys.stderr)
|
print(f'vpn-router: warning: {message}', file=sys.stderr)
|
||||||
|
|
||||||
@@ -327,55 +324,6 @@ def deferred(cfg):
|
|||||||
return (_get(cfg, 'general', 'platform') or PLATFORM_NONE) == PLATFORM_NONE
|
return (_get(cfg, 'general', 'platform') or PLATFORM_NONE) == PLATFORM_NONE
|
||||||
|
|
||||||
|
|
||||||
def _ip_route(*args):
|
|
||||||
result = subprocess.run(['ip', *args], capture_output=True, text=True)
|
|
||||||
return result.stdout if result.returncode == 0 else ''
|
|
||||||
|
|
||||||
|
|
||||||
def detect_interfaces():
|
|
||||||
"""Work out the external and internal interface names.
|
|
||||||
|
|
||||||
Only reached in auto mode, where the operator has accepted that a wrong
|
|
||||||
guess is possible. The external interface is whichever one carries the
|
|
||||||
default route; the internal one is the only other addressed interface.
|
|
||||||
"""
|
|
||||||
tokens = _ip_route('route', 'get', '1.1.1.1').split()
|
|
||||||
external = ''
|
|
||||||
for index, token in enumerate(tokens):
|
|
||||||
if token == 'dev' and index + 1 < len(tokens):
|
|
||||||
external = tokens[index + 1]
|
|
||||||
break
|
|
||||||
if not external:
|
|
||||||
raise ConfigError('auto mode: cannot determine the external interface')
|
|
||||||
|
|
||||||
candidates = [name for name in _addressed_interfaces() if name != external]
|
|
||||||
if len(candidates) != 1:
|
|
||||||
raise ConfigError(
|
|
||||||
f'auto mode: expected one internal interface, found {len(candidates)}; '
|
|
||||||
'use mode = interfaces and name them')
|
|
||||||
return external, candidates[0]
|
|
||||||
|
|
||||||
|
|
||||||
def _is_virtual(name):
|
|
||||||
"""True for an interface the kernel creates without a device behind it.
|
|
||||||
|
|
||||||
Loopback, bridges, veth pairs and tunnels live under /sys/devices/virtual,
|
|
||||||
which is where the Docker bridge is; a NIC of the virtual machine does not.
|
|
||||||
"""
|
|
||||||
link = pathlib.Path('/sys/class/net') / name
|
|
||||||
return '/devices/virtual/' in str(link.resolve())
|
|
||||||
|
|
||||||
|
|
||||||
def _addressed_interfaces():
|
|
||||||
"""Names of the NICs holding at least one IPv4 address; virtual interfaces are left out."""
|
|
||||||
names = []
|
|
||||||
for line in _ip_route('-o', '-4', 'addr', 'show').splitlines():
|
|
||||||
fields = line.split()
|
|
||||||
if len(fields) >= 4 and fields[1] not in names and not _is_virtual(fields[1]):
|
|
||||||
names.append(fields[1])
|
|
||||||
return names
|
|
||||||
|
|
||||||
|
|
||||||
def detect_gateway(iface, address):
|
def detect_gateway(iface, address):
|
||||||
"""Derive the next hop on the internal side.
|
"""Derive the next hop on the internal side.
|
||||||
|
|
||||||
@@ -383,12 +331,12 @@ def detect_gateway(iface, address):
|
|||||||
back to the first host address of the connected subnet, which is a
|
back to the first host address of the connected subnet, which is a
|
||||||
convention rather than a fact - so say so.
|
convention rather than a fact - so say so.
|
||||||
"""
|
"""
|
||||||
for line in _ip_route('-o', 'route', 'show', 'dev', iface).splitlines():
|
for line in ip_output('-o', 'route', 'show', 'dev', iface).splitlines():
|
||||||
fields = line.split()
|
fields = line.split()
|
||||||
if 'via' in fields:
|
if 'via' in fields:
|
||||||
return fields[fields.index('via') + 1]
|
return fields[fields.index('via') + 1]
|
||||||
|
|
||||||
for line in _ip_route('-o', '-4', 'addr', 'show', 'dev', iface).splitlines():
|
for line in ip_output('-o', '-4', 'addr', 'show', 'dev', iface).splitlines():
|
||||||
fields = line.split()
|
fields = line.split()
|
||||||
if len(fields) >= 4 and fields[3].split('/')[0] == address:
|
if len(fields) >= 4 and fields[3].split('/')[0] == address:
|
||||||
network = ipaddress.ip_interface(fields[3]).network
|
network = ipaddress.ip_interface(fields[3]).network
|
||||||
@@ -399,34 +347,6 @@ def detect_gateway(iface, address):
|
|||||||
return ''
|
return ''
|
||||||
|
|
||||||
|
|
||||||
def interface_addresses(name, setting):
|
|
||||||
"""IPv4 addresses configured on an interface.
|
|
||||||
|
|
||||||
The interface name is configuration; its addresses are read from the
|
|
||||||
system. Looking up the addresses of a named interface is deterministic -
|
|
||||||
unlike working out which interface to use, which is a guess the package
|
|
||||||
deliberately does not make.
|
|
||||||
"""
|
|
||||||
if not name:
|
|
||||||
return []
|
|
||||||
if not (pathlib.Path('/sys/class/net') / name).exists():
|
|
||||||
raise ConfigError(f'{setting}: interface {name!r} does not exist')
|
|
||||||
|
|
||||||
result = subprocess.run(
|
|
||||||
['ip', '-o', '-4', 'addr', 'show', 'dev', name],
|
|
||||||
capture_output=True, text=True,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
raise ConfigError(f'{setting}: cannot read addresses of {name!r}')
|
|
||||||
|
|
||||||
addresses = []
|
|
||||||
for line in result.stdout.splitlines():
|
|
||||||
fields = line.split()
|
|
||||||
if len(fields) >= 4:
|
|
||||||
addresses.append(fields[3].split('/')[0])
|
|
||||||
return addresses
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_interfaces(cfg):
|
def resolve_interfaces(cfg):
|
||||||
"""Return (ext_iface, int_iface, local_addrs, int_addr, int_gateway_ip).
|
"""Return (ext_iface, int_iface, local_addrs, int_addr, int_gateway_ip).
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""Interfaces and addresses of the router, read from the system.
|
||||||
|
|
||||||
|
Only the Python standard library is used, so the web service can use this module too.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pathlib
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigError(Exception):
|
||||||
|
"""A setting is present but malformed."""
|
||||||
|
|
||||||
|
|
||||||
|
def ip_output(*args):
|
||||||
|
"""The output of an ip command, or '' when it fails."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(['ip', *args], capture_output=True, text=True)
|
||||||
|
except OSError:
|
||||||
|
return ''
|
||||||
|
return result.stdout if result.returncode == 0 else ''
|
||||||
|
|
||||||
|
|
||||||
|
def detect_interfaces():
|
||||||
|
"""Work out the external and internal interface names.
|
||||||
|
|
||||||
|
Only reached in auto mode, where the operator has accepted that a wrong
|
||||||
|
guess is possible. The external interface is whichever one carries the
|
||||||
|
default route; the internal one is the only other addressed interface.
|
||||||
|
"""
|
||||||
|
tokens = ip_output('route', 'get', '1.1.1.1').split()
|
||||||
|
external = ''
|
||||||
|
for index, token in enumerate(tokens):
|
||||||
|
if token == 'dev' and index + 1 < len(tokens):
|
||||||
|
external = tokens[index + 1]
|
||||||
|
break
|
||||||
|
if not external:
|
||||||
|
raise ConfigError('auto mode: cannot determine the external interface')
|
||||||
|
|
||||||
|
candidates = [name for name in _addressed_interfaces() if name != external]
|
||||||
|
if len(candidates) != 1:
|
||||||
|
raise ConfigError(
|
||||||
|
f'auto mode: expected one internal interface, found {len(candidates)}; '
|
||||||
|
'use mode = interfaces and name them')
|
||||||
|
return external, candidates[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _is_virtual(name):
|
||||||
|
"""True for an interface the kernel creates without a device behind it.
|
||||||
|
|
||||||
|
Loopback, bridges, veth pairs and tunnels live under /sys/devices/virtual,
|
||||||
|
which is where the Docker bridge is; a NIC of the virtual machine does not.
|
||||||
|
"""
|
||||||
|
link = pathlib.Path('/sys/class/net') / name
|
||||||
|
return '/devices/virtual/' in str(link.resolve())
|
||||||
|
|
||||||
|
|
||||||
|
def _addressed_interfaces():
|
||||||
|
"""Names of the NICs holding at least one IPv4 address; virtual interfaces are left out."""
|
||||||
|
names = []
|
||||||
|
for line in ip_output('-o', '-4', 'addr', 'show').splitlines():
|
||||||
|
fields = line.split()
|
||||||
|
if len(fields) >= 4 and fields[1] not in names and not _is_virtual(fields[1]):
|
||||||
|
names.append(fields[1])
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
def interface_addresses(name, setting):
|
||||||
|
"""IPv4 addresses configured on an interface.
|
||||||
|
|
||||||
|
The interface name is configuration; its addresses are read from the
|
||||||
|
system. Looking up the addresses of a named interface is deterministic -
|
||||||
|
unlike working out which interface to use, which is a guess the package
|
||||||
|
deliberately does not make.
|
||||||
|
"""
|
||||||
|
if not name:
|
||||||
|
return []
|
||||||
|
if not (pathlib.Path('/sys/class/net') / name).exists():
|
||||||
|
raise ConfigError(f'{setting}: interface {name!r} does not exist')
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
['ip', '-o', '-4', 'addr', 'show', 'dev', name],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise ConfigError(f'{setting}: cannot read addresses of {name!r}')
|
||||||
|
|
||||||
|
addresses = []
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
fields = line.split()
|
||||||
|
if len(fields) >= 4:
|
||||||
|
addresses.append(fields[3].split('/')[0])
|
||||||
|
return addresses
|
||||||
|
|
||||||
|
|
||||||
|
def internal_address(mode, internal, int_addr):
|
||||||
|
"""The router's address on the internal network, as general.mode directs, or '' when it is not known yet.
|
||||||
|
|
||||||
|
manual takes local.int_addr; interfaces reads the address of interfaces.internal; auto also
|
||||||
|
works out which interface that is.
|
||||||
|
"""
|
||||||
|
if mode == 'manual':
|
||||||
|
return int_addr
|
||||||
|
try:
|
||||||
|
if mode == 'auto':
|
||||||
|
internal = detect_interfaces()[1]
|
||||||
|
addresses = interface_addresses(internal, 'interfaces.internal')
|
||||||
|
except ConfigError:
|
||||||
|
return ''
|
||||||
|
return addresses[0] if addresses else ''
|
||||||
@@ -21,12 +21,14 @@ web.jwt_secret is not set.
|
|||||||
Docker is installed, and the state of site-to-site, road-warrior (IPsec P2S), OpenVPN and
|
Docker is installed, and the state of site-to-site, road-warrior (IPsec P2S), OpenVPN and
|
||||||
WireGuard. No configuration data, key or address is shown.
|
WireGuard. No configuration data, key or address is shown.
|
||||||
|
|
||||||
The address to bind to, the port, the secret and the configuration shown by /config
|
The service needs nothing else to start and does not end on its own. A discovery thread works
|
||||||
are not read from the configuration here: vpn-router-setup resolves them, with the
|
out the internal address from /etc/vpn-router/vpn-router.json as general.mode directs, with
|
||||||
interfaces, and writes them to /run/vpn-router/web.json, readable by root only, before
|
web.port and web.enabled, every DISCOVERY_SECONDS, and runs the server thread there: it starts
|
||||||
it restarts this service. The service ends with
|
it, moves it when the address or the port changes, and stops it while the service is disabled
|
||||||
success when the file says the service is disabled, and with failure, to be
|
or the address is not known. Every request reads what it needs when it is made: the token secret
|
||||||
started again, when the file is missing or the router has no internal address yet.
|
from the configuration, and the features of the router and the facts shown by /config from
|
||||||
|
/run/vpn-router/web.json, which vpn-router-setup writes. Before setup has written that file
|
||||||
|
the router has no features, and /config shows no configuration or interfaces.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
@@ -42,7 +44,18 @@ import time
|
|||||||
from html import escape
|
from html import escape
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
|
||||||
|
|
||||||
|
import vpnrouter_net
|
||||||
|
|
||||||
STATE_FILE = '/run/vpn-router/web.json'
|
STATE_FILE = '/run/vpn-router/web.json'
|
||||||
|
CONFIG_FILE = '/etc/vpn-router/vpn-router.json'
|
||||||
|
|
||||||
|
#: The port when web.port is not set.
|
||||||
|
DEFAULT_PORT = 8080
|
||||||
|
|
||||||
|
#: Seconds between two discoveries of where the service should listen.
|
||||||
|
DISCOVERY_SECONDS = 30
|
||||||
COMMAND_TIMEOUT = 5
|
COMMAND_TIMEOUT = 5
|
||||||
CACHE_SECONDS = 2
|
CACHE_SECONDS = 2
|
||||||
|
|
||||||
@@ -73,6 +86,33 @@ _cache = {'at': 0.0, 'data': None}
|
|||||||
_lock = threading.Lock()
|
_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def read_json(path):
|
||||||
|
"""The object in a JSON file, or {} when the file is missing or holds no object."""
|
||||||
|
try:
|
||||||
|
with open(path, encoding='utf-8') as handle:
|
||||||
|
data = json.load(handle)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return {}
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def section(configuration, name):
|
||||||
|
"""A section of the configuration, or {} when it is missing or not an object."""
|
||||||
|
value = configuration.get(name)
|
||||||
|
return value if isinstance(value, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def current():
|
||||||
|
"""What a request needs, read now: the features and facts setup resolved, and the token secret.
|
||||||
|
|
||||||
|
Before setup has run the router has no features, and without a secret /config and /users are
|
||||||
|
not served.
|
||||||
|
"""
|
||||||
|
state = read_json(STATE_FILE)
|
||||||
|
secret = section(read_json(CONFIG_FILE), 'web').get('jwt_secret')
|
||||||
|
return dict(state, jwt_secret=secret if isinstance(secret, str) else '')
|
||||||
|
|
||||||
|
|
||||||
def run(*args):
|
def run(*args):
|
||||||
"""The finished process of a command, or None when it cannot be run."""
|
"""The finished process of a command, or None when it cannot be run."""
|
||||||
try:
|
try:
|
||||||
@@ -433,13 +473,12 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
def status(self):
|
def status(self):
|
||||||
with _lock:
|
with _lock:
|
||||||
if _cache['data'] is None or time.monotonic() - _cache['at'] > CACHE_SECONDS:
|
if _cache['data'] is None or time.monotonic() - _cache['at'] > CACHE_SECONDS:
|
||||||
_cache['data'] = collect(self.server.state)
|
_cache['data'] = collect(current())
|
||||||
_cache['at'] = time.monotonic()
|
_cache['at'] = time.monotonic()
|
||||||
return _cache['data']
|
return _cache['data']
|
||||||
|
|
||||||
def authorized(self):
|
def authorized(self, secret):
|
||||||
"""Whether the request carries a token signed with the secret; False also while no secret is set."""
|
"""Whether the request carries a token signed with the secret; False also while no secret is set."""
|
||||||
secret = self.server.state.get('jwt_secret')
|
|
||||||
header = self.headers.get('Authorization', '')
|
header = self.headers.get('Authorization', '')
|
||||||
return bool(secret) and header.startswith('Bearer ') and verify(header[7:].strip(), secret) is not None
|
return bool(secret) and header.startswith('Bearer ') and verify(header[7:].strip(), secret) is not None
|
||||||
|
|
||||||
@@ -450,15 +489,16 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
if self.command not in ('GET', 'HEAD'):
|
if self.command not in ('GET', 'HEAD'):
|
||||||
self.close_connection = True
|
self.close_connection = True
|
||||||
protected = path == '/config' or path == '/users' or path.startswith('/users/')
|
protected = path == '/config' or path == '/users' or path.startswith('/users/')
|
||||||
if protected and not self.server.state.get('jwt_secret'):
|
state = current() if protected else {}
|
||||||
|
if protected and not state['jwt_secret']:
|
||||||
body, kind, code = b'Not found\n', 'text/plain; charset=utf-8', 404
|
body, kind, code = b'Not found\n', 'text/plain; charset=utf-8', 404
|
||||||
elif protected and not self.authorized():
|
elif protected and not self.authorized(state['jwt_secret']):
|
||||||
body, kind, code = b'Unauthorized\n', 'text/plain; charset=utf-8', 401
|
body, kind, code = b'Unauthorized\n', 'text/plain; charset=utf-8', 401
|
||||||
extra.append(('WWW-Authenticate', 'Bearer'))
|
extra.append(('WWW-Authenticate', 'Bearer'))
|
||||||
elif path == '/config' and self.command in ('GET', 'HEAD'):
|
elif path == '/config' and self.command in ('GET', 'HEAD'):
|
||||||
body, kind, code = json.dumps(details(self.server.state), indent=2).encode() + b'\n', 'application/json', 200
|
body, kind, code = json.dumps(details(state), indent=2).encode() + b'\n', 'application/json', 200
|
||||||
elif protected and path != '/config':
|
elif protected and path != '/config':
|
||||||
code, document = users(self.server.state, self.command, path, self.request_body())
|
code, document = users(state, self.command, path, self.request_body())
|
||||||
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
||||||
elif self.command not in ('GET', 'HEAD'):
|
elif self.command not in ('GET', 'HEAD'):
|
||||||
body, kind, code = b'Method not allowed\n', 'text/plain; charset=utf-8', 405
|
body, kind, code = b'Method not allowed\n', 'text/plain; charset=utf-8', 405
|
||||||
@@ -515,31 +555,65 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
sys.stderr.write('%s - - [%s] %s\n' % (self.client_address[0], self.log_date_time_string(), format % args))
|
sys.stderr.write('%s - - [%s] %s\n' % (self.client_address[0], self.log_date_time_string(), format % args))
|
||||||
|
|
||||||
|
|
||||||
|
def wanted():
|
||||||
|
"""Where the service should listen now, as (address, port), or the reason it should not listen."""
|
||||||
|
configuration = read_json(CONFIG_FILE)
|
||||||
|
web = section(configuration, 'web')
|
||||||
|
if web.get('enabled', True) is False:
|
||||||
|
return None, 'the web service is disabled in the configuration'
|
||||||
|
port = web.get('port', DEFAULT_PORT)
|
||||||
|
if isinstance(port, bool) or not isinstance(port, int) or not 1 <= port <= 65535:
|
||||||
|
port = DEFAULT_PORT
|
||||||
|
general, interfaces, local = (section(configuration, name) for name in ('general', 'interfaces', 'local'))
|
||||||
|
address = vpnrouter_net.internal_address(str(general.get('mode') or 'manual'), str(interfaces.get('internal') or ''),
|
||||||
|
str(local.get('int_addr') or ''))
|
||||||
|
if not address:
|
||||||
|
return None, 'the internal address is not known yet'
|
||||||
|
return (address, port), ''
|
||||||
|
|
||||||
|
|
||||||
|
def log(message):
|
||||||
|
print(f'vpn-router-web: {message}', file=sys.stderr, flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def discover():
|
||||||
|
"""Work out where to listen every DISCOVERY_SECONDS, and run the server thread there.
|
||||||
|
|
||||||
|
When the address or the port changes, the server thread is stopped and a new one listens at
|
||||||
|
the new place; while the service is disabled, the address is not known or it cannot be bound,
|
||||||
|
no server runs and the next round tries again.
|
||||||
|
"""
|
||||||
|
server, thread, listening, reported = None, None, None, None
|
||||||
|
while True:
|
||||||
|
target, reason = wanted()
|
||||||
|
if target != listening and server is not None:
|
||||||
|
server.shutdown()
|
||||||
|
server.server_close()
|
||||||
|
thread.join()
|
||||||
|
server, listening = None, None
|
||||||
|
if target is not None and server is None:
|
||||||
|
try:
|
||||||
|
server = ThreadingHTTPServer(target, Handler)
|
||||||
|
except OSError as exc:
|
||||||
|
reason = f'cannot listen on {target[0]}:{target[1]}: {exc}'
|
||||||
|
else:
|
||||||
|
thread = threading.Thread(target=server.serve_forever, name='server', daemon=True)
|
||||||
|
thread.start()
|
||||||
|
listening, reason = target, f'serving on http://{target[0]}:{target[1]}'
|
||||||
|
if reason and reason != reported:
|
||||||
|
log(reason)
|
||||||
|
reported = reason
|
||||||
|
time.sleep(DISCOVERY_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
try:
|
|
||||||
with open(STATE_FILE, encoding='utf-8') as handle:
|
|
||||||
state = json.load(handle)
|
|
||||||
except (OSError, ValueError) as exc:
|
|
||||||
print(f'vpn-router-web: cannot read {STATE_FILE}: {exc}', file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
if not state.get('enabled'):
|
|
||||||
print('vpn-router-web: the web service is disabled in the configuration')
|
|
||||||
return 0
|
|
||||||
if not state.get('bind'):
|
|
||||||
print('vpn-router-web: the router has no internal address yet', file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
server = ThreadingHTTPServer((state['bind'], state['port']), Handler)
|
|
||||||
server.state = state
|
|
||||||
signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
|
signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
|
||||||
print(f'vpn-router-web: serving on http://{state["bind"]}:{state["port"]}')
|
discovery = threading.Thread(target=discover, name='discovery', daemon=True)
|
||||||
|
discovery.start()
|
||||||
try:
|
try:
|
||||||
server.serve_forever()
|
discovery.join()
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
pass
|
pass
|
||||||
finally:
|
|
||||||
server.server_close()
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user