fix: the web service listens on the internal address, the external address or both.
Release / release (push) Successful in 25s

This commit is contained in:
slawek committed 2026-10-25 11:37:43 +01:00
1 parent 581b53d447
commit 993a7a9fa9
9 files changed
+165 -89

No files matched your search

+6 -4
View File
@@ -215,11 +215,13 @@ def filter_block(ctx):
]
lines += ['# P2S DNS RULES END']
if ctx['web_enabled'] and ctx['int_iface']:
# The web service and the Router Manager choose their interfaces themselves, so their ports are open on both.
interfaces = [name for name in (ctx['int_iface'], ctx['wan_iface']) if name]
if ctx['web_enabled'] and interfaces:
lines += [
'',
'# WEB RULES START',
f'-A ufw-before-input -i {ctx["int_iface"]} -p tcp --dport {ctx["web_port"]} -j ACCEPT',
*(f'-A ufw-before-input -i {name} -p tcp --dport {ctx["web_port"]} -j ACCEPT' for name in interfaces),
'# WEB RULES END',
]
@@ -231,12 +233,12 @@ def filter_block(ctx):
'# BOOTSTRAP RULES END',
]
if MANAGER.exists() and ctx['int_iface']:
if MANAGER.exists() and interfaces:
port = manager_port()
lines += [
'',
'# MANAGER RULES START',
f'-A ufw-before-input -i {ctx["int_iface"]} -p tcp --dport {port} -j ACCEPT',
*(f'-A ufw-before-input -i {name} -p tcp --dport {port} -j ACCEPT' for name in interfaces),
'# MANAGER RULES END',
]
@@ -21,7 +21,7 @@ if str(LIB_DIR) not in sys.path:
import vpnrouter_ldap
import vpnrouter_platforms
from vpnrouter_net import ConfigError, detect_interfaces, interface_addresses, ip_output
from vpnrouter_net import DEFAULT_LISTEN, LISTEN_CHOICES, ConfigError, detect_interfaces, interface_addresses, ip_output
CONFIG_FILE = pathlib.Path('/etc/vpn-router/vpn-router.json')
TEMPLATE_DIR = pathlib.Path('/usr/share/vpn-router/templates')
@@ -54,7 +54,7 @@ SCHEMA = {
'wireguard': {'enabled': False, 'address': '', 'listen_port': 51820},
'openvpn': {'enabled': False, 'address_pool': '', 'port': 1194, 'protocol': 'udp'},
'docker': {'mode': 'auto', 'exposed_ports': []},
'web': {'enabled': True, 'port': 7081, 'database': '', 'trusted_proxies': []},
'web': {'enabled': True, 'listen': DEFAULT_LISTEN, 'port': 7081, 'database': '', 'trusted_proxies': []},
}
DOCKER_MODES = ('auto', 'on', 'off')
@@ -259,6 +259,11 @@ def validate(cfg):
if isinstance(web_port, bool) or not isinstance(web_port, int) or not 1 <= web_port <= 65535:
raise ConfigError(f'web.port: {web_port!r} is not a valid port')
web_listen = _get(cfg, 'web', 'listen')
if web_listen not in LISTEN_CHOICES:
raise ConfigError(
f'web.listen: {web_listen!r} is not one of {", ".join(LISTEN_CHOICES)}')
for item in _get_list(cfg, 'web', 'trusted_proxies'):
_check_address(item, 'web.trusted_proxies')
@@ -7,6 +7,12 @@ this module too.
import pathlib
import subprocess
#: The values of web.listen and of the Router Manager's listen: the interfaces a service listens on.
LISTEN_CHOICES = ('internal', 'external', 'both')
#: The interfaces a service listens on when it is not set.
DEFAULT_LISTEN = 'both'
class ConfigError(Exception):
"""A setting is present but malformed."""
@@ -110,26 +116,58 @@ def internal_address(mode, internal, int_addr):
return addresses[0] if addresses else ''
def web_listen(configuration, default_port):
"""Where the web service listens, from vpn-router.json read as configuration, as ((address, port), '').
def external_address(mode, external):
"""The router's address on the external interface, as general.mode directs, or '' when it is not known yet.
The address is the internal address as general.mode directs, the port web.port, else
default_port. While web.enabled is false or the address is not known, the answer is (None,
the reason).
auto works out which interface that is; the other modes read the address of interfaces.external.
"""
def section(name):
value = configuration.get(name)
return value if isinstance(value, dict) else {}
try:
if mode == 'auto':
external = detect_interfaces()[0]
addresses = interface_addresses(external, 'interfaces.external')
except ConfigError:
return ''
return addresses[0] if addresses else ''
web = section('web')
def _section(configuration, name):
"""A section of a configuration, or {} when it is missing or not an object."""
value = configuration.get(name)
return value if isinstance(value, dict) else {}
def listen_addresses(configuration, choice):
"""The addresses a service listens on for choice of LISTEN_CHOICES, from vpn-router.json read as configuration.
The internal address comes first; an address not known yet is left out. A choice that is not
one of LISTEN_CHOICES is DEFAULT_LISTEN.
"""
if choice not in LISTEN_CHOICES:
choice = DEFAULT_LISTEN
general, interfaces, local = (_section(configuration, name) for name in ('general', 'interfaces', 'local'))
mode = str(general.get('mode') or 'manual')
addresses = []
if choice in ('internal', 'both'):
addresses.append(internal_address(mode, str(interfaces.get('internal') or ''), str(local.get('int_addr') or '')))
if choice in ('external', 'both'):
addresses.append(external_address(mode, str(interfaces.get('external') or '')))
return [address for address in addresses if address]
def web_listen(configuration, default_port):
"""Where the web service listens, from vpn-router.json read as configuration, as ([(address, port), ...], '').
The addresses are those of the interfaces web.listen names (listen_addresses), the port
web.port, else default_port. While web.enabled is false or no address is known, the answer
is ([], the reason).
"""
web = _section(configuration, 'web')
if web.get('enabled', True) is False:
return None, 'the web service is disabled in the configuration'
return [], '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('general'), section('interfaces'), section('local')
address = 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), ''
addresses = listen_addresses(configuration, web.get('listen', DEFAULT_LISTEN))
if not addresses:
return [], 'the addresses to listen on are not known yet'
return [(address, port) for address in addresses], ''
@@ -10,31 +10,37 @@ from http.server import ThreadingHTTPServer
def serve(wanted, handler, log, interval):
"""Work out where to listen every interval seconds with wanted, and run an HTTP server of handler there.
"""Work out where to listen every interval seconds with wanted, and run an HTTP server of handler at each place.
wanted answers ((address, port), '') or (None, the reason not to listen). When the address or
the port changes, the server is stopped and a new one listens at the new place; while there is
no place or it cannot be bound, no server runs and the next round tries again. Every new
reason is logged with log. Does not return.
wanted answers ([(address, port), ...], '') or ([], the reason not to listen). A server whose
place is no longer wanted is stopped, and a new one listens at each new place; a place that
cannot be bound is tried again the next round. Every new message is logged with log. Does
not return.
"""
server, thread, listening, reported = None, None, None, None
servers, reported = {}, None
while True:
target, reason = wanted()
if target != listening and server is not None:
targets, reason = wanted()
for target in [target for target in servers if target not in targets]:
server, thread = servers.pop(target)
server.shutdown()
server.server_close()
thread.join()
server, listening = None, None
if target is not None and server is None:
messages = [reason] if reason else []
for target in targets:
if target in servers:
continue
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
messages.append(f'cannot listen on {target[0]}:{target[1]}: {exc}')
continue
thread = threading.Thread(target=server.serve_forever, name=f'server {target[0]}', daemon=True)
thread.start()
servers[target] = (server, thread)
if servers:
messages.append('serving on ' + ', '.join(f'http://{address}:{port}' for address, port in servers))
message = '; '.join(messages)
if message and message != reported:
log(message)
reported = message
time.sleep(interval)
+7 -6
View File
@@ -1,5 +1,5 @@
#!/usr/bin/python3
"""Serve the status of the router on its internal address.
"""Serve the status of the router on its internal address, its external address or both, as web.listen sets.
Run by vpn-router-web.service. Users are managed with vpnrouter_users, the module that
vpn-router-user uses too.
@@ -27,7 +27,7 @@ token of vpn-router-token has the admin claim and names no user; it may call eve
add or take out its members, and remove one of its members. These paths and /auth are not
served (404) while the key does not exist. /auth is limited to AUTH_LIMIT calls per client
address in AUTH_WINDOW seconds; the address of a call from one of web.trusted_proxies, by
default the address the service listens on, is the last entry of its X-Forwarded-For header.
default the address the call came in on, is the last entry of its X-Forwarded-For header.
/ and /status show only the status: whether the services run, the package version and the
installed version of vpn-router-manager (manager_version, null when it is not installed), whether
@@ -40,10 +40,11 @@ logging each failure. Then it listens. Its unit starts it only once wait-for-lib
in the linker cache, where psycopg looks it up.
The service needs nothing else to start and does not end on its own. Every DISCOVERY_SECONDS it
works out the internal address from /etc/vpn-router/vpn-router.json as general.mode directs,
with web.port and web.enabled (vpnrouter_net.web_listen), and runs the server there
(vpnrouter_serve.serve): it starts it, moves it when the address or the port changes, and stops
it while the service is disabled or the address is not known. Every request reads what it needs when it is made: the signing
works out from /etc/vpn-router/vpn-router.json the addresses of the interfaces web.listen names,
internal, external or both, as general.mode directs, with web.port and web.enabled
(vpnrouter_net.web_listen), and runs a server on each (vpnrouter_serve.serve): it starts them,
moves them when an address or the port changes, and stops them while the service is disabled or
no address is known. Every request reads what it needs when it is made: the signing
key, 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.