diff --git a/README.md b/README.md index 6f1bf04..44fa9d7 100644 --- a/README.md +++ b/README.md @@ -666,6 +666,16 @@ curl -H "Authorization: Bearer $TOKEN" http://10.0.0.4:7081/config | `ipsec` | `connections` and `sas`: one object per connection and per IKE SA, read from `swanctl --list-conns --pretty` and `swanctl --list-sas --pretty`, with the connection's name under `name` and swanctl's keys and string values | | `wireguard` | The public key and port of `wg0`, and each peer's public key, endpoint, allowed IPs, handshake age and traffic; no private or preshared key | | `docker` | `docker.exposed_ports` and the rules of the `VPN-ROUTER-DOCKER` chain | +| `postgresql` | `clusters`: each PostgreSQL cluster of `pg_lsclusters` with its version, name, port, status and data directory, and for one that is online its `settings`: `server_version`, `listen_addresses`, `port`, `max_connections`, `shared_buffers`, `effective_cache_size`, `work_mem`, `password_encryption`, `ssl` and `timezone`; `null` when `pg_lsclusters` fails | + +### The /postgresql page + +`/postgresql`, with the same token as `/config`, shows the databases of the PostgreSQL clusters on the +router: + +- **Content:** `clusters` lists each cluster with its version, name, port and status, its `databases` (name, owner, encoding and size in bytes, `pg_database_size`) and their total `size`. +- **A cluster that is not online:** `databases` and `size` are `null`. +- **Collection:** the web service reads it with `psql` as the `postgres` user at each request. ### The /users API diff --git a/debian-package/debian/changelog b/debian-package/debian/changelog index df43874..0508e63 100644 --- a/debian-package/debian/changelog +++ b/debian-package/debian/changelog @@ -1,3 +1,15 @@ +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. + + * /config has a postgresql section: each cluster of pg_lsclusters with + its version, name, port, status and data directory, and the main + settings of each online cluster. + * /postgresql, protected like /config, lists the databases of each + online cluster with owner, encoding and size, and the total size. + + -- Slawomir Koszewski Sun, 25 Oct 2026 21:00:00 +0200 + vpn-router (0.16.0-1) unstable; urgency=medium feature: the router keeps its metrics in PostgreSQL, serves them for an OpenTelemetry Collector and pushes them over OTLP. diff --git a/debian-package/src/usr/lib/vpn-router/web b/debian-package/src/usr/lib/vpn-router/web index 84debb1..f6a0333 100644 --- a/debian-package/src/usr/lib/vpn-router/web +++ b/debian-package/src/usr/lib/vpn-router/web @@ -21,8 +21,11 @@ Pages: /metrics/history the metrics the metrics service keeps, for a view: hour, day, month or year (see metrics_history()) + /postgresql + 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, /users, /groups and /account need an HS256 JWT in an Authorization: Bearer header signed +/config, /metrics/history, /postgresql, /users, /groups 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 @@ -101,6 +104,10 @@ TAILWIND = 'https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4' #: Seconds after which the status page in a browser reloads itself. REFRESH_SECONDS = 60 +#: The PostgreSQL settings /config shows for each cluster. +POSTGRESQL_SETTINGS = ('server_version', 'listen_addresses', 'port', 'max_connections', 'shared_buffers', + 'effective_cache_size', 'work_mem', 'password_encryption', 'ssl', 'timezone') + #: The largest request body accepted, in bytes. BODY_LIMIT = 4096 @@ -265,6 +272,65 @@ def wireguard_details(): } +def postgresql_query(cluster, sql): + """The JSON value an SQL statement answers on a cluster (version/name), run by psql as postgres, or None when it fails.""" + result = run('runuser', '-u', 'postgres', '--', 'psql', '--cluster', cluster, '-X', '-q', '-t', '-A', + '-v', 'ON_ERROR_STOP=1', '-d', 'postgres', '-c', sql) + if result is None or result.returncode != 0: + return None + try: + return json.loads(result.stdout) + except ValueError: + return None + + +def postgresql_clusters_of(): + """The PostgreSQL clusters of pg_lsclusters, each with version, name, port, status and data_directory, or None when it fails.""" + lines = lines_of('pg_lsclusters', '--no-header') + if lines is None: + return None + clusters = [] + for line in lines: + fields = line.split() + if len(fields) >= 6: + clusters.append({'version': fields[0], 'name': fields[1], 'port': int(fields[2]) if fields[2].isdigit() else None, + 'status': fields[3], 'data_directory': fields[5]}) + return clusters + + +def postgresql_configuration(): + """The PostgreSQL clusters on the router with the settings of POSTGRESQL_SETTINGS of each one that is online (None + for one that is not), or None when pg_lsclusters fails.""" + clusters = postgresql_clusters_of() + if clusters is None: + return None + names = ', '.join(f"'{name}'" for name in POSTGRESQL_SETTINGS) + for cluster in clusters: + cluster['settings'] = postgresql_query( + f"{cluster['version']}/{cluster['name']}", + f'SELECT json_object_agg(name, current_setting(name) ORDER BY name) FROM pg_settings WHERE name IN ({names})', + ) if cluster['status'].startswith('online') else None + return {'clusters': clusters} + + +def postgresql_databases(): + """What /postgresql shows: the PostgreSQL clusters on the router, each with its databases (name, owner, encoding and + size in bytes) and their total size, None for a cluster that is not online; clusters is None when pg_lsclusters fails.""" + clusters = postgresql_clusters_of() + if clusters is None: + return {'clusters': None} + for cluster in clusters: + databases = postgresql_query( + f"{cluster['version']}/{cluster['name']}", + "SELECT coalesce(json_agg(json_build_object('name', datname, 'owner', pg_get_userbyid(datdba), " + "'encoding', pg_encoding_to_char(encoding), 'size', pg_database_size(oid)) ORDER BY datname), '[]') " + 'FROM pg_database', + ) if cluster['status'].startswith('online') else None + cluster['databases'] = databases + cluster['size'] = sum(database['size'] for database in databases) if isinstance(databases, list) else None + return {'clusters': clusters} + + def details(state): """What /config shows: the configuration without its secrets, and what the router resolved and runs.""" configuration = state.get('configuration', {}) @@ -280,6 +346,7 @@ def details(state): 'exposed_ports': configuration.get('docker', {}).get('exposed_ports', []), 'rules': lines_of('iptables', '-S', 'VPN-ROUTER-DOCKER'), }, + 'postgresql': postgresql_configuration(), } @@ -648,7 +715,7 @@ class Handler(BaseHTTPRequestHandler): 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) - protected = path in ('/config', '/metrics/history') or directory is not 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 {} claims = self.claims(state['key']) if protected and state['key'] else None @@ -675,6 +742,8 @@ class Handler(BaseHTTPRequestHandler): body = json.dumps({'error': refused[1]}, indent=2).encode() + b'\n' elif path == '/config' and self.command in ('GET', 'HEAD'): body, kind, code = json.dumps(details(state), indent=2).encode() + b'\n', 'application/json', 200 + elif path == '/postgresql' and self.command in ('GET', 'HEAD'): + body, kind, code = json.dumps(postgresql_databases(), indent=2).encode() + b'\n', 'application/json', 200 elif path == '/metrics/history' and self.command in ('GET', 'HEAD'): query = urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query) code, document = metrics_history(query.get('range', ['hour'])[0])