Compare commits

...
2 Commits
Author SHA1 Message Date
slawek 83c1c50d8d feature: vpn-config-edit shows and changes the router's configuration.
Release / release (push) Successful in 19s
2026-10-25 13:29:12 +01:00
slawek 650896131d fix: web.status_content is basic by default.
Release / release (push) Successful in 19s
2026-10-25 13:09:57 +01:00
8 changed files with 185 additions and 14 deletions

No files matched your search

+3 -3
View File
@@ -44,9 +44,9 @@ address of the internal interface, of the external interface or of both, as `web
unless `web.enabled` is `false` (ADR-7). `/` is for people, in plain text for `curl` and `wget`
and as a Tailwind CSS page for a browser, and `/status` is the same status as JSON. Both show as
much as `web.status_content` says, since the router is reachable from the internet and the
operator decides how much it tells strangers: `basic` the overall status and the Client IP,
`standard` also the package versions and the number of active and other services, and `full`,
the default, also each service and the state of the site-to-site, road-warrior, OpenVPN and
operator decides how much it tells strangers: `basic`, the default because it tells the least,
the overall status and the Client IP, `standard` also the package versions and the number of
active and other services, and `full` also each service and the state of the site-to-site, road-warrior, OpenVPN and
WireGuard features. The Client IP is the address the request came from, through the trusted
proxies, so a person can see the address the router sees. Nothing comes from the configuration.
`/status` names its content, so clients such as manage-ui and the Router Manager show what it
+21 -3
View File
@@ -128,7 +128,7 @@ must have the type shown below; an unknown section or setting is an error.
| `web` | `port` | integer | `7081` | TCP port of the status web service |
| `web` | `database` | string | | A PostgreSQL connection string for the refresh tokens; without it, the `vpn-router` database on this host that the web service creates |
| `web` | `trusted_proxies` | list | the address the call came in on | Addresses whose `X-Forwarded-For` header names the client for the `/auth` rate limit and the Client IP of the status |
| `web` | `status_content` | string | `full` | What `/` and `/status` show: `basic`, `standard` or `full` (see Status web service) |
| `web` | `status_content` | string | `basic` | What `/` and `/status` show: `basic`, `standard` or `full` (see Status web service) |
| `https` | `enabled` | boolean | `true` | Caddy serves the status web service and the Router Manager over HTTPS for `wan.local_fqdn` |
| `https` | `email` | string | | The e-mail address of the Let's Encrypt account |
| `https` | `web_port` | integer | `7444` | HTTPS port of the status web service |
@@ -240,6 +240,24 @@ vpn-router-setup` applies a changed configuration while Docker runs. With `docke
`on`, the service fails when Docker is not installed or its daemon is not running; with
`off` it removes the rules.
## Editing the configuration
`vpn-config-edit`, run as root, shows and changes `/etc/vpn-router/vpn-router.json`:
```sh
vpn-config-edit show
vpn-config-edit set web.status_content standard
vpn-config-edit set web.port 7082
vpn-config-edit set web.trusted_proxies 127.0.0.1,10.0.0.4
```
`show` prints the file as indented JSON and does not change it. `set` takes a setting as
`section.option` and converts the value to the setting's type: a whole number, `true` or
`false`, a list separated by commas (an empty value is an empty list), or a string; an unknown
setting is refused. It checks the whole configuration as `vpn-router-setup` does, and only then
writes the file, indented, with mode 0600, and reminds that `vpn-router-setup` applies it. A
refused request exits with status 2, any other failure with 1.
## Applying changes
```sh
@@ -506,9 +524,9 @@ configuration: no key, identity, subnet, or address but the client's. They show
| `status_content` | `/` and `/status` show |
|---|---|
| `basic` | The overall status (`ok` or `degraded`), the time and the Client IP |
| `basic` (default) | The overall status (`ok` or `degraded`), the time and the Client IP |
| `standard` | Also the `vpn-router` version, the `vpn-router-manager` version or that it is not installed, and the number of active services and of services in any other state, without their names |
| `full` (default) | Also each service and its state, Docker, and the state of site-to-site, road-warrior, OpenVPN and WireGuard |
| `full` | Also each service and its state, Docker, and the state of site-to-site, road-warrior, OpenVPN and WireGuard |
The Client IP is the address the request came from: the last entry of `X-Forwarded-For` for a
request from one of `web.trusted_proxies`, by default the address the request came in on, so
+23
View File
@@ -1,3 +1,26 @@
vpn-router (0.15.0-1) unstable; urgency=medium
feature: vpn-config-edit shows and changes the router's configuration.
* vpn-config-edit show prints /etc/vpn-router/vpn-router.json as
indented JSON.
* vpn-config-edit set SECTION.OPTION VALUE converts the value to the
setting's type, checks the whole configuration as vpn-router-setup
does, writes the file indented with mode 0600, and reminds that
vpn-router-setup applies it.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sun, 25 Oct 2026 18:00:00 +0200
vpn-router (0.14.1-1) unstable; urgency=medium
fix: web.status_content is basic by default.
* Without web.status_content, or with a value that is not allowed, /
and /status show only the overall status, the time and the Client
IP; set it to standard or full to show more.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sun, 25 Oct 2026 17:00:00 +0200
vpn-router (0.14.0-1) unstable; urgency=medium
feature: the status page shows the Client IP, and web.status_content sets how much it shows.
+1
View File
@@ -9,6 +9,7 @@ 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-group usr/sbin/
src/usr/sbin/vpn-router-token usr/sbin/
src/usr/sbin/vpn-config-edit usr/sbin/
src/usr/lib/vpn-router/simple-ca usr/lib/vpn-router/
src/usr/lib/vpn-router/configure usr/lib/vpn-router/
src/usr/lib/vpn-router/docker-rules usr/lib/vpn-router/
@@ -55,7 +55,7 @@ SCHEMA = {
'openvpn': {'enabled': False, 'address_pool': '', 'port': 1194, 'protocol': 'udp'},
'docker': {'mode': 'auto', 'exposed_ports': []},
'web': {'enabled': True, 'listen': DEFAULT_LISTEN, 'port': 7081, 'database': '', 'trusted_proxies': [],
'status_content': 'full'},
'status_content': 'basic'},
'https': {'enabled': True, 'email': '', 'web_port': 7444, 'manager_port': 7443},
}
+6 -6
View File
@@ -29,10 +29,10 @@ served (404) while the key does not exist. /auth is limited to AUTH_LIMIT calls
address in AUTH_WINDOW seconds; the address of a call from one of web.trusted_proxies, by
default the address the call came in on, is the last entry of its X-Forwarded-For header.
/ and /status show only the status, as much as web.status_content says (see shown): basic 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, the default,
/ and /status show only the status, as much as web.status_content says (see shown): basic, the
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
(IPsec P2S), OpenVPN and WireGuard. No configuration data or key is shown, and no address but the
client's.
@@ -298,10 +298,10 @@ def shown(status, content, client):
Every content has status, time and client_ip, the client's address, and content, which one it is: basic nothing
more; standard adds version, manager_version and services, the number of active units and of units in every
other state; full adds the whole status document of collect. Content that is not one of
vpnrouter.STATUS_CONTENTS is full.
vpnrouter.STATUS_CONTENTS, or not set, is basic.
"""
if content not in vpnrouter.STATUS_CONTENTS:
content = 'full'
content = 'basic'
if content == 'full':
return {**status, 'content': content, 'client_ip': client}
document = {'status': status['status'], 'content': content, 'time': status['time'], 'client_ip': client}
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/python3
"""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.
vpn-config-edit show
vpn-config-edit set SECTION.OPTION VALUE
"""
import argparse
import json
import os
import sys
import tempfile
sys.path.insert(0, '/usr/lib/vpn-router')
import vpnrouter
class Failure(Exception):
"""A request that cannot be done; status is 2 invalid, 1 other."""
def __init__(self, message, status=1):
super().__init__(message)
self.status = status
class Parser(argparse.ArgumentParser):
"""An argument parser whose errors are Failures with status 2."""
def error(self, message):
raise Failure(f'{message}; see {self.prog} --help', 2)
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
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])
if kind is bool:
if text not in ('true', 'false'):
raise Failure(f'{key} takes true or false', 2)
return text == 'true'
if kind is int:
try:
return 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
def main(argv):
try:
parser = Parser(prog='vpn-config-edit', description='Show and change the router configuration.')
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('value', metavar='VALUE', help='its value; a list separated by commas')
options = parser.parse_args(argv)
data = read()
if options.command == 'show':
print(json.dumps(data, 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)
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}.')
print('Apply it with: systemctl restart vpn-router-setup')
return 0
if __name__ == '__main__':
sys.exit(main(sys.argv[1:]))
@@ -49,7 +49,7 @@
"port": 7081,
"database": "",
"trusted_proxies": [],
"status_content": "full"
"status_content": "basic"
},
"https": {
"enabled": true,