Hub stage 1: apps across servers, read-only

hub/apps/matrix reads apps/list and each app's config on every server (the read
token first, the admin token once if a server refuses) and returns per app and server
the version, available update and a config hash, plus whether versions and configs
match and how many JSON leaf paths differ. Config texts never leave the hub. In hub
mode the Apps section has the server selector: All servers shows the matrix with
filters for differing versions, differing configs and updates; one server shows its
list without write actions. The dev mock takes --variant a|b with differing apps.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-10-31 15:46:51 +07:00
1 parent 0ef2d53a65
commit 3d8df08fa0
14 files changed
+1082 -37

No files matched your search

+15 -2
View File
@@ -15,10 +15,12 @@ behaviour: it exists so a design can be seen with data in it. The real harness i
*/
import http from 'node:http'
// `--port <n>` and `--scale <x>`: two instances with different numbers stand in for two servers.
// `--port <n>`, `--scale <x>` and `--variant a|b`: two instances with different numbers (and apps) stand in for two servers.
const arg = (name) => { const i = process.argv.indexOf(`--${name}`); return i >= 0 ? process.argv[i + 1] : undefined }
const PORT = Number(arg('port') ?? process.env.PORT ?? 5380)
const SCALE = Number(arg('scale') ?? 1)
// `--variant b`: the second server of the Apps matrix (a missing app, another version, other config fields).
const VARIANT = arg('variant') === 'b' ? 'b' : 'a'
const now = Date.now()
const iso = (ms) => new Date(ms).toISOString()
const ago = (min) => iso(now - min * 60_000)
@@ -189,6 +191,17 @@ let apps = [
{ name: 'Advanced Blocking', description: 'Group based blocking with regex', version: '10.1', updateAvailable: true, updateVersion: '10.2', updateUrl: 'https://download.technitium.com/dns/apps/AdvancedBlockingApp-v10.2.zip', dnsApps: [{ ...app(false), classPath: 'AdvancedBlocking.App' }] },
{ name: 'Split Horizon', description: 'Different answers for different networks', version: '9.0', dnsApps: [{ ...app(false), classPath: 'SplitHorizon.SimpleAddress' }, { ...app(false), classPath: 'SplitHorizon.SimpleCNAME' }] },
]
if (VARIANT === 'b') {
apps = apps.filter((a) => a.name !== 'Split Horizon')
apps = apps.map((a) => (a.name === 'Query Logs (Sqlite)' ? { ...a, version: '7.1', updateAvailable: true, updateVersion: '7.2', updateUrl: 'https://download.technitium.com/dns/apps/QueryLogsSqliteApp-v7.2.zip' } : a))
}
const appConfigs = {
'Advanced Blocking': VARIANT === 'b'
? { enableBlocking: true, blockingAnswerTtl: 60, networkGroupMap: { '0.0.0.0/0': 'default' }, groups: [{ name: 'default', enableBlocking: false, blockAsNxDomain: false }] }
: { enableBlocking: true, blockingAnswerTtl: 30, networkGroupMap: { '0.0.0.0/0': 'default' }, groups: [{ name: 'default', enableBlocking: true, blockAsNxDomain: false }] },
'Query Logs (Sqlite)': { enableLogging: true, maxLogDays: 365, sqliteDbPath: VARIANT === 'b' ? '/var/lib/dns/ndns/querylogs.db' : '/var/lib/dns/mdns/querylogs.db' },
'Split Horizon': { domainGroupMap: { 'lan.example.net': 'internal' }, networkGroupMap: { '10.0.0.0/8': 'internal' } },
}
const store = [
['Failover', 'Fails over to a backup address when the primary is down', '8.0', '58 KB'], ['Geo Country', 'Answers by the country of the client', '7.2', '1.2 MB'],
['Wild IP', 'Answers with the IP address in the name', '5.1', '22 KB'], ['Auto PTR', 'Answers PTR queries from the forward zones', '6.0', '31 KB'],
@@ -267,7 +280,7 @@ function route(path, q) {
case 'dhcp/scopes/delete': scopes = scopes.filter((s) => s.name !== q.get('name')); return ok()
case 'apps/list': return ok({ apps })
case 'apps/listStoreApps': return ok({ storeApps: store })
case 'apps/config/get': return ok({ config: '{\n "enabled": true\n}' })
case 'apps/config/get': return ok({ config: appConfigs[q.get('name')] ? JSON.stringify(appConfigs[q.get('name')], null, 2) : JSON.stringify({ enabled: true }, null, 2) })
case 'apps/uninstall': apps = apps.filter((a) => a.name !== q.get('name')); return ok()
case 'logs/list': return ok({ logFiles: [{ fileName: '2026-10-26', size: '412.8 KB' }, { fileName: '2026-10-25', size: '1.2 MB' }, { fileName: '2026-10-24', size: '980.4 KB' }] })
case 'logs/query': {