feature: /config shows the router's PostgreSQL clusters and their settings, and /postgresql their databases with their sizes.
Release / release (push) Successful in 28s
Release / release (push) Successful in 28s
This commit is contained in:
1 parent
38151bf5be
commit
a79510a02f
3 files changed
+93
-2
No files matched your search
@@ -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
|
||||
|
||||
feature: the router keeps its metrics in PostgreSQL, serves them for an OpenTelemetry Collector and pushes them over OTLP.
|
||||
|
||||
@@ -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])
|
||||
|
||||
Reference in new issue
Block a user