feature: /config shows the router's PostgreSQL clusters and their settings, and /postgresql their databases with their sizes.
Release / release (push) Successful in 28s

This commit is contained in:
slawek committed 2026-10-25 15:26:54 +01:00
1 parent 38151bf5be
commit a79510a02f
3 files changed
+93 -2

No files matched your search

+10
View File
@@ -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 | | `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 | | `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 | | `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 ### The /users API
+12
View File
@@ -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 <slawek@koszewscy.waw.pl> Sun, 25 Oct 2026 21:00:00 +0200
vpn-router (0.16.0-1) unstable; urgency=medium 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. feature: the router keeps its metrics in PostgreSQL, serves them for an OpenTelemetry Collector and pushes them over OTLP.
+71 -2
View File
@@ -21,8 +21,11 @@ Pages:
/metrics/history /metrics/history
the metrics the metrics service keeps, for a view: hour, day, month or year (see the metrics the metrics service keeps, for a view: hour, day, month or year (see
metrics_history()) 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; 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 /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 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. #: Seconds after which the status page in a browser reloads itself.
REFRESH_SECONDS = 60 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. #: The largest request body accepted, in bytes.
BODY_LIMIT = 4096 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): def details(state):
"""What /config shows: the configuration without its secrets, and what the router resolved and runs.""" """What /config shows: the configuration without its secrets, and what the router resolved and runs."""
configuration = state.get('configuration', {}) configuration = state.get('configuration', {})
@@ -280,6 +346,7 @@ def details(state):
'exposed_ports': configuration.get('docker', {}).get('exposed_ports', []), 'exposed_ports': configuration.get('docker', {}).get('exposed_ports', []),
'rules': lines_of('iptables', '-S', 'VPN-ROUTER-DOCKER'), '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'): if self.command not in ('GET', 'HEAD'):
self.close_connection = True self.close_connection = True
directory = next((name for name in ('users', 'groups', 'account') if path == f'/{name}' or path.startswith(f'/{name}/')), None) 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') signing = path in ('/auth', '/auth/refresh', '/auth/logout')
state = current() if protected or signing else {} state = current() if protected or signing else {}
claims = self.claims(state['key']) if protected and state['key'] else None 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' body = json.dumps({'error': refused[1]}, indent=2).encode() + b'\n'
elif path == '/config' and self.command in ('GET', 'HEAD'): elif path == '/config' and self.command in ('GET', 'HEAD'):
body, kind, code = json.dumps(details(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 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'): elif path == '/metrics/history' and self.command in ('GET', 'HEAD'):
query = urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query) query = urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query)
code, document = metrics_history(query.get('range', ['hour'])[0]) code, document = metrics_history(query.get('range', ['hour'])[0])