diff --git a/README.md b/README.md index b3bfce7..3d6668c 100644 --- a/README.md +++ b/README.md @@ -1007,7 +1007,7 @@ curl http://10.0.0.4:7081/status ### Tokens and signing in -`/config`, `/users`, `/service-accounts`, `/groups`, `/site-to-site`, `/backup`, `/remote-access`, `/ca`, `/logs`, `/sites`, `/dns`, `/shell` and `/account` need a JWT in an `Authorization: Bearer` header, signed with +`/config`, `/metrics/history`, `/metrics/settings`, `/users`, `/service-accounts`, `/groups`, `/site-to-site`, `/backup`, `/remote-access`, `/ca`, `/logs`, `/sites`, `/dns`, `/shell` and `/account` need a JWT in an `Authorization: Bearer` header, signed with HS256 by the key the web service makes on its first run, `/etc/vpn-router/web.key` (root only). The key never leaves the router; while it does not exist these paths and `/auth` are not served (404). A missing, expired or wrongly signed token, or one of another algorithm, gets 401 with @@ -1332,6 +1332,18 @@ The answer of `GET /backup` holds: A change is made, checked and applied as for `/site-to-site`. +### The /metrics/settings API + +`/metrics/settings` shows and changes the settings of the metrics service, with the same token as +`/users`. + +| Request | Body | Answer | +| --- | --- | --- | +| `GET /metrics/settings` | | `{"settings"}`: the `metrics` section without `otlp_password`, with `otlp_password_set` | +| `PUT /metrics/settings` | any of the settings of the `metrics` section; an empty or absent `otlp_password` keeps the secret, and an empty `otlp_username` removes it | `{"settings"}` | + +A change is made, checked and applied as for `/site-to-site`. + ### The /remote-access API `/remote-access` shows and changes the remote access systems, IKEv2 (`p2s`), diff --git a/debian-package/debian/changelog b/debian-package/debian/changelog index f94c677..b54eac3 100644 --- a/debian-package/debian/changelog +++ b/debian-package/debian/changelog @@ -1,7 +1,10 @@ -vpn-router (0.33.4-1) unstable; urgency=medium +vpn-router (0.34.0-1) unstable; urgency=medium - fix: OpenVPN starts on a new router, as setup installs CRLs that revoke nothing. + feature: the web service shows and changes the settings of the metrics service at /metrics/settings, and OpenVPN starts on a new router, as setup installs CRLs that revoke nothing. + * GET and PUT /metrics/settings show and change the metrics section; + otlp_password is never shown, an empty one keeps it, and an empty + otlp_username removes it. * A CRL without revoked certificates is installed for strongSwan and OpenVPN. diff --git a/debian-package/src/usr/lib/vpn-router/web b/debian-package/src/usr/lib/vpn-router/web index e8bc4c2..af9c47a 100644 --- a/debian-package/src/usr/lib/vpn-router/web +++ b/debian-package/src/usr/lib/vpn-router/web @@ -47,7 +47,10 @@ Pages: /metrics/history the metrics the metrics service keeps, for a view: hour, day, month or year (see metrics_history()) - /logs the last lines of the journal of the router's units and of the files web.log_files lists + /metrics/settings + the settings of the metrics service: show and change, applied by vpn-router-setup (see + metrics_settings()) + /logs the last lines of the journal of the router's units and of the files web.log_files lists (see logs()) /sites the web sites Caddy serves: the settings of sites, list, add, change and remove (see sites()), and the files of each: list, download, upload, extract a zip archive, create a folder, rename and @@ -62,7 +65,7 @@ Pages: loopback address, where Caddy passes the requests of the HTTPS site; POST /oidc/login counts towards the limit of /auth calls -/status/full, /config, /metrics/history, /postgresql, /users, /service-accounts, /groups, /site-to-site, /backup, /remote-access, /ca, /logs, /sites, /dns, /shell and /account need an HS256 JWT in an Authorization: Bearer header signed +/status/full, /config, /metrics/history, /metrics/settings, /postgresql, /users, /service-accounts, /groups, /site-to-site, /backup, /remote-access, /ca, /logs, /sites, /dns, /shell 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 @@ -1351,6 +1354,39 @@ def metrics_history(view): return 500, {'error': str(exc.code)} +def metrics_shown(settings): + """The metrics section as /metrics/settings shows it: its settings but otlp_password, with otlp_password_set.""" + return {**{option: value for option, value in settings.items() if option != 'otlp_password'}, + 'otlp_password_set': bool(settings['otlp_password'])} + + +def metrics_settings(method, body): + """Answer a /metrics/settings request: the HTTP status and the JSON document. + + GET /metrics/settings {"settings"}: the metrics section without otlp_password, with otlp_password_set + PUT /metrics/settings change any of the settings of the metrics section; an empty or absent otlp_password keeps + the current secret, and an empty otlp_username removes it + + A change is 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'} + if method in ('GET', 'HEAD'): + try: + return 200, {'settings': metrics_shown(vpnrouter.load_config()['metrics'])} + except SystemExit as exc: + return 500, {'error': str(exc.code)} + if method != 'PUT': + return 404, {'error': f'no route {method} /metrics/settings'} + body = dict(body or {}) + if not body.get('otlp_password'): + body.pop('otlp_password', None) + if body.get('otlp_username') == '': + body['otlp_password'] = '' + changed = section_change('metrics', body) + return changed if isinstance(changed, tuple) else (200, {'settings': metrics_shown(changed)}) + + def changes_admins(connection, method, parts, body): """Whether a /users, /service-accounts or /groups request creates, renames to, changes or removes ADMIN_GROUP, or removes a member of it.""" @@ -1819,7 +1855,8 @@ class Handler(BaseHTTPRequestHandler): directory = next((name for name in ('users', 'service-accounts', 'groups', 'account', 'site-to-site', 'backup', 'ca', 'shell', 'remote-access', 'logs', 'sites', 'dns') if path == f'/{name}' or path.startswith(f'/{name}/')), None) - protected = path in ('/status/full', '/config', '/metrics/history', '/postgresql') or directory is not None + protected = path in ('/status/full', '/config', '/metrics/history', '/metrics/settings', '/postgresql') or \ + directory is not None signing = path in ('/auth', '/auth/passkey', '/auth/passkey/complete', '/auth/refresh', '/auth/logout') oidc = (path == '/.well-known/openid-configuration' or path.startswith('/oidc/')) and \ self.server.server_address[0] == vpnrouter_net.LOOPBACK @@ -1861,6 +1898,9 @@ class Handler(BaseHTTPRequestHandler): query = urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query) code, document = metrics_history(query.get('range', ['hour'])[0]) body, kind = json.dumps(document).encode() + b'\n', 'application/json' + elif path == '/metrics/settings': + code, document = metrics_settings(self.command, payload) + body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json' elif directory == 'shell' and (path, self.command) == ('/shell', 'GET'): return self.shell(claims.get('sub')) elif directory == 'shell':