Compare commits
19
Commits
37a74199e9
...
main
No files matched your search
@@ -4,6 +4,13 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*']
|
||||
# The hub has its own pipeline (hub.yaml): a change that only touches it does
|
||||
# not rebuild the console.
|
||||
paths-ignore:
|
||||
- 'hub/**'
|
||||
- 'docker/Dockerfile.hub'
|
||||
- '.gitea/workflows/hub.yaml'
|
||||
- 'docs/compose.hub.example.yaml'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# The hub has its own pipeline and its own image (git.shx.one/denozord/technitium-hub),
|
||||
# independent of ci.yaml. A change that only touches the console's docs does not run it.
|
||||
name: Hub
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['hub-v*']
|
||||
paths:
|
||||
- 'hub/**'
|
||||
- 'src/**'
|
||||
- 'public/**'
|
||||
- 'index.html'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'vite.config.ts'
|
||||
- 'docker/Dockerfile.hub'
|
||||
- '.gitea/workflows/hub.yaml'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: hub-${{ gitea.workflow }}-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
|
||||
- run: npm ci
|
||||
- run: npm run typecheck
|
||||
- run: npx vitest run hub
|
||||
|
||||
image:
|
||||
needs: check
|
||||
if: gitea.ref_name == 'main' || startsWith(gitea.ref, 'refs/tags/hub-v')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.shx.one
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.ACTION_PAT || secrets.ACTIONS_PAT }}
|
||||
|
||||
# The hub is not named after the repository: its own image. Registry names are lowercase.
|
||||
- name: Resolve image name
|
||||
id: image
|
||||
run: |
|
||||
owner="$(dirname '${{ gitea.repository }}' | tr '[:upper:]' '[:lower:]')"
|
||||
echo "name=git.shx.one/${owner}/technitium-hub" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# A hub-v1.2.3 tag gives 1.2.3 and 1.2 (the prefix is stripped); main gives latest.
|
||||
- id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ steps.image.outputs.name }}
|
||||
tags: |
|
||||
type=match,pattern=hub-v(\d+\.\d+\.\d+),group=1
|
||||
type=match,pattern=hub-v(\d+\.\d+)\.\d+,group=1
|
||||
type=raw,value=latest,enable=${{ gitea.ref_name == 'main' }}
|
||||
type=sha,prefix=sha-
|
||||
|
||||
- uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile.hub
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=${{ steps.image.outputs.name }}:buildcache
|
||||
cache-to: type=registry,ref=${{ steps.image.outputs.name }}:buildcache,mode=max
|
||||
+208
-16
@@ -4,6 +4,7 @@ A stand-in for the Technitium API, with made-up data, to look at and click throu
|
||||
whole console without a server and without Docker.
|
||||
|
||||
node dev/mock-api.mjs # listens on 127.0.0.1:5380
|
||||
node dev/mock-api.mjs --port 5381 --scale 2 # another port; counters and hits times 2
|
||||
npm run dev # the console, proxying /api to it
|
||||
|
||||
Any user and password log in. It answers the endpoints the screens call, keeps what you
|
||||
@@ -14,7 +15,12 @@ behaviour: it exists so a design can be seen with data in it. The real harness i
|
||||
*/
|
||||
import http from 'node:http'
|
||||
|
||||
const PORT = Number(process.env.PORT ?? 5380)
|
||||
// `--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)
|
||||
@@ -46,12 +52,12 @@ const SERIES = [
|
||||
function dashboard(type) {
|
||||
const r = RANGES[type] ?? RANGES.LastDay
|
||||
const instants = Array.from({ length: r.n }, (_, i) => iso(now - (r.n - 1 - i) * r.step))
|
||||
const wave = (base, i) => Math.max(0, Math.round(base * r.scale * (1 + 0.35 * Math.sin(i / 3.2) + 0.15 * Math.cos(i / 1.7))))
|
||||
const wave = (base, i) => Math.max(0, Math.round(SCALE * base * r.scale * (1 + 0.35 * Math.sin(i / 3.2) + 0.15 * Math.cos(i / 1.7))))
|
||||
const totals = Object.fromEntries(SERIES.map(([name, base]) => [name, instants.reduce((a, _, i) => a + wave(base, i), 0)]))
|
||||
const stats = {
|
||||
totalQueries: totals.Total, totalNoError: totals['No Error'], totalServerFailure: totals['Server Failure'], totalNxDomain: totals['NX Domain'],
|
||||
totalRefused: totals.Refused, totalAuthoritative: totals.Authoritative, totalRecursive: totals.Recursive, totalCached: totals.Cached,
|
||||
totalBlocked: totals.Blocked, totalDropped: totals.Dropped, totalClients: 128, zones: zones.length, cachedEntries: 48210,
|
||||
totalBlocked: totals.Blocked, totalDropped: totals.Dropped, totalClients: Math.round(128 * SCALE), zones: zones.length, cachedEntries: 48210,
|
||||
allowedZones: allowed.size, blockedZones: blocked.size, allowListZones: 1, blockListZones: 1482903,
|
||||
}
|
||||
return {
|
||||
@@ -67,9 +73,9 @@ const CLIENTS = [['192.168.10.14', 'laptop-anna.lan'], ['192.168.10.31', 'tv-liv
|
||||
const DOMAINS = ['connectivitycheck.gstatic.com', 'time.cloudflare.com', 'api.github.com', 'updates.example.net', 'www.example.net', 'mail.example.net', 'cdn.jsdelivr.net', 'fonts.googleapis.com', 'api.telegram.org', 'ocsp.digicert.com']
|
||||
const BLOCKED = ['ads.tracker-example.com', 'telemetry.vendor-example.io', 'pixel.metrics-example.net', 'cdn.adserver-example.org', 'beacon.analytics-example.com', 'stats.adnetwork-example.net', 'track.shop-example.org', 'ads2.tracker-example.com', 'collect.data-example.io', 'sdk.ads-example.com']
|
||||
function top(kind) {
|
||||
if (kind === 'clients') return CLIENTS.map(([name, domain], i) => ({ name, domain: i === 3 ? DOMAINS[i] : DOMAINS[(i * 3) % 10], hits: Math.round(84210 / (1 + i * 0.55)), ...(i === 7 ? { rateLimited: true } : {}) }))
|
||||
if (kind === 'clients') return CLIENTS.map(([name, domain], i) => ({ name, domain: i === 3 ? DOMAINS[i] : DOMAINS[(i * 3) % 10], hits: Math.round((SCALE * 84210) / (1 + i * 0.55)), ...(i === 7 ? { rateLimited: true } : {}) }))
|
||||
const list = kind === 'domains' ? DOMAINS : BLOCKED
|
||||
return list.map((name, i) => ({ name, hits: Math.round((kind === 'domains' ? 22480 : 9804) / (1 + i * 0.5)) }))
|
||||
return list.map((name, i) => ({ name, hits: Math.round((SCALE * (kind === 'domains' ? 22480 : 9804)) / (1 + i * 0.5)) }))
|
||||
}
|
||||
|
||||
// ── Zones ──────────────────────────────────────────────────────────────────
|
||||
@@ -87,6 +93,18 @@ let zones = [
|
||||
{ name: 'vpn.example.net', type: 'Primary', dnssecStatus: 'Unsigned', soaSerial: 12, disabled: false, notifyFailed: true, notifyFailedFor: ['ns2.example.net'], lastModified: ago(60 * 24 * 9) },
|
||||
{ name: 'staging.example.net', type: 'Primary', dnssecStatus: 'Unsigned', soaSerial: 4, disabled: true, lastModified: ago(60 * 24 * 20) },
|
||||
].map((z) => ({ notifyFailed: false, notifyFailedFor: [], ...z }))
|
||||
if (VARIANT === 'b') {
|
||||
// The second server: a zone the first lacks, a serial that moved on, and a Secondary of the first server's Primary.
|
||||
zones = zones.filter((z) => z.name !== 'dev.example.net' && z.name !== 'vpn.example.net')
|
||||
zones = zones.map((z) => (z.name === 'lan' ? { ...z, soaSerial: 44 } : z.name === 'home.example.net' ? { ...z, type: 'Secondary', soaSerial: 16, catalog: null } : z))
|
||||
zones.push({ name: 'ndns-only.example.net', type: 'Primary', dnssecStatus: 'Unsigned', soaSerial: 3, disabled: false, lastModified: ago(60 * 3), notifyFailed: false, notifyFailedFor: [] })
|
||||
}
|
||||
// Zone files (zones/export, zones/import) and options (zones/options/get, set) are kept per zone.
|
||||
const zoneFiles = {}
|
||||
const zoneOptions = {}
|
||||
const zoneFile = (name) => zoneFiles[name] ?? `$ORIGIN ${name}.\n@ IN SOA ns1.${name}. hostmaster.${name}. ${(zones.find((z) => z.name === name) ?? {}).soaSerial ?? 1} 900 300 604800 900\n@ IN NS ns1.${name}.\nwww IN A 203.0.113.${VARIANT === 'b' ? 20 : 10}\n`
|
||||
const optionsOf = (name) => ({ zoneTransfer: 'Deny', zoneTransferNetworkACL: [], zoneTransferTsigKeyNames: [], notify: 'ZoneNameServers', notifyNameServers: [], ...(zoneOptions[name] ?? {}) })
|
||||
|
||||
|
||||
function recordsFor(zone) {
|
||||
const z = zone.name
|
||||
@@ -121,6 +139,10 @@ const cacheRecords = (domain) =>
|
||||
]
|
||||
const allowed = new Set(['updates.example.net', 'api.github.com', 'time.cloudflare.com'])
|
||||
const blocked = new Set(['ads.tracker-example.com', 'telemetry.vendor-example.io', 'pixel.metrics-example.net', 'cdn.adserver-example.org', 'beacon.analytics-example.com'])
|
||||
if (VARIANT === 'b') {
|
||||
blocked.delete('beacon.analytics-example.com'); blocked.add('only-ndns.tracker-example.com')
|
||||
allowed.add('extra-allowed.example.net')
|
||||
}
|
||||
const listNode = (set, domain) => {
|
||||
const all = [...set].sort()
|
||||
return { domain, zones: domain === '' ? all : [], records: domain !== '' && set.has(domain) ? [{ name: domain, type: 'SOA', ttl: 60, rData: {}, dnssecStatus: 'Disabled' }] : [] }
|
||||
@@ -154,12 +176,21 @@ const settings = {
|
||||
cacheMaximumEntries: 10000, cacheMinimumRecordTtl: 10, cacheMaximumRecordTtl: 86400, cacheNegativeRecordTtl: 300, cacheFailureRecordTtl: 60,
|
||||
enableCachePrefetch: true, cachePrefetchEligibility: 2, cachePrefetchTrigger: 9,
|
||||
enableBlocking: true, allowTxtBlockingReport: true, temporaryDisableBlockingTill: null, blockingBypassList: [], blockingType: 'NxDomain', blockingAnswerTtl: 30,
|
||||
customBlockingAddresses: [], blockListUrls: ['https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts', '# Privacy', 'https://big.oisd.nl/domainswild', 'https://adguardteam.github.io/AdGuardSDNSFilter/Filters/filter.txt'],
|
||||
customBlockingAddresses: [], blockListUrls: ['https://lists.invalid/hosts', '# Privacy', 'https://big.oisd.nl/domainswild', 'https://adguardteam.github.io/AdGuardSDNSFilter/Filters/filter.txt'],
|
||||
blockListUpdateIntervalHours: 24, blockListNextUpdatedOn: iso(now + 3 * 3_600_000),
|
||||
proxy: null, forwarders: ['1.1.1.1', '9.9.9.9'], forwarderProtocol: 'Udp', concurrentForwarding: true, forwarderRetries: 3, forwarderTimeout: 2000, forwarderConcurrency: 2,
|
||||
loggingType: 'File, Console', ignoreResolverLogs: false, logQueries: false, useLocalTime: false, logFolder: 'logs', maxLogFileDays: 365, enableInMemoryStats: false, maxStatFileDays: 365,
|
||||
}
|
||||
|
||||
if (VARIANT === 'b') {
|
||||
// The second server: other shared values (these show up in the compare), and its own identity and log folder (local fields).
|
||||
Object.assign(settings, {
|
||||
dnsServerDomain: 'ndns.example.net', dnsServerLocalEndPoints: ['10.0.0.9:53'], logFolder: 'logs-ndns', webServiceHttpPort: 5381,
|
||||
defaultRecordTtl: 7200, forwarders: ['8.8.8.8'], blockingType: 'AnyAddress', serveStale: false,
|
||||
tsigKeys: [{ keyName: 'transfer.example.net', sharedSecret: 'bmRucy1vdGhlci1zZWNyZXQ=', algorithmName: 'hmac-sha256' }],
|
||||
})
|
||||
}
|
||||
|
||||
// ── DHCP ───────────────────────────────────────────────────────────────────
|
||||
let scopes = [
|
||||
{ name: 'Main', enabled: true, startingAddress: '192.168.10.10', endingAddress: '192.168.10.200', subnetMask: '255.255.255.0', networkAddress: '192.168.10.0', broadcastAddress: '192.168.10.255', interfaceAddress: '192.168.10.2' },
|
||||
@@ -185,11 +216,61 @@ 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' }] },
|
||||
]
|
||||
const store = [
|
||||
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))
|
||||
}
|
||||
// A small fake list the mock serves itself at /lists/<name>.txt (no internet), so the Advanced Blocking
|
||||
// "Block List Domains" count has something to download. Open to any origin, as a public list host is.
|
||||
const FAKE_LISTS = {
|
||||
'hosts.txt': ['# fake hosts list', '127.0.0.1 localhost', ...Array.from({ length: 120 }, (_, i) => `0.0.0.0 ads${i}.tracker.example`)].join('\n'),
|
||||
'easylist.txt': ['[Adblock Plus 2.0]', '! fake adblock list', ...Array.from({ length: 45 }, (_, i) => `||banner${i}.adnet.example^`)].join('\n'),
|
||||
}
|
||||
const listUrl = (name) => `http://127.0.0.1:${PORT}/lists/${name}`
|
||||
const group = (over) => ({
|
||||
allowTxtBlockingReport: true, blockAsNxDomain: true, blockingAddresses: ['0.0.0.0', '::'],
|
||||
allowed: [], blocked: [], allowListUrls: [], blockListUrls: [], allowedRegex: [], blockedRegex: [],
|
||||
regexAllowListUrls: [], regexBlockListUrls: [], adblockListUrls: [], ...over,
|
||||
})
|
||||
const appConfigs = {
|
||||
'Advanced Blocking': VARIANT === 'b'
|
||||
? {
|
||||
enableBlocking: true, blockingAnswerTtl: 60, blockListUrlUpdateIntervalHours: 24, localEndPointGroupMap: { '127.0.0.1': 'bypass' },
|
||||
networkGroupMap: { '0.0.0.0/0': 'default', '::/0': 'default' },
|
||||
groups: [
|
||||
group({ name: 'default', enableBlocking: true, blocked: ['tracker.example'], blockedRegex: ['^ads\\.'], blockListUrls: [listUrl('hosts.txt')], adblockListUrls: [listUrl('easylist.txt')] }),
|
||||
group({ name: 'bypass', enableBlocking: false }),
|
||||
],
|
||||
}
|
||||
: {
|
||||
enableBlocking: true, blockingAnswerTtl: 30, blockListUrlUpdateIntervalHours: 24, localEndPointGroupMap: { '127.0.0.1': 'bypass' },
|
||||
networkGroupMap: { '192.168.10.0/24': 'family', '0.0.0.0/0': 'everyone', '::/0': 'everyone' },
|
||||
groups: [
|
||||
group({
|
||||
name: 'everyone', enableBlocking: true, blocked: ['ads.example.com', 'telemetry.example.net'], allowed: ['good.example.org'],
|
||||
blockListUrls: [listUrl('hosts.txt'), 'https://lists.invalid/hosts'],
|
||||
allowListUrls: ['https://example.org/allow.txt'], blockedRegex: ['^ads\\.', '^track\\d+\\.'], allowedRegex: ['^cdn\\.good\\.'],
|
||||
regexBlockListUrls: ['https://example.org/regex.txt'], adblockListUrls: [listUrl('easylist.txt')],
|
||||
}),
|
||||
group({ name: 'family', enableBlocking: true, blocked: ['games.example.com'], blockListUrls: [listUrl('hosts.txt')], regexAllowListUrls: ['https://example.org/regex-allow.txt'] }),
|
||||
group({ name: 'bypass', enableBlocking: 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 catalogRows = [
|
||||
['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'],
|
||||
['Drop Requests', 'Drops requests from the listed networks', '7.0', '19 KB'], ['NX Domain', 'Answers NXDOMAIN for the listed domains', '6.1', '17 KB'],
|
||||
].map(([name, description, version, size]) => ({ name, description, version, size, url: `https://download.technitium.com/dns/apps/${name.replace(/ /g, '')}App-v${version}.zip`, installed: false }))
|
||||
['Advanced Blocking', 'Group based blocking with regex', '10.2', '240 KB'], ['Query Logs (Sqlite)', 'Logs DNS queries into a SQLite database', '7.2', '310 KB'],
|
||||
['Split Horizon', 'Different answers for different networks', '9.0', '120 KB'],
|
||||
].map(([name, description, version, size]) => ({ name, description, version, size, url: `https://download.technitium.com/dns/apps/${name.replace(/[ ()]/g, '')}App-v${version}.zip` }))
|
||||
const catalog = () => catalogRows
|
||||
const storeListing = () => catalogRows.map((c) => {
|
||||
const have = apps.find((a) => a.name === c.name)
|
||||
return have ? { ...c, installed: true, installedVersion: have.version, updateAvailable: have.version !== c.version } : { ...c, installed: false }
|
||||
})
|
||||
|
||||
// ── Query logs ─────────────────────────────────────────────────────────────
|
||||
const RTYPES = ['Recursive', 'Cached', 'Authoritative', 'Blocked', 'Cached', 'Recursive']
|
||||
@@ -220,6 +301,14 @@ let sessions = [
|
||||
const userDetails = (u) => ({ ...u, sessionTimeoutSeconds: 1800, remotelyManagedGroups: false, memberOfGroups: u.username === 'admin' ? ['Administrators'] : ['DNS Administrators', 'Everyone'], sessions: sessions.filter((s) => s.username === u.username) })
|
||||
|
||||
// ── Router ─────────────────────────────────────────────────────────────────
|
||||
const addedRecords = {}
|
||||
// Answers that are files, not JSON (the downloads and the Rules table's reads).
|
||||
const fileFor = (path, q = new URLSearchParams()) => ({
|
||||
'allowed/export': [...allowed].join('\n') + '\n',
|
||||
'blocked/export': [...blocked].join('\n') + '\n',
|
||||
'zones/export': zones.some((z) => z.name === q.get('zone')) ? zoneFile(q.get('zone')) : undefined,
|
||||
'logs/download': '[2026-10-26 10:00:00 UTC] [127.0.0.1:5380] [] DNS Server was started.\n',
|
||||
})[path]
|
||||
const ok = (response = {}) => ({ status: 'ok', response })
|
||||
function route(path, q) {
|
||||
const domain = q.get('domain') ?? q.get('zone') ?? ''
|
||||
@@ -228,17 +317,53 @@ function route(path, q) {
|
||||
case 'user/login': case 'user/session/get': return session
|
||||
case 'user/profile/get': return ok({ displayName: 'Administrator', username: 'admin', totpEnabled: true, sessionTimeoutSeconds: 1800, memberOfGroups: ['Administrators'], sessions })
|
||||
case 'user/checkForUpdate': return ok({ updateAvailable: false })
|
||||
case 'dashboard/metrics/json': {
|
||||
const total = Math.round(SCALE * 1_250_000)
|
||||
return ok({ uptimestamp: ago(60 * 24 * 9), lifetimeCounters: { totalQueries: total, totalNoError: Math.round(total * 0.96), totalServerFailure: 40, totalNxDomain: Math.round(total * 0.03), totalRefused: 180, totalAuthoritative: Math.round(total * 0.08), totalRecursive: Math.round(total * 0.33), totalCached: Math.round(total * 0.48), totalBlocked: Math.round(total * 0.15), totalDropped: 5, totalClients: 128 } })
|
||||
}
|
||||
case 'dashboard/stats/get': return ok(dashboard(q.get('type') ?? 'LastDay'))
|
||||
case 'dashboard/stats/getTop': return ok({ [{ TopClients: 'topClients', TopDomains: 'topDomains', TopBlockedDomains: 'topBlockedDomains' }[q.get('statsType')] ?? 'topDomains']: top({ TopClients: 'clients', TopDomains: 'domains', TopBlockedDomains: 'blocked' }[q.get('statsType')] ?? 'domains') })
|
||||
case 'zones/list': {
|
||||
const per = Number(q.get('zonesPerPage') ?? 10), page = Math.max(1, Number(q.get('pageNumber') ?? 1))
|
||||
return ok({ zones: zones.slice((page - 1) * per, page * per), pageNumber: page, totalPages: Math.max(1, Math.ceil(zones.length / per)), totalZones: zones.length })
|
||||
}
|
||||
case 'zones/records/get': { const z = zones.find((x) => x.name === domain) ?? zones[0]; return ok({ zone: { name: z.name, type: z.type, disabled: z.disabled, dnssecStatus: z.dnssecStatus, soaSerial: z.soaSerial, catalog: z.catalog ?? null }, records: recordsFor(z) }) }
|
||||
case 'zones/records/get': { const z = zones.find((x) => x.name === domain) ?? zones[0]; return ok({ zone: { name: z.name, type: z.type, disabled: z.disabled, dnssecStatus: z.dnssecStatus, soaSerial: z.soaSerial, catalog: z.catalog ?? null }, records: [...recordsFor(z), ...(addedRecords[z.name] ?? [])] }) }
|
||||
case 'zones/records/add': {
|
||||
// One record added for the session: it comes back in the zone's list. `type` decides what the data is called.
|
||||
const type = q.get('type') ?? 'A'
|
||||
const rData = type === 'TXT' ? { text: q.get('text') ?? '', splitText: false } : type === 'CNAME' ? { cname: q.get('cname') ?? '' } : { ipAddress: q.get('ipAddress') ?? '' }
|
||||
const added = { name: q.get('domain') ?? domain, type, ttl: Number(q.get('ttl') ?? 3600), ttlString: `${q.get('ttl') ?? 3600}`, disabled: false, rData, dnssecStatus: 'Disabled', lastUsedOn: '0001-01-01T00:00:00Z', lastModified: iso(Date.now()), expiryTtl: 0, expiryTtlString: '0' }
|
||||
const z = zones.find((x) => x.name === (q.get('zone') ?? domain)) ?? zones[0]
|
||||
addedRecords[z.name] = [...(addedRecords[z.name] ?? []), added]
|
||||
return ok({ addedRecord: added, zone: { name: z.name, type: z.type, disabled: z.disabled } })
|
||||
}
|
||||
case 'zones/enable': case 'zones/disable': zones = zones.map((z) => (q.get('zone') === z.name ? { ...z, disabled: path === 'zones/disable' } : z)); return ok()
|
||||
case 'zones/delete': { const gone = (q.get('zones') ?? q.get('zone') ?? '').split(',').filter(Boolean); zones = zones.filter((z) => !gone.includes(z.name)); return ok({ deleted: gone, failed: {} }) }
|
||||
case 'zones/create': zones = [{ name: domain, type: q.get('type') ?? 'Primary', dnssecStatus: 'Unsigned', soaSerial: 1, disabled: false, lastModified: iso(Date.now()), notifyFailed: false, notifyFailedFor: [] }, ...zones]; return ok({ domain })
|
||||
case 'zones/options/get': return ok({ name: domain, type: 'Primary', dnssecStatus: 'Unsigned', disabled: false, catalog: null, queryAccess: 'Allow', queryAccessNetworkACL: [], zoneTransfer: 'AllowOnlyZoneNameServers', zoneTransferNetworkACL: [], zoneTransferTsigKeyNames: [], notify: 'ZoneNameServers', notifyNameServers: [], update: 'Deny', updateNetworkACL: [], updateSecurityPolicies: [], availableCatalogZoneNames: [], availableTsigKeyNames: ['transfer.example.net'] })
|
||||
case 'zones/create': {
|
||||
if (zones.some((z) => z.name === domain)) return { status: 'error', errorMessage: `Zone already exists: ${domain}` }
|
||||
const type = q.get('type') ?? 'Primary'
|
||||
zones = [{ name: domain, type, dnssecStatus: type === 'Primary' ? 'Unsigned' : undefined, soaSerial: type === 'Forwarder' ? undefined : 1, disabled: false, lastModified: iso(Date.now()), notifyFailed: false, notifyFailedFor: [] }, ...zones]
|
||||
if (type === 'Secondary') zoneOptions[domain] = { primaryNameServerAddresses: (q.get('primaryNameServerAddresses') ?? '').split(','), primaryZoneTransferProtocol: q.get('zoneTransferProtocol') ?? 'Tcp', primaryZoneTransferTsigKeyName: q.get('tsigKeyName') ?? '' }
|
||||
return ok({ domain })
|
||||
}
|
||||
case 'zones/import': {
|
||||
const z = zones.find((x) => x.name === domain)
|
||||
if (!z) return { status: 'error', errorMessage: `No such zone was found: ${domain}` }
|
||||
zoneFiles[domain] = q.get('__text') ?? zoneFile(domain)
|
||||
const serial = /SOA\s+\S+\s+\S+\s+(\d+)/.exec(zoneFiles[domain])
|
||||
if (serial) zones = zones.map((x) => (x.name === domain ? { ...x, soaSerial: Number(serial[1]), lastModified: iso(Date.now()) } : x))
|
||||
return ok()
|
||||
}
|
||||
case 'zones/options/get': { const z = zones.find((x) => x.name === domain); return ok({ name: domain, type: z?.type ?? 'Primary', dnssecStatus: z?.dnssecStatus ?? 'Unsigned', disabled: false, catalog: z?.catalog ?? null, queryAccess: 'Allow', queryAccessNetworkACL: [], update: 'Deny', updateNetworkACL: [], updateSecurityPolicies: [], availableCatalogZoneNames: [], availableTsigKeyNames: ['transfer.example.net'], ...optionsOf(domain) }) }
|
||||
case 'zones/options/set': {
|
||||
const next = { ...(zoneOptions[domain] ?? {}) }
|
||||
for (const [k, v] of q) {
|
||||
if (['zone', 'node', 'token'].includes(k)) continue
|
||||
next[k] = /ACL$|Names$|Servers$|Addresses$/.test(k) ? (v === 'false' || v === '' ? [] : v.split(',')) : v
|
||||
}
|
||||
zoneOptions[domain] = next
|
||||
return ok()
|
||||
}
|
||||
case 'zones/permissions/get': return ok({ zone: domain, userPermissions: [{ username: 'admin', canView: true, canModify: true, canDelete: true }], groupPermissions: [{ name: 'DNS Administrators', canView: true, canModify: true, canDelete: false }], users: users.map((u) => u.username), groups: groups.map((g) => g.name) })
|
||||
case 'zones/catalogs/list': return ok({ catalogZoneNames: ['catalog.example.net'] })
|
||||
case 'cache/list': return ok({ domain, zones: cacheTree[domain] ?? [], records: cacheRecords(domain) })
|
||||
@@ -250,16 +375,66 @@ function route(path, q) {
|
||||
case 'allowed/flush': allowed.clear(); return ok()
|
||||
case 'blocked/flush': blocked.clear(); return ok()
|
||||
case 'settings/get': return ok(settings)
|
||||
case 'settings/set': case 'settings/forceUpdateBlockLists': case 'settings/temporaryDisableBlocking': return ok(path === 'settings/set' ? settings : { temporaryDisableBlockingTill: iso(now + 10 * 60_000) })
|
||||
case 'settings/getTsigKeyNames': return ok({ tsigKeyNames: ['transfer.example.net'] })
|
||||
case 'settings/set': {
|
||||
// Changes only what is sent and already there, by its kind (a flag, a number, a list or text); the rest is kept.
|
||||
for (const [k, v] of q) {
|
||||
if (!(k in settings) || k === 'node' || k === 'token') continue
|
||||
if (k === 'tsigKeys') {
|
||||
// Rows of keyName|sharedSecret|algorithmName joined with a pipe, or "false" for none.
|
||||
const cells = v === 'false' ? [] : v.split('|')
|
||||
settings.tsigKeys = Array.from({ length: Math.floor(cells.length / 3) }, (_, i) => ({ keyName: cells[i * 3], sharedSecret: cells[i * 3 + 1], algorithmName: cells[i * 3 + 2] }))
|
||||
continue
|
||||
}
|
||||
const old = settings[k]
|
||||
if (k === 'enableBlocking' && v === 'true') settings.temporaryDisableBlockingTill = null
|
||||
settings[k] = typeof old === 'boolean' ? v === 'true' : typeof old === 'number' ? Number(v) : Array.isArray(old) ? v.split(/[,\n]/).map((x) => x.trim()).filter((x) => x && x !== 'false') : v
|
||||
}
|
||||
return ok(settings)
|
||||
}
|
||||
case 'settings/forceUpdateBlockLists': return ok()
|
||||
case 'settings/temporaryDisableBlocking': {
|
||||
// Blocking is off until the time given, as on a server; `settings/set enableBlocking=true` turns it back on.
|
||||
const minutes = Number(q.get('minutes') ?? 10)
|
||||
settings.enableBlocking = false
|
||||
settings.temporaryDisableBlockingTill = iso(Date.now() + minutes * 60_000)
|
||||
return ok({ temporaryDisableBlockingTill: settings.temporaryDisableBlockingTill })
|
||||
}
|
||||
case 'user/createSingleUseToken': return ok({ token: 'mock-once' })
|
||||
case 'settings/getTsigKeyNames': return ok({ tsigKeyNames: settings.tsigKeys.map((k) => k.keyName) })
|
||||
case 'dhcp/leases/list': return ok({ leases })
|
||||
case 'dhcp/scopes/list': return ok({ scopes })
|
||||
case 'dhcp/scopes/get': return ok(scopeDetail(q.get('name') ?? 'Main'))
|
||||
case 'dhcp/scopes/enable': case 'dhcp/scopes/disable': scopes = scopes.map((s) => (s.name === q.get('name') ? { ...s, enabled: path === 'dhcp/scopes/enable' } : s)); return ok()
|
||||
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/listStoreApps': return ok({ storeApps: storeListing() })
|
||||
case 'apps/downloadAndInstall': case 'apps/downloadAndUpdate': {
|
||||
const entry = catalog().find((c) => c.name === q.get('name') && c.url === q.get('url'))
|
||||
if (!entry) return { status: 'error', errorMessage: 'The app was not found in the store.' }
|
||||
const update = path === 'apps/downloadAndUpdate'
|
||||
const have = apps.find((a) => a.name === entry.name)
|
||||
if (update !== Boolean(have)) return { status: 'error', errorMessage: update ? `DNS application '${entry.name}' was not found.` : `DNS application '${entry.name}' already exists.` }
|
||||
const installed = { ...(have ?? { name: entry.name, description: entry.description, dnsApps: [app(false)] }), version: entry.version }
|
||||
delete installed.updateAvailable; delete installed.updateVersion; delete installed.updateUrl
|
||||
apps = update ? apps.map((a) => (a.name === entry.name ? installed : a)) : [...apps, installed]
|
||||
return ok(update ? { updatedApp: installed } : { installedApp: installed })
|
||||
}
|
||||
case 'apps/install': case 'apps/update': {
|
||||
const name = q.get('name') ?? ''
|
||||
const have = apps.find((a) => a.name === name)
|
||||
const update = path === 'apps/update'
|
||||
if (update !== Boolean(have)) return { status: 'error', errorMessage: update ? `DNS application '${name}' was not found.` : `DNS application '${name}' already exists.` }
|
||||
const installed = { ...(have ?? { name, description: 'Installed from a zip file', dnsApps: [app(false)] }), version: update ? `${have.version}.1` : '1.0' }
|
||||
apps = update ? apps.map((a) => (a.name === name ? installed : a)) : [...apps, installed]
|
||||
return ok(update ? { updatedApp: installed } : { installedApp: installed })
|
||||
}
|
||||
case 'apps/config/set': {
|
||||
const name = q.get('name') ?? ''
|
||||
if (!apps.some((a) => a.name === name)) return { status: 'error', errorMessage: `DNS application '${name}' was not found.` }
|
||||
try { appConfigs[name] = JSON.parse(q.get('config') ?? '') } catch (e) { return { status: 'error', errorMessage: `The config is not valid JSON: ${e.message}` } }
|
||||
return ok()
|
||||
}
|
||||
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': {
|
||||
@@ -294,7 +469,24 @@ http.createServer((req, res) => {
|
||||
req.on('end', () => {
|
||||
const q = new URLSearchParams(url.search)
|
||||
if ((req.headers['content-type'] ?? '').includes('application/x-www-form-urlencoded')) for (const [k, v] of new URLSearchParams(body)) q.set(k, v)
|
||||
if ((req.headers['content-type'] ?? '').includes('text/plain') && body !== '') q.set('__text', body)
|
||||
const fakeList = url.pathname.match(/^\/lists\/([\w.-]+)$/)
|
||||
if (fakeList !== null) {
|
||||
const text = FAKE_LISTS[fakeList[1]]
|
||||
res.statusCode = text === undefined ? 404 : 200
|
||||
res.setHeader('access-control-allow-origin', '*')
|
||||
res.setHeader('content-type', 'text/plain')
|
||||
res.end(text ?? 'not found')
|
||||
return
|
||||
}
|
||||
const file = fileFor(path, q)
|
||||
if (file !== undefined) {
|
||||
res.setHeader('content-type', 'text/plain')
|
||||
res.setHeader('content-disposition', `attachment; filename="${path.replace('/', '-')}.txt"`)
|
||||
res.end(file)
|
||||
return
|
||||
}
|
||||
res.setHeader('content-type', 'application/json')
|
||||
res.end(JSON.stringify(route(path, q)))
|
||||
})
|
||||
}).listen(PORT, '127.0.0.1', () => console.log(`Mock Technitium API on http://127.0.0.1:${PORT} (any user and password)`))
|
||||
}).listen(PORT, '127.0.0.1', () => console.log(`Mock Technitium API on http://127.0.0.1:${PORT} (any user and password, scale ${SCALE})`))
|
||||
@@ -0,0 +1,34 @@
|
||||
# The hub image built by Gitea CI (git.shx.one): one small container that serves the
|
||||
# console and a read-only, Technitium-compatible API over several real servers.
|
||||
# See hub/README.md. Self-contained: it does not use the console image.
|
||||
#
|
||||
# docker run --rm -p 8080:8080 -e HUB_SERVERS=dns=https://dns.example.net \
|
||||
# -e DNS_TOKEN=... -e HUB_USER=admin -e HUB_PASSWORD=... -v hub-data:/data \
|
||||
# git.shx.one/denozord/technitium-hub:latest
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /src
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
# What the hub runs: its sources without the tests, and a package.json that makes the
|
||||
# .ts files ECMAScript modules. It imports only node: built-ins, so there is no node_modules.
|
||||
RUN mkdir -p /out/hub \
|
||||
&& cp hub/*.ts /out/hub/ \
|
||||
&& rm /out/hub/*.test.ts \
|
||||
&& echo '{"type":"module"}' > /out/package.json
|
||||
|
||||
FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
COPY --from=build /out/ ./
|
||||
COPY --from=build /src/dist ./dist
|
||||
ENV HUB_STATIC=/app/dist HUB_PORT=8080 HUB_DATA=/data NODE_ENV=production
|
||||
# The users file and the audit log live here: mount a volume on it.
|
||||
RUN mkdir -p /data && chown node:node /data
|
||||
VOLUME /data
|
||||
USER node
|
||||
EXPOSE 8080
|
||||
# No curl in alpine: node asks the hub's public endpoint itself.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD ["node", "-e", "fetch('http://127.0.0.1:'+(process.env.HUB_PORT||8080)+'/api/status').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"]
|
||||
CMD ["node", "--experimental-strip-types", "--no-warnings", "/app/hub/server.ts"]
|
||||
@@ -0,0 +1,63 @@
|
||||
version: "3.8"
|
||||
|
||||
# Хаб: одна консоль со статистикой нескольких серверов Technitium (см. hub/README.md).
|
||||
# Подключается к уже работающему Traefik из compose.shnt.example.yaml (сеть dns-net).
|
||||
#
|
||||
# На КАЖДОМ сервере Technitium, один раз:
|
||||
# 1. Administration > Groups: группа hub-readers, ей право только View на Dashboard, Logs,
|
||||
# Apps (список приложений логирования для Query Logs) и Settings (состояние блокировки).
|
||||
# 2. Administration > Users: пользователь hub-reader в этой группе.
|
||||
# 3. Войти под ним и создать токен: Administration > Sessions > Create API Token.
|
||||
# Токены кладутся в .env рядом с этим файлом (не в git):
|
||||
# MDNS_TOKEN=...
|
||||
# NDNS_TOKEN=...
|
||||
# MDNS_ADMIN_TOKEN=... # токены hub-admin (Modify и Delete только в разделах, которыми управляете из хаба, см. docs/hub-deploy.md): нужны, чтобы хаб менял приложения и серверы
|
||||
# NDNS_ADMIN_TOKEN=...
|
||||
# HUB_USER=... # логин в сам хаб
|
||||
# HUB_PASSWORD=... # первый вход; затем пользователи в /data/users.json (docs/hub-deploy.md, шаг 4a)
|
||||
#
|
||||
# Обновление: docker compose pull technitium-hub && docker compose up -d technitium-hub
|
||||
|
||||
services:
|
||||
technitium-hub:
|
||||
image: git.shx.one/denozord/technitium-hub:latest
|
||||
container_name: technitium-hub
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- dns-net
|
||||
volumes:
|
||||
- technitium-hub-data:/data
|
||||
environment:
|
||||
# id=адрес, по порядку; токен каждого берётся из <ID>_TOKEN
|
||||
- HUB_SERVERS=mdns=https://mdns.shnt.top,ndns=https://ndns.shnt.top
|
||||
- MDNS_TOKEN=${MDNS_TOKEN}
|
||||
- NDNS_TOKEN=${NDNS_TOKEN}
|
||||
- MDNS_ADMIN_TOKEN=${MDNS_ADMIN_TOKEN}
|
||||
- NDNS_ADMIN_TOKEN=${NDNS_ADMIN_TOKEN}
|
||||
# Только пока нет /data/users.json; потом пользователи создаются через users-cli (docs/hub-deploy.md)
|
||||
- HUB_USER=${HUB_USER}
|
||||
- HUB_PASSWORD=${HUB_PASSWORD}
|
||||
- HUB_DATA=/data
|
||||
# За Traefik: брать адрес клиента из X-Forwarded-For (для ограничения попыток входа и журнала)
|
||||
- HUB_TRUST_PROXY=1
|
||||
# Как часто проверять, что конфиги серверов не разошлись с эталоном (минуты, 0 выключает)
|
||||
# - HUB_DRIFT_MINUTES=15
|
||||
- HUB_DOMAIN=dnss.shnt.top
|
||||
- TZ=Asia/Novosibirsk
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=dns-net"
|
||||
|
||||
- "traefik.http.routers.hub.rule=Host(`dnss.shnt.top`)"
|
||||
- "traefik.http.routers.hub.entrypoints=websecure"
|
||||
- "traefik.http.routers.hub.tls.certresolver=myresolver"
|
||||
- "traefik.http.routers.hub.service=hub-svc"
|
||||
- "traefik.http.services.hub-svc.loadbalancer.server.port=8080"
|
||||
|
||||
volumes:
|
||||
technitium-hub-data:
|
||||
|
||||
networks:
|
||||
dns-net:
|
||||
external: true
|
||||
name: dns-net
|
||||
@@ -171,6 +171,47 @@ services:
|
||||
networks:
|
||||
- dns-net
|
||||
|
||||
# Хаб: одна консоль со статистикой обоих серверов на https://dnss.shnt.top
|
||||
# (docs/hub-deploy.md). Только чтение. Токены и вход хаба берутся из .env:
|
||||
# MDNS_TOKEN, NDNS_TOKEN (пользователь hub-reader на каждом сервере), HUB_USER, HUB_PASSWORD.
|
||||
# Обновление: docker compose pull technitium-hub && docker compose up -d technitium-hub
|
||||
technitium-hub:
|
||||
image: git.shx.one/denozord/technitium-hub:latest
|
||||
container_name: technitium-hub
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- dns-net
|
||||
volumes:
|
||||
- technitium-hub-data:/data
|
||||
environment:
|
||||
# id=адрес по порядку; токен каждого из <ID>_TOKEN. Сервер на ЭТОМ же хосте можно
|
||||
# указать изнутри сети, мимо Traefik: ndns=http://technitium-smart:5380
|
||||
- HUB_SERVERS=mdns=https://mdns.shnt.top,ndns=https://ndns.shnt.top
|
||||
- MDNS_TOKEN=${MDNS_TOKEN}
|
||||
- NDNS_TOKEN=${NDNS_TOKEN}
|
||||
# Токены hub-admin: без них хаб только читает. Нужны для установки и обновления приложений
|
||||
- MDNS_ADMIN_TOKEN=${MDNS_ADMIN_TOKEN}
|
||||
- NDNS_ADMIN_TOKEN=${NDNS_ADMIN_TOKEN}
|
||||
# Только пока нет /data/users.json; потом пользователи создаются через users-cli (docs/hub-deploy.md)
|
||||
- HUB_USER=${HUB_USER}
|
||||
- HUB_PASSWORD=${HUB_PASSWORD}
|
||||
- HUB_DATA=/data
|
||||
# За Traefik: брать адрес клиента из X-Forwarded-For (для ограничения попыток входа и журнала)
|
||||
- HUB_TRUST_PROXY=1
|
||||
# Как часто проверять, что конфиги серверов не разошлись с эталоном (минуты, 0 выключает)
|
||||
# - HUB_DRIFT_MINUTES=15
|
||||
- HUB_DOMAIN=dnss.shnt.top
|
||||
- TZ=Asia/Novosibirsk
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=dns-net"
|
||||
|
||||
- "traefik.http.routers.hub.rule=Host(`dnss.shnt.top`)"
|
||||
- "traefik.http.routers.hub.entrypoints=websecure"
|
||||
- "traefik.http.routers.hub.tls.certresolver=myresolver"
|
||||
- "traefik.http.routers.hub.service=hub-svc"
|
||||
- "traefik.http.services.hub-svc.loadbalancer.server.port=8080"
|
||||
|
||||
networks:
|
||||
dns-net:
|
||||
driver: bridge
|
||||
@@ -179,3 +220,4 @@ networks:
|
||||
volumes:
|
||||
technitium-data:
|
||||
technitium-console:
|
||||
technitium-hub-data:
|
||||
@@ -0,0 +1,423 @@
|
||||
# Хаб: запуск по шагам
|
||||
|
||||
Хаб показывает статистику двух серверов Technitium DNS (`mdns.shnt.top` и `ndns.shnt.top`) в одной консоли. Это один маленький контейнер. Он отдаёт ту же консоль и сам ходит в API обоих серверов по их токенам:
|
||||
|
||||
- запрос «All servers» хаб отправляет на оба сервера и складывает ответы;
|
||||
- запрос к одному серверу хаб пересылает только ему;
|
||||
- менять хаб умеет только приложения (установка, обновление, удаление), и только для пользователей с ролью `apps-admin` или `admin`. Остальные запросы на изменение получают отказ и попадают в журнал.
|
||||
|
||||
Серверы Technitium не меняются, кластер не нужен. В инструкции хаб ставится на хост, где уже работают Traefik и `technitium-smart` (`docs/compose.shnt.example.yaml`). Адрес хаба: `https://dnss.shnt.top`.
|
||||
|
||||
```
|
||||
браузер ─► Traefik (dnss.shnt.top, TLS) ─► technitium-hub:8080
|
||||
├─► https://mdns.shnt.top/api/... (MDNS_TOKEN)
|
||||
└─► https://ndns.shnt.top/api/... (NDNS_TOKEN)
|
||||
```
|
||||
|
||||
Что хаб показывает: Dashboard (сумма или один сервер), Logs (View Logs и Query Logs, объединённые с колонкой Server), Apps, About и разделы управления одним сервером: Zones, Cache, Blocking (Blocked и Allowed), DNS Client, Settings и DHCP. В этих разделах хаб начинает с первого сервера, а менять что-либо можно только на выбранном сервере и только ролью, у которой есть нужное право (таблица в шаге 1а).
|
||||
|
||||
Остаётся в консоли каждого сервера: Administration (пользователи, группы, сессии, токены, SSO, LDAP), удаление логов и статистики, резервная копия и восстановление настроек (в копии лежат пользователи и токены), права на зоны и настройки веб-службы сервера (адреса, порты, HTTPS), через которую хаб сам ходит на сервер.
|
||||
|
||||
---
|
||||
|
||||
## Шаг 0. Образ хаба в реестре
|
||||
|
||||
Образ `git.shx.one/denozord/technitium-hub` собирает отдельный workflow `.gitea/workflows/hub.yaml`.
|
||||
|
||||
1. Отправьте коммиты хаба на `git.shx.one`:
|
||||
```bash
|
||||
git push origin main
|
||||
```
|
||||
2. В репозитории на `git.shx.one` откройте **Actions** и дождитесь зелёного прогона **Hub**:
|
||||
- job `check` проверяет типы и тесты хаба;
|
||||
- job `image` собирает образ и кладёт его в реестр.
|
||||
3. Убедитесь, что образ появился: в репозитории или профиле откройте **Packages**, там должен быть `technitium-hub` с тегом `latest`.
|
||||
|
||||
Если job `image` упал на входе в реестр, проверьте секрет `ACTION_PAT` (или `ACTIONS_PAT`) в настройках репозитория: это токен Gitea с правом `write:package`. Он тот же, что использует образ консоли.
|
||||
|
||||
Выпуск версии с номером (необязательно):
|
||||
```bash
|
||||
git tag hub-v1.0.0 && git push origin hub-v1.0.0
|
||||
```
|
||||
Тег даёт образы `1.0.0` и `1.0` в дополнение к `latest`.
|
||||
|
||||
---
|
||||
|
||||
## Шаг 1. Пользователь только для чтения на каждом сервере
|
||||
|
||||
Сделайте это **на обоих серверах**: `https://mdns.shnt.top` и `https://ndns.shnt.top`. Войдите в консоль сервера как администратор.
|
||||
|
||||
1. **Группа.** Administration › Groups › **Add Group**: имя `hub-readers`, описание любое.
|
||||
2. **Права группы.** Administration › Permissions: у группы `hub-readers` отметьте только **View** в каждом разделе, который хаб показывает:
|
||||
|
||||
| Раздел | Зачем хабу |
|
||||
|---|---|
|
||||
| Dashboard | статистика, топы, график по часам, живые метрики |
|
||||
| Logs | список файлов логов и Query Logs |
|
||||
| Apps | список приложений логирования, из которых Query Logs читает записи |
|
||||
| Settings | настройки и состояние блокировки |
|
||||
| Zones | список зон, записи, экспорт зоны |
|
||||
| Cache | дерево кеша |
|
||||
| Allowed | список разрешённых доменов и его экспорт |
|
||||
| Blocked | список заблокированных доменов и его экспорт |
|
||||
| DnsClient | DNS-запрос из раздела DNS Client |
|
||||
| DhcpServer | области и аренды DHCP |
|
||||
|
||||
Без View на новом разделе этот раздел в хабе отвечает «в доступе отказано». Секретные значения настроек (ключи TSIG, пароли) хаб скрывает от всех ролей, кроме `admin`.
|
||||
Modify и Delete не отмечайте нигде. Остальные разделы оставьте без прав.
|
||||
3. **Пользователь.** Administration › Users › **Add User**: имя `hub-reader`, длинный случайный пароль. Затем в карточке пользователя добавьте его в группу `hub-readers`. Из группы `Everyone` можно убрать, если у неё есть лишние права.
|
||||
4. **API-токен.** Нужен токен именно для `hub-reader`, а не для администратора:
|
||||
- выйдите из консоли и войдите под `hub-reader`;
|
||||
- в меню пользователя внизу слева выберите **Create API Token**, имя токена `hub`;
|
||||
- скопируйте токен сразу: его показывают один раз.
|
||||
|
||||
Можно и под администратором: Administration › Sessions › **Create Token**, пользователь `hub-reader`.
|
||||
5. Проверьте токен с любой машины. В ответе должно быть `"status":"ok"`:
|
||||
```bash
|
||||
curl -s "https://mdns.shnt.top/api/dashboard/stats/get?type=LastHour&token=ТОКЕН" | head -c 200
|
||||
```
|
||||
- `"status":"invalid-token"` значит, что скопирован не тот токен;
|
||||
- `"status":"error"` с сообщением об отказе в доступе значит, что у группы не хватает права View.
|
||||
|
||||
Результат шага: два токена, `MDNS_TOKEN` (с `mdns.shnt.top`) и `NDNS_TOKEN` (с `ndns.shnt.top`).
|
||||
|
||||
### Шаг 1а. Пользователь для записи (нужен, чтобы менять приложения)
|
||||
|
||||
Без этого шага хаб только читает. Токены записи отдельные, у отдельного пользователя. Сделайте это **на обоих серверах**:
|
||||
|
||||
1. **Группа** `hub-admins`. Права группы (Administration › Permissions) дают только то, что хаб может сделать этим токеном:
|
||||
|
||||
| Раздел | Права | Что хаб пишет |
|
||||
|---|---|---|
|
||||
| Apps | View, Modify, Delete | установка, обновление, удаление, конфиги приложений |
|
||||
| Zones | View, Modify, Delete | зоны, записи, DNSSEC, импорт |
|
||||
| Cache | View, Delete | сброс кеша и удаление домена из кеша |
|
||||
| Allowed, Blocked | View, Modify, Delete | добавить, удалить, очистить, импортировать |
|
||||
| Settings | View, Modify | сохранение настроек и URL списков блокировки, временное отключение блокировки, включение блокировки, обновление списков. Delete не нужен: резервная копия и восстановление через хаб закрыты |
|
||||
| DnsClient | View, Modify | Import из DNS Client пишет ответ в зону |
|
||||
| DhcpServer | View, Modify, Delete | области и аренды |
|
||||
| Dashboard, Logs | View | чтобы этот токен мог читать то же, что `hub-reader`. Delete не нужен: удаление логов и статистики закрыто |
|
||||
| Administration | без прав | хаб это не использует |
|
||||
|
||||
Выдавайте Modify и Delete только в тех разделах, которыми собираетесь управлять из хаба; без права на сервере запись просто будет отклонена.
|
||||
2. **Пользователь** `hub-admin` в этой группе, длинный случайный пароль.
|
||||
3. **API-токен** для `hub-admin` (так же, как в шаге 1.4). Это `MDNS_ADMIN_TOKEN` и `NDNS_ADMIN_TOKEN`.
|
||||
|
||||
Хаб использует эти токены только для записи (приложения, зоны, кеш, блокировка, DNS Client с импортом, настройки, DHCP), для чтения конфига приложения перед изменением и для сверки веб-службы перед сохранением настроек. Если для сервера токена нет, хаб показывает его «только для чтения» и не вызывает для записи: кнопки неактивны, а под выбором сервера написано «No admin token configured on the hub».
|
||||
|
||||
Запись идёт на один выбранный сервер. В режиме «All servers» (Dashboard, Logs, Blocking › Overview) кнопки записи неактивны с подсказкой «Choose a server».
|
||||
|
||||
Действия на несколько серверов сразу (этап 6): в «All servers» кнопки Block, Allow, Disable в Blocking › Overview, «Update lists on all servers», «Edit list URLs on all servers», «Remove from Blocked» и «Remove from Allowed», а также «Flush on all servers» и «Delete a domain on all servers» в Cache и Block/Allow в строках Query Logs открывают выбор серверов. По умолчанию отмечены все серверы с токеном записи; сервер без токена виден, но отключён, с причиной. Итог показывается по каждому серверу, «Retry failed» повторяет только неудавшиеся. Для этого нужны те же права на серверах: Blocked и Allowed (Modify, Delete), Cache (Delete), Settings (Modify: URL списков и переключатель блокировки). Роли `operator` и `admin`; в аудит попадает по строке на сервер, список URL в аудит не пишется. Заменённый список URL полностью перезаписывает прежний на каждом выбранном сервере.
|
||||
|
||||
Зоны, настройки и блокировка на нескольких серверах (этап 7), только роль `admin`. Zones при «All servers» показывает общую таблицу: где зона есть, её тип и SOA serial, статус (одинаково, serial отличается, тип отличается, только на одном сервере; Secondary с отставшим serial отмечен, но расхождением не считается). В меню строки: «Open on …», «Copy to …» (экспорт зоны с одного сервера и импорт на другой; копируются только Primary и Forwarder, подписанные DNSSEC и Secondary не копируются; если зона уже есть, нужна галочка Overwrite) и «Make secondary on …» (хаб добавляет адрес второго сервера в передачу зоны и уведомления первого и создаёт Secondary на втором; адрес первого, как его видит второй, подставляется из адреса сервера в хабе, адрес второго для списка передачи вводится вручную; по желанию ключ TSIG, он должен быть на обоих серверах). Для этого на **обоих** серверах у `hub-admin` нужны Zones (View, Modify, Delete) и Settings (View, Modify). Кнопка «Compare servers» в Settings и в Blocking открывает сравнение настроек (вкладки Compare, Baseline, Local fields, History): эталон берётся с выбранного сервера кнопкой «Accept … as baseline», применение (Apply) отправляет только изменившиеся общие поля и перед записью сохраняет прежние настройки сервера в историю. По умолчанию **локальны** и не отправляются: `dnsServerDomain`, адреса привязки (`dnsServerLocalEndPoints`, `dnsServerIPv4SourceAddresses`, `dnsServerIPv6SourceAddresses`), все `webService*`, сертификат DNS (`dnsTlsCertificatePath`, `dnsTlsCertificatePassword`), сокеты и адреса обратного прокси этого хоста, `logFolder`; версия, время работы, данные кластера и временное отключение блокировки не сравниваются. Секреты (ключи TSIG, пароль прокси) сравниваются внутри хаба, нигде не показываются (только «secret changed») и в аудит не пишутся; файлы эталона и истории хранят их в `/data` с правами 0600. В Blocking сравниваются ещё свои списки Blocked и Allowed как множества («не хватает N, лишних M»); лишние удаляются только при галочке «Also remove extras».
|
||||
|
||||
Какие роли хаба что могут: `operator` меняет кеш, списки Blocked и Allowed, переключатель блокировки (включить, временно отключить) и обновляет списки, `admin` меняет и зоны, настройки и DHCP; `viewer` и `apps-admin` в этих разделах только читают.
|
||||
|
||||
Хаб отклоняет всё, чего нет в таблице путей: `admin/*` и `user/*` (кроме собственного входа хаба), `logs/delete`, `logs/deleteAll`, `dashboard/stats/deleteAll`, `settings/backup`, `settings/restore`, права на зоны (`zones/permissions/*`). Сохранение настроек с изменённым полем веб-службы сервера (`webService*`) или с полем пользователей, SSO и LDAP хаб тоже отклоняет с сообщением «Change the web service settings in that server's own console.»
|
||||
|
||||
---
|
||||
|
||||
## Шаг 2. DNS-запись для dnss.shnt.top
|
||||
|
||||
В Cloudflare (зона `shnt.top`) создайте запись `dnss` типа `A` на IP хоста, где работает хаб.
|
||||
|
||||
Сертификат Traefik получит сам. В compose уже настроен `certresolver=myresolver` с DNS-challenge через Cloudflare (`CF_DNS_API_TOKEN`), отдельно открывать порт 80 для проверки не нужно.
|
||||
|
||||
---
|
||||
|
||||
## Шаг 3. Файл .env
|
||||
|
||||
На хосте в каталоге с `docker-compose.yml` откройте `.env` (там уже лежат `CF_DNS_API_TOKEN` и `DNS_SERVER_ADMIN_PASSWORD`) и добавьте:
|
||||
|
||||
```dotenv
|
||||
# Хаб: токены пользователей hub-reader с шага 1
|
||||
MDNS_TOKEN=вставьте_токен_mdns
|
||||
NDNS_TOKEN=вставьте_токен_ndns
|
||||
|
||||
# Токены hub-admin с шага 1а (без них хаб не меняет приложения)
|
||||
MDNS_ADMIN_TOKEN=вставьте_токен_записи_mdns
|
||||
NDNS_ADMIN_TOKEN=вставьте_токен_записи_ndns
|
||||
|
||||
# Первый вход в хаб (это НЕ логин Technitium). Нужен, пока не создан файл пользователей (шаг 4a)
|
||||
HUB_USER=admin
|
||||
HUB_PASSWORD=длинный_случайный_пароль
|
||||
```
|
||||
|
||||
Пароль лучше длинный. После 5 неудачных попыток за 15 минут вход блокируется на 15 минут, но это не замена хорошему паролю. Сгенерировать можно так:
|
||||
```bash
|
||||
openssl rand -base64 24
|
||||
```
|
||||
|
||||
Закройте `.env` от посторонних:
|
||||
```bash
|
||||
chmod 600 .env
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Шаг 4. Сервис в compose
|
||||
|
||||
Добавьте в `docker-compose.yml` в раздел `services:` блок из `docs/compose.hub.example.yaml`:
|
||||
|
||||
```yaml
|
||||
technitium-hub:
|
||||
image: git.shx.one/denozord/technitium-hub:latest
|
||||
container_name: technitium-hub
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- dns-net
|
||||
environment:
|
||||
# id=адрес, по порядку; токен каждого берётся из <ID>_TOKEN
|
||||
- HUB_SERVERS=mdns=https://mdns.shnt.top,ndns=https://ndns.shnt.top
|
||||
- MDNS_TOKEN=${MDNS_TOKEN}
|
||||
- NDNS_TOKEN=${NDNS_TOKEN}
|
||||
- MDNS_ADMIN_TOKEN=${MDNS_ADMIN_TOKEN}
|
||||
- NDNS_ADMIN_TOKEN=${NDNS_ADMIN_TOKEN}
|
||||
- HUB_USER=${HUB_USER}
|
||||
- HUB_PASSWORD=${HUB_PASSWORD}
|
||||
- HUB_DATA=/data
|
||||
- HUB_TRUST_PROXY=1
|
||||
- HUB_DOMAIN=dnss.shnt.top
|
||||
- TZ=Asia/Novosibirsk
|
||||
volumes:
|
||||
- technitium-hub-data:/data
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=dns-net"
|
||||
- "traefik.http.routers.hub.rule=Host(`dnss.shnt.top`)"
|
||||
- "traefik.http.routers.hub.entrypoints=websecure"
|
||||
- "traefik.http.routers.hub.tls.certresolver=myresolver"
|
||||
- "traefik.http.routers.hub.service=hub-svc"
|
||||
- "traefik.http.services.hub-svc.loadbalancer.server.port=8080"
|
||||
```
|
||||
|
||||
Том `technitium-hub-data` нужно объявить в разделе `volumes:` того же файла:
|
||||
```yaml
|
||||
volumes:
|
||||
technitium-hub-data:
|
||||
```
|
||||
В нём лежат файл пользователей `users.json` и журнал `audit.log`. `HUB_TRUST_PROXY=1` нужен за Traefik: хаб берёт адрес клиента из заголовка `X-Forwarded-For` для ограничения попыток входа и журнала. Если до хаба можно достучаться в обход Traefik, включать эту переменную нельзя: заголовок можно подделать.
|
||||
|
||||
Если хаб стоит в том же файле, что Traefik, отдельный блок `networks:` с `external: true` из примера не нужен: сеть `dns-net` в этом файле уже объявлена.
|
||||
|
||||
Про имена в `HUB_SERVERS`:
|
||||
- имя слева от `=` видно в консоли в селекторе «Server»;
|
||||
- из имени получается переменная токена: `mdns` даёт `MDNS_TOKEN`, `ndns` даёт `NDNS_TOKEN`, `my-dns` дал бы `MY_DNS_TOKEN`;
|
||||
- первый в списке сервер считается основным.
|
||||
|
||||
Адреса серверов указываются публичные (`https://mdns.shnt.top`). API Technitium доступно по тому же адресу, что и консоль (роутер `panel-old` в Traefik), отдельный порт не нужен.
|
||||
|
||||
---
|
||||
|
||||
## Шаг 4a. Пользователи хаба
|
||||
|
||||
У хаба свои пользователи с ролями. Они лежат в `/data/users.json`, пароли в виде хешей.
|
||||
|
||||
| Роль | Что может |
|
||||
|---|---|
|
||||
| `viewer` | смотреть |
|
||||
| `operator` | смотреть, блокировка и кеш (после появления записи) |
|
||||
| `apps-admin` | смотреть, устанавливать, обновлять и удалять приложения на выбранных серверах |
|
||||
| `admin` | всё, включая журнал действий |
|
||||
|
||||
Пока файла нет, в хаб входит один `admin` из `HUB_USER` / `HUB_PASSWORD`. Когда файл появился, эти две переменные игнорируются (в логе будет строка об этом). Если нет ни файла, ни переменных, хаб не запустится.
|
||||
|
||||
После запуска (шаг 5) создайте пользователей. Пароль команда спросит дважды, при вводе он не показывается:
|
||||
```bash
|
||||
docker exec -it technitium-hub node --experimental-strip-types /app/hub/users-cli.ts add denis admin
|
||||
docker exec -it technitium-hub node --experimental-strip-types /app/hub/users-cli.ts add alice viewer
|
||||
```
|
||||
Остальные команды: `passwd <имя>`, `role <имя> <роль>`, `disable <имя>`, `enable <имя>`, `list`. Хаб замечает изменение файла на следующем запросе и завершает сессии затронутых пользователей. Перезапуск не нужен.
|
||||
|
||||
После создания своего `admin` уберите `HUB_USER` и `HUB_PASSWORD` из `.env` и compose, затем `docker compose up -d technitium-hub`.
|
||||
|
||||
Журнал: войдите админом и откройте `/api/hub/audit/list?limit=200` (JSON, новые записи первыми) или смотрите файл `docker exec technitium-hub tail -n 50 /data/audit.log`. Пароли и токены в журнал не пишутся.
|
||||
|
||||
---
|
||||
|
||||
## Шаг 5. Запуск
|
||||
|
||||
Если реестр закрытый, один раз войдите в него. Нужен токен Gitea с правом `read:package`:
|
||||
```bash
|
||||
docker login git.shx.one
|
||||
```
|
||||
|
||||
Скачайте образ и запустите хаб:
|
||||
```bash
|
||||
docker compose pull technitium-hub
|
||||
docker compose up -d technitium-hub
|
||||
```
|
||||
|
||||
Проверьте контейнер:
|
||||
```bash
|
||||
docker compose ps technitium-hub # STATUS: Up ... (healthy)
|
||||
docker compose logs -f technitium-hub
|
||||
```
|
||||
|
||||
В логе при старте не должно быть ошибок конфигурации. Если чего-то не хватает, хаб не запустится и назовёт переменную, например `NDNS_TOKEN is required for the server "ndns".`
|
||||
|
||||
---
|
||||
|
||||
## Шаг 6. Проверка
|
||||
|
||||
1. Откройте `https://dnss.shnt.top` и войдите с `HUB_USER` / `HUB_PASSWORD` из `.env`.
|
||||
2. На Dashboard над заголовком должен быть селектор **Server: All servers**.
|
||||
3. Сверьте цифры. Total Queries в «All servers» должна равняться сумме значений в консолях `mdns.shnt.top` и `ndns.shnt.top` за тот же период. Расхождение в пределах минуты нормально: хаб кеширует ответы на 15 секунд, а Last Hour обновляется на ходу.
|
||||
4. Выберите в селекторе `mdns`, затем `ndns`: цифры каждого сервера должны совпасть с его собственной консолью.
|
||||
5. Откройте Logs › Query Logs, нажмите **Query**. Записи обоих серверов идут вместе, в колонке **Server** видно, чья запись.
|
||||
6. Кнопка статуса справа в шапке показывает `dnss.shnt.top`. Если какой-то сервер не ответил, там будет «1 server not answering», а по нажатию появится причина.
|
||||
|
||||
Проверка без браузера (подставьте пароль хаба):
|
||||
```bash
|
||||
T=$(curl -s -X POST https://dnss.shnt.top/api/user/login \
|
||||
--data-urlencode "user=admin" --data-urlencode "pass=ПАРОЛЬ_ХАБА" | sed -E 's/.*"token":"([^"]+)".*/\1/')
|
||||
curl -s -H "Authorization: Bearer $T" "https://dnss.shnt.top/api/dashboard/stats/get?type=LastHour" | head -c 300
|
||||
curl -s -H "Authorization: Bearer $T" "https://dnss.shnt.top/api/dashboard/stats/get?type=LastHour&node=ndns" | head -c 300
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Обновление
|
||||
|
||||
После нового прогона **Hub** в Actions:
|
||||
```bash
|
||||
docker compose pull technitium-hub && docker compose up -d technitium-hub
|
||||
```
|
||||
Сессии хаба хранятся в памяти: после перезапуска контейнера нужно войти заново.
|
||||
|
||||
Чтобы закрепить версию, укажите в compose тег вместо `latest`, например `technitium-hub:1.0.0`.
|
||||
|
||||
## Остановка и удаление
|
||||
|
||||
```bash
|
||||
docker compose stop technitium-hub # остановить
|
||||
docker compose rm -f technitium-hub # удалить контейнер
|
||||
```
|
||||
|
||||
Чтобы отозвать доступ хаба к серверам, на каждом сервере удалите токен в Administration › Sessions или отключите пользователя `hub-reader`.
|
||||
|
||||
---
|
||||
|
||||
## Настройки хаба
|
||||
|
||||
| Переменная | По умолчанию | Что задаёт |
|
||||
|---|---|---|
|
||||
| `HUB_SERVERS` | обязательна | Серверы по порядку: `id=https://адрес,...` |
|
||||
| `<ID>_TOKEN` | обязательна для каждого | API-токен сервера (`MDNS_TOKEN`, `NDNS_TOKEN`, ...) |
|
||||
| `HUB_USER`, `HUB_PASSWORD` | нужны, пока нет `users.json` | Первый `admin`; при наличии файла игнорируются |
|
||||
| `HUB_DATA` | `/data` | Папка `users.json` и `audit.log` (на ней том) |
|
||||
| `HUB_TRUST_PROXY` | выключено | `1`: адрес клиента из `X-Forwarded-For` (только за Traefik) |
|
||||
| `<ID>_ADMIN_TOKEN` | нет | Токен записи сервера (`MDNS_ADMIN_TOKEN`). Им хаб меняет приложения; без него сервер только для чтения. В логи не пишется |
|
||||
| `HUB_DOMAIN` | `hub` | Имя, которое консоль показывает как имя сервера |
|
||||
| `HUB_CACHE_SECONDS` | `15` | Сколько секунд хранить ответы серверов (0 выключает кеш) |
|
||||
| `HUB_TIMEOUT_MS` | `8000` | Сколько ждать ответа сервера, мс |
|
||||
| `HUB_PORT` | `8080` | Порт внутри контейнера |
|
||||
| `HUB_WEBHOOK_URL` | нет | Адрес http или https, куда хаб шлёт уведомления (см. ниже). Адрес может содержать токен, храните его как секрет |
|
||||
| `HUB_WEBHOOK_SECRET` | нет | Подписывает уведомления (`X-Hub-Signature`). В логи не пишется |
|
||||
|
||||
Третий сервер добавляется строкой в `HUB_SERVERS` и его токеном:
|
||||
```yaml
|
||||
- HUB_SERVERS=mdns=https://mdns.shnt.top,ndns=https://ndns.shnt.top,lab=https://lab.shnt.top
|
||||
- LAB_TOKEN=${LAB_TOKEN}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Если что-то не так
|
||||
|
||||
| Признак | Причина и что делать |
|
||||
|---|---|
|
||||
| Контейнер сразу завершается | В `docker compose logs technitium-hub` будет названа недостающая переменная. Проверьте `.env` и что compose его видит (`docker compose config \| grep TOKEN`). |
|
||||
| `https://dnss.shnt.top` не открывается, ошибка сертификата | Нет DNS-записи `dnss` (шаг 2) или Traefik ещё получает сертификат: смотрите `docker logs traefik`. |
|
||||
| 404 от Traefik | Контейнер не в сети `dns-net` или опечатка в `Host(\`dnss.shnt.top\`)`. |
|
||||
| Вход в хаб: «Invalid username or password» | Неверный логин или пароль. Если есть `users.json`, работают только его пользователи (`users-cli.ts list`), а `HUB_USER` игнорируется. После правки `.env` нужен `docker compose up -d technitium-hub`. |
|
||||
| «Too many failed logins. Try again in N minutes.» | 5 неудач за 15 минут с одного адреса или на одно имя. Подождите; блокировка хранится в памяти, её снимает перезапуск контейнера. |
|
||||
| Все пользователи блокируются разом | Не включён `HUB_TRUST_PROXY=1`: хаб видит только адрес Traefik. |
|
||||
| Контейнер пишет `audit log is not writable` | Нет тома на `/data` или папка не принадлежит `node`. Журнал живёт в памяти (последние 1000 записей) до исправления. |
|
||||
| В шапке «1 server not answering», в подсказке `ndns: its API token was refused` | Токен `NDNS_TOKEN` отозван или скопирован с ошибкой. Создайте новый (шаг 1.4) и перезапустите хаб. |
|
||||
| В подсказке `... did not answer: ...` или `ECONNREFUSED` / `timeout` | Хаб не достучался до сервера. Проверьте с хоста хаба: `docker exec technitium-hub node -e "fetch('https://ndns.shnt.top/api/status').then(r=>console.log(r.status))"`. |
|
||||
| В подсказке `ndns: ...` с сообщением об отказе в доступе | У группы `hub-readers` на этом сервере не хватает View в каком-то разделе (шаг 1.2). |
|
||||
| Query Logs пустой или «нет приложения» | На сервере не установлено приложение логирования (Query Logs (Sqlite) и подобные) или нет права View на Apps. |
|
||||
| Цифры «All servers» больше суммы на 1-2 клиента | Clients считается по каждому серверу отдельно: клиент, который ходит на оба сервера, учтён дважды. Это ожидаемо. |
|
||||
| Нажатие на что-то пишет «The hub is read-only.» | Хаб меняет только приложения. Остальные изменения делаются в консоли нужного сервера. |
|
||||
|
||||
## Конфиги приложений: эталон и синхронизация
|
||||
|
||||
Чтобы конфиги приложений на серверах были одинаковыми, кроме полей, которые у каждого сервера свои (путь к базе, сети):
|
||||
|
||||
1. Для чтения конфигов `hub-reader` нужно право **View на Apps** (оно уже есть в таблице шага 1). Если сервер откажет, хаб попробует токен `hub-admin`.
|
||||
2. Откройте Apps › All servers, в меню строки приложения выберите **Config…** (или нажмите значок «Different · N»). Читать сравнение могут все роли.
|
||||
3. **Эталон.** Вкладка Baseline: **Import from mdns** (или другого сервера, на котором конфиг правильный). Эталон можно поправить в редакторе и сохранить. Если текст начинается с `{` или `[`, он должен быть корректным JSON, иначе хаб покажет ошибку.
|
||||
4. **Локальные поля.** Вкладка Local fields: добавьте путь поля, например `sqliteDbPath` или `networks.0.cidr`, нажмите **Fill from the current server configs** (подставит текущие значения каждого сервера) и сохраните. Такие поля не сравниваются и не рассылаются: в матрице серверы, различающиеся только ими, показываются как «Same».
|
||||
5. **Применение.** Кнопка **Apply…**: выберите серверы, посмотрите предпросмотр (что изменится, предупреждения, например «версии отличаются, сначала выровняйте версии»), нажмите **Apply**. По каждому серверу будет итог; **Retry failed** повторит только неудавшиеся.
|
||||
|
||||
Что хаб делает для безопасности:
|
||||
- перед записью сохраняет прежний конфиг сервера в `/data/apps/<имя>/history/` (50 последних) и не пишет, если сохранить не удалось;
|
||||
- если конфиг на сервере изменили после предпросмотра, этот сервер пропускается с пояснением;
|
||||
- после записи читает конфиг обратно и проверяет, что он совпал; если сервер отклонил конфиг, остаётся старый;
|
||||
- в журнал попадают кто, на каком сервере и размер с коротким хешем конфига, но не сам текст.
|
||||
|
||||
Файлы эталона и локальных полей лежат в `/data/apps/<имя>/` (`baseline.json`, `overrides.json`). Они содержат конфиги, поэтому том `/data` нужно беречь так же, как `users.json`.
|
||||
|
||||
## История, откат, контроль расхождений, перенос эталонов
|
||||
|
||||
- **История.** Вкладка History в окне Config: все прежние версии, которые хаб сохранил (конфиг сервера перед применением или сохранением, прежний эталон). Выберите версию, чтобы прочитать её и увидеть отличие от текущего конфига сервера. Роли `apps-admin` и `admin`: **Roll back…** (выбор серверов, предпросмотр, итог по каждому) и **Restore as baseline** для прежнего эталона. Хранится по 50 версий на приложение.
|
||||
- **Контроль расхождений.** Раз в 15 минут хаб читает конфиги и сравнивает их с тем, что должно быть (эталон плюс локальные поля сервера). Если конфиг поменяли прямо в консоли сервера, в шапке появляется «N config drift». В списке у каждого расхождения есть **Open config**, а для `apps-admin` и `admin` ещё **Accept as baseline** (конфиг этого сервера становится эталоном; локальные поля остаются локальными) и **Revert to baseline** (через предпросмотр). Период меняется переменной `HUB_DRIFT_MINUTES`, `0` отключает проверку. Сервер, который не ответил, показывается как «Cannot read» и в счётчик не входит. Последний результат лежит в `/data/drift.json`.
|
||||
- **Что проверяется.** Конфиги приложений, общие поля настроек (против эталона настроек) и списки Blocked/Allowed (против эталона списков). Проверка настроек и списков идёт только если соответствующий эталон принят. Расхождение настроек или списков показывает только названия полей, без значений. В списке у него **Open compare** (окно сравнения Settings или Blocking), **Accept as baseline** и **Revert to baseline** (через тот же предпросмотр); эти действия только для роли `admin`.
|
||||
- **Перенос эталонов.** Apps › All servers: **Export baselines** скачивает один файл со всеми эталонами и локальными полями, **Import baselines** загружает его в другой хаб: сначала проверка и сводка (New, Changed, Same), запись только после **Import**. На серверы импорт ничего не отправляет. Файл содержит конфиги, храните его как секрет.
|
||||
|
||||
## Уведомления (webhook)
|
||||
|
||||
Если задан `HUB_WEBHOOK_URL`, хаб отправляет POST с JSON `{event, at, hub, items}`:
|
||||
|
||||
- `drift.found`: что-то только что разошлось с эталоном (сервер, вид, имя). Шлётся один раз при изменении, а не каждые 15 минут;
|
||||
- `drift.cleared`: расхождение исчезло (сервер, который перестал отвечать, не считается исчезнувшим);
|
||||
- `write.failed`: изменение, записанное в журнал с исходом `failed` (без значений). Сбои одного момента идут одним уведомлением.
|
||||
|
||||
Пример:
|
||||
```json
|
||||
{"event":"drift.found","at":"2026-11-01T09:15:00.000Z","hub":"dnss.shnt.top",
|
||||
"items":[{"kind":"settings","server":"ndns","since":"2026-11-01T09:15:00.000Z","fields":["blockingType"]}]}
|
||||
```
|
||||
|
||||
Если задан `HUB_WEBHOOK_SECRET`, заголовок `X-Hub-Signature: sha256=<hex>` содержит HMAC-SHA256 точных байт тела. Проверка на стороне получателя (Node):
|
||||
```js
|
||||
const expected = 'sha256=' + createHmac('sha256', SECRET).update(rawBody).digest('hex')
|
||||
// сравните с заголовком через timingSafeEqual
|
||||
```
|
||||
Отправка не блокирует работу хаба: таймаут 5 секунд, не больше одного повтора. Ошибка пишется в лог хаба с именем хоста (без параметров адреса). Администратор видит состояние (настроен ли, хост, результат последней отправки) и кнопку **Send test** в окне «Config drift» (оно открывается из шапки, когда есть расхождения).
|
||||
|
||||
## Профили
|
||||
|
||||
Профиль нужен, чтобы быстро привести новый или сброшенный сервер к общему виду. Это именованный набор в `/data/profiles/`: приложения магазина (имя и версия) с эталонным конфигом каждого, общие поля настроек (поля, относящиеся к самому хосту, не входят) и эталон списков Blocked/Allowed. Файл содержит секреты настроек (ключи TSIG), берегите том `/data`.
|
||||
|
||||
Все действия только для роли `admin` (`apps-admin`, `operator` и `viewer` получают отказ). Кнопка **Profiles** на экране Apps › All servers:
|
||||
|
||||
- **Create from current baselines**: профиль собирается из эталонов, которые хаб держит сейчас; можно не брать приложения, настройки или списки. Приложение не из магазина (поставленное из zip) в профиль не входит;
|
||||
- **View**: состав профиля; секреты показаны как `<secret>`, тексты конфигов не показываются;
|
||||
- **Apply to servers…**: выбор серверов, предпросмотр по шагам для каждого сервера, затем итог по каждому шагу. Шаги идут по порядку, ошибка шага останавливает следующие шаги этого сервера: 1) установка недостающих приложений из магазина самого сервера, 2) конфиги приложений (эталон плюс локальные поля сервера, прежний конфиг сохраняется в историю), 3) общие поля настроек (через те же проверки, что и обычное сохранение), 4) списки: недостающие домены добавляются, лишние удаляются только по галочке **Also remove extras**. **Retry failed** начинает заново с шага, который не удался;
|
||||
- **Export** и **Import profiles**: перенос между хабами (файл содержит секреты, храните его как секрет; сначала проверка, запись только после **Import**).
|
||||
|
||||
## Приложения: установка, обновление, удаление
|
||||
|
||||
Роль `apps-admin` (или `admin`) видит в разделе Apps при «All servers» меню действий в каждой строке и кнопки **App Store** и **Install from file**:
|
||||
|
||||
- **Update on all**: обновить с сервера, где есть обновление;
|
||||
- **Install on missing servers**: поставить из магазина там, где приложения нет;
|
||||
- **Align versions**: подтянуть отстающие серверы до самой новой установленной версии (если магазин её предлагает);
|
||||
- **Uninstall from…**: удалить с выбранных серверов.
|
||||
|
||||
Каждое действие сначала открывает выбор серверов (сервер без токена записи недоступен, с причиной), потом показывает итог по каждому серверу. Если на каком-то сервере не вышло, кнопка **Retry failed** повторит только его.
|
||||
|
||||
Что хаб делает для безопасности:
|
||||
- ссылку на скачивание берёт из магазина самого сервера, произвольный адрес не принимает;
|
||||
- перед удалением сохраняет конфиг приложения в `/data/apps/<имя>/history/` (50 последних на приложение) и не удаляет, если сохранить не удалось. Откат: вставить сохранённый текст в Config приложения на сервере;
|
||||
- каждое действие по каждому серверу пишется в журнал (`apps.update-store`, `apps.uninstall` и т. д.).
|
||||
|
||||
Права на запись у `viewer` и `operator` нет: они получают «Your role does not allow this.».
|
||||
|
||||
## Безопасность
|
||||
|
||||
- Читает хаб токенами с одними правами View. Менять он умеет только приложения, и только токенами `*_ADMIN_TOKEN` (права Apps) по запросу пользователя с ролью `apps-admin` или `admin`. Всё остальное получает отказ в хабе и запись в журнале.
|
||||
- Токены лежат только в `.env` на хосте и в памяти контейнера. В браузер они не попадают, в ответы и логи хаба не пишутся.
|
||||
- У каждого человека свой логин и роль. Вход защищён блокировкой после 5 неудач. Нужны длинные пароли, а если хаб не должен быть виден из интернета, добавьте к роутеру `hub` в Traefik `ipallowlist` или `basicAuth`.
|
||||
@@ -0,0 +1,181 @@
|
||||
# Хаб: централизованное управление серверами Technitium без кластера
|
||||
|
||||
Общий план. Внутри два пласта:
|
||||
- **общее управление**: каждый сервер из одной консоли, действия «на все серверы», сравнение и выравнивание;
|
||||
- **упор на Apps**: приложения на всех серверах и синхронизация их конфигов.
|
||||
|
||||
Статус: этапы 0 и 1 (безопасность, Apps по серверам), 2 (установка и обновление), 3 (сравнение и синхронизация конфигов), 4 (история, откат, расхождения), 5 (управление каждым сервером), 6 (действия на все серверы вне Apps), 7 (зоны, настройки и блокировка: сравнение и выравнивание) и 8 (профили, webhook, расхождения настроек и списков) сделаны. Этапы после 8 пока планы (`docs/hub-deploy.md`).
|
||||
|
||||
---
|
||||
|
||||
## 1. Контекст и цель
|
||||
|
||||
- Есть два независимых сервера Technitium DNS: `mdns.shnt.top` и `ndns.shnt.top`. Кластера нет и не будет: серверы должны оставаться самостоятельными.
|
||||
- Хаб на `dnss.shnt.top` уже показывает статистику обоих: Dashboard, Logs, About, переключатель «All servers / mdns / ndns», предупреждение об отказе сервера.
|
||||
- **Цель:** управлять обоими серверами из хаба. Прежде всего приложениями (Apps): ставить, обновлять и держать их конфиги одинаковыми там, где нужно, не трогая того, что у каждого сервера своё.
|
||||
|
||||
---
|
||||
|
||||
## 2. Принципы
|
||||
|
||||
1. **Два режима записи.**
|
||||
- **На один сервер:** действие пересылается выбранному серверу (`node=mdns` или `node=ndns`). Так уже работает чтение.
|
||||
- **На все серверы:** хаб выполняет действие на каждом выбранном сервере и показывает итог по каждому отдельно. Тихого «ок» при частичном успехе не бывает.
|
||||
2. **Чтение и запись разделены.**
|
||||
- Токены `hub-reader` (только View) остаются для чтения.
|
||||
- Для записи на каждом сервере заводится отдельный `hub-admin` только с теми правами, которые нужны этапу.
|
||||
3. **Хаб защищается до того, как получает запись.** Роли, ограничение попыток входа, журнал действий (этап 0).
|
||||
4. **Конфиги синхронизируются, данные нет.**
|
||||
- Синхронизируются тексты конфигов приложений и настройки.
|
||||
- Базы логов, кеш и статистика у каждого сервера свои.
|
||||
5. **Общая часть плюс локальные поля.**
|
||||
- В конфигах приложений есть поля, свои для каждого сервера: сети Split Horizon, путь к базе Query Logs, локальные группы клиентов Advanced Blocking.
|
||||
- Синхронизируется только общая часть. Локальные поля задаются по каждому серверу.
|
||||
6. **Источник правды для конфигов: эталон в хабе.**
|
||||
- Хаб хранит желаемый конфиг в своём томе (`/data`), серверы приводятся к нему.
|
||||
- Первый эталон берётся импортом с любого сервера.
|
||||
- Альтернатива: ведущий сервер (mdns) как эталон. Она проще, но хуже: зависит от живучести одного сервера и не даёт истории.
|
||||
7. **Зоны DNS лучше синхронизировать средствами Technitium.** Primary-зона на одном сервере и secondary с передачей зоны (AXFR) на другом надёжнее рассылки каждой правки из хаба. Хаб помогает это настроить и показывает расхождения, но не дублирует записи сам.
|
||||
8. **Перед изменением предпросмотр, после него итог и откат.** Каждое применение конфига сохраняет прежнюю версию.
|
||||
|
||||
---
|
||||
|
||||
## 3. Этапы
|
||||
|
||||
Сначала безопасность, потом Apps (главный упор), потом общее управление.
|
||||
|
||||
### Этап 0. Безопасность записи (обязателен первым)
|
||||
|
||||
- **Пользователи хаба с ролями**, в файле `/data/users.json`, пароли в виде хешей:
|
||||
- `viewer`: только смотрит;
|
||||
- `apps-admin`: управляет приложениями;
|
||||
- `operator`: блокировка и кеш;
|
||||
- `admin`: всё.
|
||||
- **Защита входа:**
|
||||
- ограничение попыток входа и временная блокировка после серии неудач;
|
||||
- по желанию: вход через ваш auth-портал (Traefik `forwardAuth`).
|
||||
- **Журнал действий** (`/data/audit.log`, раздел Audit в консоли хаба): кто, когда, на каком сервере, что сделал, итог.
|
||||
- **Токены записи** `MDNS_ADMIN_TOKEN` и `NDNS_ADMIN_TOKEN`. На старте права только Modify и Delete на Apps, другие разделы добавляются по этапам.
|
||||
- **Права в хабе:** вместо списка «только чтение»: таблица «эндпоинт → чтение / запись на один сервер / запись на все / нужная роль».
|
||||
- **Доступ к хабу по сети:** закрыть по IP (`ipallowlist` в Traefik) или через auth-портал.
|
||||
|
||||
### Этап 1. Apps: обзор по серверам (только чтение)
|
||||
|
||||
Матрица «приложение × сервер» в разделе Apps при «All servers»:
|
||||
- установлено или нет, версия, есть ли обновление;
|
||||
- конфиг: «совпадает», «отличается (N полей)» или «отличается только в локальных полях»;
|
||||
- фильтры: «отличаются версии», «отличаются конфиги», «есть обновление».
|
||||
|
||||
Отвечает на вопрос «что где стоит и где расхождения» без единой записи.
|
||||
|
||||
### Этап 2. Apps: установка, обновление, удаление на нескольких серверах
|
||||
|
||||
- Выбор цели у действий: mdns, ndns или все серверы.
|
||||
- **Из магазина:** `apps/downloadAndInstall` и `apps/downloadAndUpdate` на каждом выбранном сервере.
|
||||
- **Свой zip:** загружается в хаб один раз, хаб отправляет его через `apps/install` или `apps/update` на каждый сервер.
|
||||
- Окно итога по серверам и кнопка «Повторить на неудавшихся».
|
||||
- **«Выровнять версии»:** обновить приложение везде до самой новой версии.
|
||||
- **Удаление на всех:** отдельное подтверждение. Хаб перед удалением сохраняет конфиг в историю.
|
||||
|
||||
### Этап 3. Apps: сравнение и синхронизация конфигов
|
||||
|
||||
- **Сравнение рядом:** конфиг на mdns и на ndns. Для JSON: по полям (добавлено, удалено, изменено), для не-JSON: построчно.
|
||||
- **Эталон в хабе:**
|
||||
- импорт эталона с выбранного сервера;
|
||||
- правка эталона в редакторе хаба;
|
||||
- хранение в `/data/apps/<приложение>/baseline.json`.
|
||||
- **Локальные поля:**
|
||||
- список путей полей, которые не синхронизируются (например, `networks`, `dbPath`), плюс их значения по каждому серверу;
|
||||
- хранение в `/data/apps/<приложение>/overrides.json`;
|
||||
- итоговый конфиг сервера = эталон + его локальные значения.
|
||||
- **Предпросмотр:** что изменится на каждом сервере, затем применение через `apps/config/set` на выбранных серверах с итогом по каждому.
|
||||
- **Проверка до записи:** JSON разбирается в хабе. Если сервер отверг конфиг, показывается его ошибка, а сервер остаётся со старым конфигом.
|
||||
|
||||
### Этап 4. Apps: история, откат, контроль расхождений
|
||||
|
||||
- **История:** каждое применение сохраняет прежний конфиг каждого сервера (`/data/apps/<приложение>/history/`). Можно посмотреть любую версию и откатить один сервер или все.
|
||||
- **Проверка расхождений по расписанию** (по умолчанию раз в 15 минут). Если конфиг поменяли прямо в консоли сервера, в шапке появляется предупреждение с действиями «Принять как эталон» и «Вернуть к эталону».
|
||||
- **Экспорт и импорт всех эталонов одним файлом:** резервная копия и подготовка нового сервера.
|
||||
|
||||
### Этап 5. Управление каждым сервером из одной консоли
|
||||
|
||||
- Запись на выбранный сервер открывается для разделов Zones, Cache, Blocking, DNS Client, Settings, DHCP, Logs (кроме удаления логов). Права токена `hub-admin` расширяются под эти разделы.
|
||||
- В «All servers» запись по-прежнему закрыта: кнопки неактивны с подсказкой «выберите сервер».
|
||||
- Администрирование пользователей, SSO и LDAP серверов остаётся в их собственных консолях.
|
||||
|
||||
### Этап 6. Действия «на все серверы» вне Apps
|
||||
|
||||
- **Блокировка:** добавить или удалить домен в Blocked и Allowed, временно отключить блокировку, обновить списки, изменить URL списков.
|
||||
- **Кеш:** сбросить весь кеш или удалить домен.
|
||||
- **Итог по каждому серверу** и повтор на неудавшихся.
|
||||
|
||||
### Этап 7. Сравнение и выравнивание остального
|
||||
|
||||
- **Zones в «All servers»:**
|
||||
- объединённый список с отметкой, где зона есть, и признаком расхождения (разный SOA serial);
|
||||
- действия «Скопировать зону на другой сервер» (экспорт и импорт) и «Сделать secondary на другом сервере» (передача зоны).
|
||||
- **Blocking и Settings:** отчёт о различиях и «Принять как эталон» с предпросмотром, по той же схеме «общая часть плюс локальные поля», что и для Apps.
|
||||
|
||||
### Этап 8. Удобство (по желанию)
|
||||
|
||||
- Профили: набор приложений с конфигами и правил, который применяется к новому серверу.
|
||||
- Уведомления о расхождениях наружу (webhook).
|
||||
|
||||
---
|
||||
|
||||
## 4. Что меняется в коде
|
||||
|
||||
**Хаб (`hub/`):**
|
||||
- `server.ts`:
|
||||
- таблица прав по эндпоинтам;
|
||||
- пересылка POST и multipart (zip приложений, импорт зон);
|
||||
- рассылка «на все» с итогами по серверам.
|
||||
- Новые модули:
|
||||
- `users.ts`: роли, хеши паролей, ограничение попыток входа;
|
||||
- `audit.ts`: журнал действий;
|
||||
- `store.ts`: файлы в `/data` (эталоны, локальные поля, история), запись атомарно через временный файл;
|
||||
- `apps-sync.ts`: сравнение конфигов, слияние эталона с локальными полями, предпросмотр, применение;
|
||||
- `drift.ts`: проверка расхождений по расписанию.
|
||||
- `config.ts`: `<ID>_ADMIN_TOKEN`, `HUB_DATA` (по умолчанию `/data`), `HUB_DRIFT_MINUTES`.
|
||||
|
||||
**Консоль (`src/`), только в режиме хаба:**
|
||||
- права разделов приходят от хаба по роли пользователя;
|
||||
- Apps: матрица по серверам, выбор цели у действий, окно итогов;
|
||||
- экран «Конфиг приложения»: сравнение рядом, эталон, локальные поля, предпросмотр, история и откат;
|
||||
- предупреждение о расхождениях в шапке;
|
||||
- раздел Audit.
|
||||
|
||||
**Развёртывание:**
|
||||
- в compose: том `technitium-hub-data:/data` и токены `MDNS_ADMIN_TOKEN`, `NDNS_ADMIN_TOKEN` в `.env`;
|
||||
- на каждом сервере пользователь `hub-admin` в отдельной группе с правами записи только на нужные разделы.
|
||||
|
||||
---
|
||||
|
||||
## 5. Риски и как их закрыть
|
||||
|
||||
| Риск | Что делаем |
|
||||
|---|---|
|
||||
| Хаб получает ключи на запись к обоим серверам | Этап 0 первым; токены только на нужные разделы; хаб закрыт по IP или через auth-портал |
|
||||
| Частичный успех при рассылке | Итог по каждому серверу, повтор на неудавшихся, откат из истории |
|
||||
| Разные версии приложения ждут разный формат конфига | Хаб показывает версии и предлагает сначала выровнять их |
|
||||
| Неверно заданные локальные поля перенесут настройки одного сервера на другой | Предпросмотр перед каждым применением, история и откат |
|
||||
| Конфиг поменяли мимо хаба | Проверка расхождений по расписанию и предупреждение |
|
||||
| Разные версии Technitium понимают API по-разному | Версия каждого сервера в хабе, предупреждение при расхождении |
|
||||
|
||||
---
|
||||
|
||||
## 6. Как проверяется каждый этап
|
||||
|
||||
- **Модульные тесты хаба** (vitest): права по эндпоинтам, роли и защита входа, слияние эталона с локальными полями, сравнение JSON, история и откат.
|
||||
- **Тест с двумя фейковыми серверами:** рассылка, частичный отказ, итоги.
|
||||
- **Мок** (`dev/mock-api.mjs`) получает приложения и конфиги, разные на двух портах. В браузере проверяются матрица, сравнение, применение и откат.
|
||||
- **Порядок работы:** код по этапам пишет Sonnet, Opus проверяет дифф, тесты и браузер, коммит после каждого принятого этапа.
|
||||
- **На серверах:** сначала на одном приложении с безобидным конфигом, потом шире.
|
||||
|
||||
---
|
||||
|
||||
## 7. Решения перед стартом
|
||||
|
||||
1. **Источник правды для конфигов:** эталон в хабе (рекомендация) или ведущий сервер mdns.
|
||||
2. **Вход в хаб:** свои пользователи хаба с ролями или ваш auth-портал через Traefik `forwardAuth`.
|
||||
3. **Какие приложения стоят на серверах** и какие поля в их конфигах заведомо локальные. Это станет начальными списками локальных полей.
|
||||
+367
@@ -0,0 +1,367 @@
|
||||
# Technitium hub
|
||||
|
||||
A small, dependency-free Node program that looks like ONE Technitium DNS Server to the
|
||||
console and answers from several real ones. It serves the console's static build and a
|
||||
read-only, Technitium-compatible `/api/*`:
|
||||
|
||||
- `node=<id>` forwards the call to that server, with that server's token.
|
||||
- No `node` (or `node=cluster`) asks every server in parallel and merges the answers
|
||||
(statistics are summed, top lists and query logs are merged). If a server does not
|
||||
answer, the others are shown and the response carries `hubWarnings`.
|
||||
- Reads go with each server's read token. Writes use a separate admin token: the app changes below (role with `apps.write`) and the per-server changes of "Managing one server" (roles with `operate` or `admin`). Anything else that would change data is refused with `403` (and written to the audit log). The hub has its own accounts with roles, a login rate limit and an audit log.
|
||||
|
||||
The servers themselves are not changed: each needs a read-only API token (View, and nothing else, on Dashboard, Logs,
|
||||
Apps, Settings, Zones, Cache, Allowed, Blocked, DnsClient and DhcpServer) and, for writes, an admin token whose group has
|
||||
Modify and Delete only where the table in `docs/hub-deploy.md` grants them.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Variable | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `HUB_SERVERS` | required | Ordered `id=url` list, e.g. `dns=https://dns.example.net,ndns=https://ndns.example.net`. The first server is shown as the Primary node. |
|
||||
| `<ID>_TOKEN` | required | API token of each server; the id is uppercased with non-alphanumerics as `_` (`dns` gives `DNS_TOKEN`, `my-dns` gives `MY_DNS_TOKEN`). |
|
||||
| `HUB_USER`, `HUB_PASSWORD` | see Users | Bootstrap: one `admin`, used only while `${HUB_DATA}/users.json` does not exist. Set both or neither. |
|
||||
| `HUB_DATA` | `/data` | Folder of `users.json` and `audit.log`. Mount a volume on it. |
|
||||
| `HUB_DRIFT_MINUTES` | `15` | How often the hub checks that every server still has the config it should (see Drift). `0` turns the check off. |
|
||||
| `HUB_WEBHOOK_URL` | none | An http or https address that gets a signed JSON notice on a change of drift and on a failed write (see Webhook notices). Treat it like a secret: it may carry a token. |
|
||||
| `HUB_WEBHOOK_SECRET` | none | Signs each notice: `X-Hub-Signature: sha256=<HMAC-SHA256 of the body>`. Never logged or returned. |
|
||||
| `HUB_TRUST_PROXY` | off | `1` believes the first `X-Forwarded-For` entry as the client address (set it behind Traefik). Leave it off when clients reach the hub directly, or anyone can fake their address. |
|
||||
| `<ID>_ADMIN_TOKEN` | optional | A write-capable token per server (`MDNS_ADMIN_TOKEN`). Only app writes use it, and only it: a server without one is read-only for the hub (the matrix and the session list the writable servers). Never logged or returned. |
|
||||
| `HUB_PORT` | `8080` | Listening port. |
|
||||
| `HUB_CACHE_SECONDS` | `15` | How long successful answers of the servers are cached. |
|
||||
| `HUB_TIMEOUT_MS` | `8000` | Per-request timeout towards a server. |
|
||||
| `HUB_LISTS_ALLOW_PRIVATE` | off | `1` lets the hub download the Advanced Blocking block lists (to count their domains, `hub/blocking/app-lists/count`) from private addresses. Off, only public addresses are fetched, so a hub cannot be pointed at your network. Counts are cached for 6 hours. |
|
||||
| `HUB_DOMAIN` | `hub` | Shown as the server's domain in the console. |
|
||||
| `HUB_STATIC` | `./dist` | The console's build to serve. |
|
||||
|
||||
## Users and roles
|
||||
|
||||
The hub has its own accounts in `${HUB_DATA}/users.json` (`{ "users": [{ "name", "role", "hash", "disabled"? }] }`,
|
||||
hash `scrypt$N$r$p$salt$key`). Names are compared case-insensitively.
|
||||
|
||||
- If the file exists, it is the only source; `HUB_USER` / `HUB_PASSWORD` are ignored (a line says so at start).
|
||||
- If it does not exist and `HUB_USER` / `HUB_PASSWORD` are set, that one user signs in as `admin`. The file is not written.
|
||||
- If neither exists, the hub refuses to start.
|
||||
|
||||
Manage the file with the CLI (`npm run hub:user -- <command>` locally):
|
||||
|
||||
```sh
|
||||
docker exec -it technitium-hub node --experimental-strip-types /app/hub/users-cli.ts add alice apps-admin
|
||||
```
|
||||
|
||||
`add <name> <role>`, `passwd <name>`, `role <name> <role>`, `disable <name>`, `enable <name>`, `list`. The password is asked
|
||||
twice without echo; for scripts use `HUB_NEW_PASSWORD=... users-cli.ts add ...` (minimum 8 characters). The file is written
|
||||
atomically with mode 0600. The running hub notices a change on the next request and ends the sessions of the users it touched
|
||||
(disabled, removed, new role or password).
|
||||
|
||||
| Role | Capabilities |
|
||||
|---|---|
|
||||
| `viewer` | read |
|
||||
| `operator` | read, operate (blocking, cache) |
|
||||
| `apps-admin` | read, apps.write |
|
||||
| `admin` | everything, including audit.read |
|
||||
|
||||
`apps.write` is what the app writes below need; `operate` and `admin` are what the per-server and several-server changes need. Every response also carries
|
||||
`hubRole` (and `info.hubRole`).
|
||||
|
||||
## Login protection
|
||||
|
||||
After 5 failed logins within 15 minutes from one client address, or for one user name, further attempts are refused for 15
|
||||
minutes with HTTP 429 and `Too many failed logins. Try again in N minutes.` (the console shows that message). A good login
|
||||
resets the user's counter. The state is in memory.
|
||||
|
||||
## Audit log
|
||||
|
||||
`${HUB_DATA}/audit.log` is JSON Lines: `{ts, user, role, ip, action, server?, target?, outcome, detail?}`. It records login
|
||||
(ok, failed, locked), logout, and every refused request outside the permission table as `refused-write` with its api path.
|
||||
Passwords, tokens and request bodies are never written. At 10 MB it rotates to `audit.log.1` (one kept). If the folder is not
|
||||
writable the hub says so once on stderr and keeps the last 1000 entries in memory. Admins read it with
|
||||
`GET /api/hub/audit/list?limit=200&before=<ISO time>`, newest first.
|
||||
|
||||
## Endpoints
|
||||
|
||||
Public: `status`, `user/login`. After signing in: `user/session/get`, `user/logout`,
|
||||
`user/checkForUpdate` (always "no update") and these reads:
|
||||
|
||||
`dashboard/stats/get`, `dashboard/stats/getTop`, `dashboard/metrics/json`, `apps/list`,
|
||||
`logs/query`, `logs/list`, `settings/get`, `admin/cluster/state` (synthesized), and the hub's own `hub/audit/list` (role with
|
||||
`audit.read`). They are listed in `routes.ts` with a kind (`read`, `write-one`, `write-all`) and the capability they need.
|
||||
|
||||
Everything else answers `403 The hub is read-only.` and is audited.
|
||||
|
||||
## Changing several servers at once
|
||||
|
||||
`POST hub/servers/run` (`write-all`, role with `operate`) runs one fixed action on the chosen servers, in parallel, each with
|
||||
that server's admin token. Parameters: `action`, `targets` (comma separated server ids) and the action's own `domain`,
|
||||
`minutes` or `urls` (lines in the grammar of the block lists, up to 300: `<url>`, `!<url>` for an allow list, `#...` for a switched-off list or a heading; http, https or file URLs; a line with a comma is refused). The answer is `{results: [{server, ok, message}]}`,
|
||||
one per server; a server with no admin token or one that does not answer fails on its own and the others still run. Empty
|
||||
targets or an unknown server are refused with `400`, and so is a bad parameter, before any server is called.
|
||||
|
||||
| action | Technitium call | parameters sent |
|
||||
|---|---|---|
|
||||
| `blocked.add`, `blocked.delete` | `blocked/add`, `blocked/delete` | `domain` |
|
||||
| `allowed.add`, `allowed.delete` | `allowed/add`, `allowed/delete` | `domain` |
|
||||
| `blocking.disable` | `settings/temporaryDisableBlocking` | `minutes` (1 to 1440) |
|
||||
| `blocking.enable` | `settings/set` | `enableBlocking=true` only |
|
||||
| `blocking.updateLists` | `settings/forceUpdateBlockLists` | none |
|
||||
| `blocking.setListUrls` | `settings/set` | `blockListUrls` only (comma joined) |
|
||||
| `cache.flush` | `cache/flush` | none |
|
||||
| `cache.delete` | `cache/delete` | `domain` |
|
||||
|
||||
The block list URLs are part of blocking, so `blocking.setListUrls` needs `operate` like the rest (the per-server
|
||||
`settings/set` of any other field still needs `admin`). `settings/set` with `enableBlocking` as its only field is `operate`
|
||||
too, so an operator can use the blocking switch on one server. The audit log gets one line per server with the action, the
|
||||
server, the domain or `minutes=N` or `urls=N` and the outcome, never the URL list. On the servers, the hub-admin needs
|
||||
Blocked and Allowed Modify and Delete, Cache Delete, and Settings Modify (list URLs, blocking switch).
|
||||
|
||||
## Managing one server
|
||||
|
||||
The sections Zones, Cache, Blocking (Blocked and Allowed), DNS Client, Settings and DHCP are managed on ONE server at a
|
||||
time. `routes.ts` lists exactly the paths the console calls; an `operate` role (`operator`, `admin`) may change the cache,
|
||||
Blocked and Allowed, the temporary blocking switch and the list update; zones (records, DNSSEC, import), settings and DHCP
|
||||
need `admin`. `viewer` and `apps-admin` only read.
|
||||
|
||||
- A write needs `node=<id>` of a server with an admin token. No node, `cluster` or an unknown id answers `400`
|
||||
("Choose a server: this change is made on one server at a time."); a server without an admin token answers `403`.
|
||||
- The parameters travel to the server in a form body (only `token` stays in the query). A zone import goes on as
|
||||
`multipart/form-data` or `text/plain` (up to 20 MB); `settings/set`, record and scope writes take up to 2 MB and the
|
||||
Blocked/Allowed imports up to 5 MB, for a signed-in caller only.
|
||||
- `dnsClient/resolve` is a read, unless `import` is set: then it writes into a zone and needs `admin`.
|
||||
- `settings/set` is refused when it changes a `webService*` field (compared with the server's current `settings/get`; the
|
||||
console sends the block unchanged with every save) or carries a user, SSO or LDAP field. `settings/get` blanks the TSIG
|
||||
shared secrets and the passwords for every role but `admin`.
|
||||
- Reads that have no merger (`zones/list`, `cache/list`, `allowed|blocked/list`, `dhcp/*`, `logs/list`, `dnsClient/resolve`,
|
||||
the exports) are refused without a node instead of being answered by the first server. `settings/get` is the exception:
|
||||
the header's blocking mark reads it without a node.
|
||||
- Downloads (`zones/export`, `logs/download`, `logs/export`, `allowed|blocked/export`) are passed through (type, file name,
|
||||
body) with the read token. A new tab has no Authorization header, so `user/createSingleUseToken` gives a token that works
|
||||
once, for 60 seconds, for these paths only.
|
||||
- Refused, even for `admin`: `admin/*`, `user/*` (except the hub's own sign-in), `logs/delete`, `logs/deleteAll`,
|
||||
`dashboard/stats/deleteAll`, `settings/backup`, `settings/restore` and `zones/permissions/*`.
|
||||
- The audit line holds the user, role, address, path (dots for slashes), server, one identifier (zone, domain, name or scope)
|
||||
and the outcome. Never a value.
|
||||
|
||||
## App writes
|
||||
|
||||
Needs the `apps.write` role (`apps-admin`, `admin`); others get `403 Your role does not allow this.` and a `denied-write` audit line.
|
||||
Every write goes to a server with its `<ID>_ADMIN_TOKEN`; a server without one answers "no admin token configured" and is not called.
|
||||
|
||||
- `POST hub/apps/run` (form or JSON): `action` = `install-store` | `update-store` | `uninstall`, `name`, `targets` (list or
|
||||
comma-separated server ids). Runs on every target in parallel and answers `{ results: [{ server, ok, message? }] }`.
|
||||
The download URL is taken from each target's own `apps/listStoreApps`; a `url` field is only accepted when it matches.
|
||||
- `POST hub/apps/upload` (multipart, at most 20 MB, kept in memory only): `action` = `install` | `update`, `name`, `targets`
|
||||
(comma-separated), file field `fileAppZip`. The same zip is forwarded to every target.
|
||||
- `apps/downloadAndInstall`, `apps/downloadAndUpdate`, `apps/uninstall`, `apps/install`, `apps/update` with `node=<id>`:
|
||||
the console's own single-server calls, forwarded to that server (store URLs checked as above). Refused without a node, and
|
||||
with `node=cluster`.
|
||||
- `apps/listStoreApps`: the first server's store, each app tagged with `installedOn`.
|
||||
- `apps/config/get` (read, `node` required) and `apps/config/set` (`apps.write`, `node` required; the old config is saved to the
|
||||
history first and the call is refused if that fails): the console's own per-server Config dialog.
|
||||
|
||||
## Config sync
|
||||
|
||||
Keeps one app's config the same on every server, except for the fields that must differ. Per app the hub stores, under
|
||||
`${HUB_DATA}/apps/<app>/`:
|
||||
|
||||
- `baseline.json`: `{ app, text, importedFrom?, updatedAt, updatedBy }`. The config as TEXT, so non-JSON configs work too.
|
||||
- `overrides.json`: `{ paths: [...], values: { <server>: { <path>: <value> } } }`. Paths are dotted (`db.path`,
|
||||
`networks.0.cidr`); at most 50; only for a JSON baseline.
|
||||
- `history/<time>__<server>__before-apply.txt` (and `before-uninstall`): the config a server had before the hub changed it
|
||||
(50 files per app kept).
|
||||
|
||||
A server's effective config is the baseline with that server's local values put in (JSON, pretty-printed with 2 spaces). A local
|
||||
path with no value for a server keeps the baseline value and is reported as a warning. Comparisons are by meaning (key order and
|
||||
spacing do not count) and leave the local fields out, so two servers that differ only there are "Same" in the matrix, and the
|
||||
matrix hash ignores them too.
|
||||
|
||||
| Endpoint | Method | Needs | Does |
|
||||
|---|---|---|---|
|
||||
| `hub/apps/config/compare?name=` | GET | read | Each server's live config, the baseline, the local fields, `inSync`, and the differences against the baseline and against the other servers. Config texts are returned here, to roles that can read. |
|
||||
| `hub/apps/overrides/current` | POST `{name, paths}` | `apps.write` | The servers' current values of those paths. |
|
||||
| `hub/apps/baseline/import` | POST `{name, server}` | `apps.write` | Baseline := that server's live config. |
|
||||
| `hub/apps/baseline/save` | POST `{name, text}` | `apps.write` | Saves the baseline (1 MB at most). Text that starts with `{` or `[` must be valid JSON; the parse error is returned. |
|
||||
| `hub/apps/overrides/save` | POST `{name, paths, values}` | `apps.write` | Saves the local fields. |
|
||||
| `hub/apps/config/preview` | POST `{name, targets}` | `apps.write` | Per target: current and effective text, the diff, warnings (a missing local value, a version older than the newest installed, no admin token) and `currentHash`. |
|
||||
| `hub/apps/config/apply` | POST `{name, targets, expect?}` | `apps.write` | Per target: re-reads the config; refuses if its hash differs from `expect[server]`; saves the old config to the history; `apps/config/set` with the admin token; reads it back and checks it. A server that rejects the config keeps its old one. |
|
||||
|
||||
## Zones, settings and blocking across the servers (stage 7)
|
||||
|
||||
| Endpoint | Kind | Needs | Does |
|
||||
|---|---|---|---|
|
||||
| `hub/zones/matrix` | read | `read` | Every zone of every server, one row per name: per server `present`, `absent` or `unknown` with type, SOA serial, disabled, DNSSEC and last modified. Row `status`: `same`, `serial-differs` (Primary zones, different serials), `type-differs`, `only-on` (`onlyOn`), `missing-on`, `unknown`. A Primary with a Secondary of it is the intended setup: it stays `same` with `note: primary-secondary`, or `secondary-behind` when the Secondary lags. A server that did not answer gives `unknown` cells and a `hubWarnings` line; a zone is never called `only-on` while a server that might have it is silent. Pages of 500, internal zones left out. |
|
||||
| `hub/zones/copy/preview` | read | `admin` | `{source, zone, targets}`: the source zone (type, serial, about N record lines) and per target whether it exists (type, serial, `needsOverwrite`). |
|
||||
| `hub/zones/copy` | write-all | `admin` | `{source, zone, targets, overwrite}`. Reads `zones/export` from the source with the read token (cap 5 MB, 30 s), then per target `zones/create` (the source's type; a Forwarder with `initializeForwarder=false`) when the zone is missing and `zones/import` (`overwrite`, `overwriteZone`, `overwriteSoaSerial` all true, the text as `text/plain`: what the console's Import zone sends). A target that has the zone is touched only if its id is in `overwrite` (checked in the hub). Only Primary and Forwarder zones; Secondary, Stub, Catalog and DNSSEC-signed zones are refused with a reason; a target with the zone as another type is refused; a copy to the source is refused. If the import fails after the create, the result says an empty zone was left. |
|
||||
| `hub/zones/secondary/preview` | read | `admin` | `{source, target, zone, primaryAddress, targetAddress, tsigKeyName?}`: the two steps in words. |
|
||||
| `hub/zones/secondary` | write-all | `admin` | Step 1 on the source: `zones/options/set` with only `zoneTransfer`, `zoneTransferNetworkACL`, `zoneTransferTsigKeyNames`, `notify`, `notifyNameServers`; what is listed stays, the target address is appended once (so it can run again), a mode that cannot hold a list is widened (Deny to UseSpecifiedNetworkACL, AllowOnlyZoneNameServers to AllowZoneNameServersAndUseSpecifiedNetworkACL, notify None to SpecifiedNameServers, ZoneNameServers to BothZoneAndSpecifiedNameServers). Step 2 on the target: `zones/create` type Secondary with `primaryNameServerAddresses`, `zoneTransferProtocol=Tcp`, `tsigKeyName`. The source goes first; if the target step fails the source change is kept and the result says so. The TSIG key must exist on both servers (`settings/getTsigKeyNames`). Results are two rows, one per server. |
|
||||
| `hub/settings/compare` | read | `admin` | Per server `same`, `differs` (N fields) or `local-only`, and the differing fields with each server's value (secrets as `<secret>`, a row only says `secretChanged`). Without a baseline the servers are compared with the first. |
|
||||
| `hub/settings/baseline/import` | write-all | `admin` | `{server, group?}`: the server's settings (read with its admin token) become the baseline; `group=blocking` replaces only the blocking fields. The old baseline goes to the history. |
|
||||
| `hub/settings/baseline/save` | write-all | `admin` | `{text}`: the baseline from the editor (JSON, up to 2 MB). A secret shown as `<secret>` keeps the stored one; a placeholder with nothing to keep is refused. |
|
||||
| `hub/settings/overrides/save` | write-all | `admin` | `{paths}`: extra local field names (up to 50). |
|
||||
| `hub/settings/preview` | read | `admin` | `{targets, group?}`: per target the fields an apply would change, as `{kind:'json', changes}`, `secretFields`, `currentHash`. |
|
||||
| `hub/settings/apply` | write-all | `admin` | `{targets, expect?, group?}`. Per server: read now, refuse if it changed since the preview, send ONLY the shared fields that differ in one `settings/set` (through the checks of `guards.ts`: never `webService*`, never user, SSO or LDAP fields), after saving the server's settings to the history; read back and compare the fields sent. `enableBlocking` is left alone while blocking is paused. |
|
||||
| `hub/settings/history/list`, `hub/settings/history/get` | read | `admin` | The settings kept before an apply, the lists kept before extras were removed, and old baselines. View only, secrets masked: there is no rollback for settings (put values back by accepting a server as baseline, or by hand). |
|
||||
| `hub/blocking/lists/compare` | read | `admin` | The custom Blocked and Allowed lists (`blocked/export`, `allowed/export`, read as text with a size cap) as sets: per server `missing N, extra M` with samples. |
|
||||
| `hub/blocking/lists/baseline/import` | write-all | `admin` | `{server}`: both lists of that server become the lists baseline (up to 5000 domains each). |
|
||||
| `hub/blocking/lists/preview`, `hub/blocking/lists/apply` | read, write-all | `admin` | `{targets, removeExtras}`. Adds the missing domains, one `add` call each (at most 1000); deletes the extras only when `removeExtras=true` (at most 500, after saving the server's lists to the history); reads back. |
|
||||
|
||||
**What is local.** Never sent by an apply, listed for information: `dnsServerDomain`, `dnsServerLocalEndPoints`,
|
||||
`dnsServerIPv4SourceAddresses`, `dnsServerIPv6SourceAddresses` (the host's identity and bind addresses), every `webService*`
|
||||
(how the hub reaches the server), `dnsTlsCertificatePath`, `dnsTlsCertificatePassword` (certificate), `dnsOverHttpUnixSocket`,
|
||||
`dnsOverHttpsUnixSocket`, `enableDnsOverHttpUnixSocket`, `enableDnsOverHttpsUnixSocket`, `dnsReverseProxyNetworkACL` (this host's sockets
|
||||
and proxy) and `logFolder` (a path). User, SSO and LDAP fields are always local. More can be added in the Local fields tab
|
||||
(`${HUB_DATA}/settings/overrides.json`); the built-in ones cannot be removed. **Never compared:** `version`, `uptimestamp`,
|
||||
`clusterInitialized`, `clusterNodes`, `temporaryDisableBlockingTill`, `blockListNextUpdatedOn`. Fields only one side reports are skipped.
|
||||
|
||||
**Secrets.** TSIG shared secrets and the proxy password are compared inside the hub by their real value and are stored in the
|
||||
baseline and the history (mode 0600, protect the data folder like the users file). No answer carries a secret or a hash of one, to
|
||||
any role; the endpoints are `admin` only anyway. A changed secret is shown as "secret changed". The audit log has field names, counts
|
||||
and outcomes, never values. Applying a baseline with TSIG keys does send the secrets to the servers (that is the point), over the
|
||||
connection you configured for them. Files: `${HUB_DATA}/settings/{baseline,overrides}.json`, `settings/history/` (50 files),
|
||||
`${HUB_DATA}/blocking/baseline.json`. Settings and the lists are part of the drift check (see Drift).
|
||||
|
||||
## History, rollback, drift, export
|
||||
|
||||
**History.** Everything the hub replaces is kept in `${HUB_DATA}/apps/<app>/history/` (50 files per app):
|
||||
`<time>__<server>__before-apply.txt`, `before-set` (a single-server config save), `before-uninstall`, and
|
||||
`<time>__baseline__before-save.txt` for the baseline's earlier text on every baseline save, import or restore.
|
||||
|
||||
| Endpoint | Method | Needs | Does |
|
||||
|---|---|---|---|
|
||||
| `hub/apps/history/list?name=` | GET | read | Newest first: `{ id, at, server, kind, size, hash }`, never the text. |
|
||||
| `hub/apps/history/get?name=&id=` | GET | read | The text. `id` has to be a name the listing returns, so nothing outside the folder can be read. |
|
||||
| `hub/apps/history/diff?name=&id=&server=` | GET | read | A version against the server's config now (`changes`, `currentHash`, `unchanged`). |
|
||||
| `hub/apps/history/rollback` | POST `{name, id, targets, expect?}` | `apps.write` | Puts that version on the targets through the same machinery as apply (old config to the history, `apps/config/set` with the admin token, read back and verify, a result per target, audit). Not for baseline entries. |
|
||||
| `hub/apps/baseline/restore` | POST `{name, id}` | `apps.write` | An old baseline entry becomes the baseline again (the current text goes to the history). |
|
||||
|
||||
**Drift.** Every `HUB_DRIFT_MINUTES` (and once shortly after start) the hub reads each server's config of every app that has a
|
||||
baseline, with the read token, and compares it by meaning with the server's effective config (baseline plus its local values). The
|
||||
result is kept in memory and in `${HUB_DATA}/drift.json`: `{ checkedAt, apps: [{ kind, name?, server, since, status, fields? }] }`, where
|
||||
`kind` is `app`, `settings` or `lists` (stage 8), `status` is `differs`, `unreadable` (the server or the read failed; listed, not an
|
||||
alarm) or `not-installed`, and `since` is the first check that saw it. Two checks never run at once. See "Webhook notices and
|
||||
profiles" for the settings and lists kinds.
|
||||
|
||||
- `GET hub/drift/get` (read): the state. `POST hub/drift/check` (`apps.write`): check now.
|
||||
- `user/session/get` carries `info.hubDrift`, the number of `differs` entries of every kind; the console shows it in the header.
|
||||
- `POST hub/apps/drift/accept {name, server}` (`apps.write`): "Accept as baseline". The server's config becomes the baseline; if the
|
||||
app has local fields, they keep the baseline's old values and the server's own values go to the overrides instead.
|
||||
- "Revert to baseline" is the normal apply on that server.
|
||||
|
||||
**Export and import.** `GET hub/apps/export` (`apps.write`, because configs can hold secrets) downloads
|
||||
`{ hub: 'technitium-hub', version: 1, exportedAt, apps: [{ app, baseline, overrides }] }`. `POST hub/apps/import {data, apply}`
|
||||
(`apps.write`, up to 5 MB, signed-in callers only) validates the file and answers per app `new`, `changed` or `same`; with
|
||||
`apply=true` it writes the baselines and overrides (the replaced baselines go to the history). Local values for servers this hub
|
||||
does not have are ignored and reported. It never writes to a server.
|
||||
|
||||
Reading configs needs View on Apps for the `hub-reader` user (and `*_ADMIN_TOKEN` is used as a fallback if a server refuses the
|
||||
read token). The audit log records who did what, with the size and a short hash of a config, never the text.
|
||||
|
||||
Before an uninstall the app's config is saved to `${HUB_DATA}/apps/<app>/history/<time>__<server>__before-uninstall.txt`
|
||||
(50 files per app are kept). If that cannot be done the uninstall does not happen. Every target's result is written to the
|
||||
audit log as `apps.<action>`; the history files hold configs, so protect the data folder like the users file.
|
||||
After any write the hub forgets its cached answers of that server.
|
||||
|
||||
## Webhook notices and profiles (stage 8)
|
||||
|
||||
**Webhook.** With `HUB_WEBHOOK_URL` (http or https) the hub POSTs a small JSON body when something changes. `HUB_WEBHOOK_SECRET`
|
||||
is optional and signs the body. Events:
|
||||
|
||||
| Event | When |
|
||||
|---|---|
|
||||
| `drift.found` | A server/kind/name newly differs from its baseline. Sent once, not at every check. |
|
||||
| `drift.cleared` | An entry that differed is back in line. An entry that became unreadable is not cleared: it is not known. |
|
||||
| `write.failed` | An audited write ended `failed` (not `refused`, not a failed login). Failures of the same moment are one notice. |
|
||||
| `webhook.test` | The admin pressed "Send test" (`POST hub/webhook/test`). |
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "drift.found",
|
||||
"at": "2026-11-01T09:15:00.000Z",
|
||||
"hub": "hub.example.net",
|
||||
"items": [
|
||||
{ "kind": "settings", "server": "ndns", "since": "2026-11-01T09:15:00.000Z", "fields": ["blockingType", "defaultRecordTtl"] },
|
||||
{ "kind": "app", "name": "Advanced Blocking", "server": "ndns", "since": "2026-11-01T09:15:00.000Z" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Items carry names and field names, never a value. A `write.failed` item has `action`, `server`, `target`, `user`, `at` and the
|
||||
audited `detail`. The first state is the one in `drift.json`, so a restart does not repeat notices.
|
||||
|
||||
With a secret, the header `X-Hub-Signature: sha256=<hex>` is the HMAC-SHA256 of the exact request body. Verify it before parsing:
|
||||
|
||||
```js
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto'
|
||||
const expected = 'sha256=' + createHmac('sha256', SECRET).update(rawBody).digest('hex')
|
||||
const ok = expected.length === received.length && timingSafeEqual(Buffer.from(expected), Buffer.from(received))
|
||||
```
|
||||
|
||||
Delivery is fire-and-forget: a 5 second timeout, at most one retry (after a second), redirects are not followed, and it never
|
||||
blocks or fails the request that caused it. A failure goes to the server log with the HOST only (never the query string, which may
|
||||
carry a token) and the reason (`HTTP 500`, `ECONNREFUSED`, `TimeoutError`).
|
||||
|
||||
| Endpoint | Method | Needs | Does |
|
||||
|---|---|---|---|
|
||||
| `hub/webhook/status` | GET | `admin` | `{ configured, host, signed, last: { event, at, ok, error? } }`. The host and port only, never the address. |
|
||||
| `hub/webhook/test` | POST | `admin` | Sends a `webhook.test` notice now and waits for it; answers the same status. Audited as `webhook.test`. |
|
||||
|
||||
**Drift kinds.** `GET hub/drift/get` answers `{ checkedAt, apps: [entry] }` (the key is kept) with
|
||||
`entry = { kind: 'app' | 'settings' | 'lists', name?, app?, server, since, status, fields? }`. `status` is `differs`, `unreadable`
|
||||
or `not-installed`; `fields` are setting names, or `blocked` / `allowed` for the lists; `name` (and `app`) are the app. A
|
||||
`drift.json` from before stage 8, where `kind` held the status and `app` the name, still loads. Settings are compared like the
|
||||
Settings compare view (shared fields against the settings baseline), lists as sets against the lists baseline, and only when that
|
||||
baseline exists. The header count is the number of `differs` entries of every kind.
|
||||
|
||||
**Profiles.** A profile is a named bundle in `${HUB_DATA}/profiles/<name>.json` (mode 0600; it holds settings secrets as the
|
||||
settings baseline does, so protect the folder): store apps with the version they had and the hub's baseline config of each at that
|
||||
moment, the shared fields of the settings baseline (host fields never travel), and the Blocked/Allowed lists baseline. Every
|
||||
profile endpoint needs `admin`; viewer, operator and apps-admin are refused.
|
||||
|
||||
| Endpoint | Method | Does |
|
||||
|---|---|---|
|
||||
| `hub/profiles/list` | GET | Names with counts. |
|
||||
| `hub/profiles/get?name=` | GET | One profile with secrets as `<secret>` and no app config text (only whether there is one). |
|
||||
| `hub/profiles/snapshot {name, apps?, settings, lists}` | POST | Makes or replaces a profile from the current baselines. `apps` is a comma list (absent: every app with a baseline; empty: none). A zip app, or one installed nowhere, is refused. |
|
||||
| `hub/profiles/delete {name}` | POST | Deletes it. |
|
||||
| `hub/profiles/export?name=` | GET | `{ hub: 'technitium-hub', type: 'profiles', version: 1, exportedAt, profiles }`, with the real secrets: keep the file as a secret. |
|
||||
| `hub/profiles/import {data, apply}` | POST | Same size limit as the baselines import (5 MB, signed-in callers only). Dry run first (`new`, `changed`, `same`); `apply=true` writes. A `<secret>` placeholder or a host field in the file is refused or dropped. |
|
||||
| `hub/profiles/preview {name, targets, removeExtras}` | GET/POST | Per target: apps to install, already there, or another version; app configs that would change; settings fields that would change; lists missing and extra. Writes nothing. |
|
||||
| `hub/profiles/apply {name, targets, removeExtras, from}` | POST | Runs the steps on each target. |
|
||||
|
||||
**Apply.** The targets go in parallel; the steps of one server go in order, and a failed step stops the later ones for that server:
|
||||
|
||||
1. `apps`: the missing apps are installed from THAT server's own `apps/listStoreApps` (the URL never comes from the request; an
|
||||
installed app is not updated, a version difference is only shown).
|
||||
2. `configs`: the profile's config of each app plus that server's own local values, with the old config saved to the history first
|
||||
and a read-back check (the same as the config apply).
|
||||
3. `settings`: the changed shared fields only, through the checks of `guards.ts` (no `webService*`, user, SSO or LDAP field), the
|
||||
old settings in the settings history, and a read-back check.
|
||||
4. `lists`: the missing domains are added; extras are removed only with `removeExtras`.
|
||||
|
||||
The answer is `{ results: [{ server, ok, message, steps: [{ step, status, message }] }] }` with `status` one of `ok`, `failed`,
|
||||
`skipped` (done by an earlier run) or `not-run`. `from` is `{ "<server>": "<step>" }`: the step to start at, so "Retry failed" does
|
||||
not repeat what worked. The audit has one `profiles.apply` line per server and step (names, counts and the outcome, no values).
|
||||
|
||||
## Run locally
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
node dev/mock-api.mjs --port 5380 &
|
||||
node dev/mock-api.mjs --port 5381 --scale 2 &
|
||||
HUB_SERVERS=a=http://127.0.0.1:5380,b=http://127.0.0.1:5381 A_TOKEN=x B_TOKEN=y \
|
||||
HUB_USER=admin HUB_PASSWORD=admin HUB_DATA=./hub-data HUB_PORT=5390 npm run hub
|
||||
```
|
||||
|
||||
Then open http://localhost:5390. Tests: `npx vitest run hub`.
|
||||
|
||||
## Docker
|
||||
|
||||
```sh
|
||||
docker run --rm -p 8080:8080 \
|
||||
-e HUB_SERVERS=dns=https://dns.example.net,ndns=https://ndns.example.net \
|
||||
-e DNS_TOKEN=... -e NDNS_TOKEN=... -e HUB_USER=admin -e HUB_PASSWORD=... -v hub-data:/data \
|
||||
git.shx.one/denozord/technitium-hub:latest
|
||||
```
|
||||
|
||||
The image is built by `docker/Dockerfile.hub` and published by `.gitea/workflows/hub.yaml`
|
||||
(`latest` from `main`, `sha-<7>`, and `X.Y.Z` / `X.Y` from a `hub-vX.Y.Z` tag). A compose
|
||||
example with Traefik is in `docs/compose.hub.example.yaml`.
|
||||
@@ -0,0 +1,219 @@
|
||||
// @vitest-environment node
|
||||
/*
|
||||
Stage 9: counting the domains in an Advanced Blocking app's block lists. The unit tests inject
|
||||
the fetch and the DNS lookup; the hub tests run against fake servers and a fake list server.
|
||||
*/
|
||||
import http from 'node:http'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { AppLists, DomainCounter, countDomains, publicAddress } from './app-lists.ts'
|
||||
import type { HubServer } from './config.ts'
|
||||
import type { Upstream } from './upstream.ts'
|
||||
import { fake, startHub, type Fake, type TestHub } from './fake-servers.ts'
|
||||
|
||||
const server: HubServer = { id: 'a', url: 'http://a.invalid', token: 't' }
|
||||
|
||||
const config = (groups: unknown[]) => JSON.stringify({ enableBlocking: true, groups })
|
||||
|
||||
function upstreamWith(text: string | null): Upstream {
|
||||
return { call: async () => ({ config: text }) } as unknown as Upstream
|
||||
}
|
||||
|
||||
const HOSTS = '# hosts\n127.0.0.1 localhost\n0.0.0.0 a.example\n0.0.0.0 b.example\n0.0.0.0 a.example\n'
|
||||
|
||||
function listFetch(files: Record<string, string>, seen: string[] = []): typeof fetch {
|
||||
return (async (input: URL | string) => {
|
||||
const url = String(input)
|
||||
seen.push(url)
|
||||
const body = files[url]
|
||||
return body === undefined ? new Response('nope', { status: 404 }) : new Response(body, { status: 200 })
|
||||
}) as typeof fetch
|
||||
}
|
||||
|
||||
const PUBLIC = async () => ['93.184.216.34']
|
||||
|
||||
describe('countDomains', () => {
|
||||
it('counts hosts, plain and adblock lines once each', () => {
|
||||
expect(countDomains(HOSTS)).toBe(2)
|
||||
expect(countDomains('x.example\n*.y.example\n! c\n||z.example^$third-party\n@@||ok.example^\n[Adblock Plus]\n')).toBe(3)
|
||||
})
|
||||
|
||||
it('counts across chunks that cut a line in two', () => {
|
||||
const c = new DomainCounter()
|
||||
c.push('0.0.0.0 a.exa')
|
||||
c.push('mple\n0.0.0.0 b.example')
|
||||
expect(c.end()).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('publicAddress', () => {
|
||||
it('refuses loopback, private, link-local, shared and unspecified addresses', () => {
|
||||
for (const a of ['127.0.0.1', '10.1.2.3', '172.16.0.1', '192.168.1.1', '169.254.169.254', '100.64.0.1', '0.0.0.0', '224.0.0.1', '::1', '::', 'fe80::1', 'fd00::1', '::ffff:10.0.0.1']) {
|
||||
expect(publicAddress(a), a).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts public ones', () => {
|
||||
for (const a of ['93.184.216.34', '8.8.8.8', '2606:4700:4700::1111']) expect(publicAddress(a), a).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AppLists', () => {
|
||||
it('reads the block and adblock URLs of every group, strings or objects, each once, http(s) only', async () => {
|
||||
const text = config([
|
||||
{ name: 'one', blockListUrls: ['https://x.example/a', { url: 'https://x.example/b' }, 'file:///etc/hosts'], adblockListUrls: ['https://x.example/c'], regexBlockListUrls: ['https://x.example/re'] },
|
||||
{ name: 'two', blockListUrls: ['https://x.example/a'], allowListUrls: ['https://x.example/allow'] },
|
||||
])
|
||||
const lists = new AppLists(upstreamWith(text))
|
||||
expect(await lists.urlsOf(server, 'Advanced Blocking')).toEqual(['https://x.example/a', 'https://x.example/b', 'https://x.example/c'])
|
||||
})
|
||||
|
||||
it('has no URLs for a missing, empty or broken config', async () => {
|
||||
expect(await new AppLists(upstreamWith(null)).urlsOf(server, 'x')).toEqual([])
|
||||
expect(await new AppLists(upstreamWith('{nope')).urlsOf(server, 'x')).toEqual([])
|
||||
})
|
||||
|
||||
it('counts each list and totals them, and caches by URL', async () => {
|
||||
const seen: string[] = []
|
||||
const files = { 'https://x.example/a': HOSTS, 'https://x.example/b': 'c.example\n' }
|
||||
let now = 1_000
|
||||
const lists = new AppLists(upstreamWith(config([{ name: 'g', blockListUrls: ['https://x.example/a', 'https://x.example/b'] }])), {
|
||||
fetch: listFetch(files, seen), lookup: PUBLIC, now: () => now,
|
||||
})
|
||||
const first = await lists.count(server, 'Advanced Blocking')
|
||||
expect(first.lists).toEqual([{ url: 'https://x.example/a', count: 2 }, { url: 'https://x.example/b', count: 1 }])
|
||||
expect(first.total).toBe(3)
|
||||
await lists.count(server, 'Advanced Blocking')
|
||||
expect(seen).toHaveLength(2)
|
||||
now += 7 * 3_600_000
|
||||
await lists.count(server, 'Advanced Blocking')
|
||||
expect(seen).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('reports a list that fails, with a reason, and keeps the others', async () => {
|
||||
const lists = new AppLists(upstreamWith(config([{ name: 'g', blockListUrls: ['https://x.example/a', 'https://x.example/gone'] }])), {
|
||||
fetch: listFetch({ 'https://x.example/a': HOSTS }), lookup: PUBLIC,
|
||||
})
|
||||
const out = await lists.count(server, 'x')
|
||||
expect(out.lists[0].count).toBe(2)
|
||||
expect(out.lists[1]).toEqual({ url: 'https://x.example/gone', count: null, error: 'the list server answered 404' })
|
||||
expect(out.total).toBe(2)
|
||||
})
|
||||
|
||||
it('never fetches an address that is not public, or a name that resolves to one', async () => {
|
||||
const seen: string[] = []
|
||||
const lists = new AppLists(
|
||||
upstreamWith(config([{ name: 'g', blockListUrls: ['http://127.0.0.1/a', 'http://[::1]/a', 'http://169.254.169.254/latest', 'https://internal.example/a', 'ftp://x.example/a'] }])),
|
||||
{ fetch: listFetch({}, seen), lookup: async (h) => (h === 'internal.example' ? ['10.0.0.5'] : ['93.184.216.34']) },
|
||||
)
|
||||
const out = await lists.count(server, 'x')
|
||||
expect(seen).toEqual([])
|
||||
expect(out.lists.every((l) => l.count === null)).toBe(true)
|
||||
expect(out.lists[0].error).toBe('the address of this list is not a public one')
|
||||
})
|
||||
|
||||
it('checks every redirect, and stops after three', async () => {
|
||||
const seen: string[] = []
|
||||
const fetcher = (async (input: URL | string) => {
|
||||
const url = String(input)
|
||||
seen.push(url)
|
||||
if (url === 'https://x.example/start') return new Response(null, { status: 302, headers: { location: 'http://10.0.0.1/inner' } })
|
||||
if (url.startsWith('https://x.example/loop')) return new Response(null, { status: 302, headers: { location: 'https://x.example/loop' } })
|
||||
return new Response('a.example\n')
|
||||
}) as typeof fetch
|
||||
const lists = new AppLists(upstreamWith(config([{ name: 'g', blockListUrls: ['https://x.example/start', 'https://x.example/loop'] }])), { fetch: fetcher, lookup: async (h) => (h === 'x.example' ? ['93.184.216.34'] : ['10.0.0.1']) })
|
||||
const out = await lists.count(server, 'x')
|
||||
expect(seen).not.toContain('http://10.0.0.1/inner')
|
||||
expect(out.lists[0].error).toBe('the address of this list is not a public one')
|
||||
expect(out.lists[1].error).toBe('too many redirects')
|
||||
})
|
||||
|
||||
it('refuses a list over the size cap, declared or streamed', async () => {
|
||||
const big = (async (input: URL | string) =>
|
||||
String(input).endsWith('/declared')
|
||||
? new Response('a.example\n', { headers: { 'content-length': '5000' } })
|
||||
: new Response('a.example\n'.repeat(1000))) as typeof fetch
|
||||
const lists = new AppLists(upstreamWith(config([{ name: 'g', blockListUrls: ['https://x.example/declared', 'https://x.example/streamed'] }])), { fetch: big, lookup: PUBLIC, maxBytes: 1000 })
|
||||
const out = await lists.count(server, 'x')
|
||||
expect(out.lists.map((l) => l.error)).toEqual(['the list is larger than the limit', 'the list is larger than the limit'])
|
||||
})
|
||||
|
||||
it('times a slow list out', async () => {
|
||||
const slow = ((_: URL | string, init?: RequestInit) =>
|
||||
new Promise((_resolve, reject) => init?.signal?.addEventListener('abort', () => reject(Object.assign(new Error('x'), { name: 'AbortError' }))))) as typeof fetch
|
||||
const lists = new AppLists(upstreamWith(config([{ name: 'g', blockListUrls: ['https://x.example/slow'] }])), { fetch: slow, lookup: PUBLIC, timeoutMs: 20 })
|
||||
expect((await lists.count(server, 'x')).lists[0].error).toBe('timed out')
|
||||
})
|
||||
|
||||
it('takes at most maxLists lists', async () => {
|
||||
const urls = Array.from({ length: 10 }, (_, i) => `https://x.example/${i}`)
|
||||
const lists = new AppLists(upstreamWith(config([{ name: 'g', blockListUrls: urls }])), { fetch: listFetch({}), lookup: PUBLIC, maxLists: 3 })
|
||||
expect((await lists.count(server, 'x')).lists).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hub/blocking/app-lists/count', () => {
|
||||
let a: Fake
|
||||
let b: Fake
|
||||
let hub: TestHub
|
||||
let files: http.Server
|
||||
let base = ''
|
||||
|
||||
beforeAll(async () => {
|
||||
files = http.createServer((req, res) => {
|
||||
res.setHeader('content-type', 'text/plain')
|
||||
res.end(req.url === '/hosts' ? HOSTS : 'p.example\nq.example\nr.example\n')
|
||||
})
|
||||
await new Promise<void>((resolve) => files.listen(0, '127.0.0.1', resolve))
|
||||
base = `http://127.0.0.1:${(files.address() as AddressInfo).port}`
|
||||
a = await fake()
|
||||
b = await fake()
|
||||
a.on('apps/config/get', () => ({
|
||||
response: { config: config([{ name: 'g', enableBlocking: true, blockListUrls: [`${base}/hosts`], adblockListUrls: [`${base}/other`] }]) },
|
||||
}))
|
||||
b.on('apps/config/get', () => ({ response: { config: null } }))
|
||||
hub = await startHub(a, b, { HUB_LISTS_ALLOW_PRIVATE: '1' })
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
hub.close()
|
||||
a.server.close()
|
||||
b.server.close()
|
||||
files.close()
|
||||
})
|
||||
|
||||
it('counts the lists of the chosen server with its read token', async () => {
|
||||
const r = await hub.call('viewer', 'hub/blocking/app-lists/count', { node: 'a', name: 'Advanced Blocking' }, 'GET')
|
||||
expect(r.code).toBe(200)
|
||||
expect(r.body.response.total).toBe(5)
|
||||
expect(r.body.response.lists).toEqual([{ url: `${base}/hosts`, count: 2 }, { url: `${base}/other`, count: 3 }])
|
||||
const read = a.calls.find((c) => c.path === 'apps/config/get')
|
||||
expect(read?.token).toBe('read-a')
|
||||
expect(read?.query.get('name')).toBe('Advanced Blocking')
|
||||
})
|
||||
|
||||
it('answers an empty list for a server whose app has no config', async () => {
|
||||
const r = await hub.call('viewer', 'hub/blocking/app-lists/count', { node: 'b' }, 'GET')
|
||||
expect(r.body.response).toEqual({ lists: [], total: 0 })
|
||||
})
|
||||
|
||||
it('needs a server', async () => {
|
||||
for (const node of ['', 'cluster', 'nope']) {
|
||||
const r = await hub.call('viewer', 'hub/blocking/app-lists/count', { node }, 'GET')
|
||||
expect(r.code).toBe(400)
|
||||
}
|
||||
})
|
||||
|
||||
it('is refused without a session', async () => {
|
||||
const res = await fetch(`${hub.base}/api/hub/blocking/app-lists/count?node=a`)
|
||||
expect(((await res.json()) as { status: string }).status).toBe('invalid-token')
|
||||
})
|
||||
|
||||
it('does not fetch private addresses unless the hub is told to', async () => {
|
||||
const strict = await startHub(a, b)
|
||||
const r = await strict.call('viewer', 'hub/blocking/app-lists/count', { node: 'a' }, 'GET')
|
||||
strict.close()
|
||||
expect(r.body.response.lists.map((l: { error?: string }) => l.error)).toEqual(['the address of this list is not a public one', 'the address of this list is not a public one'])
|
||||
expect(r.body.response.total).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,304 @@
|
||||
/*
|
||||
How many domains the block lists of a server's Advanced Blocking app hold (stage 9). The app has
|
||||
no API that says it, so the hub reads the app's config from the server (with the READ token, like
|
||||
any `apps/config/get`), downloads each http(s) block list and counts the distinct domains.
|
||||
|
||||
Counted: a hosts line (`0.0.0.0 example.com`, every name after the address), a plain domain
|
||||
(`example.com`, `*.example.com`) and an Adblock Plus domain rule (`||example.com^`). Not counted:
|
||||
blank lines, comments (`#`, `!`, `[`), Adblock exceptions (`@@`) and regular expressions. The
|
||||
console has the same rules for the no-hub case (`src/lib/list-count.ts`); the two are kept in step
|
||||
by hand and tested on the same sample.
|
||||
|
||||
The hub fetches a URL an administrator typed into a config, so it is careful where it goes:
|
||||
- only `http:` and `https:`, no credentials in the URL;
|
||||
- the host must resolve to public addresses only (no loopback, private, link-local, carrier-grade
|
||||
NAT, multicast or unspecified ones, IPv4 or IPv6). Redirects are followed by hand, up to three,
|
||||
and checked at every step. `HUB_LISTS_ALLOW_PRIVATE=1` lifts this for lists kept on the LAN.
|
||||
The check resolves the name and the fetch resolves it again, so a name that changes between the
|
||||
two is not caught; the hub is not a place an attacker can write DNS for;
|
||||
- a timeout and a size cap per list, a cap on how many lists, and four downloads at a time.
|
||||
|
||||
Results are cached per URL for several hours (a failure for minutes), so opening the Overview
|
||||
does not download a hundred megabytes again.
|
||||
*/
|
||||
import { lookup as dnsLookup } from 'node:dns/promises'
|
||||
import { isIP } from 'node:net'
|
||||
import type { HubServer } from './config.ts'
|
||||
import type { Upstream } from './upstream.ts'
|
||||
|
||||
export interface ListCount {
|
||||
url: string
|
||||
count: number | null
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface ListCounts {
|
||||
lists: ListCount[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface AppListsOptions {
|
||||
fetch?: typeof fetch
|
||||
/** Resolves a host to its addresses. */
|
||||
lookup?: (host: string) => Promise<string[]>
|
||||
now?: () => number
|
||||
allowPrivate?: boolean
|
||||
/** How long a counted list is remembered (default 6 hours). */
|
||||
ttlMs?: number
|
||||
/** How long a failure is remembered (default 5 minutes). */
|
||||
failureTtlMs?: number
|
||||
timeoutMs?: number
|
||||
maxBytes?: number
|
||||
maxLists?: number
|
||||
}
|
||||
|
||||
const HOURS = 3_600_000
|
||||
const MAX_CACHED = 500
|
||||
const MAX_REDIRECTS = 3
|
||||
const CONCURRENCY = 4
|
||||
|
||||
/* --- counting --- */
|
||||
|
||||
const ADDRESS = /^(?:\d{1,3}(?:\.\d{1,3}){3}|[0-9a-f:]*:[0-9a-f:]*)$/i
|
||||
const NAME = /^[a-z0-9_]([a-z0-9_.-]*[a-z0-9_])?$/i
|
||||
const OWN = new Set(['localhost', 'localhost.localdomain', 'local', 'broadcasthost', '0.0.0.0'])
|
||||
|
||||
function accept(raw: string, into: Set<string>): void {
|
||||
const name = raw.replace(/^\*\./, '').replace(/\.$/, '').toLowerCase()
|
||||
if (name === '' || OWN.has(name) || name.startsWith('ip6-') || !NAME.test(name)) return
|
||||
into.add(name)
|
||||
}
|
||||
|
||||
function countLine(line: string, into: Set<string>): void {
|
||||
const t = line.trim()
|
||||
if (t === '' || t.startsWith('#') || t.startsWith('!') || t.startsWith('[') || t.startsWith('@@') || t.startsWith('/')) return
|
||||
if (t.startsWith('||')) {
|
||||
const end = t.search(/[\^$/]/)
|
||||
accept(end < 0 ? t.slice(2) : t.slice(2, end), into)
|
||||
return
|
||||
}
|
||||
const tokens = t.split('#')[0].trim().split(/\s+/)
|
||||
if (tokens.length > 1 && ADDRESS.test(tokens[0])) for (const name of tokens.slice(1)) accept(name, into)
|
||||
else if (tokens.length === 1) accept(tokens[0], into)
|
||||
}
|
||||
|
||||
/* Counts as text arrives in pieces, so a big list is never held whole. */
|
||||
export class DomainCounter {
|
||||
#domains = new Set<string>()
|
||||
#rest = ''
|
||||
|
||||
push(chunk: string): void {
|
||||
const lines = (this.#rest + chunk).split(/\r?\n/)
|
||||
this.#rest = lines.pop() ?? ''
|
||||
for (const line of lines) countLine(line, this.#domains)
|
||||
}
|
||||
|
||||
end(): number {
|
||||
countLine(this.#rest, this.#domains)
|
||||
this.#rest = ''
|
||||
return this.#domains.size
|
||||
}
|
||||
}
|
||||
|
||||
export function countDomains(text: string): number {
|
||||
const c = new DomainCounter()
|
||||
c.push(text)
|
||||
return c.end()
|
||||
}
|
||||
|
||||
/* --- where a fetch may go --- */
|
||||
|
||||
function ipv4Allowed(a: number, b: number): boolean {
|
||||
if (a === 0 || a === 10 || a === 127 || a >= 224) return false
|
||||
if (a === 100 && b >= 64 && b <= 127) return false
|
||||
if (a === 169 && b === 254) return false
|
||||
if (a === 172 && b >= 16 && b <= 31) return false
|
||||
if (a === 192 && b === 168) return false
|
||||
if (a === 192 && b === 0) return false
|
||||
if (a === 198 && (b === 18 || b === 19)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
/** Whether an address is one a list may be fetched from: public unicast only. */
|
||||
export function publicAddress(address: string): boolean {
|
||||
const kind = isIP(address)
|
||||
if (kind === 4) {
|
||||
const [a, b] = address.split('.').map(Number)
|
||||
return ipv4Allowed(a, b)
|
||||
}
|
||||
if (kind === 6) {
|
||||
const a = address.toLowerCase()
|
||||
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(a)
|
||||
if (mapped !== null) return publicAddress(mapped[1])
|
||||
if (a === '::' || a === '::1') return false
|
||||
const first = parseInt(a.split(':')[0] || '0', 16)
|
||||
if ((first & 0xfe00) === 0xfc00) return false // fc00::/7 unique local
|
||||
if ((first & 0xffc0) === 0xfe80) return false // fe80::/10 link local
|
||||
if ((first & 0xff00) === 0xff00) return false // multicast
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function defaultLookup(host: string): Promise<string[]> {
|
||||
return (await dnsLookup(host, { all: true })).map((r) => r.address)
|
||||
}
|
||||
|
||||
class Refused extends Error {}
|
||||
|
||||
/* --- the service --- */
|
||||
|
||||
interface Cached {
|
||||
until: number
|
||||
result: ListCount
|
||||
}
|
||||
|
||||
export class AppLists {
|
||||
#upstream: Upstream
|
||||
#fetch: typeof fetch
|
||||
#lookup: (host: string) => Promise<string[]>
|
||||
#now: () => number
|
||||
#allowPrivate: boolean
|
||||
#ttl: number
|
||||
#failureTtl: number
|
||||
#timeout: number
|
||||
#maxBytes: number
|
||||
#maxLists: number
|
||||
#cache = new Map<string, Cached>()
|
||||
#inflight = new Map<string, Promise<ListCount>>()
|
||||
|
||||
constructor(upstream: Upstream, options: AppListsOptions = {}) {
|
||||
this.#upstream = upstream
|
||||
this.#fetch = options.fetch ?? fetch
|
||||
this.#lookup = options.lookup ?? defaultLookup
|
||||
this.#now = options.now ?? Date.now
|
||||
this.#allowPrivate = options.allowPrivate ?? false
|
||||
this.#ttl = options.ttlMs ?? 6 * HOURS
|
||||
this.#failureTtl = options.failureTtlMs ?? 5 * 60_000
|
||||
this.#timeout = options.timeoutMs ?? 20_000
|
||||
this.#maxBytes = options.maxBytes ?? 64 * 1024 * 1024
|
||||
this.#maxLists = options.maxLists ?? 100
|
||||
}
|
||||
|
||||
/** The distinct block list URLs (plain and Adblock) of the app's config on one server. */
|
||||
async urlsOf(server: HubServer, appName: string): Promise<string[]> {
|
||||
const response = (await this.#upstream.call(server, 'apps/config/get', { name: appName })) as { config?: string | null } | null
|
||||
const text = response?.config
|
||||
if (typeof text !== 'string' || text.trim() === '') return []
|
||||
let root: unknown
|
||||
try {
|
||||
root = JSON.parse(text)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
const groups = root !== null && typeof root === 'object' && Array.isArray((root as { groups?: unknown }).groups) ? ((root as { groups: unknown[] }).groups) : []
|
||||
const urls = new Set<string>()
|
||||
for (const g of groups) {
|
||||
if (g === null || typeof g !== 'object') continue
|
||||
for (const field of ['blockListUrls', 'adblockListUrls']) {
|
||||
const list = (g as Record<string, unknown>)[field]
|
||||
if (!Array.isArray(list)) continue
|
||||
for (const e of list) {
|
||||
const url = typeof e === 'string' ? e : e !== null && typeof e === 'object' && typeof (e as { url?: unknown }).url === 'string' ? (e as { url: string }).url : ''
|
||||
if (/^https?:\/\//i.test(url.trim())) urls.add(url.trim())
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...urls]
|
||||
}
|
||||
|
||||
async count(server: HubServer, appName: string): Promise<ListCounts> {
|
||||
const urls = (await this.urlsOf(server, appName)).slice(0, this.#maxLists)
|
||||
const lists: ListCount[] = new Array(urls.length)
|
||||
let next = 0
|
||||
const worker = async () => {
|
||||
while (next < urls.length) {
|
||||
const i = next++
|
||||
lists[i] = await this.countUrl(urls[i])
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, urls.length) }, worker))
|
||||
return { lists, total: lists.reduce((n, l) => n + (l.count ?? 0), 0) }
|
||||
}
|
||||
|
||||
countUrl(url: string): Promise<ListCount> {
|
||||
const hit = this.#cache.get(url)
|
||||
if (hit !== undefined && hit.until > this.#now()) return Promise.resolve(hit.result)
|
||||
const running = this.#inflight.get(url)
|
||||
if (running !== undefined) return running
|
||||
const job = this.#download(url).then((result) => {
|
||||
if (this.#cache.size >= MAX_CACHED) this.#prune()
|
||||
this.#cache.set(url, { until: this.#now() + (result.count === null ? this.#failureTtl : this.#ttl), result })
|
||||
return result
|
||||
})
|
||||
this.#inflight.set(url, job)
|
||||
void job.finally(() => this.#inflight.delete(url))
|
||||
return job
|
||||
}
|
||||
|
||||
#prune(): void {
|
||||
const now = this.#now()
|
||||
for (const [key, entry] of this.#cache) if (entry.until <= now) this.#cache.delete(key)
|
||||
while (this.#cache.size >= MAX_CACHED) this.#cache.delete(this.#cache.keys().next().value as string)
|
||||
}
|
||||
|
||||
async #allowed(url: URL): Promise<void> {
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new Refused('only http and https lists are read')
|
||||
if (url.username !== '' || url.password !== '') throw new Refused('a list address may not carry credentials')
|
||||
if (this.#allowPrivate) return
|
||||
const host = url.hostname.replace(/^\[|\]$/g, '')
|
||||
const addresses = isIP(host) !== 0 ? [host] : await this.#lookup(host)
|
||||
if (addresses.length === 0 || !addresses.every(publicAddress)) throw new Refused('the address of this list is not a public one')
|
||||
}
|
||||
|
||||
async #download(start: string): Promise<ListCount> {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), this.#timeout)
|
||||
try {
|
||||
let url = new URL(start)
|
||||
for (let hop = 0; ; hop++) {
|
||||
await this.#allowed(url)
|
||||
const res = await this.#fetch(url, { redirect: 'manual', signal: controller.signal, headers: { accept: 'text/plain, */*' } })
|
||||
if (res.status >= 300 && res.status < 400 && res.headers.get('location') !== null) {
|
||||
void res.body?.cancel()
|
||||
if (hop >= MAX_REDIRECTS) throw new Refused('too many redirects')
|
||||
url = new URL(res.headers.get('location') as string, url)
|
||||
continue
|
||||
}
|
||||
if (!res.ok) {
|
||||
void res.body?.cancel()
|
||||
throw new Refused(`the list server answered ${res.status}`)
|
||||
}
|
||||
const declared = Number(res.headers.get('content-length') ?? 0)
|
||||
if (declared > this.#maxBytes) {
|
||||
void res.body?.cancel()
|
||||
throw new Refused('the list is larger than the limit')
|
||||
}
|
||||
const counter = new DomainCounter()
|
||||
const decoder = new TextDecoder()
|
||||
let bytes = 0
|
||||
if (res.body !== null) {
|
||||
const reader = res.body.getReader()
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
bytes += value.byteLength
|
||||
if (bytes > this.#maxBytes) {
|
||||
void reader.cancel()
|
||||
throw new Refused('the list is larger than the limit')
|
||||
}
|
||||
counter.push(decoder.decode(value, { stream: true }))
|
||||
}
|
||||
counter.push(decoder.decode())
|
||||
}
|
||||
return { url: start, count: counter.end() }
|
||||
}
|
||||
} catch (e) {
|
||||
const message =
|
||||
e instanceof Refused ? e.message : e instanceof Error && e.name === 'AbortError' ? 'timed out' : 'could not be downloaded'
|
||||
return { url: start, count: null, error: message }
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
/*
|
||||
Config sync for one app across the servers: compare, baseline, local fields, preview, apply.
|
||||
|
||||
The baseline (kept in the hub) plus a server's local values is that server's effective config.
|
||||
Apply brings the selected servers to it: it re-reads the server, refuses if the config changed
|
||||
since the preview, saves the old config to the history, writes with the server's ADMIN token and
|
||||
reads the config back to check it. Config texts are returned to the caller (it is what they edit)
|
||||
but never written to the audit log.
|
||||
*/
|
||||
import type { HubConfig, HubServer } from './config.ts'
|
||||
import {
|
||||
MAX_CONFIG,
|
||||
acceptServerConfig,
|
||||
cleanValues,
|
||||
diffConfigs,
|
||||
effectiveConfig,
|
||||
importLocalValues,
|
||||
looksLikeJson,
|
||||
parseJson,
|
||||
sameConfig,
|
||||
textHash,
|
||||
validatePaths,
|
||||
type Diff,
|
||||
} from './apps-sync.ts'
|
||||
import { Upstream, UpstreamError } from './upstream.ts'
|
||||
import {
|
||||
listBaselineApps,
|
||||
listHistory,
|
||||
readBaseline,
|
||||
readHistory,
|
||||
readOverrides,
|
||||
writeBaseline,
|
||||
writeHistory,
|
||||
writeOverrides,
|
||||
type Baseline,
|
||||
type HistoryEntry,
|
||||
type Overrides,
|
||||
} from './store.ts'
|
||||
|
||||
export class UserError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'UserError'
|
||||
}
|
||||
}
|
||||
|
||||
const reason = (e: unknown): string => (e instanceof Error ? e.message : 'failed')
|
||||
|
||||
export interface Live {
|
||||
installed: boolean
|
||||
version?: string
|
||||
ok: boolean
|
||||
error?: string
|
||||
/** The server's config text; null when the app has none. */
|
||||
text: string | null
|
||||
}
|
||||
|
||||
export interface ServerView {
|
||||
id: string
|
||||
ok: boolean
|
||||
error?: string
|
||||
installed: boolean
|
||||
writable: boolean
|
||||
version?: string
|
||||
text: string | null
|
||||
hash: string
|
||||
effective?: string
|
||||
warnings: string[]
|
||||
/** Null without a baseline. */
|
||||
inSync: boolean | null
|
||||
/** What applying would change: this server's config to its effective config. */
|
||||
vsBaseline?: Diff
|
||||
/** This server's config relative to each other server's, local fields left out. */
|
||||
vsOthers: Record<string, Diff>
|
||||
}
|
||||
|
||||
export interface TargetPreview {
|
||||
server: string
|
||||
ok: boolean
|
||||
error?: string
|
||||
writable: boolean
|
||||
current: string | null
|
||||
currentHash: string
|
||||
effective: string
|
||||
changes: Diff
|
||||
unchanged: boolean
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
export interface TargetResult {
|
||||
server: string
|
||||
ok: boolean
|
||||
message?: string
|
||||
}
|
||||
|
||||
/* 9.10 is newer than 9.9: compared part by part, numerically. */
|
||||
export function compareVersions(a: string, b: string): number {
|
||||
const [x, y] = [a, b].map((v) => v.split('.').map((p) => Number.parseInt(p, 10) || 0))
|
||||
for (let i = 0; i < Math.max(x.length, y.length); i++) {
|
||||
const d = (x[i] ?? 0) - (y[i] ?? 0)
|
||||
if (d !== 0) return d
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/* The read token first, the admin token when the server refuses it. */
|
||||
export async function readConfigText(upstream: Upstream, server: HubServer, name: string): Promise<string | null> {
|
||||
let response: unknown
|
||||
try {
|
||||
response = await upstream.call(server, 'apps/config/get', { name })
|
||||
} catch (e) {
|
||||
if (!(e instanceof UpstreamError) || e.kind !== 'rejected' || server.adminToken === undefined) throw e
|
||||
response = await upstream.call(server, 'apps/config/get', { name }, server.adminToken)
|
||||
}
|
||||
const config = (response as { config?: unknown } | null)?.config
|
||||
return typeof config === 'string' ? config : null
|
||||
}
|
||||
|
||||
export class AppsConfig {
|
||||
#config: HubConfig
|
||||
#upstream: Upstream
|
||||
#now: () => number
|
||||
|
||||
constructor(config: HubConfig, upstream: Upstream, now: () => number = Date.now) {
|
||||
this.#config = config
|
||||
this.#upstream = upstream
|
||||
this.#now = now
|
||||
}
|
||||
|
||||
#server(id: string): HubServer {
|
||||
const found = this.#config.servers.find((s) => s.id === id)
|
||||
if (found === undefined) throw new UserError(`Unknown server: ${id}.`)
|
||||
return found
|
||||
}
|
||||
|
||||
#targets(ids: string[]): HubServer[] {
|
||||
const unique = [...new Set(ids)]
|
||||
if (unique.length === 0) throw new UserError('Choose at least one server.')
|
||||
return unique.map((id) => this.#server(id))
|
||||
}
|
||||
|
||||
async #readConfig(server: HubServer, name: string, fresh: boolean): Promise<string | null> {
|
||||
if (fresh) this.#upstream.invalidate(server.id)
|
||||
return readConfigText(this.#upstream, server, name)
|
||||
}
|
||||
|
||||
async #live(server: HubServer, name: string, fresh: boolean): Promise<Live> {
|
||||
let version: string | undefined
|
||||
try {
|
||||
if (fresh) this.#upstream.invalidate(server.id)
|
||||
const list = (await this.#upstream.call(server, 'apps/list')) as { apps?: { name: string; version?: string }[] } | null
|
||||
const app = (list?.apps ?? []).find((a) => a.name === name)
|
||||
if (app === undefined) return { installed: false, ok: true, text: null }
|
||||
version = app.version
|
||||
} catch (e) {
|
||||
return { installed: false, ok: false, error: reason(e), text: null }
|
||||
}
|
||||
try {
|
||||
return { installed: true, ok: true, version, text: await this.#readConfig(server, name, false) }
|
||||
} catch (e) {
|
||||
return { installed: true, ok: false, version, error: reason(e), text: null }
|
||||
}
|
||||
}
|
||||
|
||||
async #liveAll(name: string, fresh: boolean): Promise<Map<string, Live>> {
|
||||
const entries = await Promise.all(this.#config.servers.map(async (s): Promise<[string, Live]> => [s.id, await this.#live(s, name, fresh)]))
|
||||
return new Map(entries)
|
||||
}
|
||||
|
||||
getBaseline(name: string): Baseline | null {
|
||||
return readBaseline(this.#config.dataDir, name)
|
||||
}
|
||||
|
||||
getOverrides(name: string): Overrides {
|
||||
return readOverrides(this.#config.dataDir, name)
|
||||
}
|
||||
|
||||
async compare(name: string): Promise<unknown> {
|
||||
const baseline = this.getBaseline(name)
|
||||
const overrides = this.getOverrides(name)
|
||||
const live = await this.#liveAll(name, false)
|
||||
const views: ServerView[] = this.#config.servers.map((s) => {
|
||||
const l = live.get(s.id) as Live
|
||||
const text = l.text
|
||||
const view: ServerView = {
|
||||
id: s.id,
|
||||
ok: l.ok,
|
||||
installed: l.installed,
|
||||
writable: s.adminToken !== undefined,
|
||||
text,
|
||||
hash: textHash(text ?? ''),
|
||||
warnings: [],
|
||||
inSync: null,
|
||||
vsOthers: {},
|
||||
}
|
||||
if (l.error !== undefined) view.error = l.error
|
||||
if (l.version !== undefined) view.version = l.version
|
||||
if (baseline !== null && l.installed && l.ok) {
|
||||
const effective = effectiveConfig(baseline.text, overrides, s.id)
|
||||
view.effective = effective.text
|
||||
view.warnings = effective.warnings
|
||||
view.inSync = sameConfig(text ?? '', effective.text)
|
||||
view.vsBaseline = diffConfigs(text ?? '', effective.text, [])
|
||||
}
|
||||
return view
|
||||
})
|
||||
for (const v of views) {
|
||||
if (!v.installed || !v.ok) continue
|
||||
for (const o of views) {
|
||||
if (o.id !== v.id && o.installed && o.ok) v.vsOthers[o.id] = diffConfigs(o.text ?? '', v.text ?? '', overrides.paths)
|
||||
}
|
||||
}
|
||||
return { name, baseline, overrides, servers: views }
|
||||
}
|
||||
|
||||
async importBaseline(name: string, serverId: string, user: string): Promise<{ detail: string }> {
|
||||
const server = this.#server(serverId)
|
||||
let text: string | null
|
||||
try {
|
||||
text = await this.#readConfig(server, name, true)
|
||||
} catch (e) {
|
||||
throw new UserError(`${serverId}: ${reason(e)}`)
|
||||
}
|
||||
if (text === null || text.trim() === '') throw new UserError(`${serverId} has no config for "${name}" to import.`)
|
||||
if (text.length > MAX_CONFIG) throw new UserError('The config is larger than 1 MB.')
|
||||
this.#setBaseline(name, text, serverId, user)
|
||||
return { detail: `from ${serverId}, ${text.length} bytes, ${textHash(text)}` }
|
||||
}
|
||||
|
||||
/* Writes the baseline; the text it replaces goes to the history first (when it differs). */
|
||||
#setBaseline(name: string, text: string, importedFrom: string | undefined, user: string): void {
|
||||
const old = this.getBaseline(name)
|
||||
if (old !== null && old.text !== text) {
|
||||
try {
|
||||
writeHistory(this.#config.dataDir, name, 'baseline', old.text, 'before-save', this.#now())
|
||||
} catch (e) {
|
||||
throw new UserError(`The old baseline could not be saved first (${reason(e)}); nothing was changed.`)
|
||||
}
|
||||
}
|
||||
writeBaseline(this.#config.dataDir, {
|
||||
app: name,
|
||||
text,
|
||||
...(importedFrom === undefined ? {} : { importedFrom }),
|
||||
updatedAt: new Date(this.#now()).toISOString(),
|
||||
updatedBy: user,
|
||||
})
|
||||
}
|
||||
|
||||
saveBaseline(name: string, text: string, user: string): { detail: string } {
|
||||
if (text.length > MAX_CONFIG) throw new UserError('The baseline is larger than 1 MB.')
|
||||
if (looksLikeJson(text)) {
|
||||
const parsed = parseJson(text)
|
||||
if (!parsed.ok) throw new UserError(`The baseline is not valid JSON: ${parsed.error}`)
|
||||
}
|
||||
this.#setBaseline(name, text, this.getBaseline(name)?.importedFrom, user)
|
||||
return { detail: `${text.length} bytes, ${textHash(text)}` }
|
||||
}
|
||||
|
||||
saveOverrides(name: string, pathsRaw: unknown, valuesRaw: unknown): { detail: string } {
|
||||
let paths: string[]
|
||||
let values: Overrides['values']
|
||||
try {
|
||||
paths = validatePaths(pathsRaw)
|
||||
values = cleanValues(valuesRaw ?? {}, paths, this.#config.servers.map((s) => s.id))
|
||||
} catch (e) {
|
||||
throw new UserError(reason(e))
|
||||
}
|
||||
const baseline = this.getBaseline(name)
|
||||
if (paths.length > 0 && baseline !== null && (!looksLikeJson(baseline.text) || !parseJson(baseline.text).ok)) {
|
||||
throw new UserError('The baseline is plain text, so it cannot have local fields.')
|
||||
}
|
||||
if (JSON.stringify(values).length > MAX_CONFIG) throw new UserError('The local values are larger than 1 MB.')
|
||||
writeOverrides(this.#config.dataDir, name, { paths, values })
|
||||
return { detail: `${paths.length} local field(s)` }
|
||||
}
|
||||
|
||||
/* Reads each local path from the servers' live configs, for the "fill from current" button. */
|
||||
async currentLocalValues(name: string, pathsRaw: unknown): Promise<Overrides['values']> {
|
||||
let paths: string[]
|
||||
try {
|
||||
paths = validatePaths(pathsRaw)
|
||||
} catch (e) {
|
||||
throw new UserError(reason(e))
|
||||
}
|
||||
const live = await this.#liveAll(name, true)
|
||||
return importLocalValues(Object.fromEntries([...live].map(([id, l]) => [id, l.installed && l.ok ? l.text : null])), paths)
|
||||
}
|
||||
|
||||
async preview(name: string, ids: string[], overrideText?: string): Promise<{ targets: TargetPreview[] }> {
|
||||
const targets = this.#targets(ids)
|
||||
const baseline = overrideText === undefined ? this.getBaseline(name) : ({ app: name, text: overrideText, updatedAt: '', updatedBy: '' } satisfies Baseline)
|
||||
if (baseline === null) throw new UserError('This app has no baseline yet. Import one from a server first.')
|
||||
const overrides = this.getOverrides(name)
|
||||
const live = await this.#liveAll(name, true)
|
||||
const versions = [...live].filter(([, l]) => l.installed && l.version !== undefined).map(([id, l]) => ({ id, version: l.version as string }))
|
||||
const newest = versions.reduce<{ id: string; version: string } | null>((best, v) => (best === null || compareVersions(v.version, best.version) > 0 ? v : best), null)
|
||||
|
||||
return {
|
||||
targets: targets.map((s): TargetPreview => {
|
||||
const l = live.get(s.id) as Live
|
||||
const effective = effectiveConfig(baseline.text, overrides, s.id)
|
||||
const warnings = [...effective.warnings]
|
||||
if (s.adminToken === undefined) warnings.push('The hub has no admin token for this server, so it cannot write to it.')
|
||||
if (l.installed && l.version !== undefined && newest !== null && compareVersions(l.version, newest.version) < 0) {
|
||||
warnings.push(`Version ${l.version} here, ${newest.version} on ${newest.id}: versions differ, align versions first.`)
|
||||
}
|
||||
const current = l.text
|
||||
const base = {
|
||||
server: s.id,
|
||||
writable: s.adminToken !== undefined,
|
||||
current,
|
||||
currentHash: textHash(current ?? ''),
|
||||
effective: effective.text,
|
||||
warnings,
|
||||
}
|
||||
if (!l.ok) return { ...base, ok: false, error: l.error ?? 'did not answer', changes: { kind: 'text', lines: [], truncated: false, changed: 0 }, unchanged: false }
|
||||
if (!l.installed) return { ...base, ok: false, error: 'The app is not installed on this server.', changes: { kind: 'text', lines: [], truncated: false, changed: 0 }, unchanged: false }
|
||||
const changes = diffConfigs(current ?? '', effective.text, [])
|
||||
return { ...base, ok: true, changes, unchanged: sameConfig(current ?? '', effective.text) }
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Brings one server to `desired`: re-read, refuse if it changed since `expectHash`, save the old
|
||||
config to the history, write with the admin token, read back and check.
|
||||
*/
|
||||
async #applyText(s: HubServer, name: string, desired: (current: string) => string, expectHash: string | undefined): Promise<{ result: TargetResult; detail?: string }> {
|
||||
if (s.adminToken === undefined) return { result: { server: s.id, ok: false, message: 'no admin token configured' } }
|
||||
try {
|
||||
const l = await this.#live(s, name, true)
|
||||
if (!l.ok) return { result: { server: s.id, ok: false, message: l.error ?? 'did not answer' } }
|
||||
if (!l.installed) return { result: { server: s.id, ok: false, message: 'the app is not installed on this server' } }
|
||||
const current = l.text ?? ''
|
||||
if (expectHash !== undefined && expectHash !== textHash(current)) {
|
||||
return { result: { server: s.id, ok: false, message: 'the config changed on the server since the preview; run the preview again' } }
|
||||
}
|
||||
const text = desired(current)
|
||||
if (sameConfig(current, text)) return { result: { server: s.id, ok: true, message: 'Already in sync; nothing was written.' } }
|
||||
if (text.length > MAX_CONFIG) return { result: { server: s.id, ok: false, message: 'the config is larger than 1 MB' } }
|
||||
try {
|
||||
writeHistory(this.#config.dataDir, name, s.id, current, 'before-apply', this.#now())
|
||||
} catch (e) {
|
||||
return { result: { server: s.id, ok: false, message: `the old config could not be saved first (${reason(e)}); nothing was changed` } }
|
||||
}
|
||||
await this.#upstream.write(s, 'apps/config/set', {}, new URLSearchParams({ name, config: text }))
|
||||
const after = await this.#readConfig(s, name, true)
|
||||
const detail = `${textHash(current)} to ${textHash(text)}, ${text.length} bytes`
|
||||
if (after === null || !sameConfig(after, text)) {
|
||||
return { result: { server: s.id, ok: false, message: 'the server accepted the config but reads back a different one' }, detail }
|
||||
}
|
||||
return { result: { server: s.id, ok: true, message: 'Applied and verified.' }, detail }
|
||||
} catch (e) {
|
||||
return { result: { server: s.id, ok: false, message: reason(e) } }
|
||||
}
|
||||
}
|
||||
|
||||
async #applyMany(
|
||||
name: string,
|
||||
ids: string[],
|
||||
desiredFor: (s: HubServer) => (current: string) => string,
|
||||
expect: Record<string, string>,
|
||||
): Promise<{ results: TargetResult[]; details: Record<string, string> }> {
|
||||
const targets = this.#targets(ids)
|
||||
const done = await Promise.all(targets.map((s) => this.#applyText(s, name, desiredFor(s), expect[s.id])))
|
||||
const details: Record<string, string> = {}
|
||||
for (const d of done) if (d.detail !== undefined) details[d.result.server] = d.detail
|
||||
return { results: done.map((d) => d.result), details }
|
||||
}
|
||||
|
||||
/* `overrideText` is a config that is not the hub's baseline (a profile's). The server's local values still apply. */
|
||||
async apply(name: string, ids: string[], expect: Record<string, string>, overrideText?: string): Promise<{ results: TargetResult[]; details: Record<string, string> }> {
|
||||
const baseline = overrideText === undefined ? this.getBaseline(name) : ({ app: name, text: overrideText, updatedAt: '', updatedBy: '' } satisfies Baseline)
|
||||
if (baseline === null) throw new UserError('This app has no baseline yet. Import one from a server first.')
|
||||
const overrides = this.getOverrides(name)
|
||||
return this.#applyMany(name, ids, (s) => () => effectiveConfig(baseline.text, overrides, s.id).text, expect)
|
||||
}
|
||||
|
||||
/* ---- History ---- */
|
||||
|
||||
historyList(name: string): HistoryEntry[] {
|
||||
return listHistory(this.#config.dataDir, name)
|
||||
}
|
||||
|
||||
#entry(name: string, id: string): { entry: HistoryEntry; text: string } {
|
||||
const entry = this.historyList(name).find((e) => e.id === id)
|
||||
const text = entry === undefined ? null : readHistory(this.#config.dataDir, name, id)
|
||||
if (entry === undefined || text === null) throw new UserError('There is no such history entry.')
|
||||
return { entry, text }
|
||||
}
|
||||
|
||||
historyGet(name: string, id: string): { entry: HistoryEntry; text: string } {
|
||||
return this.#entry(name, id)
|
||||
}
|
||||
|
||||
/* A saved version against a server's config now: what rolling back would change. */
|
||||
async historyDiff(name: string, id: string, serverId: string): Promise<unknown> {
|
||||
const { entry, text } = this.#entry(name, id)
|
||||
const server = this.#server(serverId)
|
||||
const l = await this.#live(server, name, true)
|
||||
if (!l.ok || !l.installed) throw new UserError(l.ok ? `The app is not installed on ${serverId}.` : `${serverId}: ${l.error ?? 'did not answer'}`)
|
||||
const current = l.text ?? ''
|
||||
return {
|
||||
entry,
|
||||
server: serverId,
|
||||
writable: server.adminToken !== undefined,
|
||||
current,
|
||||
currentHash: textHash(current),
|
||||
version: text,
|
||||
changes: diffConfigs(current, text, []),
|
||||
unchanged: sameConfig(current, text),
|
||||
}
|
||||
}
|
||||
|
||||
async rollback(name: string, id: string, ids: string[], expect: Record<string, string>): Promise<{ results: TargetResult[]; details: Record<string, string> }> {
|
||||
const { entry, text } = this.#entry(name, id)
|
||||
if (entry.kind === 'before-save') throw new UserError('That entry is an old baseline. Restore it as the baseline instead.')
|
||||
return this.#applyMany(name, ids, () => () => text, expect)
|
||||
}
|
||||
|
||||
restoreBaseline(name: string, id: string, user: string): { detail: string } {
|
||||
const { entry, text } = this.#entry(name, id)
|
||||
if (entry.kind !== 'before-save') throw new UserError('Only an old baseline can be restored as the baseline.')
|
||||
this.#setBaseline(name, text, this.getBaseline(name)?.importedFrom, user)
|
||||
return { detail: `entry ${entry.hash}, ${text.length} bytes` }
|
||||
}
|
||||
|
||||
/*
|
||||
"Accept as baseline" for a drifted server: its config becomes the baseline, except that local
|
||||
fields keep the baseline's values and the server's own values go into the overrides.
|
||||
*/
|
||||
async acceptDrift(name: string, serverId: string, user: string): Promise<{ detail: string }> {
|
||||
const server = this.#server(serverId)
|
||||
let text: string | null
|
||||
try {
|
||||
text = await this.#readConfig(server, name, true)
|
||||
} catch (e) {
|
||||
throw new UserError(`${serverId}: ${reason(e)}`)
|
||||
}
|
||||
if (text === null || text.trim() === '') throw new UserError(`${serverId} has no config for "${name}".`)
|
||||
const overrides = this.getOverrides(name)
|
||||
let next
|
||||
try {
|
||||
next = acceptServerConfig(text, this.getBaseline(name)?.text ?? null, overrides, serverId)
|
||||
} catch (e) {
|
||||
throw new UserError(reason(e))
|
||||
}
|
||||
this.#setBaseline(name, next.baselineText, serverId, user)
|
||||
if (overrides.paths.length > 0) writeOverrides(this.#config.dataDir, name, next.overrides)
|
||||
return { detail: `from ${serverId}, ${next.baselineText.length} bytes, ${textHash(next.baselineText)}` }
|
||||
}
|
||||
|
||||
/* ---- Export and import of all baselines ---- */
|
||||
|
||||
exportAll(): { hub: string; version: number; exportedAt: string; apps: { app: string; baseline: Baseline; overrides: Overrides }[] } {
|
||||
const apps = listBaselineApps(this.#config.dataDir).flatMap((app) => {
|
||||
const baseline = this.getBaseline(app)
|
||||
return baseline === null ? [] : [{ app, baseline, overrides: this.getOverrides(app) }]
|
||||
})
|
||||
return { hub: 'technitium-hub', version: 1, exportedAt: new Date(this.#now()).toISOString(), apps }
|
||||
}
|
||||
|
||||
/* Validates a file and says what it would change; with `apply` also writes baselines and overrides (never to a server). */
|
||||
importAll(raw: string, apply: boolean, user: string): { apps: { app: string; status: 'new' | 'changed' | 'same'; ignoredServers: string[] }[]; applied: boolean } {
|
||||
const parsed = parseJson(raw)
|
||||
if (!parsed.ok) throw new UserError('The file is not valid JSON.')
|
||||
const file = parsed.value as { hub?: unknown; version?: unknown; apps?: unknown }
|
||||
if (file === null || typeof file !== 'object' || file.hub !== 'technitium-hub') throw new UserError('This is not a baselines file of the hub.')
|
||||
if (file.version !== 1) throw new UserError('This file has a version the hub does not know.')
|
||||
if (!Array.isArray(file.apps) || file.apps.length > 500) throw new UserError('The file has no list of apps, or has more than 500.')
|
||||
const known = this.#config.servers.map((s) => s.id)
|
||||
const plan: { app: string; text: string; overrides: Overrides; status: 'new' | 'changed' | 'same'; ignoredServers: string[] }[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const item of file.apps as Record<string, unknown>[]) {
|
||||
const app = item?.app
|
||||
const baseline = item?.baseline as { text?: unknown } | null | undefined
|
||||
if (typeof app !== 'string' || app === '' || app.length > 200 || seen.has(app)) throw new UserError('An app in the file has no valid, unique name.')
|
||||
seen.add(app)
|
||||
if (typeof baseline?.text !== 'string') throw new UserError(`"${app}" has no baseline text.`)
|
||||
if (baseline.text.length > MAX_CONFIG) throw new UserError(`The baseline of "${app}" is larger than 1 MB.`)
|
||||
if (looksLikeJson(baseline.text) && !parseJson(baseline.text).ok) throw new UserError(`The baseline of "${app}" is not valid JSON.`)
|
||||
const rawOverrides = (item.overrides ?? { paths: [], values: {} }) as { paths?: unknown; values?: unknown }
|
||||
let paths: string[]
|
||||
try {
|
||||
paths = validatePaths(rawOverrides.paths ?? [])
|
||||
} catch (e) {
|
||||
throw new UserError(`"${app}": ${reason(e)}`)
|
||||
}
|
||||
if (paths.length > 0 && !looksLikeJson(baseline.text)) throw new UserError(`"${app}": a plain text baseline cannot have local fields.`)
|
||||
const values = rawOverrides.values !== null && typeof rawOverrides.values === 'object' && !Array.isArray(rawOverrides.values) ? (rawOverrides.values as Record<string, unknown>) : {}
|
||||
const ignoredServers = Object.keys(values).filter((id) => !known.includes(id))
|
||||
let cleaned: Overrides['values']
|
||||
try {
|
||||
cleaned = cleanValues(Object.fromEntries(Object.entries(values).filter(([id]) => known.includes(id))), paths, known)
|
||||
} catch (e) {
|
||||
throw new UserError(`"${app}": ${reason(e)}`)
|
||||
}
|
||||
const overrides: Overrides = { paths, values: cleaned }
|
||||
const old = this.getBaseline(app)
|
||||
const oldOverrides = this.getOverrides(app)
|
||||
const status = old === null ? 'new' : old.text === baseline.text && JSON.stringify(oldOverrides) === JSON.stringify(overrides) ? 'same' : 'changed'
|
||||
plan.push({ app, text: baseline.text, overrides, status, ignoredServers })
|
||||
}
|
||||
if (apply) {
|
||||
for (const p of plan) {
|
||||
if (p.status === 'same') continue
|
||||
this.#setBaseline(p.app, p.text, this.getBaseline(p.app)?.importedFrom, user)
|
||||
writeOverrides(this.#config.dataDir, p.app, p.overrides)
|
||||
}
|
||||
}
|
||||
return { apps: plan.map(({ app, status, ignoredServers }) => ({ app, status, ignoredServers })), applied: apply }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
// @vitest-environment node
|
||||
import http from 'node:http'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { parseConfig } from './config.ts'
|
||||
import { createHub } from './server.ts'
|
||||
import { acceptServerConfig } from './apps-sync.ts'
|
||||
import { addUser, writeUsersFile } from './users.ts'
|
||||
|
||||
const json = (o: unknown): string => JSON.stringify(o, null, 2)
|
||||
|
||||
interface Fake {
|
||||
server: http.Server
|
||||
url: string
|
||||
apps: Record<string, { version: string; config: string | null }>
|
||||
calls: { path: string; token: string; method: string }[]
|
||||
rejectSet: string | null
|
||||
}
|
||||
|
||||
function fake(): Promise<Fake> {
|
||||
return new Promise((resolve) => {
|
||||
const f: Fake = { server: http.createServer(), url: '', apps: {}, calls: [], rejectSet: null }
|
||||
f.server.on('request', (req, res) => {
|
||||
const chunks: Buffer[] = []
|
||||
req.on('data', (c: Buffer) => chunks.push(c))
|
||||
req.on('end', () => {
|
||||
const url = new URL(req.url ?? '/', 'http://fake')
|
||||
const apiPath = url.pathname.replace(/^\/api\//, '')
|
||||
const token = url.searchParams.get('token') ?? ''
|
||||
const form = new URLSearchParams(Buffer.concat(chunks).toString('utf8'))
|
||||
f.calls.push({ path: apiPath, token, method: req.method ?? '' })
|
||||
const name = url.searchParams.get('name') ?? form.get('name') ?? ''
|
||||
res.setHeader('content-type', 'application/json')
|
||||
const ok = (response: unknown = {}) => res.end(JSON.stringify({ status: 'ok', response }))
|
||||
const error = (m: string) => res.end(JSON.stringify({ status: 'error', errorMessage: m }))
|
||||
if (apiPath === 'apps/list') return ok({ apps: Object.entries(f.apps).map(([n, a]) => ({ name: n, version: a.version })) })
|
||||
if (apiPath === 'apps/config/get') return f.apps[name] ? ok({ config: f.apps[name].config }) : error('not found')
|
||||
if (apiPath === 'apps/config/set') {
|
||||
if (!token.startsWith('admin-')) return error('Access was denied.')
|
||||
if (f.rejectSet !== null) return error(f.rejectSet)
|
||||
f.apps[name].config = form.get('config')
|
||||
return ok()
|
||||
}
|
||||
ok()
|
||||
})
|
||||
})
|
||||
f.server.listen(0, '127.0.0.1', () => {
|
||||
f.url = `http://127.0.0.1:${(f.server.address() as AddressInfo).port}`
|
||||
resolve(f)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
let a: Fake
|
||||
let b: Fake
|
||||
let dataDir: string
|
||||
let staticDir: string
|
||||
let hub: http.Server | undefined
|
||||
let base: string
|
||||
const tokens: Record<string, string> = {}
|
||||
|
||||
const V1 = json({ ttl: 30, db: '/base.db' })
|
||||
const V2 = json({ ttl: 45, db: '/base.db' })
|
||||
|
||||
function reset(): void {
|
||||
for (const f of [a, b]) {
|
||||
f.calls = []
|
||||
f.rejectSet = null
|
||||
}
|
||||
a.apps = { Blocker: { version: '1.0', config: V1 } }
|
||||
b.apps = { Blocker: { version: '1.0', config: V1 } }
|
||||
}
|
||||
|
||||
async function start(dir: string = dataDir): Promise<void> {
|
||||
hub?.close()
|
||||
hub = createHub(
|
||||
parseConfig({
|
||||
HUB_SERVERS: `a=${a.url},b=${b.url}`,
|
||||
A_TOKEN: 'read-a',
|
||||
B_TOKEN: 'read-b',
|
||||
A_ADMIN_TOKEN: 'admin-a',
|
||||
B_ADMIN_TOKEN: 'admin-b',
|
||||
HUB_DATA: dir,
|
||||
HUB_STATIC: staticDir,
|
||||
HUB_CACHE_SECONDS: '0',
|
||||
HUB_TIMEOUT_MS: '2000',
|
||||
HUB_DRIFT_MINUTES: '0',
|
||||
}),
|
||||
)
|
||||
await new Promise<void>((resolve) => hub!.listen(0, '127.0.0.1', resolve))
|
||||
base = `http://127.0.0.1:${(hub.address() as AddressInfo).port}`
|
||||
for (const role of ['viewer', 'apps-admin', 'admin']) {
|
||||
const res = await fetch(`${base}/api/user/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: `user=${role}&pass=password-${role}`,
|
||||
})
|
||||
tokens[role] = ((await res.json()) as { token: string }).token
|
||||
}
|
||||
}
|
||||
|
||||
async function raw(role: string, apiPath: string, init: { params?: Record<string, string>; json?: unknown; form?: Record<string, string> } = {}) {
|
||||
const headers: Record<string, string> = { authorization: `Bearer ${tokens[role]}` }
|
||||
let body: string | undefined
|
||||
if (init.json !== undefined) {
|
||||
headers['content-type'] = 'application/json'
|
||||
body = JSON.stringify(init.json)
|
||||
} else if (init.form !== undefined) {
|
||||
headers['content-type'] = 'application/x-www-form-urlencoded'
|
||||
body = new URLSearchParams(init.form).toString()
|
||||
}
|
||||
return fetch(`${base}/api/${apiPath}?${new URLSearchParams(init.params ?? {})}`, { method: body === undefined ? 'GET' : 'POST', headers, body })
|
||||
}
|
||||
|
||||
async function api(role: string, apiPath: string, init: Parameters<typeof raw>[2] = {}) {
|
||||
const res = await raw(role, apiPath, init)
|
||||
return { code: res.status, body: (await res.json()) as Record<string, any> }
|
||||
}
|
||||
|
||||
const dirOf = (d: string = dataDir) => path.join(d, 'apps', 'Blocker')
|
||||
const historyDir = (d: string = dataDir) => path.join(dirOf(d), 'history')
|
||||
const writes = (f: Fake) => f.calls.filter((c) => c.path === 'apps/config/set')
|
||||
const list = async (role = 'viewer') => (await api(role, 'hub/apps/history/list', { params: { name: 'Blocker' } })).body.response.entries as Record<string, any>[]
|
||||
|
||||
beforeAll(async () => {
|
||||
a = await fake()
|
||||
b = await fake()
|
||||
dataDir = mkdtempSync(path.join(tmpdir(), 'hub-hist-data-'))
|
||||
staticDir = mkdtempSync(path.join(tmpdir(), 'hub-hist-static-'))
|
||||
let users = addUser([], 'viewer', 'viewer', 'password-viewer')
|
||||
users = addUser(users, 'apps-admin', 'apps-admin', 'password-apps-admin')
|
||||
users = addUser(users, 'admin', 'admin', 'password-admin')
|
||||
writeUsersFile(dataDir, users)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
hub?.close()
|
||||
a.server.close()
|
||||
b.server.close()
|
||||
rmSync(dataDir, { recursive: true, force: true })
|
||||
rmSync(staticDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
reset()
|
||||
rmSync(path.join(dataDir, 'apps'), { recursive: true, force: true })
|
||||
rmSync(path.join(dataDir, 'audit.log'), { force: true })
|
||||
rmSync(path.join(dataDir, 'drift.json'), { force: true })
|
||||
await start()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
hub?.close()
|
||||
})
|
||||
|
||||
const post = (role: string, p: string, body: Record<string, unknown>) => api(role, p, { json: body })
|
||||
const importFrom = (server: string) => post('apps-admin', 'hub/apps/baseline/import', { name: 'Blocker', server })
|
||||
const saveBaseline = (text: string) => post('apps-admin', 'hub/apps/baseline/save', { name: 'Blocker', text })
|
||||
|
||||
describe('history', () => {
|
||||
it('keeps the old baseline text on every save and import, and lists it without the text', async () => {
|
||||
await importFrom('a')
|
||||
expect(await list()).toEqual([])
|
||||
await saveBaseline(V2)
|
||||
await importFrom('b')
|
||||
const entries = await list()
|
||||
expect(entries).toHaveLength(2)
|
||||
expect(entries[0]).toMatchObject({ server: 'baseline', kind: 'before-save', size: V2.length })
|
||||
expect(entries[0].at).toMatch(/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/)
|
||||
expect(entries[0].hash).toMatch(/^[0-9a-f]{16}$/)
|
||||
expect(entries[0].text).toBeUndefined()
|
||||
expect(entries[1].size).toBe(V1.length)
|
||||
expect(entries[0].id > entries[1].id).toBe(true)
|
||||
})
|
||||
|
||||
it('does not add an entry when the baseline text did not change', async () => {
|
||||
await importFrom('a')
|
||||
await saveBaseline(V1)
|
||||
expect(await list()).toEqual([])
|
||||
})
|
||||
|
||||
it('reads one entry by the id the listing gives and refuses anything else', async () => {
|
||||
await importFrom('a')
|
||||
await saveBaseline(V2)
|
||||
const [entry] = await list()
|
||||
const got = await api('viewer', 'hub/apps/history/get', { params: { name: 'Blocker', id: entry.id } })
|
||||
expect(got.body.response.text).toBe(V1)
|
||||
for (const id of ['../../../users.json', '..\\..\\users.json', entry.id.replace('2', '3'), entry.id + '/', '/etc/passwd', '', `../history/${entry.id}`, 'baseline.json']) {
|
||||
const r = await api('viewer', 'hub/apps/history/get', { params: { name: 'Blocker', id } })
|
||||
expect([id, r.code]).toEqual([id, 400])
|
||||
}
|
||||
// The app name cannot walk out either.
|
||||
const r = await api('viewer', 'hub/apps/history/get', { params: { name: '../..', id: 'users.json' } })
|
||||
expect(r.code).toBe(400)
|
||||
})
|
||||
|
||||
it('ignores files in the folder that are not history entries', async () => {
|
||||
await importFrom('a')
|
||||
mkdirSync(historyDir(), { recursive: true })
|
||||
writeFileSync(path.join(historyDir(), 'notes.txt'), 'x')
|
||||
expect(await list()).toEqual([])
|
||||
})
|
||||
|
||||
it('shows what a saved version would change on a server', async () => {
|
||||
await importFrom('a')
|
||||
await saveBaseline(V2)
|
||||
const [entry] = await list()
|
||||
const r = await api('viewer', 'hub/apps/history/diff', { params: { name: 'Blocker', id: entry.id, server: 'b' } })
|
||||
expect(r.body.response).toMatchObject({ server: 'b', writable: true, unchanged: true, current: V1 })
|
||||
a.apps.Blocker.config = V2
|
||||
const d = await api('viewer', 'hub/apps/history/diff', { params: { name: 'Blocker', id: entry.id, server: 'a' } })
|
||||
expect(d.body.response.changes.changes.map((c: any) => [c.path, c.a, c.b])).toEqual([['ttl', 45, 30]])
|
||||
expect(d.body.response.unchanged).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps at most 50 entries per app', async () => {
|
||||
await importFrom('a')
|
||||
for (let i = 0; i < 55; i++) await saveBaseline(json({ n: i }))
|
||||
expect((await list()).length).toBe(50)
|
||||
})
|
||||
|
||||
it('also records the config a single-server set replaced', async () => {
|
||||
await api('apps-admin', 'apps/config/set', { params: { node: 'a' }, json: { name: 'Blocker', config: '{}' } })
|
||||
const entries = await list()
|
||||
expect(entries[0]).toMatchObject({ server: 'a', kind: 'before-set' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('rollback and restore', () => {
|
||||
async function twoVersions() {
|
||||
// Both servers were changed by the hub once: their old config (V1) is in the history.
|
||||
await importFrom('a')
|
||||
await saveBaseline(V2)
|
||||
await post('apps-admin', 'hub/apps/config/apply', { name: 'Blocker', targets: ['a', 'b'] })
|
||||
return (await list()).filter((e) => e.kind === 'before-apply')
|
||||
}
|
||||
|
||||
it('rolls a version back to the chosen servers, one result each, history first, verified', async () => {
|
||||
const applied = await twoVersions()
|
||||
expect(applied.map((e) => e.server).sort()).toEqual(['a', 'b'])
|
||||
expect(a.apps.Blocker.config).toBe(V2)
|
||||
const entry = applied.find((e) => e.server === 'a') as Record<string, any>
|
||||
b.rejectSet = 'Disk full.'
|
||||
const r = await post('apps-admin', 'hub/apps/history/rollback', { name: 'Blocker', id: entry.id, targets: ['a', 'b'] })
|
||||
expect(r.body.response.results).toEqual([
|
||||
{ server: 'a', ok: true, message: 'Applied and verified.' },
|
||||
{ server: 'b', ok: false, message: 'Disk full.' },
|
||||
])
|
||||
expect(a.apps.Blocker.config).toBe(V1)
|
||||
expect(b.apps.Blocker.config).toBe(V2)
|
||||
// The config rolled away from is kept too.
|
||||
const files = readdirSync(historyDir()).filter((f) => f.endsWith('__a__before-apply.txt'))
|
||||
expect(files).toHaveLength(2)
|
||||
expect(writes(a).at(-1)?.token).toBe('admin-a')
|
||||
})
|
||||
|
||||
it('refuses a rollback when the config changed since the preview, and for an old baseline entry', async () => {
|
||||
const applied = await twoVersions()
|
||||
const entry = applied[0]
|
||||
const stale = await post('apps-admin', 'hub/apps/history/rollback', { name: 'Blocker', id: entry.id, targets: ['a'], expect: { a: 'deadbeefdeadbeef' } })
|
||||
expect(stale.body.response.results[0].message).toMatch(/changed on the server since the preview/)
|
||||
const baselineEntry = (await list()).find((e) => e.kind === 'before-save') as Record<string, any>
|
||||
const refused = await post('apps-admin', 'hub/apps/history/rollback', { name: 'Blocker', id: baselineEntry.id, targets: ['a'] })
|
||||
expect(refused.code).toBe(400)
|
||||
expect(refused.body.errorMessage).toMatch(/Restore it as the baseline/)
|
||||
})
|
||||
|
||||
it('restores an old baseline as the baseline, keeping the current one in the history', async () => {
|
||||
await importFrom('a')
|
||||
await saveBaseline(V2)
|
||||
const old = (await list())[0]
|
||||
const r = await post('apps-admin', 'hub/apps/baseline/restore', { name: 'Blocker', id: old.id })
|
||||
expect(r.body.response.baseline.text).toBe(V1)
|
||||
const entries = await list()
|
||||
expect(entries[0]).toMatchObject({ kind: 'before-save', size: V2.length })
|
||||
expect((await post('apps-admin', 'hub/apps/baseline/restore', { name: 'Blocker', id: 'nope' })).code).toBe(400)
|
||||
// A server entry is not a baseline.
|
||||
b.apps.Blocker.config = V2
|
||||
await post('apps-admin', 'hub/apps/config/apply', { name: 'Blocker', targets: ['b'] })
|
||||
const serverEntry = (await list()).find((e) => e.kind === 'before-apply') as Record<string, any>
|
||||
expect((await post('apps-admin', 'hub/apps/baseline/restore', { name: 'Blocker', id: serverEntry.id })).code).toBe(400)
|
||||
})
|
||||
|
||||
it('lets a viewer look but not roll back or restore', async () => {
|
||||
await twoVersions()
|
||||
const [entry] = await list()
|
||||
expect((await post('viewer', 'hub/apps/history/rollback', { name: 'Blocker', id: entry.id, targets: ['a'] })).code).toBe(403)
|
||||
expect((await post('viewer', 'hub/apps/baseline/restore', { name: 'Blocker', id: entry.id })).code).toBe(403)
|
||||
expect((await api('viewer', 'hub/apps/history/get', { params: { name: 'Blocker', id: entry.id } })).code).toBe(200)
|
||||
})
|
||||
|
||||
it('audits rollbacks and restores without any config text', async () => {
|
||||
const applied = await twoVersions()
|
||||
await post('apps-admin', 'hub/apps/history/rollback', { name: 'Blocker', id: applied[0].id, targets: ['a'] })
|
||||
const text = readFileSync(path.join(dataDir, 'audit.log'), 'utf8')
|
||||
expect(text).toContain('apps.history-rollback')
|
||||
for (const secret of ['/base.db', 'admin-a', 'admin-b']) expect(text).not.toContain(secret)
|
||||
})
|
||||
})
|
||||
|
||||
describe('drift over the API', () => {
|
||||
it('counts a drifted server in the session, shows it in drift/get and clears it when fixed', async () => {
|
||||
await importFrom('a')
|
||||
expect((await api('viewer', 'user/session/get')).body.info.hubDrift).toBe(0)
|
||||
b.apps.Blocker.config = json({ ttl: 99, db: '/base.db' })
|
||||
|
||||
expect((await post('viewer', 'hub/drift/check', {})).code).toBe(403)
|
||||
const checked = await post('apps-admin', 'hub/drift/check', {})
|
||||
expect(checked.body.response.apps).toMatchObject([{ kind: 'app', app: 'Blocker', name: 'Blocker', server: 'b', status: 'differs' }])
|
||||
expect((await api('viewer', 'user/session/get')).body.info.hubDrift).toBe(1)
|
||||
const got = await api('viewer', 'hub/drift/get')
|
||||
expect(got.body.response.apps).toHaveLength(1)
|
||||
expect(got.body.response.checking).toBe(false)
|
||||
|
||||
// Revert to the baseline is the sync apply on that server.
|
||||
await post('apps-admin', 'hub/apps/config/apply', { name: 'Blocker', targets: ['b'] })
|
||||
await post('apps-admin', 'hub/drift/check', {})
|
||||
expect((await api('viewer', 'user/session/get')).body.info.hubDrift).toBe(0)
|
||||
expect((await api('viewer', 'hub/drift/get')).body.response.apps).toEqual([])
|
||||
expect(readFileSync(path.join(dataDir, 'drift.json'), 'utf8')).toContain('"apps": []')
|
||||
})
|
||||
|
||||
it('accepts a drifted config as the baseline', async () => {
|
||||
await importFrom('a')
|
||||
b.apps.Blocker.config = json({ ttl: 99, db: '/base.db' })
|
||||
const r = await post('apps-admin', 'hub/apps/drift/accept', { name: 'Blocker', server: 'b' })
|
||||
expect(JSON.parse(r.body.response.baseline.text)).toEqual({ ttl: 99, db: '/base.db' })
|
||||
expect(r.body.response.baseline.importedFrom).toBe('b')
|
||||
expect((await list())[0]).toMatchObject({ server: 'baseline', kind: 'before-save' })
|
||||
expect((await api('viewer', 'user/session/get')).body.info.hubDrift).toBe(1)
|
||||
expect((await post('viewer', 'hub/apps/drift/accept', { name: 'Blocker', server: 'b' })).code).toBe(403)
|
||||
})
|
||||
|
||||
it('on accept keeps local fields out of the baseline and moves the server values into the overrides', () => {
|
||||
const out = acceptServerConfig(
|
||||
json({ ttl: 99, db: '/b.db' }),
|
||||
json({ ttl: 30, db: '/base.db' }),
|
||||
{ paths: ['db'], values: { a: { db: '/a.db' } } },
|
||||
'b',
|
||||
)
|
||||
expect(JSON.parse(out.baselineText)).toEqual({ ttl: 99, db: '/base.db' })
|
||||
expect(out.overrides.values).toEqual({ a: { db: '/a.db' }, b: { db: '/b.db' } })
|
||||
expect(() => acceptServerConfig('plain', null, { paths: ['db'], values: {} }, 'b')).toThrow(/plain text/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('export and import of baselines', () => {
|
||||
async function setup() {
|
||||
await importFrom('a')
|
||||
await post('apps-admin', 'hub/apps/overrides/save', { name: 'Blocker', paths: ['db'], values: JSON.stringify({ a: { db: '/a.db' }, b: { db: '/b.db' } }) })
|
||||
}
|
||||
|
||||
it('exports one file with every baseline and its local fields, as a download', async () => {
|
||||
await setup()
|
||||
const res = await raw('apps-admin', 'hub/apps/export')
|
||||
expect(res.headers.get('content-disposition')).toMatch(/^attachment; filename="hub-baselines-\d{4}-\d\d-\d\d\.json"$/)
|
||||
const file = (await res.json()) as Record<string, any>
|
||||
expect(file).toMatchObject({ hub: 'technitium-hub', version: 1 })
|
||||
expect(file.apps).toHaveLength(1)
|
||||
expect(file.apps[0]).toMatchObject({ app: 'Blocker', baseline: { text: V1 }, overrides: { paths: ['db'], values: { a: { db: '/a.db' } } } })
|
||||
expect((await api('viewer', 'hub/apps/export')).code).toBe(403)
|
||||
expect(readFileSync(path.join(dataDir, 'audit.log'), 'utf8')).toContain('apps.export')
|
||||
})
|
||||
|
||||
it('imports into another hub: a dry run says what is new, applying writes it, and no server is touched', async () => {
|
||||
await setup()
|
||||
const file = await (await raw('apps-admin', 'hub/apps/export')).text()
|
||||
const other = mkdtempSync(path.join(tmpdir(), 'hub-hist-other-'))
|
||||
writeUsersFile(other, [...(await import('./users.ts')).readUsersFile(dataDir)!])
|
||||
try {
|
||||
await start(other)
|
||||
const before = a.calls.length + b.calls.length
|
||||
const dry = await post('apps-admin', 'hub/apps/import', { data: file })
|
||||
expect(dry.body.response).toEqual({ apps: [{ app: 'Blocker', status: 'new', ignoredServers: [] }], applied: false })
|
||||
expect(readdirSync(other)).not.toContain('apps')
|
||||
const done = await post('apps-admin', 'hub/apps/import', { data: file, apply: true })
|
||||
expect(done.body.response.applied).toBe(true)
|
||||
expect(JSON.parse(readFileSync(path.join(dirOf(other), 'baseline.json'), 'utf8')).text).toBe(V1)
|
||||
expect(JSON.parse(readFileSync(path.join(dirOf(other), 'overrides.json'), 'utf8')).values.b).toEqual({ db: '/b.db' })
|
||||
expect(a.calls.length + b.calls.length).toBe(before)
|
||||
// The same file again changes nothing.
|
||||
expect((await post('apps-admin', 'hub/apps/import', { data: file })).body.response.apps[0].status).toBe('same')
|
||||
} finally {
|
||||
rmSync(other, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('says changed, and keeps the replaced baseline in the history when applying', async () => {
|
||||
await setup()
|
||||
const file = JSON.parse(await (await raw('apps-admin', 'hub/apps/export')).text())
|
||||
file.apps[0].baseline.text = V2
|
||||
await saveBaseline('{"x":1}')
|
||||
const dry = await post('apps-admin', 'hub/apps/import', { data: JSON.stringify(file) })
|
||||
expect(dry.body.response.apps[0].status).toBe('changed')
|
||||
await post('apps-admin', 'hub/apps/import', { data: JSON.stringify(file), apply: true })
|
||||
expect(JSON.parse(readFileSync(path.join(dirOf(), 'baseline.json'), 'utf8')).text).toBe(V2)
|
||||
expect((await list()).some((e) => e.kind === 'before-save' && e.size === '{"x":1}'.length)).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores local values of servers this hub does not have, and says so', async () => {
|
||||
await setup()
|
||||
const file = JSON.parse(await (await raw('apps-admin', 'hub/apps/export')).text())
|
||||
file.apps[0].overrides.values.zzz = { db: '/z.db' }
|
||||
const r = await post('apps-admin', 'hub/apps/import', { data: JSON.stringify(file) })
|
||||
expect(r.body.response.apps[0].ignoredServers).toEqual(['zzz'])
|
||||
})
|
||||
|
||||
it('refuses a file that is not valid, writing nothing', async () => {
|
||||
await setup()
|
||||
const good = JSON.parse(await (await raw('apps-admin', 'hub/apps/export')).text())
|
||||
const cases: [string, unknown][] = [
|
||||
['not json', 'this is not json'],
|
||||
['another file', { hello: 1 }],
|
||||
['wrong version', { ...good, version: 2 }],
|
||||
['no apps', { ...good, apps: 'x' }],
|
||||
['bad json baseline', { ...good, apps: [{ app: 'X', baseline: { text: '{ nope' }, overrides: { paths: [], values: {} } }] }],
|
||||
['no text', { ...good, apps: [{ app: 'X', baseline: {}, overrides: {} }] }],
|
||||
['bad path', { ...good, apps: [{ app: 'X', baseline: { text: '{}' }, overrides: { paths: ['a b'], values: {} } }] }],
|
||||
['duplicate app', { ...good, apps: [good.apps[0], good.apps[0]] }],
|
||||
['text with local fields', { ...good, apps: [{ app: 'X', baseline: { text: 'plain' }, overrides: { paths: ['a'], values: {} } }] }],
|
||||
]
|
||||
for (const [label, content] of cases) {
|
||||
const r = await post('apps-admin', 'hub/apps/import', { data: typeof content === 'string' ? content : JSON.stringify(content), apply: true })
|
||||
expect([label, r.code]).toEqual([label, 400])
|
||||
}
|
||||
expect(readdirSync(path.join(dataDir, 'apps'))).toEqual(['Blocker'])
|
||||
})
|
||||
|
||||
it('is for writers only, and accepts a file up to 5 MB from a signed-in caller', async () => {
|
||||
const big = JSON.stringify({ hub: 'technitium-hub', version: 1, apps: [{ app: 'Big', baseline: { text: JSON.stringify({ pad: 'x'.repeat(900_000) }) }, overrides: { paths: [], values: {} } }, ...[1, 2, 3].map((n) => ({ app: `Big${n}`, baseline: { text: JSON.stringify({ pad: 'y'.repeat(900_000) }) }, overrides: { paths: [], values: {} } }))] })
|
||||
expect(big.length).toBeGreaterThan(2 * 1024 * 1024)
|
||||
expect((await post('viewer', 'hub/apps/import', { data: big })).code).toBe(403)
|
||||
const r = await post('apps-admin', 'hub/apps/import', { data: big })
|
||||
expect(r.body.response.apps).toHaveLength(4)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,280 @@
|
||||
// @vitest-environment node
|
||||
import http from 'node:http'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { parseConfig } from './config.ts'
|
||||
import { createHub } from './server.ts'
|
||||
import { countDifferences } from './apps-matrix.ts'
|
||||
import { jsonDiff } from './json-diff.ts'
|
||||
|
||||
interface FakeApp {
|
||||
version: string
|
||||
config: string | null
|
||||
updateAvailable?: boolean
|
||||
updateVersion?: string
|
||||
}
|
||||
|
||||
interface Fake {
|
||||
server: http.Server
|
||||
url: string
|
||||
apps: Record<string, FakeApp>
|
||||
down: boolean
|
||||
/** Config reads with the read token are refused with a permission error. */
|
||||
denyRead: boolean
|
||||
tokens: string[]
|
||||
}
|
||||
|
||||
function fake(apps: Fake['apps']): Promise<Fake> {
|
||||
return new Promise((resolve) => {
|
||||
const f: Fake = { server: http.createServer(), url: '', apps, down: false, denyRead: false, tokens: [] }
|
||||
f.server.on('request', (req, res) => {
|
||||
if (f.down) return void req.socket.destroy()
|
||||
const url = new URL(req.url ?? '/', 'http://fake')
|
||||
const apiPath = url.pathname.replace(/^\/api\//, '')
|
||||
const token = url.searchParams.get('token') ?? ''
|
||||
res.setHeader('content-type', 'application/json')
|
||||
if (apiPath === 'apps/list') {
|
||||
const list = Object.entries(f.apps).map(([name, a]) => ({
|
||||
name,
|
||||
description: `${name} description`,
|
||||
version: a.version,
|
||||
...(a.updateAvailable ? { updateAvailable: true, updateVersion: a.updateVersion } : {}),
|
||||
}))
|
||||
return void res.end(JSON.stringify({ status: 'ok', response: { apps: list } }))
|
||||
}
|
||||
if (apiPath === 'apps/config/get') {
|
||||
f.tokens.push(token)
|
||||
if (f.denyRead && !token.startsWith('admin-')) {
|
||||
return void res.end(JSON.stringify({ status: 'error', errorMessage: "Access was denied to user 'reader' for apps." }))
|
||||
}
|
||||
const app = f.apps[url.searchParams.get('name') ?? '']
|
||||
return void res.end(JSON.stringify({ status: 'ok', response: { config: app?.config ?? null } }))
|
||||
}
|
||||
res.end(JSON.stringify({ status: 'ok', response: {} }))
|
||||
})
|
||||
f.server.listen(0, '127.0.0.1', () => {
|
||||
f.url = `http://127.0.0.1:${(f.server.address() as AddressInfo).port}`
|
||||
resolve(f)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const json = (o: unknown): string => JSON.stringify(o, null, 2)
|
||||
|
||||
let a: Fake
|
||||
let b: Fake
|
||||
let dataDir: string
|
||||
let staticDir: string
|
||||
let hub: http.Server
|
||||
let base: string
|
||||
let token: string
|
||||
|
||||
function reset(): void {
|
||||
a.down = b.down = false
|
||||
a.denyRead = b.denyRead = false
|
||||
a.tokens = []
|
||||
b.tokens = []
|
||||
a.apps = {
|
||||
Same: { version: '1.0', config: json({ x: 1 }) },
|
||||
Differs: { version: '2.0', config: json({ x: 1, y: { z: 2 }, list: [1, 2] }) },
|
||||
'Only A': { version: '3.0', config: json({ secret: 'top-secret-value' }) },
|
||||
Plain: { version: '4.0', config: 'line one\nline two\nline three' },
|
||||
Empty: { version: '5.0', config: null },
|
||||
}
|
||||
b.apps = {
|
||||
Same: { version: '1.0', config: json({ x: 1 }) },
|
||||
Differs: { version: '2.1', updateAvailable: true, updateVersion: '2.2', config: json({ x: 2, y: { z: 3 }, list: [1, 2, 3] }) },
|
||||
Plain: { version: '4.0', config: 'line one\nline TWO\nline three' },
|
||||
Empty: { version: '5.0', config: null },
|
||||
}
|
||||
}
|
||||
|
||||
async function start(extra: Record<string, string> = {}): Promise<void> {
|
||||
hub?.close()
|
||||
hub = createHub(
|
||||
parseConfig({
|
||||
HUB_SERVERS: `a=${a.url},b=${b.url}`,
|
||||
A_TOKEN: 'read-a',
|
||||
B_TOKEN: 'read-b',
|
||||
HUB_USER: 'admin',
|
||||
HUB_PASSWORD: 'secret',
|
||||
HUB_DATA: dataDir,
|
||||
HUB_STATIC: staticDir,
|
||||
HUB_CACHE_SECONDS: '0',
|
||||
HUB_TIMEOUT_MS: '2000',
|
||||
...extra,
|
||||
}),
|
||||
)
|
||||
await new Promise<void>((resolve) => hub.listen(0, '127.0.0.1', resolve))
|
||||
base = `http://127.0.0.1:${(hub.address() as AddressInfo).port}`
|
||||
const res = await fetch(`${base}/api/user/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: 'user=admin&pass=secret',
|
||||
})
|
||||
token = ((await res.json()) as { token: string }).token
|
||||
}
|
||||
|
||||
async function matrix() {
|
||||
const res = await fetch(`${base}/api/hub/apps/matrix`, { headers: { authorization: `Bearer ${token}` } })
|
||||
const text = await res.text()
|
||||
return { text, body: JSON.parse(text) as Record<string, any> }
|
||||
}
|
||||
|
||||
const byName = (body: Record<string, any>, name: string) => (body.response.apps as any[]).find((x) => x.name === name)
|
||||
|
||||
beforeAll(async () => {
|
||||
a = await fake({})
|
||||
b = await fake({})
|
||||
dataDir = mkdtempSync(path.join(tmpdir(), 'hub-matrix-data-'))
|
||||
staticDir = mkdtempSync(path.join(tmpdir(), 'hub-matrix-static-'))
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
hub?.close()
|
||||
a.server.close()
|
||||
b.server.close()
|
||||
rmSync(dataDir, { recursive: true, force: true })
|
||||
rmSync(staticDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
reset()
|
||||
await start()
|
||||
})
|
||||
|
||||
describe('jsonDiff', () => {
|
||||
it('compares objects by key and arrays by index', () => {
|
||||
expect(jsonDiff({ a: 1 }, { a: 1 })).toEqual([])
|
||||
expect(jsonDiff({ a: 1, b: { c: 2 } }, { a: 2, b: { c: 3 } })).toEqual([
|
||||
{ path: 'a', a: 1, b: 2 },
|
||||
{ path: 'b.c', a: 2, b: 3 },
|
||||
])
|
||||
expect(jsonDiff([1, 2], [1, 3, 4])).toEqual([
|
||||
{ path: '[1]', a: 2, b: 3 },
|
||||
{ path: '[2]', a: undefined, b: 4 },
|
||||
])
|
||||
})
|
||||
|
||||
it('reports type changes once and missing keys on either side', () => {
|
||||
expect(jsonDiff({ a: { x: 1 } }, { a: [1] })).toEqual([{ path: 'a', a: { x: 1 }, b: [1] }])
|
||||
expect(jsonDiff({ a: null }, { a: 0 })).toEqual([{ path: 'a', a: null, b: 0 }])
|
||||
expect(jsonDiff({ a: 1 }, { b: 1 })).toEqual([
|
||||
{ path: 'a', a: 1, b: undefined },
|
||||
{ path: 'b', a: undefined, b: 1 },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('countDifferences', () => {
|
||||
it('counts leaf paths for JSON and lines otherwise', () => {
|
||||
expect(countDifferences([json({ a: 1, b: 2 }), json({ a: 2, b: 3 })])).toBe(2)
|
||||
expect(countDifferences(['one\ntwo', 'one\nTWO'])).toBe(2)
|
||||
expect(countDifferences([json({ a: 1 }), 'not json'])).toBeGreaterThan(0)
|
||||
expect(countDifferences([json({ a: 1 })])).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hub/apps/matrix', () => {
|
||||
it('has the documented shape and classifies each app', async () => {
|
||||
const { body } = await matrix()
|
||||
expect(body.status).toBe('ok')
|
||||
expect(body.hubWarnings).toEqual([])
|
||||
expect(body.response.servers).toEqual([{ id: 'a', ok: true, writable: false }, { id: 'b', ok: true, writable: false }])
|
||||
expect((body.response.apps as any[]).map((x) => x.name)).toEqual(['Differs', 'Empty', 'Only A', 'Plain', 'Same'])
|
||||
|
||||
const same = byName(body, 'Same')
|
||||
expect(same).toMatchObject({ configs: 'same', versions: 'same', description: 'Same description' })
|
||||
expect(same.diffCount).toBeUndefined()
|
||||
expect(same.servers.a).toMatchObject({ installed: true, version: '1.0', configStatus: 'read' })
|
||||
expect(same.servers.a.configHash).toBe(same.servers.b.configHash)
|
||||
expect(same.servers.a.configHash).toMatch(/^[0-9a-f]{16}$/)
|
||||
|
||||
const differs = byName(body, 'Differs')
|
||||
expect(differs).toMatchObject({ configs: 'different', versions: 'different', diffCount: 3 })
|
||||
expect(differs.servers.b).toMatchObject({ updateAvailable: true, updateVersion: '2.2', version: '2.1' })
|
||||
expect(differs.servers.a.updateAvailable).toBeUndefined()
|
||||
|
||||
expect(byName(body, 'Plain')).toMatchObject({ configs: 'different', diffCount: 2 })
|
||||
|
||||
const only = byName(body, 'Only A')
|
||||
expect(only).toMatchObject({ configs: 'single', versions: 'single' })
|
||||
expect(only.servers.b).toEqual({ installed: false, configStatus: 'none' })
|
||||
|
||||
const empty = byName(body, 'Empty')
|
||||
expect(empty).toMatchObject({ configs: 'same' })
|
||||
expect(empty.servers.a).toMatchObject({ configStatus: 'none' })
|
||||
expect(empty.servers.a.configHash).toBeUndefined()
|
||||
})
|
||||
|
||||
it('never returns a config text', async () => {
|
||||
const { text } = await matrix()
|
||||
expect(text).not.toContain('top-secret-value')
|
||||
expect(text).not.toContain('line one')
|
||||
expect(text).not.toContain('read-a')
|
||||
})
|
||||
|
||||
it('marks a config unreadable when the read token is refused and there is no admin token', async () => {
|
||||
a.denyRead = true
|
||||
const { body } = await matrix()
|
||||
const same = byName(body, 'Same')
|
||||
expect(same.servers.a).toMatchObject({ installed: true, configStatus: 'unreadable' })
|
||||
expect(same.servers.a.configError).toMatch(/denied/)
|
||||
expect(same.servers.a.configHash).toBeUndefined()
|
||||
expect(same.configs).toBe('unknown')
|
||||
expect(byName(body, 'Only A').configs).toBe('unknown')
|
||||
// Versions are still known.
|
||||
expect(same.versions).toBe('same')
|
||||
})
|
||||
|
||||
it('retries with the admin token when the read token is refused', async () => {
|
||||
a.denyRead = true
|
||||
await start({ A_ADMIN_TOKEN: 'admin-a' })
|
||||
const { body, text } = await matrix()
|
||||
expect(byName(body, 'Same')).toMatchObject({ configs: 'same' })
|
||||
expect(byName(body, 'Same').servers.a.configStatus).toBe('read')
|
||||
expect(a.tokens).toContain('read-a')
|
||||
expect(a.tokens).toContain('admin-a')
|
||||
expect(b.tokens.every((t) => t === 'read-b')).toBe(true)
|
||||
expect(text).not.toContain('admin-a')
|
||||
})
|
||||
|
||||
it('shows a server that is down and still renders the rest', async () => {
|
||||
b.down = true
|
||||
const { body } = await matrix()
|
||||
expect(body.status).toBe('ok')
|
||||
expect(body.response.servers[0]).toEqual({ id: 'a', ok: true, writable: false })
|
||||
expect(body.response.servers[1]).toMatchObject({ id: 'b', ok: false })
|
||||
expect(body.response.servers[1].error).toBeTruthy()
|
||||
expect(body.hubWarnings).toHaveLength(1)
|
||||
expect(body.hubWarnings[0]).toMatch(/^b:/)
|
||||
const same = byName(body, 'Same')
|
||||
expect(same.servers.a.installed).toBe(true)
|
||||
expect(same.servers.b).toMatchObject({ installed: false, configStatus: 'unreadable' })
|
||||
expect(same.configs).toBe('unknown')
|
||||
})
|
||||
|
||||
it('fails when no server answers', async () => {
|
||||
a.down = true
|
||||
b.down = true
|
||||
const { body } = await matrix()
|
||||
expect(body.status).toBe('error')
|
||||
expect(body.errorMessage).toMatch(/No server answered/)
|
||||
})
|
||||
|
||||
it('is open to every role that can read', async () => {
|
||||
const res = await fetch(`${base}/api/hub/apps/matrix`)
|
||||
expect(((await res.json()) as { status: string }).status).toBe('invalid-token')
|
||||
})
|
||||
})
|
||||
|
||||
describe('session permissions', () => {
|
||||
it('lets the admin view and modify Apps (the other roles are in apps-write.test.ts)', async () => {
|
||||
const res = await fetch(`${base}/api/user/session/get`, { headers: { authorization: `Bearer ${token}` } })
|
||||
const info = ((await res.json()) as any).info
|
||||
expect(info.permissions.Apps).toEqual({ canView: true, canModify: true, canDelete: true })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,172 @@
|
||||
/*
|
||||
`hub/apps/matrix`: which DNS apps are installed on which server, with what version, and
|
||||
whether their configs agree. Read-only. The config texts are compared here and never
|
||||
leave: the answer carries a short hash per copy and the comparison, because a config may
|
||||
hold secrets.
|
||||
*/
|
||||
import { createHash } from 'node:crypto'
|
||||
import type { HubConfig, HubServer } from './config.ts'
|
||||
import { comparable } from './apps-sync.ts'
|
||||
import { readOverrides } from './store.ts'
|
||||
import { jsonDiff } from './json-diff.ts'
|
||||
import { Upstream, UpstreamError } from './upstream.ts'
|
||||
|
||||
export interface ServerCell {
|
||||
installed: boolean
|
||||
version?: string
|
||||
updateAvailable?: boolean
|
||||
updateVersion?: string
|
||||
configStatus: 'none' | 'read' | 'unreadable'
|
||||
configHash?: string
|
||||
configError?: string
|
||||
}
|
||||
|
||||
export interface MatrixApp {
|
||||
name: string
|
||||
description: string
|
||||
servers: Record<string, ServerCell>
|
||||
configs: 'same' | 'different' | 'single' | 'unknown'
|
||||
diffCount?: number
|
||||
versions: 'same' | 'different' | 'single'
|
||||
}
|
||||
|
||||
export interface Matrix {
|
||||
servers: { id: string; ok: boolean; writable: boolean; error?: string }[]
|
||||
apps: MatrixApp[]
|
||||
}
|
||||
|
||||
interface Listed {
|
||||
name: string
|
||||
description?: string
|
||||
version?: string
|
||||
updateAvailable?: boolean
|
||||
updateVersion?: string
|
||||
}
|
||||
|
||||
const hash = (text: string): string => createHash('sha256').update(text).digest('hex').slice(0, 16)
|
||||
|
||||
const reason = (e: unknown): string => (e instanceof Error ? e.message : 'failed')
|
||||
|
||||
/* The read token first; when the server refuses it, once more with the admin token if there is one. */
|
||||
async function readConfig(upstream: Upstream, server: HubServer, name: string): Promise<string | null> {
|
||||
const params = { name }
|
||||
let response: unknown
|
||||
try {
|
||||
response = await upstream.call(server, 'apps/config/get', params)
|
||||
} catch (e) {
|
||||
if (!(e instanceof UpstreamError) || e.kind !== 'rejected' || server.adminToken === undefined) throw e
|
||||
response = await upstream.call(server, 'apps/config/get', params, server.adminToken)
|
||||
}
|
||||
const config = (response as { config?: unknown } | null)?.config
|
||||
return typeof config === 'string' ? config : null
|
||||
}
|
||||
|
||||
/* How many places differ between the readable configs. JSON: differing leaf paths; else differing lines. */
|
||||
export function countDifferences(texts: string[]): number {
|
||||
if (texts.length < 2) return 0
|
||||
let parsed: unknown[] | null = []
|
||||
for (const text of texts) {
|
||||
try {
|
||||
parsed.push(JSON.parse(text))
|
||||
} catch {
|
||||
parsed = null
|
||||
break
|
||||
}
|
||||
}
|
||||
if (parsed !== null) {
|
||||
const paths = new Set<string>()
|
||||
for (let i = 1; i < parsed.length; i++) for (const d of jsonDiff(parsed[0], parsed[i])) paths.add(d.path)
|
||||
return paths.size
|
||||
}
|
||||
const lines = texts.map((t) => t.split(/\r?\n/).map((l) => l.trim()))
|
||||
let most = 0
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const [a, b] = [new Set(lines[0]), new Set(lines[i])]
|
||||
let n = 0
|
||||
for (const l of a) if (!b.has(l)) n++
|
||||
for (const l of b) if (!a.has(l)) n++
|
||||
most = Math.max(most, n)
|
||||
}
|
||||
return most
|
||||
}
|
||||
|
||||
/* The matrix and the servers that did not answer, as `hubWarnings` lines. */
|
||||
export async function buildMatrix(config: HubConfig, upstream: Upstream): Promise<{ matrix: Matrix; warnings: string[] }> {
|
||||
type Copy = { cell: ServerCell; text: string | null }
|
||||
const perServer = await Promise.all(
|
||||
config.servers.map(async (server) => {
|
||||
let list: Listed[]
|
||||
try {
|
||||
const response = (await upstream.call(server, 'apps/list')) as { apps?: Listed[] } | null
|
||||
list = response?.apps ?? []
|
||||
} catch (e) {
|
||||
return { server, error: reason(e), apps: new Map<string, { listed: Listed } & Copy>() }
|
||||
}
|
||||
const entries = await Promise.all(
|
||||
list.map(async (listed): Promise<[string, { listed: Listed } & Copy]> => {
|
||||
const cell: ServerCell = {
|
||||
installed: true,
|
||||
...(listed.version === undefined ? {} : { version: listed.version }),
|
||||
...(listed.updateAvailable === undefined ? {} : { updateAvailable: listed.updateAvailable }),
|
||||
...(listed.updateVersion === undefined ? {} : { updateVersion: listed.updateVersion }),
|
||||
configStatus: 'none',
|
||||
}
|
||||
let text: string | null = null
|
||||
try {
|
||||
text = await readConfig(upstream, server, listed.name)
|
||||
if (text !== null && text !== '') {
|
||||
cell.configStatus = 'read'
|
||||
cell.configHash = hash(comparable(text, readOverrides(config.dataDir, listed.name).paths))
|
||||
}
|
||||
} catch (e) {
|
||||
cell.configStatus = 'unreadable'
|
||||
cell.configError = reason(e)
|
||||
text = null
|
||||
}
|
||||
return [listed.name, { listed, cell, text }]
|
||||
}),
|
||||
)
|
||||
return { server, error: undefined, apps: new Map(entries) }
|
||||
}),
|
||||
)
|
||||
|
||||
const servers = perServer.map((p) => (p.error === undefined
|
||||
? { id: p.server.id, ok: true, writable: p.server.adminToken !== undefined }
|
||||
: { id: p.server.id, ok: false, writable: p.server.adminToken !== undefined, error: p.error }))
|
||||
const warnings = perServer.filter((p) => p.error !== undefined).map((p) => `${p.server.id}: ${p.error}`)
|
||||
if (warnings.length === perServer.length) throw new Error(`No server answered. ${warnings.join('; ')}`.trim())
|
||||
|
||||
const names = [...new Set(perServer.flatMap((p) => [...p.apps.keys()]))].sort((a, b) => a.localeCompare(b))
|
||||
const apps: MatrixApp[] = names.map((name) => {
|
||||
const cells: Record<string, ServerCell> = {}
|
||||
const copies: { id: string; cell: ServerCell; text: string | null }[] = []
|
||||
let description = ''
|
||||
for (const p of perServer) {
|
||||
const found = p.apps.get(name)
|
||||
if (found !== undefined) {
|
||||
cells[p.server.id] = found.cell
|
||||
copies.push({ id: p.server.id, cell: found.cell, text: found.text })
|
||||
if (description === '' && found.listed.description) description = found.listed.description
|
||||
} else if (p.error !== undefined) {
|
||||
cells[p.server.id] = { installed: false, configStatus: 'unreadable', configError: `${p.server.id} did not answer` }
|
||||
} else {
|
||||
cells[p.server.id] = { installed: false, configStatus: 'none' }
|
||||
}
|
||||
}
|
||||
|
||||
const versionSet = new Set(copies.map((c) => c.cell.version ?? ''))
|
||||
const versions = copies.length < 2 ? 'single' : versionSet.size === 1 ? 'same' : 'different'
|
||||
|
||||
// Fields marked local do not count: two servers that differ only there are the same.
|
||||
const localPaths = readOverrides(config.dataDir, name).paths
|
||||
const readable = copies.filter((c) => c.cell.configStatus !== 'unreadable').map((c) => comparable(c.text ?? '', localPaths))
|
||||
const hasUnknown = copies.some((c) => c.cell.configStatus === 'unreadable') || (warnings.length > 0 && copies.length < 2)
|
||||
const differs = new Set(readable).size > 1
|
||||
const configs: MatrixApp['configs'] = differs ? 'different' : hasUnknown ? 'unknown' : copies.length < 2 ? 'single' : 'same'
|
||||
const app: MatrixApp = { name, description, servers: cells, configs, versions }
|
||||
if (differs) app.diffCount = countDifferences(readable)
|
||||
return app
|
||||
})
|
||||
|
||||
return { matrix: { servers, apps }, warnings }
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
// @vitest-environment node
|
||||
import http from 'node:http'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { parseConfig } from './config.ts'
|
||||
import { createHub } from './server.ts'
|
||||
import {
|
||||
comparable,
|
||||
diffConfigs,
|
||||
effectiveConfig,
|
||||
importLocalValues,
|
||||
lineDiff,
|
||||
parsePath,
|
||||
sameConfig,
|
||||
stripLocal,
|
||||
validatePaths,
|
||||
} from './apps-sync.ts'
|
||||
import { addUser, writeUsersFile } from './users.ts'
|
||||
|
||||
const json = (o: unknown): string => JSON.stringify(o, null, 2)
|
||||
|
||||
describe('effectiveConfig', () => {
|
||||
const baseline = json({ enable: true, db: { path: '/base.db', keep: 30 }, nets: [{ name: 'lan', cidr: '10.0.0.0/8' }, { name: 'wan' }] })
|
||||
|
||||
it('puts the server local values over the baseline, nested and in arrays', () => {
|
||||
const e = effectiveConfig(baseline, { paths: ['db.path', 'nets.0.cidr'], values: { a: { 'db.path': '/a.db', 'nets.0.cidr': '192.168.0.0/16' } } }, 'a')
|
||||
expect(JSON.parse(e.text)).toEqual({ enable: true, db: { path: '/a.db', keep: 30 }, nets: [{ name: 'lan', cidr: '192.168.0.0/16' }, { name: 'wan' }] })
|
||||
expect(e.warnings).toEqual([])
|
||||
expect(e.text).toBe(JSON.stringify(JSON.parse(e.text), null, 2))
|
||||
})
|
||||
|
||||
it('keeps the baseline value and warns when a server has no local value', () => {
|
||||
const e = effectiveConfig(baseline, { paths: ['db.path'], values: { a: {} } }, 'b')
|
||||
expect(JSON.parse(e.text).db.path).toBe('/base.db')
|
||||
expect(e.warnings[0]).toMatch(/No local value set for "db.path" on b/)
|
||||
})
|
||||
|
||||
it('leaves a field absent when it is local, not in the baseline and has no value', () => {
|
||||
const e = effectiveConfig(baseline, { paths: ['extra.deep'], values: {} }, 'a')
|
||||
expect(JSON.parse(e.text).extra).toBeUndefined()
|
||||
expect(e.warnings).toEqual([])
|
||||
})
|
||||
|
||||
it('creates the path when the local value is for a field the baseline lacks', () => {
|
||||
const e = effectiveConfig(baseline, { paths: ['extra.deep'], values: { a: { 'extra.deep': 5 } } }, 'a')
|
||||
expect(JSON.parse(e.text).extra).toEqual({ deep: 5 })
|
||||
})
|
||||
|
||||
it('uses a text baseline as it is', () => {
|
||||
const e = effectiveConfig('plain = 1\nother = 2', { paths: ['a'], values: {} }, 'a')
|
||||
expect(e.text).toBe('plain = 1\nother = 2')
|
||||
expect(e.warnings[0]).toMatch(/plain text/)
|
||||
expect(effectiveConfig('plain', { paths: [], values: {} }, 'a').warnings).toEqual([])
|
||||
})
|
||||
|
||||
it('never writes through __proto__', () => {
|
||||
const e = effectiveConfig('{}', { paths: ['__proto__.polluted'], values: { a: { '__proto__.polluted': true } } }, 'a')
|
||||
expect(({} as Record<string, unknown>).polluted).toBeUndefined()
|
||||
expect(e.warnings[0]).toMatch(/does not fit/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('paths', () => {
|
||||
it('parses dot paths and refuses odd ones', () => {
|
||||
expect(parsePath('a.b.0.c')).toEqual(['a', 'b', '0', 'c'])
|
||||
expect(parsePath('0.0.0.0/0')).toEqual(['0', '0', '0', '0/0'])
|
||||
for (const bad of ['', '.a', 'a..b', 'a b', 'a[0]', 'a,b']) expect(parsePath(bad)).toBeNull()
|
||||
expect(() => validatePaths(['a', 'a'])).toThrow(/twice/)
|
||||
expect(() => validatePaths(Array.from({ length: 51 }, (_, i) => `p${i}`))).toThrow(/50/)
|
||||
expect(() => validatePaths('a')).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('comparisons that ignore local fields', () => {
|
||||
const a = json({ shared: 1, db: { path: '/a' }, list: [1, 2] })
|
||||
const b = json({ db: { path: '/b' }, list: [1, 2], shared: 1 })
|
||||
|
||||
it('treats two configs that differ only in local fields as the same', () => {
|
||||
expect(sameConfig(a, b)).toBe(false)
|
||||
expect(sameConfig(a, b, ['db.path'])).toBe(true)
|
||||
expect(comparable(a, ['db.path'])).toBe(comparable(b, ['db.path']))
|
||||
expect(comparable(a, [])).toBe(a)
|
||||
})
|
||||
|
||||
it('removes array items from the end first', () => {
|
||||
expect(stripLocal([1, 2, 3], ['0', '2'])).toEqual([2])
|
||||
})
|
||||
|
||||
it('diffs JSON by field and leaves local fields out', () => {
|
||||
const d = diffConfigs(json({ a: 1, b: 2, c: { d: 1 }, local: 1 }), json({ a: 1, b: 3, c: { d: 1, e: 2 }, f: 1, local: 2 }), ['local'])
|
||||
expect(d).toEqual({
|
||||
kind: 'json',
|
||||
changes: [
|
||||
{ path: 'b', type: 'changed', a: 2, b: 3 },
|
||||
{ path: 'c.e', type: 'added', a: undefined, b: 2 },
|
||||
{ path: 'f', type: 'added', a: undefined, b: 1 },
|
||||
],
|
||||
})
|
||||
expect(diffConfigs(json({ x: 1 }), json({}), []).kind).toBe('json')
|
||||
expect((diffConfigs(json({ x: 1 }), json({}), []) as { changes: { type: string }[] }).changes[0].type).toBe('removed')
|
||||
})
|
||||
|
||||
it('diffs text by line', () => {
|
||||
const d = diffConfigs('one\ntwo\nthree', 'one\n2\nthree\nfour')
|
||||
expect(d.kind).toBe('text')
|
||||
expect(d).toMatchObject({ changed: 3, truncated: false })
|
||||
expect((d as { lines: { op: string; text: string }[] }).lines).toEqual([
|
||||
{ op: 'same', text: 'one' },
|
||||
{ op: 'remove', text: 'two' },
|
||||
{ op: 'add', text: '2' },
|
||||
{ op: 'same', text: 'three' },
|
||||
{ op: 'add', text: 'four' },
|
||||
])
|
||||
})
|
||||
|
||||
it('does not build a table for a huge text', () => {
|
||||
const big = Array.from({ length: 2500 }, (_, i) => `line ${i}`).join('\n')
|
||||
expect(lineDiff(big, big + '\nx')).toMatchObject({ truncated: true, lines: [] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('importLocalValues', () => {
|
||||
it('reads each server own value of each path', () => {
|
||||
const values = importLocalValues({ a: json({ db: { path: '/a' } }), b: json({ db: { path: '/b' } }), c: 'not json', d: null }, ['db.path', 'missing'])
|
||||
expect(values).toEqual({ a: { 'db.path': '/a' }, b: { 'db.path': '/b' }, c: {}, d: {} })
|
||||
})
|
||||
})
|
||||
|
||||
/* ---- With fake servers ---- */
|
||||
|
||||
interface Fake {
|
||||
server: http.Server
|
||||
url: string
|
||||
apps: Record<string, { version: string; config: string | null }>
|
||||
calls: { path: string; token: string; method: string; form: URLSearchParams }[]
|
||||
rejectSet: string | null
|
||||
mangleSet: boolean
|
||||
denyRead: boolean
|
||||
}
|
||||
|
||||
function fake(): Promise<Fake> {
|
||||
return new Promise((resolve) => {
|
||||
const f: Fake = { server: http.createServer(), url: '', apps: {}, calls: [], rejectSet: null, mangleSet: false, denyRead: false }
|
||||
f.server.on('request', (req, res) => {
|
||||
const chunks: Buffer[] = []
|
||||
req.on('data', (c: Buffer) => chunks.push(c))
|
||||
req.on('end', () => {
|
||||
const url = new URL(req.url ?? '/', 'http://fake')
|
||||
const apiPath = url.pathname.replace(/^\/api\//, '')
|
||||
const token = url.searchParams.get('token') ?? ''
|
||||
const form = new URLSearchParams(Buffer.concat(chunks).toString('utf8'))
|
||||
f.calls.push({ path: apiPath, token, method: req.method ?? '', form })
|
||||
const name = url.searchParams.get('name') ?? form.get('name') ?? ''
|
||||
res.setHeader('content-type', 'application/json')
|
||||
const ok = (response: unknown = {}) => res.end(JSON.stringify({ status: 'ok', response }))
|
||||
const error = (m: string) => res.end(JSON.stringify({ status: 'error', errorMessage: m }))
|
||||
if (apiPath === 'apps/list') return ok({ apps: Object.entries(f.apps).map(([n, a]) => ({ name: n, version: a.version })) })
|
||||
if (apiPath === 'apps/config/get') {
|
||||
if (f.denyRead && !token.startsWith('admin-')) return error('Access was denied.')
|
||||
return f.apps[name] ? ok({ config: f.apps[name].config }) : error('not found')
|
||||
}
|
||||
if (apiPath === 'apps/config/set') {
|
||||
if (!token.startsWith('admin-')) return error('Access was denied.')
|
||||
if (f.rejectSet !== null) return error(f.rejectSet)
|
||||
f.apps[name].config = f.mangleSet ? '{"mangled":true}' : form.get('config')
|
||||
return ok()
|
||||
}
|
||||
ok()
|
||||
})
|
||||
})
|
||||
f.server.listen(0, '127.0.0.1', () => {
|
||||
f.url = `http://127.0.0.1:${(f.server.address() as AddressInfo).port}`
|
||||
resolve(f)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
let a: Fake
|
||||
let b: Fake
|
||||
let dataDir: string
|
||||
let staticDir: string
|
||||
let hub: http.Server | undefined
|
||||
let base: string
|
||||
const tokens: Record<string, string> = {}
|
||||
|
||||
const CFG_A = json({ enable: true, ttl: 30, dbPath: '/data/a.db' })
|
||||
const CFG_B = json({ enable: true, ttl: 60, dbPath: '/data/b.db' })
|
||||
|
||||
function reset(): void {
|
||||
a.calls = []
|
||||
b.calls = []
|
||||
for (const f of [a, b]) {
|
||||
f.rejectSet = null
|
||||
f.mangleSet = false
|
||||
f.denyRead = false
|
||||
}
|
||||
a.apps = { Blocker: { version: '1.0', config: CFG_A } }
|
||||
b.apps = { Blocker: { version: '1.0', config: CFG_B } }
|
||||
}
|
||||
|
||||
async function start(extra: Record<string, string> = {}): Promise<void> {
|
||||
hub?.close()
|
||||
hub = createHub(
|
||||
parseConfig({
|
||||
HUB_SERVERS: `a=${a.url},b=${b.url}`,
|
||||
A_TOKEN: 'read-a',
|
||||
B_TOKEN: 'read-b',
|
||||
A_ADMIN_TOKEN: 'admin-a',
|
||||
B_ADMIN_TOKEN: 'admin-b',
|
||||
HUB_DATA: dataDir,
|
||||
HUB_STATIC: staticDir,
|
||||
HUB_CACHE_SECONDS: '0',
|
||||
HUB_TIMEOUT_MS: '2000',
|
||||
...extra,
|
||||
}),
|
||||
)
|
||||
await new Promise<void>((resolve) => hub!.listen(0, '127.0.0.1', resolve))
|
||||
base = `http://127.0.0.1:${(hub.address() as AddressInfo).port}`
|
||||
for (const role of ['viewer', 'apps-admin', 'admin']) {
|
||||
const res = await fetch(`${base}/api/user/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: `user=${role}&pass=password-${role}`,
|
||||
})
|
||||
tokens[role] = ((await res.json()) as { token: string }).token
|
||||
}
|
||||
}
|
||||
|
||||
async function api(role: string, apiPath: string, init: { params?: Record<string, string>; json?: unknown; method?: string } = {}) {
|
||||
const headers: Record<string, string> = { authorization: `Bearer ${tokens[role]}` }
|
||||
let body: string | undefined
|
||||
if (init.json !== undefined) {
|
||||
headers['content-type'] = 'application/json'
|
||||
body = JSON.stringify(init.json)
|
||||
}
|
||||
const res = await fetch(`${base}/api/${apiPath}?${new URLSearchParams(init.params ?? {})}`, { method: init.method ?? (body === undefined ? 'GET' : 'POST'), headers, body })
|
||||
return { code: res.status, body: (await res.json()) as Record<string, any> }
|
||||
}
|
||||
|
||||
const dirOf = () => path.join(dataDir, 'apps', 'Blocker')
|
||||
const history = () => readdirSync(path.join(dirOf(), 'history'))
|
||||
const writes = (f: Fake) => f.calls.filter((c) => c.path === 'apps/config/set')
|
||||
|
||||
beforeAll(async () => {
|
||||
a = await fake()
|
||||
b = await fake()
|
||||
dataDir = mkdtempSync(path.join(tmpdir(), 'hub-sync-data-'))
|
||||
staticDir = mkdtempSync(path.join(tmpdir(), 'hub-sync-static-'))
|
||||
let users = addUser([], 'viewer', 'viewer', 'password-viewer')
|
||||
users = addUser(users, 'apps-admin', 'apps-admin', 'password-apps-admin')
|
||||
users = addUser(users, 'admin', 'admin', 'password-admin')
|
||||
writeUsersFile(dataDir, users)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
hub?.close()
|
||||
a.server.close()
|
||||
b.server.close()
|
||||
rmSync(dataDir, { recursive: true, force: true })
|
||||
rmSync(staticDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
reset()
|
||||
rmSync(path.join(dataDir, 'apps'), { recursive: true, force: true })
|
||||
rmSync(path.join(dataDir, 'audit.log'), { force: true })
|
||||
await start()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
hub?.close()
|
||||
})
|
||||
|
||||
const importFrom = (server: string) => api('apps-admin', 'hub/apps/baseline/import', { json: { name: 'Blocker', server } })
|
||||
const saveOverrides = (paths: string[], values: Record<string, Record<string, unknown>>) => api('apps-admin', 'hub/apps/overrides/save', { json: { name: 'Blocker', paths, values } })
|
||||
|
||||
describe('baseline and local fields', () => {
|
||||
it('has no baseline at first, and compare shows each server live config', async () => {
|
||||
const r = await api('viewer', 'hub/apps/config/compare', { params: { name: 'Blocker' } })
|
||||
expect(r.body.response.baseline).toBeNull()
|
||||
const [sa, sb] = r.body.response.servers
|
||||
expect(sa).toMatchObject({ id: 'a', installed: true, writable: true, version: '1.0', inSync: null, text: CFG_A })
|
||||
expect(sb.text).toBe(CFG_B)
|
||||
expect(sa.vsOthers.b.changes.map((c: any) => c.path)).toEqual(['ttl', 'dbPath'])
|
||||
})
|
||||
|
||||
it('imports a baseline from a server and stores it as text', async () => {
|
||||
const r = await importFrom('a')
|
||||
expect(r.body.response.baseline).toMatchObject({ app: 'Blocker', text: CFG_A, importedFrom: 'a', updatedBy: 'apps-admin' })
|
||||
const file = JSON.parse(readFileSync(path.join(dirOf(), 'baseline.json'), 'utf8'))
|
||||
expect(file.text).toBe(CFG_A)
|
||||
const cmp = (await api('viewer', 'hub/apps/config/compare', { params: { name: 'Blocker' } })).body.response
|
||||
expect(cmp.servers[0].inSync).toBe(true)
|
||||
expect(cmp.servers[1].inSync).toBe(false)
|
||||
expect(cmp.servers[1].vsBaseline.changes.map((c: any) => c.path)).toEqual(['ttl', 'dbPath'])
|
||||
})
|
||||
|
||||
it('refuses an import from a server with no config', async () => {
|
||||
a.apps.Blocker.config = null
|
||||
const r = await importFrom('a')
|
||||
expect(r.code).toBe(400)
|
||||
expect(r.body.errorMessage).toMatch(/no config/)
|
||||
})
|
||||
|
||||
it('validates a JSON baseline and reports the parse error', async () => {
|
||||
const bad = await api('apps-admin', 'hub/apps/baseline/save', { json: { name: 'Blocker', text: '{ "a": 1,, }' } })
|
||||
expect(bad.code).toBe(400)
|
||||
expect(bad.body.errorMessage).toMatch(/not valid JSON: .*position/)
|
||||
const text = await api('apps-admin', 'hub/apps/baseline/save', { json: { name: 'Blocker', text: 'plain = text' } })
|
||||
expect(text.body.response.baseline.text).toBe('plain = text')
|
||||
})
|
||||
|
||||
it('limits the baseline to 1 MB', async () => {
|
||||
const r = await api('apps-admin', 'hub/apps/baseline/save', { json: { name: 'Blocker', text: 'x'.repeat(1024 * 1024 + 1) } })
|
||||
expect(r.code).toBe(400)
|
||||
expect(r.body.errorMessage).toMatch(/1 MB/)
|
||||
})
|
||||
|
||||
it('saves local fields, rejects bad paths and a text baseline', async () => {
|
||||
await importFrom('a')
|
||||
const ok = await saveOverrides(['dbPath'], { a: { dbPath: '/data/a.db' }, b: { dbPath: '/data/b.db' } })
|
||||
expect(ok.body.response.overrides).toEqual({ paths: ['dbPath'], values: { a: { dbPath: '/data/a.db' }, b: { dbPath: '/data/b.db' } } })
|
||||
expect((await saveOverrides(['a b'], {})).code).toBe(400)
|
||||
expect((await saveOverrides(['dbPath'], { zzz: {} })).code).toBe(400)
|
||||
await api('apps-admin', 'hub/apps/baseline/save', { json: { name: 'Blocker', text: 'plain' } })
|
||||
const refused = await saveOverrides(['dbPath'], {})
|
||||
expect(refused.body.errorMessage).toMatch(/plain text/)
|
||||
})
|
||||
|
||||
it('fills local values from the current server configs', async () => {
|
||||
const r = await api('apps-admin', 'hub/apps/overrides/current', { json: { name: 'Blocker', paths: ['dbPath', 'ttl'] } })
|
||||
expect(r.body.response.values).toEqual({ a: { dbPath: '/data/a.db', ttl: 30 }, b: { dbPath: '/data/b.db', ttl: 60 } })
|
||||
})
|
||||
|
||||
it('makes the matrix say Same when servers differ only in local fields', async () => {
|
||||
const before = (await api('viewer', 'hub/apps/matrix')).body.response.apps[0]
|
||||
expect(before.configs).toBe('different')
|
||||
expect(before.diffCount).toBe(2)
|
||||
await saveOverrides(['ttl', 'dbPath'], {})
|
||||
const after = (await api('viewer', 'hub/apps/matrix')).body.response.apps[0]
|
||||
expect(after.configs).toBe('same')
|
||||
expect(after.servers.a.configHash).toBe(after.servers.b.configHash)
|
||||
await saveOverrides(['dbPath'], {})
|
||||
const some = (await api('viewer', 'hub/apps/matrix')).body.response.apps[0]
|
||||
expect(some).toMatchObject({ configs: 'different', diffCount: 1 })
|
||||
})
|
||||
|
||||
it('refuses writers-only calls to a viewer', async () => {
|
||||
for (const [p, json] of [
|
||||
['hub/apps/baseline/import', { name: 'Blocker', server: 'a' }],
|
||||
['hub/apps/baseline/save', { name: 'Blocker', text: '{}' }],
|
||||
['hub/apps/overrides/save', { name: 'Blocker', paths: [], values: {} }],
|
||||
['hub/apps/config/preview', { name: 'Blocker', targets: ['a'] }],
|
||||
['hub/apps/config/apply', { name: 'Blocker', targets: ['a'] }],
|
||||
] as const) {
|
||||
const r = await api('viewer', p, { json })
|
||||
expect([p, r.code]).toEqual([p, 403])
|
||||
}
|
||||
expect(writes(a)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('preview and apply', () => {
|
||||
async function setup() {
|
||||
await importFrom('a')
|
||||
await saveOverrides(['dbPath'], { a: { dbPath: '/data/a.db' }, b: { dbPath: '/data/b.db' } })
|
||||
}
|
||||
|
||||
it('previews what would change on each target, with the local values in place', async () => {
|
||||
await setup()
|
||||
const r = await api('apps-admin', 'hub/apps/config/preview', { json: { name: 'Blocker', targets: ['a', 'b'] } })
|
||||
const [pa, pb] = r.body.response.targets
|
||||
expect(pa).toMatchObject({ server: 'a', ok: true, unchanged: true, writable: true })
|
||||
expect(pb).toMatchObject({ server: 'b', ok: true, unchanged: false, current: CFG_B })
|
||||
expect(pb.changes.changes.map((c: any) => [c.path, c.a, c.b])).toEqual([['ttl', 60, 30]])
|
||||
expect(JSON.parse(pb.effective)).toEqual({ enable: true, ttl: 30, dbPath: '/data/b.db' })
|
||||
expect(pb.currentHash).toMatch(/^[0-9a-f]{16}$/)
|
||||
})
|
||||
|
||||
it('warns about a missing local value, a version difference and a server without a token', async () => {
|
||||
await importFrom('a')
|
||||
await saveOverrides(['dbPath'], { a: { dbPath: '/data/a.db' } })
|
||||
b.apps.Blocker.version = '0.9'
|
||||
await start({ B_ADMIN_TOKEN: '' })
|
||||
const r = await api('apps-admin', 'hub/apps/config/preview', { json: { name: 'Blocker', targets: ['b'] } })
|
||||
const warnings: string[] = r.body.response.targets[0].warnings
|
||||
expect(r.body.response.targets[0].writable).toBe(false)
|
||||
expect(warnings.some((w) => /No local value set for "dbPath" on b/.test(w))).toBe(true)
|
||||
expect(warnings.some((w) => /align versions first/.test(w))).toBe(true)
|
||||
expect(warnings.some((w) => /no admin token/.test(w))).toBe(true)
|
||||
})
|
||||
|
||||
it('needs a baseline', async () => {
|
||||
const r = await api('apps-admin', 'hub/apps/config/preview', { json: { name: 'Blocker', targets: ['a'] } })
|
||||
expect(r.code).toBe(400)
|
||||
expect(r.body.errorMessage).toMatch(/no baseline/)
|
||||
})
|
||||
|
||||
it('applies: saves the old config, writes with the admin token, verifies', async () => {
|
||||
await setup()
|
||||
const preview = (await api('apps-admin', 'hub/apps/config/preview', { json: { name: 'Blocker', targets: ['a', 'b'] } })).body.response.targets
|
||||
const expectHashes = Object.fromEntries(preview.map((t: any) => [t.server, t.currentHash]))
|
||||
const r = await api('apps-admin', 'hub/apps/config/apply', { json: { name: 'Blocker', targets: ['a', 'b'], expect: expectHashes } })
|
||||
const results = r.body.response.results
|
||||
expect(results.find((x: any) => x.server === 'a')).toMatchObject({ ok: true, message: expect.stringMatching(/Already in sync/) })
|
||||
expect(results.find((x: any) => x.server === 'b')).toEqual({ server: 'b', ok: true, message: 'Applied and verified.' })
|
||||
expect(JSON.parse(b.apps.Blocker.config as string)).toEqual({ enable: true, ttl: 30, dbPath: '/data/b.db' })
|
||||
expect(writes(a)).toHaveLength(0)
|
||||
const [w] = writes(b)
|
||||
expect(w.token).toBe('admin-b')
|
||||
expect(w.method).toBe('POST')
|
||||
expect(w.form.get('name')).toBe('Blocker')
|
||||
const files = history()
|
||||
expect(files).toHaveLength(1)
|
||||
expect(files[0]).toMatch(/__b__before-apply\.txt$/)
|
||||
expect(readFileSync(path.join(dirOf(), 'history', files[0]), 'utf8')).toBe(CFG_B)
|
||||
expect((await api('viewer', 'hub/apps/matrix')).body.response.apps[0].configs).toBe('same')
|
||||
})
|
||||
|
||||
it('refuses a target whose config changed since the preview', async () => {
|
||||
await setup()
|
||||
const preview = (await api('apps-admin', 'hub/apps/config/preview', { json: { name: 'Blocker', targets: ['b'] } })).body.response.targets
|
||||
b.apps.Blocker.config = json({ enable: false })
|
||||
const r = await api('apps-admin', 'hub/apps/config/apply', { json: { name: 'Blocker', targets: ['b'], expect: { b: preview[0].currentHash } } })
|
||||
expect(r.body.response.results[0]).toMatchObject({ server: 'b', ok: false })
|
||||
expect(r.body.response.results[0].message).toMatch(/changed on the server since the preview/)
|
||||
expect(writes(b)).toHaveLength(0)
|
||||
expect(b.apps.Blocker.config).toBe(json({ enable: false }))
|
||||
})
|
||||
|
||||
it('reports a server that refuses the config and leaves it as it was', async () => {
|
||||
await setup()
|
||||
b.rejectSet = 'The config is invalid.'
|
||||
const r = await api('apps-admin', 'hub/apps/config/apply', { json: { name: 'Blocker', targets: ['b'] } })
|
||||
expect(r.body.response.results[0]).toEqual({ server: 'b', ok: false, message: 'The config is invalid.' })
|
||||
expect(b.apps.Blocker.config).toBe(CFG_B)
|
||||
})
|
||||
|
||||
it('handles one target failing and the other succeeding', async () => {
|
||||
await setup()
|
||||
a.apps.Blocker.config = json({ enable: false })
|
||||
b.rejectSet = 'Disk full.'
|
||||
const r = await api('admin', 'hub/apps/config/apply', { json: { name: 'Blocker', targets: ['a', 'b'] } })
|
||||
expect(r.body.response.results.map((x: any) => [x.server, x.ok])).toEqual([['a', true], ['b', false]])
|
||||
expect(JSON.parse(a.apps.Blocker.config as string).ttl).toBe(30)
|
||||
})
|
||||
|
||||
it('says so when the server reads back something else', async () => {
|
||||
await setup()
|
||||
b.mangleSet = true
|
||||
const r = await api('apps-admin', 'hub/apps/config/apply', { json: { name: 'Blocker', targets: ['b'] } })
|
||||
expect(r.body.response.results[0].ok).toBe(false)
|
||||
expect(r.body.response.results[0].message).toMatch(/reads back a different one/)
|
||||
})
|
||||
|
||||
it('does not write to a server without an admin token', async () => {
|
||||
await setup()
|
||||
await start({ B_ADMIN_TOKEN: '' })
|
||||
const r = await api('apps-admin', 'hub/apps/config/apply', { json: { name: 'Blocker', targets: ['b'] } })
|
||||
expect(r.body.response.results[0]).toEqual({ server: 'b', ok: false, message: 'no admin token configured' })
|
||||
expect(writes(b)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps config texts out of the audit log', async () => {
|
||||
await setup()
|
||||
await api('apps-admin', 'hub/apps/baseline/save', { json: { name: 'Blocker', text: '{ "bad": ,, "secret-baseline-text": 1 }' } })
|
||||
await api('apps-admin', 'hub/apps/config/apply', { json: { name: 'Blocker', targets: ['a', 'b'] } })
|
||||
const raw = readFileSync(path.join(dataDir, 'audit.log'), 'utf8')
|
||||
for (const text of ['/data/a.db', '/data/b.db', 'secret-baseline-text', 'admin-a', 'admin-b']) expect(raw).not.toContain(text)
|
||||
const entries = (await api('admin', 'hub/audit/list', { params: { limit: '50' } })).body.response.entries as Record<string, string>[]
|
||||
const applied = entries.filter((e) => e.action === 'apps.config-apply')
|
||||
expect(applied).toHaveLength(2)
|
||||
expect(applied.find((e) => e.server === 'b')).toMatchObject({ user: 'apps-admin', target: 'Blocker', outcome: 'ok' })
|
||||
expect(applied.find((e) => e.server === 'b')?.detail).toMatch(/[0-9a-f]{16} to [0-9a-f]{16}/)
|
||||
expect(entries.some((e) => e.action === 'apps.baseline-import' && e.outcome === 'ok')).toBe(true)
|
||||
expect(entries.some((e) => e.action === 'apps.baseline-save' && e.outcome === 'refused' && e.detail === 'invalid baseline')).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps at most 50 history files', async () => {
|
||||
await setup()
|
||||
mkdirSync(path.join(dirOf(), 'history'), { recursive: true })
|
||||
for (let i = 0; i < 55; i++) writeFileSync(path.join(dirOf(), 'history', `2020-01-01T00-00-${String(i).padStart(2, '0')}.000Z__b__before-apply.txt`), 'old')
|
||||
await api('apps-admin', 'hub/apps/config/apply', { json: { name: 'Blocker', targets: ['b'] } })
|
||||
expect(history()).toHaveLength(50)
|
||||
})
|
||||
})
|
||||
|
||||
describe('single-server config', () => {
|
||||
it('reads a config from one node with the read token and needs a node', async () => {
|
||||
const r = await api('viewer', 'apps/config/get', { params: { node: 'a', name: 'Blocker' } })
|
||||
expect(r.body.response.config).toBe(CFG_A)
|
||||
expect(a.calls.at(-1)?.token).toBe('read-a')
|
||||
expect((await api('viewer', 'apps/config/get', { params: { name: 'Blocker' } })).code).toBe(400)
|
||||
expect((await api('viewer', 'apps/config/get', { params: { node: 'cluster', name: 'Blocker' } })).code).toBe(400)
|
||||
})
|
||||
|
||||
it('sets a config on one node with the admin token, saving the old one first', async () => {
|
||||
const r = await api('apps-admin', 'apps/config/set', {
|
||||
params: { node: 'b' },
|
||||
method: 'POST',
|
||||
json: { name: 'Blocker', config: '{"enable":false}' },
|
||||
})
|
||||
expect(r.body.status).toBe('ok')
|
||||
const [w] = writes(b)
|
||||
expect(w.token).toBe('admin-b')
|
||||
expect(b.apps.Blocker.config).toBe('{"enable":false}')
|
||||
expect(history()).toHaveLength(1)
|
||||
expect(readFileSync(path.join(dirOf(), 'history', history()[0]), 'utf8')).toBe(CFG_B)
|
||||
const raw = readFileSync(path.join(dataDir, 'audit.log'), 'utf8')
|
||||
expect(raw).toContain('apps.config-set')
|
||||
expect(raw).not.toContain('"enable":false')
|
||||
})
|
||||
|
||||
it('refuses the write without a node, to a viewer, and when the old config cannot be saved', async () => {
|
||||
expect((await api('apps-admin', 'apps/config/set', { json: { name: 'Blocker', config: '{}' } })).code).toBe(400)
|
||||
expect((await api('viewer', 'apps/config/set', { params: { node: 'a' }, json: { name: 'Blocker', config: '{}' } })).code).toBe(403)
|
||||
a.denyRead = true
|
||||
a.apps.Blocker.config = 'unchanged'
|
||||
// The admin token can still read, so make the read fail by removing the app.
|
||||
delete a.apps.Blocker
|
||||
const r = await api('apps-admin', 'apps/config/set', { params: { node: 'a' }, json: { name: 'Blocker', config: '{}' } })
|
||||
expect(r.body.status).toBe('error')
|
||||
expect(r.body.errorMessage).toMatch(/nothing was changed/)
|
||||
expect(writes(a)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,304 @@
|
||||
/*
|
||||
Pure functions behind the config sync: the effective config of a server (the baseline plus
|
||||
that server's local values), comparisons that leave the local fields out, and the diffs the
|
||||
console shows. No I/O here.
|
||||
*/
|
||||
import { createHash } from 'node:crypto'
|
||||
import { jsonDiff } from './json-diff.ts'
|
||||
import type { Overrides } from './store.ts'
|
||||
|
||||
export const MAX_CONFIG = 1024 * 1024
|
||||
export const MAX_PATHS = 50
|
||||
const MAX_LINES = 2000
|
||||
|
||||
type Json = unknown
|
||||
|
||||
export type Parsed = { ok: true; value: Json } | { ok: false; error: string }
|
||||
|
||||
export function parseJson(text: string): Parsed {
|
||||
try {
|
||||
return { ok: true, value: JSON.parse(text) }
|
||||
} catch (e) {
|
||||
return { ok: false, error: e instanceof Error ? e.message : 'invalid JSON' }
|
||||
}
|
||||
}
|
||||
|
||||
/** A config that starts like JSON must be JSON; anything else is treated as plain text. */
|
||||
export const looksLikeJson = (text: string): boolean => /^\s*[{[]/.test(text)
|
||||
|
||||
/* ---- Paths: `a.b.0.c`; a numeric segment indexes an array. ---- */
|
||||
|
||||
const SEGMENT = /^[^.\s,[\]]+$/
|
||||
|
||||
export function parsePath(path: string): string[] | null {
|
||||
if (path.length === 0 || path.length > 200) return null
|
||||
const segments = path.split('.')
|
||||
return segments.every((s) => SEGMENT.test(s)) ? segments : null
|
||||
}
|
||||
|
||||
export function validatePaths(paths: unknown): string[] {
|
||||
if (!Array.isArray(paths)) throw new Error('paths must be a list.')
|
||||
if (paths.length > MAX_PATHS) throw new Error(`At most ${MAX_PATHS} local fields.`)
|
||||
const seen = new Set<string>()
|
||||
for (const p of paths) {
|
||||
if (typeof p !== 'string' || parsePath(p) === null) throw new Error(`"${String(p)}" is not a field path such as a.b.0.c.`)
|
||||
if (seen.has(p)) throw new Error(`"${p}" is listed twice.`)
|
||||
seen.add(p)
|
||||
}
|
||||
return paths as string[]
|
||||
}
|
||||
|
||||
const isContainer = (v: Json): v is Record<string, Json> | Json[] => v !== null && typeof v === 'object'
|
||||
const isIndex = (s: string): boolean => /^\d+$/.test(s)
|
||||
|
||||
export function getAt(root: Json, segments: string[]): { found: boolean; value?: Json } {
|
||||
let at: Json = root
|
||||
for (const s of segments) {
|
||||
if (!isContainer(at)) return { found: false }
|
||||
if (Array.isArray(at)) {
|
||||
if (!isIndex(s) || Number(s) >= at.length) return { found: false }
|
||||
at = at[Number(s)]
|
||||
} else {
|
||||
if (!Object.hasOwn(at, s)) return { found: false }
|
||||
at = at[s]
|
||||
}
|
||||
}
|
||||
return { found: true, value: at }
|
||||
}
|
||||
|
||||
/* Sets a value, making the containers on the way when they are missing. Returns false if the path cannot be set. */
|
||||
export function setAt(root: Json, segments: string[], value: Json): boolean {
|
||||
let at: Json = root
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const s = segments[i]
|
||||
const last = i === segments.length - 1
|
||||
if (!isContainer(at)) return false
|
||||
if (Array.isArray(at)) {
|
||||
if (!isIndex(s)) return false
|
||||
const index = Number(s)
|
||||
if (index > at.length) return false
|
||||
if (last) at[index] = value
|
||||
else {
|
||||
if (at[index] === undefined) at[index] = isIndex(segments[i + 1]) ? [] : {}
|
||||
at = at[index]
|
||||
}
|
||||
} else {
|
||||
if (s === '__proto__') return false
|
||||
if (last) at[s] = value
|
||||
else {
|
||||
if (!Object.hasOwn(at, s) || !isContainer(at[s])) at[s] = isIndex(segments[i + 1]) ? [] : {}
|
||||
at = at[s]
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function removeAt(root: Json, segments: string[]): void {
|
||||
const parent = getAt(root, segments.slice(0, -1))
|
||||
const last = segments[segments.length - 1]
|
||||
if (!parent.found || !isContainer(parent.value)) return
|
||||
if (Array.isArray(parent.value)) {
|
||||
if (isIndex(last) && Number(last) < parent.value.length) parent.value.splice(Number(last), 1)
|
||||
} else delete parent.value[last]
|
||||
}
|
||||
|
||||
const clone = <T>(value: T): T => structuredClone(value)
|
||||
|
||||
/* A copy of the value without the local fields. Removing array items shifts the rest, so the deepest and highest go first. */
|
||||
export function stripLocal(value: Json, paths: string[]): Json {
|
||||
const copy = clone(value)
|
||||
const sorted = paths
|
||||
.map((p) => parsePath(p))
|
||||
.filter((s): s is string[] => s !== null)
|
||||
.sort((a, b) => b.length - a.length || b.join('.').localeCompare(a.join('.'), undefined, { numeric: true }))
|
||||
for (const segments of sorted) removeAt(copy, segments)
|
||||
return copy
|
||||
}
|
||||
|
||||
function sortKeys(value: Json): Json {
|
||||
if (Array.isArray(value)) return value.map(sortKeys)
|
||||
if (isContainer(value)) return Object.fromEntries(Object.keys(value).sort().map((k) => [k, sortKeys((value as Record<string, Json>)[k])]))
|
||||
return value
|
||||
}
|
||||
|
||||
/*
|
||||
A string two configs can be compared by: for JSON, the config without the local fields with its keys
|
||||
sorted; for anything else, the text. Without local fields it is the text itself, as before.
|
||||
*/
|
||||
export function comparable(text: string, paths: string[]): string {
|
||||
if (paths.length === 0) return text
|
||||
const parsed = parseJson(text)
|
||||
if (!parsed.ok) return text
|
||||
return JSON.stringify(sortKeys(stripLocal(parsed.value, paths)))
|
||||
}
|
||||
|
||||
export const textHash = (text: string): string => createHash('sha256').update(text).digest('hex').slice(0, 16)
|
||||
|
||||
/* ---- The effective config of one server ---- */
|
||||
|
||||
export interface Effective {
|
||||
text: string
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
export function effectiveConfig(baselineText: string, overrides: Overrides, server: string): Effective {
|
||||
const parsed = parseJson(baselineText)
|
||||
if (!parsed.ok || !looksLikeJson(baselineText)) {
|
||||
const warnings = overrides.paths.length > 0 ? ['The baseline is plain text, so local fields cannot be applied; the baseline is used as it is.'] : []
|
||||
return { text: baselineText, warnings }
|
||||
}
|
||||
const value = clone(parsed.value)
|
||||
const warnings: string[] = []
|
||||
const mine = overrides.values[server] ?? {}
|
||||
for (const path of overrides.paths) {
|
||||
const segments = parsePath(path)
|
||||
if (segments === null) continue
|
||||
if (Object.hasOwn(mine, path)) {
|
||||
if (!setAt(value, segments, clone(mine[path]))) warnings.push(`Local field "${path}" does not fit the baseline structure and was skipped.`)
|
||||
} else if (getAt(value, segments).found) {
|
||||
warnings.push(`No local value set for "${path}" on ${server}; the baseline value is used.`)
|
||||
}
|
||||
// No local value and not in the baseline: nothing to remove, the field stays absent.
|
||||
}
|
||||
return { text: JSON.stringify(value, null, 2), warnings }
|
||||
}
|
||||
|
||||
/* The same config? JSON is compared by meaning (key order and spacing do not count), anything else by text. */
|
||||
export function sameConfig(a: string, b: string, paths: string[] = []): boolean {
|
||||
const [pa, pb] = [parseJson(a), parseJson(b)]
|
||||
if (pa.ok && pb.ok && looksLikeJson(a) && looksLikeJson(b)) return jsonDiff(stripLocal(pa.value, paths), stripLocal(pb.value, paths)).length === 0
|
||||
return a.replace(/\r\n/g, '\n').trimEnd() === b.replace(/\r\n/g, '\n').trimEnd()
|
||||
}
|
||||
|
||||
/* ---- Diffs ---- */
|
||||
|
||||
export interface FieldChange {
|
||||
path: string
|
||||
type: 'added' | 'removed' | 'changed'
|
||||
a?: Json
|
||||
b?: Json
|
||||
}
|
||||
|
||||
export interface LineOp {
|
||||
op: 'same' | 'add' | 'remove'
|
||||
text: string
|
||||
}
|
||||
|
||||
export type Diff =
|
||||
| { kind: 'json'; changes: FieldChange[] }
|
||||
| { kind: 'text'; lines: LineOp[]; truncated: boolean; changed: number }
|
||||
|
||||
/* A to B: `added` means B has it and A does not. Local fields are left out of both. */
|
||||
export function diffConfigs(a: string, b: string, localPaths: string[] = []): Diff {
|
||||
const [pa, pb] = [parseJson(a), parseJson(b)]
|
||||
if (pa.ok && pb.ok && looksLikeJson(a) && looksLikeJson(b)) {
|
||||
const changes = jsonDiff(stripLocal(pa.value, localPaths), stripLocal(pb.value, localPaths)).map((d): FieldChange => {
|
||||
const type = d.a === undefined ? 'added' : d.b === undefined ? 'removed' : 'changed'
|
||||
return { path: d.path === '' ? '(whole config)' : d.path, type, a: d.a, b: d.b }
|
||||
})
|
||||
return { kind: 'json', changes }
|
||||
}
|
||||
return { kind: 'text', ...lineDiff(a, b) }
|
||||
}
|
||||
|
||||
export function lineDiff(a: string, b: string): { lines: LineOp[]; truncated: boolean; changed: number } {
|
||||
const [x, y] = [a.replace(/\r\n/g, '\n').split('\n'), b.replace(/\r\n/g, '\n').split('\n')]
|
||||
if (x.length > MAX_LINES || y.length > MAX_LINES) {
|
||||
const set = new Set(x)
|
||||
const changed = y.filter((l) => !set.has(l)).length + x.filter((l) => !new Set(y).has(l)).length
|
||||
return { lines: [], truncated: true, changed }
|
||||
}
|
||||
// Longest common subsequence table, filled from the end.
|
||||
const w = y.length + 1
|
||||
const table = new Uint16Array((x.length + 1) * w)
|
||||
for (let i = x.length - 1; i >= 0; i--) {
|
||||
for (let j = y.length - 1; j >= 0; j--) {
|
||||
table[i * w + j] = x[i] === y[j] ? table[(i + 1) * w + j + 1] + 1 : Math.max(table[(i + 1) * w + j], table[i * w + j + 1])
|
||||
}
|
||||
}
|
||||
const lines: LineOp[] = []
|
||||
let i = 0
|
||||
let j = 0
|
||||
while (i < x.length || j < y.length) {
|
||||
if (i < x.length && j < y.length && x[i] === y[j]) {
|
||||
lines.push({ op: 'same', text: x[i] })
|
||||
i++
|
||||
j++
|
||||
} else if (j < y.length && (i === x.length || table[i * w + j + 1] > table[(i + 1) * w + j])) {
|
||||
lines.push({ op: 'add', text: y[j++] })
|
||||
} else lines.push({ op: 'remove', text: x[i++] })
|
||||
}
|
||||
return { lines, truncated: false, changed: lines.filter((l) => l.op !== 'same').length }
|
||||
}
|
||||
|
||||
export const diffIsEmpty = (d: Diff): boolean => (d.kind === 'json' ? d.changes.length === 0 : d.changed === 0)
|
||||
|
||||
/* ---- Local values ---- */
|
||||
|
||||
/*
|
||||
Fills each server's current value of each local path from its live config. A server whose config is
|
||||
not JSON, or that has no such field, gets no entry for it.
|
||||
*/
|
||||
export function importLocalValues(configs: Record<string, string | null>, paths: string[]): Overrides['values'] {
|
||||
const out: Overrides['values'] = {}
|
||||
for (const [server, text] of Object.entries(configs)) {
|
||||
out[server] = {}
|
||||
if (text === null) continue
|
||||
const parsed = parseJson(text)
|
||||
if (!parsed.ok) continue
|
||||
for (const path of paths) {
|
||||
const segments = parsePath(path)
|
||||
if (segments === null) continue
|
||||
const found = getAt(parsed.value, segments)
|
||||
if (found.found) out[server][path] = clone(found.value)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/*
|
||||
"Accept as baseline": the baseline becomes this server's config, except that the local fields keep
|
||||
the baseline's old values, and this server's own values for them move into the overrides.
|
||||
*/
|
||||
export function acceptServerConfig(
|
||||
serverText: string,
|
||||
oldBaselineText: string | null,
|
||||
overrides: Overrides,
|
||||
server: string,
|
||||
): { baselineText: string; overrides: Overrides } {
|
||||
if (overrides.paths.length === 0) return { baselineText: serverText, overrides }
|
||||
const parsed = parseJson(serverText)
|
||||
if (!parsed.ok || !looksLikeJson(serverText)) throw new Error('The config is plain text, so its local fields cannot be separated out.')
|
||||
const value = clone(parsed.value)
|
||||
const old = oldBaselineText === null ? null : parseJson(oldBaselineText)
|
||||
const mine: Record<string, Json> = {}
|
||||
for (const path of overrides.paths) {
|
||||
const segments = parsePath(path)
|
||||
if (segments === null) continue
|
||||
const here = getAt(value, segments)
|
||||
if (here.found) mine[path] = clone(here.value)
|
||||
const before = old !== null && old.ok ? getAt(old.value, segments) : { found: false }
|
||||
if (before.found) setAt(value, segments, clone(before.value))
|
||||
else removeAt(value, segments)
|
||||
}
|
||||
return {
|
||||
baselineText: JSON.stringify(value, null, 2),
|
||||
overrides: { paths: overrides.paths, values: { ...overrides.values, [server]: { ...(overrides.values[server] ?? {}), ...mine } } },
|
||||
}
|
||||
}
|
||||
|
||||
/* Local values for paths that are listed, from servers that exist; anything else is dropped. */
|
||||
export function cleanValues(values: unknown, paths: string[], servers: string[]): Overrides['values'] {
|
||||
if (values === null || typeof values !== 'object' || Array.isArray(values)) throw new Error('values must be an object of servers.')
|
||||
const out: Overrides['values'] = {}
|
||||
for (const [server, mine] of Object.entries(values as Record<string, unknown>)) {
|
||||
if (!servers.includes(server)) throw new Error(`Unknown server: ${server}.`)
|
||||
if (mine === null || typeof mine !== 'object' || Array.isArray(mine)) throw new Error(`The values of ${server} must be an object of paths.`)
|
||||
out[server] = {}
|
||||
for (const [path, value] of Object.entries(mine as Record<string, unknown>)) {
|
||||
if (paths.includes(path)) out[server][path] = value
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
// @vitest-environment node
|
||||
import http from 'node:http'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { parseConfig } from './config.ts'
|
||||
import { createHub } from './server.ts'
|
||||
import { boundaryOf, parseMultipart } from './multipart.ts'
|
||||
import { safeName } from './apps-write.ts'
|
||||
import { addUser, writeUsersFile } from './users.ts'
|
||||
|
||||
interface Call {
|
||||
path: string
|
||||
token: string
|
||||
method: string
|
||||
query: URLSearchParams
|
||||
body: Buffer
|
||||
contentType: string
|
||||
}
|
||||
|
||||
interface Fake {
|
||||
server: http.Server
|
||||
url: string
|
||||
apps: Record<string, { version: string; config: string | null }>
|
||||
store: { name: string; version: string; url: string; description: string; size: string }[]
|
||||
calls: Call[]
|
||||
failWrites: boolean
|
||||
denyConfig: boolean
|
||||
}
|
||||
|
||||
function fake(): Promise<Fake> {
|
||||
return new Promise((resolve) => {
|
||||
const f: Fake = { server: http.createServer(), url: '', apps: {}, store: [], calls: [], failWrites: false, denyConfig: false }
|
||||
f.server.on('request', (req, res) => {
|
||||
const chunks: Buffer[] = []
|
||||
req.on('data', (c: Buffer) => chunks.push(c))
|
||||
req.on('end', () => {
|
||||
const url = new URL(req.url ?? '/', 'http://fake')
|
||||
const apiPath = url.pathname.replace(/^\/api\//, '')
|
||||
const token = url.searchParams.get('token') ?? ''
|
||||
const contentType = String(req.headers['content-type'] ?? '')
|
||||
// Technitium reads query or form alike; `query` here is both (`token` is the only one the hub leaves in the URL).
|
||||
const query = new URLSearchParams(url.searchParams)
|
||||
if (contentType.includes('application/x-www-form-urlencoded')) for (const [k, v] of new URLSearchParams(Buffer.concat(chunks).toString('utf8'))) query.set(k, v)
|
||||
const name = query.get('name') ?? ''
|
||||
f.calls.push({ path: apiPath, token, method: req.method ?? '', query, body: Buffer.concat(chunks), contentType })
|
||||
res.setHeader('content-type', 'application/json')
|
||||
const ok = (response: unknown = {}) => res.end(JSON.stringify({ status: 'ok', response }))
|
||||
const error = (m: string) => res.end(JSON.stringify({ status: 'error', errorMessage: m }))
|
||||
const isWrite = ['apps/downloadAndInstall', 'apps/downloadAndUpdate', 'apps/uninstall', 'apps/install', 'apps/update'].includes(apiPath)
|
||||
if (isWrite && !token.startsWith('admin-')) return error("Access was denied to user 'reader' for apps.")
|
||||
if (isWrite && f.failWrites) return error('Disk full.')
|
||||
switch (apiPath) {
|
||||
case 'apps/list':
|
||||
return ok({ apps: Object.entries(f.apps).map(([n, a]) => ({ name: n, description: `${n} d`, version: a.version })) })
|
||||
case 'apps/listStoreApps':
|
||||
return ok({ storeApps: f.store })
|
||||
case 'apps/config/get':
|
||||
if (f.denyConfig) return error('Access was denied.')
|
||||
return f.apps[name] ? ok({ config: f.apps[name].config }) : error('not found')
|
||||
case 'apps/downloadAndInstall':
|
||||
case 'apps/downloadAndUpdate': {
|
||||
const entry = f.store.find((s) => s.name === name && s.url === query.get('url'))
|
||||
if (!entry) return error('not in store')
|
||||
f.apps[name] = { version: entry.version, config: f.apps[name]?.config ?? null }
|
||||
return ok()
|
||||
}
|
||||
case 'apps/uninstall':
|
||||
delete f.apps[name]
|
||||
return ok()
|
||||
case 'apps/install':
|
||||
case 'apps/update':
|
||||
f.apps[name] = { version: 'zip', config: null }
|
||||
return ok()
|
||||
default:
|
||||
return ok()
|
||||
}
|
||||
})
|
||||
})
|
||||
f.server.listen(0, '127.0.0.1', () => {
|
||||
f.url = `http://127.0.0.1:${(f.server.address() as AddressInfo).port}`
|
||||
resolve(f)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
let a: Fake
|
||||
let b: Fake
|
||||
let dataDir: string
|
||||
let staticDir: string
|
||||
let hub: http.Server | undefined
|
||||
let base: string
|
||||
const tokens: Record<string, string> = {}
|
||||
|
||||
const STORE = (v: string) => [{ name: 'Blocker', version: v, url: `https://store.example/Blocker-v${v}.zip`, description: 'd', size: '1 KB' }]
|
||||
|
||||
function reset(): void {
|
||||
for (const f of [a, b]) {
|
||||
f.calls = []
|
||||
f.failWrites = false
|
||||
f.denyConfig = false
|
||||
f.store = STORE('2.0')
|
||||
f.apps = { Blocker: { version: '1.0', config: '{"secret":"cfg"}' } }
|
||||
}
|
||||
}
|
||||
|
||||
async function start(extra: Record<string, string> = {}): Promise<void> {
|
||||
hub?.close()
|
||||
hub = createHub(
|
||||
parseConfig({
|
||||
HUB_SERVERS: `a=${a.url},b=${b.url}`,
|
||||
A_TOKEN: 'read-a',
|
||||
B_TOKEN: 'read-b',
|
||||
A_ADMIN_TOKEN: 'admin-a',
|
||||
B_ADMIN_TOKEN: 'admin-b',
|
||||
HUB_DATA: dataDir,
|
||||
HUB_STATIC: staticDir,
|
||||
HUB_CACHE_SECONDS: '0',
|
||||
HUB_TIMEOUT_MS: '2000',
|
||||
...extra,
|
||||
}),
|
||||
)
|
||||
await new Promise<void>((resolve) => hub!.listen(0, '127.0.0.1', resolve))
|
||||
base = `http://127.0.0.1:${(hub.address() as AddressInfo).port}`
|
||||
for (const role of ['viewer', 'operator', 'apps-admin', 'admin']) {
|
||||
const res = await fetch(`${base}/api/user/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: `user=${role}&pass=password-${role}`,
|
||||
})
|
||||
tokens[role] = ((await res.json()) as { token: string }).token
|
||||
}
|
||||
}
|
||||
|
||||
async function api(role: string, apiPath: string, init: { params?: Record<string, string>; method?: string; json?: unknown; body?: string | FormData; headers?: Record<string, string> } = {}) {
|
||||
const query = new URLSearchParams(init.params ?? {})
|
||||
const headers: Record<string, string> = { authorization: `Bearer ${tokens[role]}`, ...(init.headers ?? {}) }
|
||||
let body = init.body
|
||||
if (init.json !== undefined) {
|
||||
headers['content-type'] = 'application/json'
|
||||
body = JSON.stringify(init.json)
|
||||
}
|
||||
const res = await fetch(`${base}/api/${apiPath}?${query}`, { method: init.method ?? (body === undefined ? 'GET' : 'POST'), headers, body })
|
||||
return { code: res.status, body: (await res.json()) as Record<string, any> }
|
||||
}
|
||||
|
||||
const run = (role: string, json: Record<string, unknown>) => api(role, 'hub/apps/run', { json })
|
||||
|
||||
function zipForm(extra: Record<string, string>, bytes: Uint8Array): FormData {
|
||||
const form = new FormData()
|
||||
for (const [k, v] of Object.entries(extra)) form.append(k, v)
|
||||
form.append('fileAppZip', new Blob([bytes], { type: 'application/zip' }), 'Blocker.zip')
|
||||
return form
|
||||
}
|
||||
|
||||
const writes = (f: Fake) => f.calls.filter((c) => c.path.startsWith('apps/') && c.method === 'POST')
|
||||
|
||||
beforeAll(async () => {
|
||||
a = await fake()
|
||||
b = await fake()
|
||||
dataDir = mkdtempSync(path.join(tmpdir(), 'hub-write-data-'))
|
||||
staticDir = mkdtempSync(path.join(tmpdir(), 'hub-write-static-'))
|
||||
let users = addUser([], 'viewer', 'viewer', 'password-viewer')
|
||||
users = addUser(users, 'operator', 'operator', 'password-operator')
|
||||
users = addUser(users, 'apps-admin', 'apps-admin', 'password-apps-admin')
|
||||
users = addUser(users, 'admin', 'admin', 'password-admin')
|
||||
writeUsersFile(dataDir, users)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
hub?.close()
|
||||
a.server.close()
|
||||
b.server.close()
|
||||
rmSync(dataDir, { recursive: true, force: true })
|
||||
rmSync(staticDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
reset()
|
||||
rmSync(path.join(dataDir, 'apps'), { recursive: true, force: true })
|
||||
rmSync(path.join(dataDir, 'audit.log'), { force: true })
|
||||
await start()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
hub?.close()
|
||||
})
|
||||
|
||||
describe('multipart parser', () => {
|
||||
const boundary = '----testBoundary123'
|
||||
const type = `multipart/form-data; boundary=${boundary}`
|
||||
|
||||
it('reads fields and a binary file, whatever bytes it holds', () => {
|
||||
const binary = Buffer.alloc(256)
|
||||
for (let i = 0; i < 256; i++) binary[i] = i
|
||||
const tricky = Buffer.concat([binary, Buffer.from(`\r\n--${boundary.slice(0, 5)}\r\n\r\n--`), binary])
|
||||
const body = Buffer.concat([
|
||||
Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="name"\r\n\r\nBlocker\r\n`),
|
||||
Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="fileAppZip"; filename="a b.zip"\r\nContent-Type: application/zip\r\n\r\n`),
|
||||
tricky,
|
||||
Buffer.from(`\r\n--${boundary}--\r\n`),
|
||||
])
|
||||
const parsed = parseMultipart(body, type)
|
||||
expect(parsed.fields).toEqual({ name: 'Blocker' })
|
||||
expect(parsed.files).toHaveLength(1)
|
||||
expect(parsed.files[0]).toMatchObject({ field: 'fileAppZip', filename: 'a b.zip', type: 'application/zip' })
|
||||
expect(parsed.files[0].data.equals(tricky)).toBe(true)
|
||||
})
|
||||
|
||||
it('reads a quoted boundary and an empty file', () => {
|
||||
expect(boundaryOf('multipart/form-data; boundary="x y"')).toBe('x y')
|
||||
const body = Buffer.from(`--q\r\nContent-Disposition: form-data; name="f"; filename="e.zip"\r\n\r\n\r\n--q--`)
|
||||
expect(parseMultipart(body, 'multipart/form-data; boundary=q').files[0].data.length).toBe(0)
|
||||
})
|
||||
|
||||
it('refuses what is not multipart', () => {
|
||||
expect(() => parseMultipart(Buffer.from('x'), 'text/plain')).toThrow()
|
||||
expect(() => parseMultipart(Buffer.from('nothing here'), type)).toThrow(/boundary/)
|
||||
expect(() => parseMultipart(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="a"\r\n\r\nno end`), type)).toThrow(/terminated/)
|
||||
})
|
||||
|
||||
it('names folders safely', () => {
|
||||
expect(safeName('Blocker')).toBe('Blocker')
|
||||
expect(safeName('Query Logs (Sqlite)')).toMatch(/^Query_Logs__Sqlite_-[0-9a-f]{6}$/)
|
||||
expect(safeName('../../etc')).not.toContain('/')
|
||||
expect(safeName('..')).not.toMatch(/^\./)
|
||||
})
|
||||
})
|
||||
|
||||
describe('roles', () => {
|
||||
it('refuses viewer and operator on every write and audits it', async () => {
|
||||
for (const role of ['viewer', 'operator']) {
|
||||
const r = await run(role, { action: 'uninstall', name: 'Blocker', targets: ['a'] })
|
||||
expect(r.code).toBe(403)
|
||||
expect(r.body).toEqual({ status: 'error', errorMessage: 'Your role does not allow this.' })
|
||||
expect((await api(role, 'apps/uninstall', { params: { name: 'Blocker', node: 'a' } })).code).toBe(403)
|
||||
expect((await api(role, 'hub/apps/upload', { body: zipForm({ action: 'install', name: 'X', targets: 'a' }, new Uint8Array([1])) })).code).toBe(403)
|
||||
}
|
||||
expect(writes(a)).toHaveLength(0)
|
||||
const entries = (await api('admin', 'hub/audit/list', { params: { limit: '100' } })).body.response.entries as Record<string, string>[]
|
||||
expect(entries.filter((e) => e.action === 'denied-write' && e.user === 'viewer').length).toBeGreaterThanOrEqual(3)
|
||||
expect(entries.some((e) => e.action === 'denied-write' && e.user === 'operator' && e.target === 'hub/apps/run')).toBe(true)
|
||||
})
|
||||
|
||||
it('lets apps-admin and admin write', async () => {
|
||||
for (const role of ['apps-admin', 'admin']) {
|
||||
b.apps = a.apps = { Blocker: { version: '1.0', config: null } }
|
||||
const r = await run(role, { action: 'update-store', name: 'Blocker', targets: ['a'] })
|
||||
expect(r.body.status).toBe('ok')
|
||||
expect(r.body.response.results).toEqual([{ server: 'a', ok: true, message: 'Updated from the store.' }])
|
||||
}
|
||||
})
|
||||
|
||||
it('does not let a write endpoint be called with GET', async () => {
|
||||
expect((await api('admin', 'hub/apps/run', { method: 'GET', params: { action: 'uninstall', name: 'Blocker', targets: 'a' } })).code).toBe(405)
|
||||
expect(writes(a)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('tells the session who may write what', async () => {
|
||||
const viewer = (await api('viewer', 'user/session/get')).body.info
|
||||
const admin = (await api('apps-admin', 'user/session/get')).body.info
|
||||
expect(viewer.permissions.Apps).toEqual({ canView: true, canModify: false, canDelete: false })
|
||||
expect(admin.permissions.Apps).toEqual({ canView: true, canModify: true, canDelete: true })
|
||||
expect(admin.permissions.Zones.canModify).toBe(false)
|
||||
expect(admin.hubWritable).toEqual(['a', 'b'])
|
||||
await start({ B_ADMIN_TOKEN: '' })
|
||||
expect((await api('viewer', 'user/session/get')).body.info.hubWritable).toEqual(['a'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('hub/apps/run', () => {
|
||||
it('uses only the admin token and the URL of the target own store', async () => {
|
||||
a.store = STORE('2.0')
|
||||
b.store = [{ ...STORE('2.5')[0], url: 'https://other.example/b.zip' }]
|
||||
const r = await run('admin', { action: 'update-store', name: 'Blocker', targets: ['a', 'b'] })
|
||||
expect(r.body.response.results.map((x: any) => x.ok)).toEqual([true, true])
|
||||
const [wa] = writes(a)
|
||||
const [wb] = writes(b)
|
||||
expect(wa.token).toBe('admin-a')
|
||||
expect(wa.query.get('url')).toBe('https://store.example/Blocker-v2.0.zip')
|
||||
expect(wb.token).toBe('admin-b')
|
||||
expect(wb.query.get('url')).toBe('https://other.example/b.zip')
|
||||
expect(a.calls.filter((c) => c.token === 'read-a').every((c) => !c.path.includes('downloadAnd'))).toBe(true)
|
||||
})
|
||||
|
||||
it('refuses a url that is not from the store listing, and an app the store does not have', async () => {
|
||||
const r = await run('admin', { action: 'install-store', name: 'Blocker', url: 'https://evil.example/x.zip', targets: ['a'] })
|
||||
expect(r.code).toBe(400)
|
||||
expect(r.body.errorMessage).toMatch(/store listing/)
|
||||
expect(writes(a)).toHaveLength(0)
|
||||
|
||||
const same = await run('admin', { action: 'update-store', name: 'Blocker', url: STORE('2.0')[0].url, targets: ['a'] })
|
||||
expect(same.body.status).toBe('ok')
|
||||
|
||||
const missing = await run('admin', { action: 'install-store', name: 'Nope', targets: ['a'] })
|
||||
expect(missing.body.response.results[0]).toMatchObject({ server: 'a', ok: false })
|
||||
expect(missing.body.response.results[0].message).toMatch(/not in this server/)
|
||||
})
|
||||
|
||||
it('refuses unknown servers, no targets and bad actions', async () => {
|
||||
expect((await run('admin', { action: 'update-store', name: 'Blocker', targets: ['zzz'] })).code).toBe(400)
|
||||
expect((await run('admin', { action: 'update-store', name: 'Blocker', targets: [] })).code).toBe(400)
|
||||
expect((await run('admin', { action: 'format', name: 'Blocker', targets: ['a'] })).code).toBe(400)
|
||||
expect((await run('admin', { action: 'uninstall', name: '', targets: ['a'] })).code).toBe(400)
|
||||
})
|
||||
|
||||
it('accepts a form body with comma separated targets', async () => {
|
||||
const r = await api('admin', 'hub/apps/run', {
|
||||
body: new URLSearchParams({ action: 'update-store', name: 'Blocker', targets: 'a,b' }).toString(),
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
})
|
||||
expect(r.body.response.results).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('refuses a server without an admin token, without calling it', async () => {
|
||||
await start({ B_ADMIN_TOKEN: '' })
|
||||
const r = await run('admin', { action: 'update-store', name: 'Blocker', targets: ['a', 'b'] })
|
||||
const results = r.body.response.results
|
||||
expect(results.find((x: any) => x.server === 'a').ok).toBe(true)
|
||||
expect(results.find((x: any) => x.server === 'b')).toEqual({ server: 'b', ok: false, message: 'no admin token configured' })
|
||||
expect(writes(b)).toHaveLength(0)
|
||||
expect(b.calls.every((c) => c.token === 'read-b')).toBe(true)
|
||||
const m = await api('admin', 'hub/apps/matrix')
|
||||
expect(m.body.response.servers).toEqual([{ id: 'a', ok: true, writable: true }, { id: 'b', ok: true, writable: false }])
|
||||
})
|
||||
|
||||
it('reports each target on its own when one fails, and audits every result', async () => {
|
||||
b.failWrites = true
|
||||
const r = await run('apps-admin', { action: 'update-store', name: 'Blocker', targets: ['a', 'b'] })
|
||||
expect(r.body.response.results).toEqual([
|
||||
{ server: 'a', ok: true, message: 'Updated from the store.' },
|
||||
{ server: 'b', ok: false, message: 'Disk full.' },
|
||||
])
|
||||
expect(a.apps.Blocker.version).toBe('2.0')
|
||||
expect(b.apps.Blocker.version).toBe('1.0')
|
||||
const entries = (await api('admin', 'hub/audit/list', { params: { limit: '20' } })).body.response.entries as Record<string, string>[]
|
||||
const mine = entries.filter((e) => e.action === 'apps.update-store')
|
||||
expect(mine).toHaveLength(2)
|
||||
expect(mine.find((e) => e.server === 'a')).toMatchObject({ user: 'apps-admin', role: 'apps-admin', target: 'Blocker', outcome: 'ok' })
|
||||
expect(mine.find((e) => e.server === 'b')).toMatchObject({ outcome: 'failed', detail: 'Disk full.' })
|
||||
const raw = readFileSync(path.join(dataDir, 'audit.log'), 'utf8')
|
||||
for (const secret of ['admin-a', 'admin-b', 'read-a', 'password-apps-admin', '"secret":"cfg"']) expect(raw).not.toContain(secret)
|
||||
})
|
||||
|
||||
it('shows the new version in the matrix after an update (the cache is dropped)', async () => {
|
||||
await start({ HUB_CACHE_SECONDS: '60' })
|
||||
const before = (await api('viewer', 'hub/apps/matrix')).body.response.apps[0]
|
||||
expect(before.servers.a.version).toBe('1.0')
|
||||
await run('admin', { action: 'update-store', name: 'Blocker', targets: ['a'] })
|
||||
const after = (await api('viewer', 'hub/apps/matrix')).body.response.apps[0]
|
||||
expect(after.servers.a.version).toBe('2.0')
|
||||
expect(after.servers.b.version).toBe('1.0')
|
||||
expect(after.versions).toBe('different')
|
||||
})
|
||||
})
|
||||
|
||||
describe('uninstall and its history', () => {
|
||||
const history = () => path.join(dataDir, 'apps', 'Blocker', 'history')
|
||||
|
||||
it('saves the config first, then uninstalls', async () => {
|
||||
const r = await run('admin', { action: 'uninstall', name: 'Blocker', targets: ['a', 'b'] })
|
||||
expect(r.body.response.results.map((x: any) => x.ok)).toEqual([true, true])
|
||||
expect(a.apps.Blocker).toBeUndefined()
|
||||
const files = readdirSync(history()).sort()
|
||||
expect(files).toHaveLength(2)
|
||||
expect(files.some((f) => /^\d{4}-\d\d-\d\dT\d\d-\d\d-\d\d\.\d{3}Z__a__before-uninstall\.txt$/.test(f))).toBe(true)
|
||||
expect(readFileSync(path.join(history(), files[0]), 'utf8')).toBe('{"secret":"cfg"}')
|
||||
expect(readdirSync(history()).some((f) => f.endsWith('.tmp'))).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps at most 50 files per app, dropping the oldest', async () => {
|
||||
mkdirSync(history(), { recursive: true })
|
||||
for (let i = 0; i < 55; i++) writeFileSync(path.join(history(), `2020-01-01T00-00-${String(i).padStart(2, '0')}.000Z__a__before-uninstall.txt`), 'old')
|
||||
await run('admin', { action: 'uninstall', name: 'Blocker', targets: ['a'] })
|
||||
const files = readdirSync(history()).sort()
|
||||
expect(files).toHaveLength(50)
|
||||
expect(files.some((f) => f.startsWith('2020-01-01T00-00-00.'))).toBe(false)
|
||||
expect(files.some((f) => f.includes('__a__') && f.startsWith('20') && !f.startsWith('2020-'))).toBe(true)
|
||||
})
|
||||
|
||||
it('does not uninstall when the config cannot be saved', async () => {
|
||||
a.denyConfig = true
|
||||
const r = await run('admin', { action: 'uninstall', name: 'Blocker', targets: ['a'] })
|
||||
expect(r.body.response.results[0].ok).toBe(false)
|
||||
expect(r.body.response.results[0].message).toMatch(/nothing was uninstalled/)
|
||||
expect(a.apps.Blocker).toBeDefined()
|
||||
expect(writes(a)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps an odd app name inside the data folder', async () => {
|
||||
a.apps['../../evil'] = { version: '1', config: 'x' }
|
||||
await run('admin', { action: 'uninstall', name: '../../evil', targets: ['a'] })
|
||||
expect(existsSync(path.join(dataDir, '..', 'evil'))).toBe(false)
|
||||
const dirs = readdirSync(path.join(dataDir, 'apps'))
|
||||
expect(dirs.every((d) => d !== '.' && d !== '..' && !/[\/]/.test(d) && !d.includes('..'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hub/apps/upload', () => {
|
||||
const bytes = new Uint8Array(70_000).map((_, i) => (i * 7 + 3) % 256)
|
||||
|
||||
it('forwards the same zip to every target, byte for byte', async () => {
|
||||
b.apps = {}
|
||||
a.apps = {}
|
||||
const r = await api('apps-admin', 'hub/apps/upload', { body: zipForm({ action: 'install', name: 'Blocker', targets: 'a,b' }, bytes) })
|
||||
expect(r.body.response.results).toEqual([
|
||||
{ server: 'a', ok: true, message: 'Installed from the file.' },
|
||||
{ server: 'b', ok: true, message: 'Installed from the file.' },
|
||||
])
|
||||
for (const [f, admin] of [[a, 'admin-a'], [b, 'admin-b']] as const) {
|
||||
const [w] = writes(f)
|
||||
expect(w.path).toBe('apps/install')
|
||||
expect(w.token).toBe(admin)
|
||||
expect(w.query.get('name')).toBe('Blocker')
|
||||
const parsed = parseMultipart(w.body, w.contentType)
|
||||
expect(Buffer.from(parsed.files[0].data).equals(Buffer.from(bytes))).toBe(true)
|
||||
}
|
||||
const audit = (await api('admin', 'hub/audit/list', { params: { limit: '10' } })).body.response.entries as Record<string, string>[]
|
||||
expect(audit.filter((e) => e.action === 'apps.install-file' && e.outcome === 'ok')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('updates, and reports an app that is not there as that target failure', async () => {
|
||||
delete b.apps.Blocker
|
||||
const r = await api('admin', 'hub/apps/upload', { body: zipForm({ action: 'update', name: 'Blocker', targets: 'a,b' }, bytes) })
|
||||
expect(writes(a)[0].path).toBe('apps/update')
|
||||
expect(r.body.response.results.map((x: any) => x.ok)).toEqual([true, true])
|
||||
})
|
||||
|
||||
it('refuses a missing file, a bad action and a body above 20 MB', async () => {
|
||||
const noFile = new FormData()
|
||||
noFile.append('action', 'install')
|
||||
noFile.append('name', 'X')
|
||||
noFile.append('targets', 'a')
|
||||
expect((await api('admin', 'hub/apps/upload', { body: noFile })).code).toBe(400)
|
||||
expect((await api('admin', 'hub/apps/upload', { body: zipForm({ action: 'wipe', name: 'X', targets: 'a' }, bytes) })).code).toBe(400)
|
||||
|
||||
const big = new Uint8Array(21 * 1024 * 1024)
|
||||
const r = await api('admin', 'hub/apps/upload', { body: zipForm({ action: 'install', name: 'X', targets: 'a' }, big) })
|
||||
expect(r.code).toBe(413)
|
||||
expect(r.body.errorMessage).toMatch(/20 MB/)
|
||||
expect(writes(a)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not read a big body from someone who is not allowed to upload', async () => {
|
||||
const r = await api('viewer', 'hub/apps/upload', { body: zipForm({ action: 'install', name: 'X', targets: 'a' }, bytes) })
|
||||
expect(r.code).toBe(403)
|
||||
})
|
||||
})
|
||||
|
||||
describe('single-server writes', () => {
|
||||
it('forwards with the admin token', async () => {
|
||||
const r = await api('apps-admin', 'apps/downloadAndUpdate', { params: { node: 'a', name: 'Blocker', url: STORE('2.0')[0].url } })
|
||||
expect(r.body.status).toBe('ok')
|
||||
const [w] = writes(a)
|
||||
expect(w).toMatchObject({ path: 'apps/downloadAndUpdate', token: 'admin-a' })
|
||||
expect(w.query.get('node')).toBeNull()
|
||||
expect(writes(b)).toHaveLength(0)
|
||||
expect(a.apps.Blocker.version).toBe('2.0')
|
||||
})
|
||||
|
||||
it('works with a form POST too, and uninstalls with the history', async () => {
|
||||
const r = await api('admin', 'apps/uninstall', {
|
||||
body: new URLSearchParams({ node: 'b', name: 'Blocker' }).toString(),
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
})
|
||||
expect(r.body.status).toBe('ok')
|
||||
expect(b.apps.Blocker).toBeUndefined()
|
||||
expect(readdirSync(path.join(dataDir, 'apps', 'Blocker', 'history'))).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('is refused without a node or with the aggregate', async () => {
|
||||
for (const params of [{ name: 'Blocker' } as Record<string, string>, { name: 'Blocker', node: 'cluster' }, { name: 'Blocker', node: 'zzz' }]) {
|
||||
const r = await api('admin', 'apps/uninstall', { params })
|
||||
expect(r.code).toBe(400)
|
||||
}
|
||||
expect(writes(a)).toHaveLength(0)
|
||||
expect(writes(b)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not forward a download url the store did not offer', async () => {
|
||||
const r = await api('admin', 'apps/downloadAndInstall', { params: { node: 'a', name: 'Blocker', url: 'https://evil.example/x.zip' } })
|
||||
expect(r.body.status).toBe('error')
|
||||
expect(r.body.errorMessage).toMatch(/store listing/)
|
||||
expect(writes(a)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('reports a server without an admin token', async () => {
|
||||
await start({ A_ADMIN_TOKEN: '' })
|
||||
const r = await api('admin', 'apps/uninstall', { params: { node: 'a', name: 'Blocker' } })
|
||||
expect(r.body).toEqual({ status: 'error', errorMessage: 'a: no admin token configured' })
|
||||
expect(a.calls.filter((c) => c.method === 'POST')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('forwards a multipart install to the node', async () => {
|
||||
delete a.apps.Blocker
|
||||
const bytes = new Uint8Array([80, 75, 3, 4, 13, 10, 45, 45, 0, 255])
|
||||
const r = await api('admin', 'apps/install', { params: { node: 'a', name: 'Blocker' }, body: zipForm({}, bytes) })
|
||||
expect(r.body.status).toBe('ok')
|
||||
const [w] = writes(a)
|
||||
expect(w.token).toBe('admin-a')
|
||||
expect(Buffer.from(parseMultipart(w.body, w.contentType).files[0].data).equals(Buffer.from(bytes))).toBe(true)
|
||||
})
|
||||
|
||||
it('needs a node for a config read or write', async () => {
|
||||
for (const p of ['apps/config/get', 'apps/config/set']) {
|
||||
expect((await api('admin', p, { params: { name: 'Blocker' } })).code).toBe(400)
|
||||
}
|
||||
})
|
||||
|
||||
it('lists the store with the servers each app is installed on', async () => {
|
||||
delete b.apps.Blocker
|
||||
const r = await api('viewer', 'apps/listStoreApps')
|
||||
expect(r.body.response.storeApps[0]).toMatchObject({ name: 'Blocker', installedOn: ['a'] })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,178 @@
|
||||
/*
|
||||
App writes: install, update and uninstall on one or several servers. The first part of the
|
||||
hub that changes anything, so the rules are strict:
|
||||
|
||||
* only a server's ADMIN token writes (`<ID>_ADMIN_TOKEN`); a server without one is refused;
|
||||
* a store download URL is never taken from the client: it is looked up in the target
|
||||
server's own `apps/listStoreApps`, and a URL the client sends must match it;
|
||||
* before an uninstall the app's config is saved to `${HUB_DATA}/apps/<app>/history/`, and
|
||||
the uninstall is refused if that cannot be done;
|
||||
* every target's result is audited (the caller does that with `Audit`).
|
||||
*/
|
||||
import type { HubConfig, HubServer } from './config.ts'
|
||||
import type { UploadedFile } from './multipart.ts'
|
||||
import { MAX_CONFIG } from './apps-sync.ts'
|
||||
import { safeName, writeHistory } from './store.ts'
|
||||
import { Upstream } from './upstream.ts'
|
||||
|
||||
export { safeName }
|
||||
|
||||
export type StoreAction = 'install-store' | 'update-store' | 'uninstall'
|
||||
export type FileAction = 'install-file' | 'update-file'
|
||||
export type AppAction = StoreAction | FileAction | 'config-set'
|
||||
|
||||
export interface TargetResult {
|
||||
server: string
|
||||
ok: boolean
|
||||
message?: string
|
||||
}
|
||||
|
||||
const API: Record<AppAction, string> = {
|
||||
'install-store': 'apps/downloadAndInstall',
|
||||
'update-store': 'apps/downloadAndUpdate',
|
||||
uninstall: 'apps/uninstall',
|
||||
'install-file': 'apps/install',
|
||||
'update-file': 'apps/update',
|
||||
'config-set': 'apps/config/set',
|
||||
}
|
||||
|
||||
const DONE: Record<AppAction, string> = {
|
||||
'install-store': 'Installed from the store.',
|
||||
'update-store': 'Updated from the store.',
|
||||
uninstall: 'Uninstalled.',
|
||||
'install-file': 'Installed from the file.',
|
||||
'update-file': 'Updated from the file.',
|
||||
'config-set': 'Config saved.',
|
||||
}
|
||||
|
||||
/** The audit/api action for an upstream write path, or null when it is not an app write. */
|
||||
export function actionForPath(apiPath: string): AppAction | null {
|
||||
for (const [action, p] of Object.entries(API)) if (p === apiPath) return action as AppAction
|
||||
return null
|
||||
}
|
||||
|
||||
const reason = (e: unknown): string => (e instanceof Error ? e.message : 'failed')
|
||||
|
||||
export class AppsWriter {
|
||||
#config: HubConfig
|
||||
#upstream: Upstream
|
||||
#now: () => number
|
||||
|
||||
constructor(config: HubConfig, upstream: Upstream, now: () => number = Date.now) {
|
||||
this.#config = config
|
||||
this.#upstream = upstream
|
||||
this.#now = now
|
||||
}
|
||||
|
||||
/** The ids of the servers that have an admin token. */
|
||||
writableIds(): string[] {
|
||||
return this.#config.servers.filter((s) => s.adminToken !== undefined).map((s) => s.id)
|
||||
}
|
||||
|
||||
/* The URL the server's own store gives for this app, or null. */
|
||||
async storeUrl(server: HubServer, name: string): Promise<string | null> {
|
||||
const response = (await this.#upstream.call(server, 'apps/listStoreApps')) as { storeApps?: { name: string; url?: string }[] } | null
|
||||
const found = (response?.storeApps ?? []).find((a) => a.name === name)
|
||||
return typeof found?.url === 'string' && found.url !== '' ? found.url : null
|
||||
}
|
||||
|
||||
/* Saves the app's current config next to the hub's data; throws if it cannot. */
|
||||
async saveHistory(server: HubServer, name: string, kind: 'before-uninstall' | 'before-set' = 'before-uninstall'): Promise<string> {
|
||||
if (server.adminToken === undefined) throw new Error('no admin token configured')
|
||||
this.#upstream.invalidate(server.id)
|
||||
const response = (await this.#upstream.call(server, 'apps/config/get', { name }, server.adminToken)) as { config?: unknown } | null
|
||||
const text = typeof response?.config === 'string' ? response.config : ''
|
||||
return writeHistory(this.#config.dataDir, name, server.id, text, kind, this.#now())
|
||||
}
|
||||
|
||||
/*
|
||||
One write on one server. `url` must already be checked against the store; `file` is the zip
|
||||
for the two file actions. Returns the server's response, or throws.
|
||||
*/
|
||||
async perform(server: HubServer, action: AppAction, name: string, url?: string, file?: UploadedFile, config?: string): Promise<unknown> {
|
||||
if (server.adminToken === undefined) throw new Error('no admin token configured')
|
||||
switch (action) {
|
||||
case 'install-store':
|
||||
case 'update-store':
|
||||
return await this.#upstream.write(server, API[action], { name, url: url ?? '' })
|
||||
case 'uninstall':
|
||||
try {
|
||||
await this.saveHistory(server, name)
|
||||
} catch (e) {
|
||||
throw new Error(`the config could not be saved before the uninstall (${reason(e)}); nothing was uninstalled`)
|
||||
}
|
||||
return await this.#upstream.write(server, API[action], { name })
|
||||
case 'config-set': {
|
||||
if (config === undefined || config.length > MAX_CONFIG) throw new Error('the config is missing or larger than 1 MB')
|
||||
try {
|
||||
await this.saveHistory(server, name, 'before-set')
|
||||
} catch (e) {
|
||||
throw new Error(`the old config could not be saved first (${reason(e)}); nothing was changed`)
|
||||
}
|
||||
return await this.#upstream.write(server, API[action], {}, new URLSearchParams({ name, config }))
|
||||
}
|
||||
case 'install-file':
|
||||
case 'update-file': {
|
||||
if (file === undefined) throw new Error('no zip file')
|
||||
const form = new FormData()
|
||||
form.append('fileAppZip', new Blob([file.data], { type: file.type }), file.filename)
|
||||
return await this.#upstream.write(server, API[action], { name }, form)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
The same write on several servers, in parallel. A store action first resolves each target's
|
||||
URL from its own store; `suppliedUrl` (if the client sent one) has to match every one of them.
|
||||
Returns `{ error }` when the request itself is refused, else one result per target.
|
||||
*/
|
||||
async runMany(
|
||||
action: AppAction,
|
||||
name: string,
|
||||
targets: string[],
|
||||
options: { suppliedUrl?: string; file?: UploadedFile } = {},
|
||||
): Promise<{ error: string } | { results: TargetResult[] }> {
|
||||
const known = new Map(this.#config.servers.map((s) => [s.id, s]))
|
||||
const unique = [...new Set(targets)]
|
||||
if (name === '' || name.length > 200) return { error: 'An app name is required.' }
|
||||
if (unique.length === 0) return { error: 'Choose at least one server.' }
|
||||
const unknown = unique.filter((t) => !known.has(t))
|
||||
if (unknown.length > 0) return { error: `Unknown server: ${unknown.join(', ')}.` }
|
||||
|
||||
const urls = new Map<string, string>()
|
||||
const early = new Map<string, string>()
|
||||
if (action === 'install-store' || action === 'update-store') {
|
||||
await Promise.all(
|
||||
unique.map(async (id) => {
|
||||
const server = known.get(id) as HubServer
|
||||
if (server.adminToken === undefined) return early.set(id, 'no admin token configured')
|
||||
try {
|
||||
const url = await this.storeUrl(server, name)
|
||||
if (url === null) early.set(id, `"${name}" is not in this server's store listing`)
|
||||
else urls.set(id, url)
|
||||
} catch (e) {
|
||||
early.set(id, `the store listing could not be read (${reason(e)})`)
|
||||
}
|
||||
}),
|
||||
)
|
||||
if (options.suppliedUrl !== undefined && options.suppliedUrl !== '' && [...urls.values()].some((u) => u !== options.suppliedUrl)) {
|
||||
return { error: 'The url is not from the store listing; the hub only installs what a server offers.' }
|
||||
}
|
||||
}
|
||||
|
||||
const results = await Promise.all(
|
||||
unique.map(async (id): Promise<TargetResult> => {
|
||||
const server = known.get(id) as HubServer
|
||||
const refused = early.get(id) ?? (server.adminToken === undefined ? 'no admin token configured' : undefined)
|
||||
if (refused !== undefined) return { server: id, ok: false, message: refused }
|
||||
try {
|
||||
await this.perform(server, action, name, urls.get(id), options.file)
|
||||
return { server: id, ok: true, message: DONE[action] }
|
||||
} catch (e) {
|
||||
return { server: id, ok: false, message: reason(e) }
|
||||
}
|
||||
}),
|
||||
)
|
||||
return { results }
|
||||
}
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
/*
|
||||
The audit log: JSON Lines in `${HUB_DATA}/audit.log`, one entry per line, newest last on
|
||||
disk and newest first from `list`. Rotated at 10 MB to audit.log.1 (one kept). If the
|
||||
folder is not writable, one line goes to stderr and the log lives in memory (last 1000).
|
||||
|
||||
Entries carry fixed fields only. Callers never pass passwords, tokens or request bodies.
|
||||
*/
|
||||
import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
export const MAX_BYTES = 10 * 1024 * 1024
|
||||
const RING = 1000
|
||||
const DETAIL_MAX = 300
|
||||
|
||||
export interface AuditEntry {
|
||||
ts: string
|
||||
user: string
|
||||
role?: string
|
||||
ip: string
|
||||
action: string
|
||||
server?: string
|
||||
target?: string
|
||||
outcome: 'ok' | 'failed' | 'refused'
|
||||
detail?: string
|
||||
}
|
||||
|
||||
export type AuditInput = Omit<AuditEntry, 'ts'>
|
||||
|
||||
const cut = (value: string, max: number): string => value.slice(0, max)
|
||||
|
||||
export class Audit {
|
||||
#file: string
|
||||
#maxBytes: number
|
||||
#now: () => number
|
||||
#ring: AuditEntry[] = []
|
||||
#size = 0
|
||||
#memoryOnly = false
|
||||
#listener: ((entry: AuditEntry) => void) | null = null
|
||||
|
||||
constructor(dir: string, options: { maxBytes?: number; now?: () => number } = {}) {
|
||||
this.#maxBytes = options.maxBytes ?? MAX_BYTES
|
||||
this.#now = options.now ?? Date.now
|
||||
this.#file = path.join(dir, 'audit.log')
|
||||
try {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
this.#size = existsSync(this.#file) ? statSync(this.#file).size : 0
|
||||
appendFileSync(this.#file, '')
|
||||
} catch (e) {
|
||||
this.#fallBack(e)
|
||||
}
|
||||
}
|
||||
|
||||
get inMemory(): boolean {
|
||||
return this.#memoryOnly
|
||||
}
|
||||
|
||||
#fallBack(e: unknown): void {
|
||||
if (!this.#memoryOnly) {
|
||||
console.error(
|
||||
`hub: audit log is not writable (${e instanceof Error ? e.message : String(e)}); keeping the last ${RING} entries in memory.`,
|
||||
)
|
||||
}
|
||||
this.#memoryOnly = true
|
||||
}
|
||||
|
||||
/** One function is told about every entry as it is written (the webhook uses it for failed writes). */
|
||||
onEntry(listener: (entry: AuditEntry) => void): void {
|
||||
this.#listener = listener
|
||||
}
|
||||
|
||||
log(input: AuditInput): void {
|
||||
const entry: AuditEntry = {
|
||||
ts: new Date(this.#now()).toISOString(),
|
||||
user: cut(input.user, 64),
|
||||
...(input.role === undefined ? {} : { role: input.role }),
|
||||
ip: cut(input.ip, 64),
|
||||
action: input.action,
|
||||
...(input.server === undefined ? {} : { server: input.server }),
|
||||
...(input.target === undefined ? {} : { target: cut(input.target, 200) }),
|
||||
outcome: input.outcome,
|
||||
...(input.detail === undefined ? {} : { detail: cut(input.detail, DETAIL_MAX) }),
|
||||
}
|
||||
this.#ring.push(entry)
|
||||
try {
|
||||
this.#listener?.(entry)
|
||||
} catch {
|
||||
// A listener never stops the log.
|
||||
}
|
||||
if (this.#ring.length > RING) this.#ring.shift()
|
||||
if (this.#memoryOnly) return
|
||||
const line = JSON.stringify(entry) + '\n'
|
||||
try {
|
||||
if (this.#size + Buffer.byteLength(line) > this.#maxBytes) this.#rotate()
|
||||
appendFileSync(this.#file, line)
|
||||
this.#size += Buffer.byteLength(line)
|
||||
} catch (e) {
|
||||
this.#fallBack(e)
|
||||
}
|
||||
}
|
||||
|
||||
#rotate(): void {
|
||||
rmSync(`${this.#file}.1`, { force: true })
|
||||
renameSync(this.#file, `${this.#file}.1`)
|
||||
this.#size = 0
|
||||
}
|
||||
|
||||
#read(file: string): AuditEntry[] {
|
||||
if (!existsSync(file)) return []
|
||||
const out: AuditEntry[] = []
|
||||
for (const line of readFileSync(file, 'utf8').split('\n')) {
|
||||
if (line === '') continue
|
||||
try {
|
||||
out.push(JSON.parse(line) as AuditEntry)
|
||||
} catch {
|
||||
// A torn line is skipped.
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Newest first. `before` is an ISO time or epoch milliseconds: only older entries are returned. */
|
||||
list(limit = 200, before?: string): AuditEntry[] {
|
||||
const max = Math.min(Math.max(Math.trunc(limit) || 200, 1), 1000)
|
||||
let cutoff = Infinity
|
||||
if (before !== undefined && before !== '') {
|
||||
const t = /^\d+$/.test(before) ? Number(before) : Date.parse(before)
|
||||
if (Number.isFinite(t)) cutoff = t
|
||||
}
|
||||
const all = this.#memoryOnly ? this.#ring : [...this.#read(`${this.#file}.1`), ...this.#read(this.#file)]
|
||||
const result: AuditEntry[] = []
|
||||
for (let i = all.length - 1; i >= 0 && result.length < max; i--) {
|
||||
if (Date.parse(all[i].ts) < cutoff) result.push(all[i])
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
The hub's configuration, read from the environment. `parseConfig` is pure so the
|
||||
tests can feed it any environment; `loadConfig` is the one that reads `process.env`.
|
||||
*/
|
||||
|
||||
export const HUB_VERSION = '1.0.0'
|
||||
|
||||
export interface HubServer {
|
||||
id: string
|
||||
url: string
|
||||
token: string
|
||||
/** Optional write-capable token (`<ID>_ADMIN_TOKEN`). Not used yet; never logged. */
|
||||
adminToken?: string
|
||||
}
|
||||
|
||||
/** The single admin from HUB_USER and HUB_PASSWORD, used only while there is no users file. */
|
||||
export interface Bootstrap {
|
||||
user: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface HubConfig {
|
||||
servers: HubServer[]
|
||||
/** From HUB_USER and HUB_PASSWORD; null when they are not set. Whether it is used depends on the users file. */
|
||||
bootstrap: Bootstrap | null
|
||||
/** Where users.json and audit.log live (HUB_DATA). */
|
||||
dataDir: string
|
||||
/** Believe the first X-Forwarded-For entry as the client address (HUB_TRUST_PROXY=1). */
|
||||
trustProxy: boolean
|
||||
port: number
|
||||
cacheSeconds: number
|
||||
/** How often the drift check runs (HUB_DRIFT_MINUTES); 0 turns it off. */
|
||||
driftMinutes: number
|
||||
timeoutMs: number
|
||||
/** Where drift and failed-write notices are POSTed (HUB_WEBHOOK_URL); null when not set. */
|
||||
webhookUrl: string | null
|
||||
/** Signs the notices with HMAC-SHA256 (HUB_WEBHOOK_SECRET); null when not set. Never logged. */
|
||||
webhookSecret: string | null
|
||||
/** Lets the block list counter fetch from private addresses (HUB_LISTS_ALLOW_PRIVATE=1), for lists kept on the LAN. */
|
||||
listsAllowPrivate: boolean
|
||||
/** Shown as `dnsServerDomain` (and `clusterDomain`) in the session. */
|
||||
domain: string
|
||||
/** The console's static build. */
|
||||
staticDir: string
|
||||
}
|
||||
|
||||
type Env = Record<string, string | undefined>
|
||||
|
||||
/* `ndns` -> `NDNS_TOKEN`, `my-dns.1` -> `MY_DNS_1_TOKEN`. */
|
||||
export function tokenVariable(id: string): string {
|
||||
return `${id.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_TOKEN`
|
||||
}
|
||||
|
||||
/* `ndns` -> `NDNS_ADMIN_TOKEN`. */
|
||||
export function adminTokenVariable(id: string): string {
|
||||
return `${id.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_ADMIN_TOKEN`
|
||||
}
|
||||
|
||||
function number(env: Env, name: string, fallback: number, min: number): number {
|
||||
const raw = env[name]
|
||||
if (raw === undefined || raw.trim() === '') return fallback
|
||||
const value = Number(raw)
|
||||
if (!Number.isFinite(value) || value < min) throw new Error(`${name} must be a number of at least ${min}.`)
|
||||
return value
|
||||
}
|
||||
|
||||
export function parseConfig(env: Env): HubConfig {
|
||||
// The users file decides whether these are needed; the config does not read the file.
|
||||
const user = env.HUB_USER ?? ''
|
||||
const password = env.HUB_PASSWORD ?? ''
|
||||
if ((user === '') !== (password === '')) throw new Error('HUB_USER and HUB_PASSWORD must be set together.')
|
||||
|
||||
const servers: HubServer[] = []
|
||||
for (const part of (env.HUB_SERVERS ?? '').split(',')) {
|
||||
const entry = part.trim()
|
||||
if (entry === '') continue
|
||||
const at = entry.indexOf('=')
|
||||
const id = at < 0 ? '' : entry.slice(0, at).trim()
|
||||
const url = at < 0 ? '' : entry.slice(at + 1).trim().replace(/\/+$/, '')
|
||||
if (!/^[A-Za-z0-9._-]+$/.test(id) || id === 'cluster' || !/^https?:\/\/[^/]/.test(url)) {
|
||||
throw new Error(`HUB_SERVERS entry "${entry}" is not of the form id=https://host (id: letters, digits, . _ -).`)
|
||||
}
|
||||
if (servers.some((s) => s.id === id)) throw new Error(`HUB_SERVERS lists "${id}" twice.`)
|
||||
const variable = tokenVariable(id)
|
||||
const token = env[variable] ?? ''
|
||||
if (token === '') throw new Error(`${variable} is required for the server "${id}".`)
|
||||
const adminToken = env[adminTokenVariable(id)] ?? ''
|
||||
servers.push(adminToken === '' ? { id, url, token } : { id, url, token, adminToken })
|
||||
}
|
||||
if (servers.length === 0) {
|
||||
throw new Error('HUB_SERVERS is required, e.g. dns=https://dns.example.net,ndns=https://ndns.example.net.')
|
||||
}
|
||||
|
||||
const webhookUrl = (env.HUB_WEBHOOK_URL ?? '').trim()
|
||||
if (webhookUrl !== '') {
|
||||
let ok = /^https?:\/\/[^/\s]/i.test(webhookUrl)
|
||||
try {
|
||||
new URL(webhookUrl)
|
||||
} catch {
|
||||
ok = false
|
||||
}
|
||||
if (!ok) throw new Error('HUB_WEBHOOK_URL must be an http or https address.')
|
||||
}
|
||||
|
||||
return {
|
||||
servers,
|
||||
bootstrap: user === '' ? null : { user, password },
|
||||
dataDir: env.HUB_DATA?.trim() || '/data',
|
||||
trustProxy: ['1', 'true'].includes((env.HUB_TRUST_PROXY ?? '').trim().toLowerCase()),
|
||||
port: number(env, 'HUB_PORT', 8080, 0),
|
||||
cacheSeconds: number(env, 'HUB_CACHE_SECONDS', 15, 0),
|
||||
driftMinutes: number(env, 'HUB_DRIFT_MINUTES', 15, 0),
|
||||
timeoutMs: number(env, 'HUB_TIMEOUT_MS', 8000, 1),
|
||||
webhookUrl: webhookUrl === '' ? null : webhookUrl,
|
||||
webhookSecret: (env.HUB_WEBHOOK_SECRET ?? '') === '' ? null : (env.HUB_WEBHOOK_SECRET as string),
|
||||
listsAllowPrivate: ['1', 'true'].includes((env.HUB_LISTS_ALLOW_PRIVATE ?? '').trim().toLowerCase()),
|
||||
domain: env.HUB_DOMAIN?.trim() || 'hub',
|
||||
staticDir: env.HUB_STATIC?.trim() || './dist',
|
||||
}
|
||||
}
|
||||
|
||||
export function loadConfig(): HubConfig {
|
||||
return parseConfig(process.env)
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
// @vitest-environment node
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { parseConfig } from './config.ts'
|
||||
import { Drift } from './drift.ts'
|
||||
import { writeBaseline, writeListsBaseline, writeOverrides, writeSettingsBaseline } from './store.ts'
|
||||
import { Webhook } from './webhook.ts'
|
||||
import { Upstream } from './upstream.ts'
|
||||
|
||||
const json = (o: unknown): string => JSON.stringify(o, null, 2)
|
||||
|
||||
interface FakeServer {
|
||||
apps: Record<string, string>
|
||||
down: boolean
|
||||
denyConfig: boolean
|
||||
settings: Record<string, unknown>
|
||||
blocked: string[]
|
||||
allowed: string[]
|
||||
}
|
||||
|
||||
let dir: string
|
||||
let servers: Record<string, FakeServer>
|
||||
let calls: string[]
|
||||
let gate: Promise<void> | null
|
||||
let clock: number
|
||||
|
||||
/* A fetch that answers from memory: no sockets, no timers. */
|
||||
const fakeFetch = (async (input: string | URL | Request) => {
|
||||
const url = new URL(String(input))
|
||||
const id = url.port === '1' ? 'a' : 'b'
|
||||
const server = servers[id]
|
||||
const apiPath = url.pathname.replace(/^\/api\//, '')
|
||||
calls.push(`${id}:${apiPath}`)
|
||||
if (gate !== null) await gate
|
||||
if (server.down) throw new TypeError('fetch failed')
|
||||
const name = url.searchParams.get('name') ?? ''
|
||||
const reply = (response: unknown) => Response.json({ status: 'ok', response })
|
||||
if (apiPath === 'apps/list') return reply({ apps: Object.keys(server.apps).map((n) => ({ name: n, version: '1.0' })) })
|
||||
if (apiPath === 'apps/config/get') {
|
||||
if (server.denyConfig) return Response.json({ status: 'error', errorMessage: 'Access was denied.' })
|
||||
return reply({ config: server.apps[name] ?? null })
|
||||
}
|
||||
if (apiPath === 'settings/get') return reply(server.settings)
|
||||
if (apiPath === 'blocked/export' || apiPath === 'allowed/export') {
|
||||
return new Response((apiPath === 'blocked/export' ? server.blocked : server.allowed).join('\n') + '\n', { headers: { 'content-type': 'text/plain' } })
|
||||
}
|
||||
return reply({})
|
||||
}) as typeof fetch
|
||||
|
||||
function make(webhook?: Webhook) {
|
||||
const config = parseConfig({
|
||||
HUB_SERVERS: 'a=http://127.0.0.1:1,b=http://127.0.0.1:2',
|
||||
A_TOKEN: 'ra',
|
||||
B_TOKEN: 'rb',
|
||||
HUB_DATA: dir,
|
||||
HUB_CACHE_SECONDS: '0',
|
||||
})
|
||||
const drift = new Drift(config, new Upstream(config, fakeFetch, () => clock), () => clock, { webhook })
|
||||
return { config, drift }
|
||||
}
|
||||
|
||||
const baseline = (app: string, text: string) =>
|
||||
writeBaseline(dir, { app, text, updatedAt: '2026-01-01T00:00:00Z', updatedBy: 'test' })
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(path.join(tmpdir(), 'hub-drift-'))
|
||||
const blank = (): FakeServer => ({ apps: {}, down: false, denyConfig: false, settings: {}, blocked: [], allowed: [] })
|
||||
servers = { a: blank(), b: blank() }
|
||||
calls = []
|
||||
gate = null
|
||||
clock = Date.parse('2026-10-31T10:00:00Z')
|
||||
})
|
||||
|
||||
afterEach(() => rmSync(dir, { recursive: true, force: true }))
|
||||
|
||||
describe('drift check', () => {
|
||||
it('finds nothing when no app has a baseline, and calls no server', async () => {
|
||||
servers.a.apps.X = json({ a: 1 })
|
||||
const { drift } = make()
|
||||
const state = await drift.check()
|
||||
expect(state.apps).toEqual([])
|
||||
expect(state.checkedAt).toBe('2026-10-31T10:00:00.000Z')
|
||||
expect(calls).toEqual([])
|
||||
expect(drift.count()).toBe(0)
|
||||
})
|
||||
|
||||
it('reports a server that differs from the baseline and not one that matches', async () => {
|
||||
baseline('Blocker', json({ ttl: 30 }))
|
||||
servers.a.apps.Blocker = json({ ttl: 30 })
|
||||
servers.b.apps.Blocker = json({ ttl: 99 })
|
||||
const { drift } = make()
|
||||
const state = await drift.check()
|
||||
expect(state.apps).toEqual([{ kind: 'app', name: 'Blocker', app: 'Blocker', server: 'b', since: '2026-10-31T10:00:00.000Z', status: 'differs' }])
|
||||
expect(drift.count()).toBe(1)
|
||||
})
|
||||
|
||||
it('compares by meaning, not by spacing or key order', async () => {
|
||||
baseline('Blocker', json({ a: 1, b: 2 }))
|
||||
servers.a.apps.Blocker = '{"b":2,"a":1}'
|
||||
servers.b.apps.Blocker = json({ a: 1, b: 2 })
|
||||
expect((await make().drift.check()).apps).toEqual([])
|
||||
})
|
||||
|
||||
it('uses each server own local values, so a local difference is not drift', async () => {
|
||||
baseline('Blocker', json({ ttl: 30, db: '/base' }))
|
||||
writeOverrides(dir, 'Blocker', { paths: ['db'], values: { a: { db: '/a.db' }, b: { db: '/b.db' } } })
|
||||
servers.a.apps.Blocker = json({ ttl: 30, db: '/a.db' })
|
||||
servers.b.apps.Blocker = json({ ttl: 30, db: '/b.db' })
|
||||
const { drift } = make()
|
||||
expect((await drift.check()).apps).toEqual([])
|
||||
// A wrong local value is drift.
|
||||
servers.b.apps.Blocker = json({ ttl: 30, db: '/elsewhere.db' })
|
||||
expect((await drift.check()).apps.map((e) => [e.server, e.status])).toEqual([['b', 'differs']])
|
||||
})
|
||||
|
||||
it('lists a server that does not answer as unreadable, and does not count it', async () => {
|
||||
baseline('Blocker', json({ ttl: 30 }))
|
||||
servers.a.apps.Blocker = json({ ttl: 30 })
|
||||
servers.b.down = true
|
||||
const { drift } = make()
|
||||
const state = await drift.check()
|
||||
expect(state.apps).toEqual([{ kind: 'app', name: 'Blocker', app: 'Blocker', server: 'b', since: '2026-10-31T10:00:00.000Z', status: 'unreadable' }])
|
||||
expect(drift.count()).toBe(0)
|
||||
})
|
||||
|
||||
it('lists a refused config read as unreadable and an app that is missing as not-installed', async () => {
|
||||
baseline('Blocker', json({ ttl: 30 }))
|
||||
servers.a.apps.Blocker = json({ ttl: 30 })
|
||||
servers.a.denyConfig = true
|
||||
const { drift } = make()
|
||||
const state = await drift.check()
|
||||
expect(state.apps.map((e) => [e.server, e.status])).toEqual([['a', 'unreadable'], ['b', 'not-installed']])
|
||||
})
|
||||
|
||||
it('keeps `since` across checks and drops the entry once the problem is fixed', async () => {
|
||||
baseline('Blocker', json({ ttl: 30 }))
|
||||
servers.a.apps.Blocker = json({ ttl: 30 })
|
||||
servers.b.apps.Blocker = json({ ttl: 99 })
|
||||
const { drift } = make()
|
||||
await drift.check()
|
||||
clock += 15 * 60_000
|
||||
const second = await drift.check()
|
||||
expect(second.apps[0].since).toBe('2026-10-31T10:00:00.000Z')
|
||||
expect(second.checkedAt).toBe('2026-10-31T10:15:00.000Z')
|
||||
|
||||
servers.b.apps.Blocker = json({ ttl: 30 })
|
||||
clock += 15 * 60_000
|
||||
expect((await drift.check()).apps).toEqual([])
|
||||
expect(drift.count()).toBe(0)
|
||||
|
||||
// It drifts again: a new `since`.
|
||||
servers.b.apps.Blocker = json({ ttl: 5 })
|
||||
clock += 15 * 60_000
|
||||
expect((await drift.check()).apps[0].since).toBe('2026-10-31T10:45:00.000Z')
|
||||
})
|
||||
|
||||
it('changes the status (and `since`) when the problem changes', async () => {
|
||||
baseline('Blocker', json({ ttl: 30 }))
|
||||
servers.a.apps.Blocker = json({ ttl: 30 })
|
||||
servers.b.apps.Blocker = json({ ttl: 99 })
|
||||
const { drift } = make()
|
||||
await drift.check()
|
||||
servers.b.down = true
|
||||
clock += 60_000
|
||||
const state = await drift.check()
|
||||
expect(state.apps[0]).toMatchObject({ status: 'unreadable', since: '2026-10-31T10:01:00.000Z' })
|
||||
})
|
||||
|
||||
it('never runs two checks at once', async () => {
|
||||
baseline('Blocker', json({ ttl: 30 }))
|
||||
servers.a.apps.Blocker = json({ ttl: 30 })
|
||||
servers.b.apps.Blocker = json({ ttl: 30 })
|
||||
let open!: () => void
|
||||
gate = new Promise<void>((resolve) => (open = resolve))
|
||||
const { drift } = make()
|
||||
const first = drift.check()
|
||||
const second = drift.check()
|
||||
expect(drift.get().checking).toBe(true)
|
||||
open()
|
||||
expect(await second).toBe(await first)
|
||||
// Two servers, two app lists and two config reads: one check's worth of calls.
|
||||
expect(calls).toHaveLength(4)
|
||||
expect(drift.get().checking).toBe(false)
|
||||
gate = null
|
||||
await drift.check()
|
||||
expect(calls).toHaveLength(8)
|
||||
})
|
||||
|
||||
it('keeps the last result in drift.json and loads it again', async () => {
|
||||
baseline('Blocker', json({ ttl: 30 }))
|
||||
servers.a.apps.Blocker = json({ ttl: 30 })
|
||||
servers.b.apps.Blocker = json({ ttl: 99 })
|
||||
await make().drift.check()
|
||||
const file = JSON.parse(readFileSync(path.join(dir, 'drift.json'), 'utf8'))
|
||||
expect(file).toEqual({
|
||||
checkedAt: '2026-10-31T10:00:00.000Z',
|
||||
apps: [{ kind: 'app', name: 'Blocker', app: 'Blocker', server: 'b', since: '2026-10-31T10:00:00.000Z', status: 'differs' }],
|
||||
})
|
||||
const again = make().drift
|
||||
expect(again.get()).toMatchObject({ checkedAt: '2026-10-31T10:00:00.000Z', checking: false })
|
||||
expect(again.count()).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
const baselineSettings = (settings: Record<string, unknown>) => writeSettingsBaseline(dir, { settings, updatedAt: '2026-01-01T00:00:00Z', updatedBy: 'test' })
|
||||
const baselineLists = (blocked: string[], allowed: string[]) => writeListsBaseline(dir, { blocked, allowed, updatedAt: '2026-01-01T00:00:00Z', updatedBy: 'test' })
|
||||
|
||||
describe('drift of settings and lists', () => {
|
||||
it('reports shared settings fields that differ from the baseline, by field name only', async () => {
|
||||
baselineSettings({ blockingType: 'NxDomain', defaultRecordTtl: 3600, dnsServerDomain: 'x.example' })
|
||||
servers.a.settings = { blockingType: 'NxDomain', defaultRecordTtl: 3600, dnsServerDomain: 'a.example' }
|
||||
servers.b.settings = { blockingType: 'AnyAddress', defaultRecordTtl: 60, dnsServerDomain: 'b.example' }
|
||||
const { drift } = make()
|
||||
const state = await drift.check()
|
||||
// dnsServerDomain is local: server a is fine even though its value differs.
|
||||
expect(state.apps).toEqual([{ kind: 'settings', server: 'b', since: '2026-10-31T10:00:00.000Z', status: 'differs', fields: ['blockingType', 'defaultRecordTtl'] }])
|
||||
expect(JSON.stringify(state)).not.toContain('AnyAddress')
|
||||
expect(drift.count()).toBe(1)
|
||||
})
|
||||
|
||||
it('reports Blocked and Allowed lists that differ, without a domain', async () => {
|
||||
baselineLists(['ads.example'], ['ok.example'])
|
||||
servers.a.blocked = ['ads.example']
|
||||
servers.a.allowed = ['ok.example']
|
||||
servers.b.blocked = ['ads.example', 'extra.example']
|
||||
servers.b.allowed = ['ok.example']
|
||||
const { drift } = make()
|
||||
const state = await drift.check()
|
||||
expect(state.apps).toEqual([{ kind: 'lists', server: 'b', since: '2026-10-31T10:00:00.000Z', status: 'differs', fields: ['blocked'] }])
|
||||
expect(JSON.stringify(state)).not.toContain('extra.example')
|
||||
})
|
||||
|
||||
it('reads no settings and no lists when there is no baseline for them', async () => {
|
||||
const { drift } = make()
|
||||
await drift.check()
|
||||
expect(calls).toEqual([])
|
||||
})
|
||||
|
||||
it('lists a server that does not answer as unreadable for each baseline, and does not count it', async () => {
|
||||
baselineSettings({ blockingType: 'NxDomain' })
|
||||
baselineLists([], [])
|
||||
servers.b.down = true
|
||||
const { drift } = make()
|
||||
const state = await drift.check()
|
||||
expect(state.apps.map((e) => [e.kind, e.server, e.status])).toEqual([['lists', 'b', 'unreadable'], ['settings', 'b', 'unreadable']])
|
||||
expect(drift.count()).toBe(0)
|
||||
})
|
||||
|
||||
it('counts every kind in one number', async () => {
|
||||
baseline('Blocker', json({ ttl: 30 }))
|
||||
baselineSettings({ blockingType: 'NxDomain' })
|
||||
baselineLists(['x.example'], [])
|
||||
servers.a.apps.Blocker = json({ ttl: 1 })
|
||||
servers.b.apps.Blocker = json({ ttl: 30 })
|
||||
servers.a.settings = { blockingType: 'AnyAddress' }
|
||||
servers.b.settings = { blockingType: 'NxDomain' }
|
||||
servers.a.blocked = ['x.example']
|
||||
const { drift } = make()
|
||||
await drift.check()
|
||||
// a: app and settings; b: lists (the baseline has x.example, b has none).
|
||||
expect(drift.get().apps.map((e) => `${e.kind}:${e.server}`).sort()).toEqual(['app:a', 'lists:b', 'settings:a'])
|
||||
expect(drift.count()).toBe(3)
|
||||
})
|
||||
|
||||
it('loads a drift.json written before stage 8, where `kind` held the status', () => {
|
||||
writeFileSync(
|
||||
path.join(dir, 'drift.json'),
|
||||
JSON.stringify({
|
||||
checkedAt: '2026-10-31T09:00:00.000Z',
|
||||
apps: [
|
||||
{ app: 'Blocker', server: 'b', since: '2026-10-31T08:00:00.000Z', kind: 'differs' },
|
||||
{ app: 'Other', server: 'a', since: '2026-10-31T08:00:00.000Z', kind: 'not-installed' },
|
||||
{ server: 'a', since: 'x', kind: 'bogus', app: 'Z' },
|
||||
],
|
||||
}),
|
||||
)
|
||||
const { drift } = make()
|
||||
expect(drift.get().apps).toEqual([
|
||||
{ kind: 'app', name: 'Blocker', app: 'Blocker', server: 'b', since: '2026-10-31T08:00:00.000Z', status: 'differs' },
|
||||
{ kind: 'app', name: 'Other', app: 'Other', server: 'a', since: '2026-10-31T08:00:00.000Z', status: 'not-installed' },
|
||||
])
|
||||
expect(drift.count()).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('drift notices', () => {
|
||||
const sent: { event: string; items: Record<string, unknown>[] }[] = []
|
||||
const hook = () =>
|
||||
new Webhook(
|
||||
{ webhookUrl: 'https://hooks.example/x?token=SECRET', webhookSecret: null, domain: 'hub' },
|
||||
{
|
||||
retryDelayMs: 0,
|
||||
fetch: (async (_url: unknown, init: RequestInit) => {
|
||||
sent.push(JSON.parse(String(init.body)))
|
||||
return new Response('ok')
|
||||
}) as typeof fetch,
|
||||
},
|
||||
)
|
||||
|
||||
beforeEach(() => {
|
||||
sent.length = 0
|
||||
})
|
||||
|
||||
it('tells what newly differs once, and what is cleared, and nothing when nothing changed', async () => {
|
||||
baseline('Blocker', json({ ttl: 30 }))
|
||||
servers.a.apps.Blocker = json({ ttl: 30 })
|
||||
servers.b.apps.Blocker = json({ ttl: 99 })
|
||||
const webhook = hook()
|
||||
const { drift } = make(webhook)
|
||||
await drift.check()
|
||||
await webhook.settled()
|
||||
expect(sent.map((s) => s.event)).toEqual(['drift.found'])
|
||||
expect(sent[0].items).toMatchObject([{ kind: 'app', name: 'Blocker', server: 'b' }])
|
||||
|
||||
clock += 15 * 60_000
|
||||
await drift.check()
|
||||
await webhook.settled()
|
||||
expect(sent).toHaveLength(1)
|
||||
|
||||
servers.b.apps.Blocker = json({ ttl: 30 })
|
||||
await drift.check()
|
||||
await webhook.settled()
|
||||
expect(sent.map((s) => s.event)).toEqual(['drift.found', 'drift.cleared'])
|
||||
})
|
||||
|
||||
it('does not repeat a notice after a restart, and does not call an unreadable server cleared', async () => {
|
||||
baseline('Blocker', json({ ttl: 30 }))
|
||||
servers.a.apps.Blocker = json({ ttl: 30 })
|
||||
servers.b.apps.Blocker = json({ ttl: 99 })
|
||||
await make().drift.check()
|
||||
const webhook = hook()
|
||||
const restarted = make(webhook).drift
|
||||
await restarted.check()
|
||||
await webhook.settled()
|
||||
expect(sent).toEqual([])
|
||||
servers.b.down = true
|
||||
await restarted.check()
|
||||
await webhook.settled()
|
||||
expect(sent).toEqual([])
|
||||
})
|
||||
})
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
/*
|
||||
Drift: a server whose config is no longer what the hub says it should be, usually because someone
|
||||
changed it in that server's own console. Three kinds:
|
||||
|
||||
app an app's config is not the baseline plus the server's own local values
|
||||
settings the shared settings fields differ from the settings baseline
|
||||
lists the custom Blocked or Allowed list differs from the lists baseline
|
||||
|
||||
`check` reads every server with the same rules as the compare views and keeps the result in memory
|
||||
and in `${HUB_DATA}/drift.json`; `since` is the first check that saw the same problem, and it is
|
||||
dropped once the problem is gone. An entry carries names and field names only, never a value.
|
||||
Two checks never run at once. The timer lives in server.ts, so the tests drive `check` themselves.
|
||||
|
||||
When a webhook is given, a CHANGE of state is sent to it: `drift.found` for what newly differs and
|
||||
`drift.cleared` for what no longer does (an entry that became unreadable is not cleared: it is not
|
||||
known). The first state is the one loaded from drift.json, so a restart does not repeat notices.
|
||||
*/
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import type { HubConfig } from './config.ts'
|
||||
import { effectiveConfig, sameConfig } from './apps-sync.ts'
|
||||
import { readConfigText } from './apps-config.ts'
|
||||
import { SettingsConfig } from './settings-config.ts'
|
||||
import { listBaselineApps, readBaseline, readOverrides, writeAtomic } from './store.ts'
|
||||
import type { Upstream } from './upstream.ts'
|
||||
import type { Webhook } from './webhook.ts'
|
||||
|
||||
export type DriftStatus = 'differs' | 'unreadable' | 'not-installed'
|
||||
export type DriftType = 'app' | 'settings' | 'lists'
|
||||
|
||||
export interface DriftEntry {
|
||||
kind: DriftType
|
||||
/** The app's name; absent for settings and lists. */
|
||||
name?: string
|
||||
/** Same as `name` for an app (the console's matrix reads it). */
|
||||
app?: string
|
||||
server: string
|
||||
since: string
|
||||
status: DriftStatus
|
||||
/** Setting names, or `blocked` / `allowed`. Never values. */
|
||||
fields?: string[]
|
||||
}
|
||||
|
||||
export interface DriftState {
|
||||
checkedAt: string | null
|
||||
/** Every entry, whatever its kind (the key is kept from stage 4). */
|
||||
apps: DriftEntry[]
|
||||
}
|
||||
|
||||
const STATUSES: DriftStatus[] = ['differs', 'unreadable', 'not-installed']
|
||||
const TYPES: DriftType[] = ['app', 'settings', 'lists']
|
||||
|
||||
export const keyOf = (e: Pick<DriftEntry, 'kind' | 'name' | 'server'>): string => `${e.kind}\n${e.name ?? ''}\n${e.server}`
|
||||
|
||||
/* An entry from drift.json. Before stage 8 `kind` held the status and `app` the name. */
|
||||
export function readEntry(raw: unknown): DriftEntry | null {
|
||||
if (raw === null || typeof raw !== 'object') return null
|
||||
const e = raw as Record<string, unknown>
|
||||
if (typeof e.server !== 'string' || typeof e.since !== 'string') return null
|
||||
const oldKind = typeof e.kind === 'string' && STATUSES.includes(e.kind as DriftStatus)
|
||||
const status = (oldKind ? e.kind : e.status) as DriftStatus
|
||||
if (!STATUSES.includes(status)) return null
|
||||
const kind = (oldKind || e.kind === undefined ? 'app' : e.kind) as DriftType
|
||||
if (!TYPES.includes(kind)) return null
|
||||
const name = typeof e.name === 'string' ? e.name : typeof e.app === 'string' ? e.app : undefined
|
||||
if (kind === 'app' && name === undefined) return null
|
||||
const fields = Array.isArray(e.fields) ? e.fields.filter((f): f is string => typeof f === 'string').slice(0, 100) : undefined
|
||||
return {
|
||||
kind,
|
||||
...(name === undefined ? {} : { name }),
|
||||
...(kind === 'app' ? { app: name } : {}),
|
||||
server: e.server,
|
||||
since: e.since,
|
||||
status,
|
||||
...(fields === undefined || fields.length === 0 ? {} : { fields }),
|
||||
}
|
||||
}
|
||||
|
||||
export class Drift {
|
||||
#config: HubConfig
|
||||
#upstream: Upstream
|
||||
#now: () => number
|
||||
#settings: SettingsConfig
|
||||
#webhook: Webhook | null
|
||||
#state: DriftState
|
||||
#running: Promise<DriftState> | null = null
|
||||
|
||||
constructor(config: HubConfig, upstream: Upstream, now: () => number = Date.now, options: { webhook?: Webhook } = {}) {
|
||||
this.#config = config
|
||||
this.#upstream = upstream
|
||||
this.#now = now
|
||||
this.#settings = new SettingsConfig(config, upstream, now)
|
||||
this.#webhook = options.webhook ?? null
|
||||
this.#state = this.#load()
|
||||
}
|
||||
|
||||
#file(): string {
|
||||
return path.join(this.#config.dataDir, 'drift.json')
|
||||
}
|
||||
|
||||
#load(): DriftState {
|
||||
try {
|
||||
if (!existsSync(this.#file())) return { checkedAt: null, apps: [] }
|
||||
const data = JSON.parse(readFileSync(this.#file(), 'utf8')) as Partial<DriftState>
|
||||
const apps = (Array.isArray(data.apps) ? data.apps : []).map(readEntry).filter((e): e is DriftEntry => e !== null)
|
||||
return { checkedAt: typeof data.checkedAt === 'string' ? data.checkedAt : null, apps }
|
||||
} catch {
|
||||
return { checkedAt: null, apps: [] }
|
||||
}
|
||||
}
|
||||
|
||||
get(): DriftState & { checking: boolean } {
|
||||
return { ...this.#state, checking: this.#running !== null }
|
||||
}
|
||||
|
||||
/** How many entries differ from their baseline, of any kind (unreadable ones are listed, not counted). */
|
||||
count(): number {
|
||||
return this.#state.apps.filter((e) => e.status === 'differs').length
|
||||
}
|
||||
|
||||
/* Runs a check, or joins the one that is already running. */
|
||||
check(): Promise<DriftState> {
|
||||
if (this.#running !== null) return this.#running
|
||||
const run = this.#run().finally(() => {
|
||||
this.#running = null
|
||||
})
|
||||
this.#running = run
|
||||
return run
|
||||
}
|
||||
|
||||
async #run(): Promise<DriftState> {
|
||||
const apps = listBaselineApps(this.#config.dataDir).map((app) => ({
|
||||
app,
|
||||
baseline: readBaseline(this.#config.dataDir, app),
|
||||
overrides: readOverrides(this.#config.dataDir, app),
|
||||
}))
|
||||
const found: Omit<DriftEntry, 'since'>[] = []
|
||||
|
||||
const appChecks = Promise.all(
|
||||
this.#config.servers.map(async (server) => {
|
||||
if (apps.length === 0) return
|
||||
this.#upstream.invalidate(server.id)
|
||||
let installed: Set<string>
|
||||
try {
|
||||
const list = (await this.#upstream.call(server, 'apps/list')) as { apps?: { name: string }[] } | null
|
||||
installed = new Set((list?.apps ?? []).map((a) => a.name))
|
||||
} catch {
|
||||
for (const a of apps) found.push({ kind: 'app', name: a.app, app: a.app, server: server.id, status: 'unreadable' })
|
||||
return
|
||||
}
|
||||
await Promise.all(
|
||||
apps.map(async (a) => {
|
||||
if (a.baseline === null) return
|
||||
const entry = { kind: 'app' as const, name: a.app, app: a.app, server: server.id }
|
||||
if (!installed.has(a.app)) return void found.push({ ...entry, status: 'not-installed' })
|
||||
try {
|
||||
const text = (await readConfigText(this.#upstream, server, a.app)) ?? ''
|
||||
const effective = effectiveConfig(a.baseline.text, a.overrides, server.id).text
|
||||
if (!sameConfig(text, effective)) found.push({ ...entry, status: 'differs' })
|
||||
} catch {
|
||||
found.push({ ...entry, status: 'unreadable' })
|
||||
}
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
const otherChecks = this.#settings.driftFindings().then(
|
||||
(list) => {
|
||||
for (const f of list) found.push({ kind: f.kind, server: f.server, status: f.status, ...(f.fields.length > 0 ? { fields: f.fields } : {}) })
|
||||
},
|
||||
() => undefined,
|
||||
)
|
||||
await Promise.all([appChecks, otherChecks])
|
||||
|
||||
const at = new Date(this.#now()).toISOString()
|
||||
const before = new Map(this.#state.apps.map((e) => [`${keyOf(e)}\n${e.status}`, e.since]))
|
||||
const entries = found
|
||||
.map((f): DriftEntry => ({ ...f, since: before.get(`${keyOf(f)}\n${f.status}`) ?? at }))
|
||||
.sort((x, y) => x.kind.localeCompare(y.kind) || (x.name ?? '').localeCompare(y.name ?? '') || x.server.localeCompare(y.server))
|
||||
const previous = this.#state.apps
|
||||
this.#state = { checkedAt: at, apps: entries }
|
||||
try {
|
||||
writeAtomic(this.#file(), JSON.stringify(this.#state, null, 2) + '\n')
|
||||
} catch (e) {
|
||||
console.error(`hub: drift.json could not be written (${e instanceof Error ? e.message : String(e)}).`)
|
||||
}
|
||||
this.#notify(previous, entries)
|
||||
return this.#state
|
||||
}
|
||||
|
||||
/* A notice for what newly differs and for what is back in line. Names and field names only. */
|
||||
#notify(previous: DriftEntry[], now: DriftEntry[]): void {
|
||||
if (this.#webhook === null || !this.#webhook.configured) return
|
||||
const item = (e: DriftEntry): Record<string, unknown> => ({
|
||||
kind: e.kind,
|
||||
...(e.name === undefined ? {} : { name: e.name }),
|
||||
server: e.server,
|
||||
since: e.since,
|
||||
...(e.fields === undefined ? {} : { fields: e.fields }),
|
||||
})
|
||||
const was = new Set(previous.filter((e) => e.status === 'differs').map(keyOf))
|
||||
const nowDiffers = new Set(now.filter((e) => e.status === 'differs').map(keyOf))
|
||||
const unknown = new Set(now.filter((e) => e.status === 'unreadable').map(keyOf))
|
||||
const newly = now.filter((e) => e.status === 'differs' && !was.has(keyOf(e)))
|
||||
const cleared = previous.filter((e) => e.status === 'differs' && !nowDiffers.has(keyOf(e)) && !unknown.has(keyOf(e)))
|
||||
if (newly.length > 0) this.#webhook.send('drift.found', newly.map(item))
|
||||
if (cleared.length > 0) this.#webhook.send('drift.cleared', cleared.map(item))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
Test helpers for the stage 7 suites: fake Technitium servers that answer by path and record every
|
||||
call, and a hub started against two of them with one user per role. Not used by the hub itself.
|
||||
*/
|
||||
import http from 'node:http'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { parseConfig } from './config.ts'
|
||||
import { createHub } from './server.ts'
|
||||
import { addUser, writeUsersFile } from './users.ts'
|
||||
|
||||
export interface Call {
|
||||
path: string
|
||||
method: string
|
||||
token: string
|
||||
query: URLSearchParams
|
||||
form: URLSearchParams
|
||||
/* A text/plain body (a zone file). */
|
||||
text: string
|
||||
}
|
||||
|
||||
/* What a handler answers: the envelope's `response`, an error message, or a file. */
|
||||
export type Reply = { response?: unknown } | { error: string } | { text: string }
|
||||
|
||||
export interface Fake {
|
||||
server: http.Server
|
||||
url: string
|
||||
calls: Call[]
|
||||
down: boolean
|
||||
handlers: Map<string, (call: Call) => Reply>
|
||||
/** Answers one path; later calls replace earlier ones. */
|
||||
on: (apiPath: string, handler: (call: Call) => Reply) => void
|
||||
}
|
||||
|
||||
export function fake(): Promise<Fake> {
|
||||
return new Promise((resolve) => {
|
||||
const f: Fake = {
|
||||
server: http.createServer(),
|
||||
url: '',
|
||||
calls: [],
|
||||
down: false,
|
||||
handlers: new Map(),
|
||||
on(apiPath, handler) {
|
||||
f.handlers.set(apiPath, handler)
|
||||
},
|
||||
}
|
||||
f.server.on('request', (req, res) => {
|
||||
const chunks: Buffer[] = []
|
||||
req.on('data', (c: Buffer) => chunks.push(c))
|
||||
req.on('end', () => {
|
||||
if (f.down) return req.socket.destroy()
|
||||
const url = new URL(req.url ?? '/', 'http://fake')
|
||||
const type = String(req.headers['content-type'] ?? '')
|
||||
const raw = Buffer.concat(chunks).toString('utf8')
|
||||
const call: Call = {
|
||||
path: url.pathname.replace(/^\/api\//, ''),
|
||||
method: req.method ?? '',
|
||||
token: url.searchParams.get('token') ?? '',
|
||||
query: url.searchParams,
|
||||
form: new URLSearchParams(type.includes('x-www-form-urlencoded') ? raw : ''),
|
||||
text: type.includes('text/plain') ? raw : '',
|
||||
}
|
||||
f.calls.push(call)
|
||||
const reply: Reply = f.handlers.get(call.path)?.(call) ?? { response: {} }
|
||||
if ('text' in reply) {
|
||||
res.setHeader('content-type', 'text/plain')
|
||||
return res.end(reply.text)
|
||||
}
|
||||
res.setHeader('content-type', 'application/json')
|
||||
res.end(JSON.stringify('error' in reply ? { status: 'error', errorMessage: reply.error } : { status: 'ok', response: reply.response ?? {} }))
|
||||
})
|
||||
})
|
||||
f.server.listen(0, '127.0.0.1', () => {
|
||||
f.url = `http://127.0.0.1:${(f.server.address() as AddressInfo).port}`
|
||||
resolve(f)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export const ROLES = ['viewer', 'operator', 'apps-admin', 'admin'] as const
|
||||
|
||||
export interface TestHub {
|
||||
base: string
|
||||
tokens: Record<string, string>
|
||||
dataDir: string
|
||||
/** Calls the hub with a role's session. */
|
||||
call: (role: string, apiPath: string, params?: Record<string, string>, method?: 'GET' | 'POST') => Promise<{ code: number; body: Record<string, any> }>
|
||||
audit: () => Promise<Record<string, string>[]>
|
||||
close: () => void
|
||||
}
|
||||
|
||||
export async function startHub(a: Fake, b: Fake, extra: Record<string, string> = {}): Promise<TestHub> {
|
||||
const dataDir = mkdtempSync(path.join(tmpdir(), 'hub-s7-data-'))
|
||||
const staticDir = mkdtempSync(path.join(tmpdir(), 'hub-s7-static-'))
|
||||
let users = addUser([], 'viewer', 'viewer', 'password-viewer')
|
||||
users = addUser(users, 'operator', 'operator', 'password-operator')
|
||||
users = addUser(users, 'apps-admin', 'apps-admin', 'password-apps-admin')
|
||||
users = addUser(users, 'admin', 'admin', 'password-admin')
|
||||
writeUsersFile(dataDir, users)
|
||||
const hub = createHub(
|
||||
parseConfig({
|
||||
HUB_SERVERS: `a=${a.url},b=${b.url}`,
|
||||
A_TOKEN: 'read-a',
|
||||
B_TOKEN: 'read-b',
|
||||
A_ADMIN_TOKEN: 'admin-a',
|
||||
B_ADMIN_TOKEN: 'admin-b',
|
||||
HUB_DATA: dataDir,
|
||||
HUB_STATIC: staticDir,
|
||||
HUB_CACHE_SECONDS: '0',
|
||||
HUB_TIMEOUT_MS: '2000',
|
||||
HUB_DRIFT_MINUTES: '0',
|
||||
...extra,
|
||||
}),
|
||||
)
|
||||
await new Promise<void>((resolve) => hub.listen(0, '127.0.0.1', resolve))
|
||||
const base = `http://127.0.0.1:${(hub.address() as AddressInfo).port}`
|
||||
const tokens: Record<string, string> = {}
|
||||
for (const role of ROLES) {
|
||||
const res = await fetch(`${base}/api/user/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: `user=${role}&pass=password-${role}`,
|
||||
})
|
||||
tokens[role] = ((await res.json()) as { token: string }).token
|
||||
}
|
||||
const call: TestHub['call'] = async (role, apiPath, params = {}, method = 'POST') => {
|
||||
const query = new URLSearchParams(method === 'GET' ? params : {})
|
||||
const res = await fetch(`${base}/api/${apiPath}${query.size > 0 ? `?${query}` : ''}`, {
|
||||
method,
|
||||
headers: { authorization: `Bearer ${tokens[role]}`, ...(method === 'POST' ? { 'content-type': 'application/x-www-form-urlencoded' } : {}) },
|
||||
...(method === 'POST' ? { body: new URLSearchParams(params).toString() } : {}),
|
||||
})
|
||||
return { code: res.status, body: (await res.json()) as Record<string, any> }
|
||||
}
|
||||
return {
|
||||
base,
|
||||
tokens,
|
||||
dataDir,
|
||||
call,
|
||||
audit: async () => {
|
||||
const res = await fetch(`${base}/api/hub/audit/list?limit=500`, { headers: { authorization: `Bearer ${tokens.admin}` } })
|
||||
return ((await res.json()) as { response: { entries: Record<string, string>[] } }).response.entries
|
||||
},
|
||||
close: () => {
|
||||
hub.close()
|
||||
rmSync(dataDir, { recursive: true, force: true })
|
||||
rmSync(staticDir, { recursive: true, force: true })
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
Checks the hub makes on what passes through it to a server (stage 5): which `settings/set`
|
||||
fields it will not forward, which values of `settings/get` a role may see, and which single
|
||||
identifier of a write may go into the audit log.
|
||||
|
||||
Pure functions, so the tests feed them any input.
|
||||
*/
|
||||
|
||||
/*
|
||||
`settings/set` and the hub's own way in.
|
||||
|
||||
The hub reaches a server through that server's web service, with an API token. Changing the
|
||||
service's bind addresses, ports, TLS or reverse-proxy settings from here can cut the hub off with
|
||||
no way back except the server's own console, so the hub refuses a change to any `webService*`
|
||||
field. The console sends the whole Web Service block with every save, unchanged values included,
|
||||
so a field is refused only when its value DIFFERS from what the server holds now (see
|
||||
`webServiceChanges`). Users, SSO and LDAP are not part of `settings/set`; a field that looks like
|
||||
one (`user*`, `sso*`, `ldap*`, `auth*`) is refused outright, in case a server version adds it.
|
||||
*/
|
||||
export const WEB_SERVICE_MESSAGE = "Change the web service settings in that server's own console."
|
||||
|
||||
const FORBIDDEN_FIELD = /^(user|sso|ldap|auth)/i
|
||||
const WEB_FIELD = /^webService/
|
||||
|
||||
/** True for a field the hub never forwards, whatever its value. */
|
||||
export const isForbiddenSettingsField = (name: string): boolean => FORBIDDEN_FIELD.test(name)
|
||||
|
||||
/* A list as the console sends it (`a,b`, one per line, or `false` for none) or as the server holds it. */
|
||||
function listOf(value: unknown): string[] {
|
||||
const items = Array.isArray(value) ? value.map(String) : String(value ?? '').split(/[,\n]/)
|
||||
return items.map((v) => v.trim()).filter((v) => v !== '' && v !== 'false')
|
||||
}
|
||||
|
||||
/* Whether a submitted text means what the server holds. Booleans and numbers by value, lists as sets, text trimmed. */
|
||||
function sameSetting(submitted: string, current: unknown): boolean {
|
||||
if (typeof current === 'boolean') return submitted.trim().toLowerCase() === String(current)
|
||||
if (typeof current === 'number') return submitted.trim() !== '' && Number(submitted) === current
|
||||
if (Array.isArray(current)) {
|
||||
const a = listOf(submitted).sort()
|
||||
const b = listOf(current).sort()
|
||||
return a.length === b.length && a.every((v, i) => v === b[i])
|
||||
}
|
||||
return submitted.trim() === String(current ?? '').trim()
|
||||
}
|
||||
|
||||
/*
|
||||
The `webService*` fields of a `settings/set` whose value differs from the server's current
|
||||
settings (`settings/get`). A field the server does not report is not compared: it would be ignored
|
||||
there too.
|
||||
*/
|
||||
export function webServiceChanges(submitted: Record<string, string>, current: unknown): string[] {
|
||||
const now = (current !== null && typeof current === 'object' ? current : {}) as Record<string, unknown>
|
||||
const changed: string[] = []
|
||||
for (const [name, value] of Object.entries(submitted)) {
|
||||
if (!WEB_FIELD.test(name) || !Object.hasOwn(now, name)) continue
|
||||
if (!sameSetting(value, now[name])) changed.push(name)
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
/*
|
||||
`settings/get` carries secrets: the TSIG shared secrets, the forward proxy password and the two
|
||||
certificate passwords. A role without `admin` gets a copy with those values blanked (a viewer
|
||||
needs the screen, not the keys); an admin gets them, since saving the form sends them back. The
|
||||
blanked copy is for display only and `settings/set` is `admin`-only, so a blank cannot be saved
|
||||
over a real secret by someone who may not read it.
|
||||
*/
|
||||
export function redactSettings(response: unknown): unknown {
|
||||
if (response === null || typeof response !== 'object') return response
|
||||
const copy = { ...(response as Record<string, unknown>) }
|
||||
for (const key of ['webServiceTlsCertificatePassword', 'dnsTlsCertificatePassword']) {
|
||||
if (typeof copy[key] === 'string' && copy[key] !== '') copy[key] = ''
|
||||
}
|
||||
if (Array.isArray(copy.tsigKeys)) {
|
||||
copy.tsigKeys = copy.tsigKeys.map((k: unknown) => (k !== null && typeof k === 'object' ? { ...k, sharedSecret: '' } : k))
|
||||
}
|
||||
const proxy = copy.proxy
|
||||
if (proxy !== null && typeof proxy === 'object' && typeof (proxy as { password?: unknown }).password === 'string') {
|
||||
copy.proxy = { ...proxy, password: '' }
|
||||
}
|
||||
return copy
|
||||
}
|
||||
|
||||
/*
|
||||
The one identifier an audit line may carry for a write: a zone, a domain, a name or a scope. Never
|
||||
the other parameters (records carry data, settings carry passwords and TSIG secrets). Cut short.
|
||||
*/
|
||||
const IDENTIFIERS = ['zone', 'domain', 'name', 'scope', 'zones']
|
||||
|
||||
export function safeTarget(params: Record<string, string>): string | undefined {
|
||||
for (const key of IDENTIFIERS) {
|
||||
const value = params[key]
|
||||
if (typeof value === 'string' && value.trim() !== '') return value.trim().slice(0, 120)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
A small structural diff of two JSON values: objects by key, arrays by index. A leaf that
|
||||
differs, a key present on one side only, and a change of type each give one entry.
|
||||
*/
|
||||
export interface JsonDifference {
|
||||
path: string
|
||||
a: unknown
|
||||
b: unknown
|
||||
}
|
||||
|
||||
const kind = (v: unknown): string => (v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v)
|
||||
|
||||
export function jsonDiff(a: unknown, b: unknown, path = ''): JsonDifference[] {
|
||||
const ka = kind(a)
|
||||
if (ka !== kind(b)) return [{ path, a, b }]
|
||||
if (ka === 'array') {
|
||||
const [xs, ys] = [a as unknown[], b as unknown[]]
|
||||
const out: JsonDifference[] = []
|
||||
for (let i = 0; i < Math.max(xs.length, ys.length); i++) {
|
||||
const here = `${path}[${i}]`
|
||||
if (i >= xs.length || i >= ys.length) out.push({ path: here, a: xs[i], b: ys[i] })
|
||||
else out.push(...jsonDiff(xs[i], ys[i], here))
|
||||
}
|
||||
return out
|
||||
}
|
||||
if (ka === 'object') {
|
||||
const [x, y] = [a as Record<string, unknown>, b as Record<string, unknown>]
|
||||
const out: JsonDifference[] = []
|
||||
for (const key of new Set([...Object.keys(x), ...Object.keys(y)])) {
|
||||
const here = path === '' ? key : `${path}.${key}`
|
||||
const [inX, inY] = [Object.hasOwn(x, key), Object.hasOwn(y, key)]
|
||||
if (inX && inY) out.push(...jsonDiff(x[key], y[key], here))
|
||||
else out.push({ path: here, a: x[key], b: y[key] })
|
||||
}
|
||||
return out
|
||||
}
|
||||
return a === b || (Number.isNaN(a) && Number.isNaN(b)) ? [] : [{ path, a, b }]
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
mergeQueryLogs,
|
||||
mergeSettled,
|
||||
mergeStats,
|
||||
mergeTop,
|
||||
mergeTopResponses,
|
||||
type ChartData,
|
||||
type DashboardStats,
|
||||
type QueryLogEntry,
|
||||
type QueryLogPage,
|
||||
type Settled,
|
||||
} from './merge.ts'
|
||||
|
||||
/* Fixtures in the shape of `dashboard/stats/get` (see dev/mock-api.mjs). */
|
||||
function stats(over: Partial<Record<string, number>> = {}): Record<string, number> {
|
||||
return {
|
||||
totalQueries: 100, totalNoError: 90, totalServerFailure: 1, totalNxDomain: 5, totalRefused: 4,
|
||||
totalAuthoritative: 10, totalRecursive: 30, totalCached: 50, totalBlocked: 8, totalDropped: 2,
|
||||
totalClients: 12, zones: 3, cachedEntries: 400, allowedZones: 1, blockedZones: 2, allowListZones: 1,
|
||||
blockListZones: 1000, ...over,
|
||||
} as Record<string, number>
|
||||
}
|
||||
|
||||
function response(over: Partial<DashboardStats> = {}): DashboardStats {
|
||||
return {
|
||||
stats: stats(),
|
||||
mainChartData: {
|
||||
labelFormat: 'HH:mm',
|
||||
labels: ['2026-01-01T10:00:00Z', '2026-01-01T11:00:00Z'],
|
||||
datasets: [{ label: 'Total', data: [40, 60] }, { label: 'Blocked', data: [3, 5] }],
|
||||
},
|
||||
queryResponseChartData: {
|
||||
labels: ['Authoritative', 'Recursive', 'Cached'],
|
||||
datasets: [{ label: 'Total', data: [10, 30, 50], backgroundColor: ['#111', '#222', '#333'] }],
|
||||
},
|
||||
queryTypeChartData: { labels: ['A', 'AAAA'], datasets: [{ label: 'Total', data: [70, 30], backgroundColor: [] }] },
|
||||
protocolTypeChartData: { labels: ['UDP', 'TCP'], datasets: [{ label: 'Total', data: [90, 10], backgroundColor: [] }] },
|
||||
topClients: [{ name: '10.0.0.1', domain: 'a.lan', hits: 50 }],
|
||||
topDomains: [{ name: 'example.com', hits: 30 }],
|
||||
topBlockedDomains: [{ name: 'ads.example.com', hits: 8 }],
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
describe('mergeStats counters', () => {
|
||||
it('sums every numeric field of stats', () => {
|
||||
const merged = mergeStats([response(), response({ stats: stats({ totalQueries: 250, zones: 7 }) })])
|
||||
expect(merged.stats.totalQueries).toBe(350)
|
||||
expect(merged.stats.totalNoError).toBe(180)
|
||||
expect(merged.stats.zones).toBe(10)
|
||||
expect(merged.stats.blockListZones).toBe(2000)
|
||||
expect(Object.keys(merged.stats)).toEqual(Object.keys(stats()))
|
||||
})
|
||||
|
||||
it('sums what each server holds: zones, cache, allowed, blocked and the two lists', () => {
|
||||
const a = response({ stats: stats({ zones: 3, cachedEntries: 400, allowedZones: 1, blockedZones: 2, allowListZones: 1, blockListZones: 1000 }) })
|
||||
const b = response({ stats: stats({ zones: 5, cachedEntries: 50, allowedZones: 4, blockedZones: 6, allowListZones: 2, blockListZones: 10 }) })
|
||||
const merged = mergeStats([a, b])
|
||||
expect(merged.stats).toMatchObject({ zones: 8, cachedEntries: 450, allowedZones: 5, blockedZones: 8, allowListZones: 3, blockListZones: 1010 })
|
||||
})
|
||||
|
||||
it('counts a numeric string, and a server that left a field out as none', () => {
|
||||
const a = response({ stats: { ...stats(), zones: '4' as unknown as number } })
|
||||
const b = response({ stats: { totalQueries: 1 } })
|
||||
const merged = mergeStats([a, b])
|
||||
expect(merged.stats.zones).toBe(4)
|
||||
expect(merged.stats.allowedZones).toBe(1)
|
||||
expect(merged.stats.totalQueries).toBe(101)
|
||||
expect(mergeStats([b]).stats.blockListZones).toBe(0)
|
||||
})
|
||||
|
||||
it('returns a single response with the same numbers', () => {
|
||||
const merged = mergeStats([response()])
|
||||
expect(merged.stats).toEqual(stats())
|
||||
expect(merged.mainChartData?.datasets[0].data).toEqual([40, 60])
|
||||
})
|
||||
|
||||
it('throws on nothing to merge', () => {
|
||||
expect(() => mergeStats([])).toThrow()
|
||||
})
|
||||
|
||||
it('does not mutate its inputs', () => {
|
||||
const a = response()
|
||||
const b = response()
|
||||
const before = JSON.stringify([a, b])
|
||||
mergeStats([a, b])
|
||||
expect(JSON.stringify([a, b])).toBe(before)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeStats main chart', () => {
|
||||
it('sums the values of equal labels per dataset', () => {
|
||||
const merged = mergeStats([response(), response()])
|
||||
expect(merged.mainChartData?.labels).toEqual(['2026-01-01T10:00:00Z', '2026-01-01T11:00:00Z'])
|
||||
expect(merged.mainChartData?.datasets).toEqual([
|
||||
{ label: 'Total', data: [80, 120] },
|
||||
{ label: 'Blocked', data: [6, 10] },
|
||||
])
|
||||
expect(merged.mainChartData?.labelFormat).toBe('HH:mm')
|
||||
})
|
||||
|
||||
it('aligns differing label sets by label, missing points count as 0, order stays chronological', () => {
|
||||
const a: ChartData = { labels: ['t1', 't2', 't4'], datasets: [{ label: 'Total', data: [1, 2, 4] }] }
|
||||
const b: ChartData = { labels: ['t2', 't3', 't4', 't5'], datasets: [{ label: 'Total', data: [20, 30, 40, 50] }] }
|
||||
const merged = mergeStats([response({ mainChartData: a }), response({ mainChartData: b })])
|
||||
expect(merged.mainChartData?.labels).toEqual(['t1', 't2', 't3', 't4', 't5'])
|
||||
expect(merged.mainChartData?.datasets[0].data).toEqual([1, 22, 30, 44, 50])
|
||||
})
|
||||
|
||||
it('slips earlier labels of a later server before the first shared label', () => {
|
||||
const a: ChartData = { labels: ['t3', 't4'], datasets: [{ label: 'Total', data: [3, 4] }] }
|
||||
const b: ChartData = { labels: ['t1', 't2', 't3'], datasets: [{ label: 'Total', data: [10, 20, 30] }] }
|
||||
const merged = mergeStats([response({ mainChartData: a }), response({ mainChartData: b })])
|
||||
expect(merged.mainChartData?.labels).toEqual(['t1', 't2', 't3', 't4'])
|
||||
expect(merged.mainChartData?.datasets[0].data).toEqual([10, 20, 33, 4])
|
||||
})
|
||||
|
||||
it('matches datasets by label, not by position', () => {
|
||||
const a: ChartData = {
|
||||
labels: ['t1'],
|
||||
datasets: [{ label: 'Total', data: [5] }, { label: 'Blocked', data: [1] }],
|
||||
}
|
||||
const b: ChartData = {
|
||||
labels: ['t1'],
|
||||
datasets: [{ label: 'Blocked', data: [10] }, { label: 'Cached', data: [7] }, { label: 'Total', data: [50] }],
|
||||
}
|
||||
const merged = mergeStats([response({ mainChartData: a }), response({ mainChartData: b })])
|
||||
expect(merged.mainChartData?.datasets).toEqual([
|
||||
{ label: 'Total', data: [55] },
|
||||
{ label: 'Blocked', data: [11] },
|
||||
{ label: 'Cached', data: [7] },
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps a dataset that only one server has, zero where the other labels are', () => {
|
||||
const a: ChartData = { labels: ['t1'], datasets: [{ label: 'Total', data: [5] }] }
|
||||
const b: ChartData = { labels: ['t2'], datasets: [{ label: 'Cached', data: [7] }] }
|
||||
const merged = mergeStats([response({ mainChartData: a }), response({ mainChartData: b })])
|
||||
expect(merged.mainChartData?.labels).toEqual(['t1', 't2'])
|
||||
expect(merged.mainChartData?.datasets).toEqual([
|
||||
{ label: 'Total', data: [5, 0] },
|
||||
{ label: 'Cached', data: [0, 7] },
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the other properties of the first dataset that has them', () => {
|
||||
const a: ChartData = { labels: ['t1'], datasets: [{ label: 'Total', data: [1], backgroundColor: '#aaa' }] }
|
||||
const b: ChartData = { labels: ['t1'], datasets: [{ label: 'Total', data: [2], backgroundColor: '#bbb' }] }
|
||||
const merged = mergeStats([response({ mainChartData: a }), response({ mainChartData: b })])
|
||||
expect(merged.mainChartData?.datasets[0]).toEqual({ label: 'Total', data: [3], backgroundColor: '#aaa' })
|
||||
})
|
||||
|
||||
it('survives a server without a main chart', () => {
|
||||
const merged = mergeStats([response({ mainChartData: undefined }), response()])
|
||||
expect(merged.mainChartData?.datasets[0].data).toEqual([40, 60])
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeStats breakdown charts', () => {
|
||||
it('sums values per label and keeps the colours of the first server', () => {
|
||||
const merged = mergeStats([response(), response()])
|
||||
expect(merged.queryResponseChartData).toEqual({
|
||||
labels: ['Authoritative', 'Recursive', 'Cached'],
|
||||
datasets: [{ label: 'Total', data: [20, 60, 100], backgroundColor: ['#111', '#222', '#333'] }],
|
||||
})
|
||||
expect(merged.queryTypeChartData?.datasets[0].data).toEqual([140, 60])
|
||||
expect(merged.protocolTypeChartData?.datasets[0].data).toEqual([180, 20])
|
||||
})
|
||||
|
||||
it('keeps the label order of the first server and appends new labels', () => {
|
||||
const a: ChartData = { labels: ['A', 'AAAA'], datasets: [{ label: 'Total', data: [1, 2] }] }
|
||||
const b: ChartData = { labels: ['TXT', 'AAAA', 'A', 'MX'], datasets: [{ label: 'Total', data: [10, 20, 30, 40] }] }
|
||||
const merged = mergeStats([response({ queryTypeChartData: a }), response({ queryTypeChartData: b })])
|
||||
expect(merged.queryTypeChartData?.labels).toEqual(['A', 'AAAA', 'TXT', 'MX'])
|
||||
expect(merged.queryTypeChartData?.datasets[0].data).toEqual([31, 22, 10, 40])
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeStats top lists', () => {
|
||||
it('merges the three top lists inside the stats', () => {
|
||||
const a = response({
|
||||
topClients: [{ name: '10.0.0.1', domain: '', hits: 50 }, { name: '10.0.0.2', hits: 10, rateLimited: true }],
|
||||
topDomains: [{ name: 'a.com', hits: 5 }],
|
||||
})
|
||||
const b = response({
|
||||
topClients: [{ name: '10.0.0.1', domain: 'host.lan', hits: 25 }],
|
||||
topDomains: [{ name: 'b.com', hits: 9 }],
|
||||
topBlockedDomains: [{ name: 'ads.example.com', hits: 2 }],
|
||||
})
|
||||
const merged = mergeStats([a, b])
|
||||
expect(merged.topClients).toEqual([
|
||||
{ name: '10.0.0.1', domain: 'host.lan', hits: 75 },
|
||||
{ name: '10.0.0.2', hits: 10, rateLimited: true },
|
||||
])
|
||||
// The default limit is the longest input list (1 here).
|
||||
expect(merged.topDomains).toEqual([{ name: 'b.com', hits: 9 }])
|
||||
expect(merged.topBlockedDomains).toEqual([{ name: 'ads.example.com', hits: 10 }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeTop', () => {
|
||||
it('sums hits by name, sorts by hits and cuts to the longest input by default', () => {
|
||||
const a = [{ name: 'a', hits: 10 }, { name: 'b', hits: 5 }, { name: 'c', hits: 1 }]
|
||||
const b = [{ name: 'c', hits: 20 }, { name: 'd', hits: 7 }]
|
||||
expect(mergeTop([a, b])).toEqual([
|
||||
{ name: 'c', hits: 21 },
|
||||
{ name: 'a', hits: 10 },
|
||||
{ name: 'd', hits: 7 },
|
||||
])
|
||||
})
|
||||
|
||||
it('cuts to an explicit limit', () => {
|
||||
const a = [{ name: 'a', hits: 10 }, { name: 'b', hits: 9 }]
|
||||
const b = [{ name: 'c', hits: 8 }, { name: 'd', hits: 7 }]
|
||||
expect(mergeTop([a, b], 3).map((e) => e.name)).toEqual(['a', 'b', 'c'])
|
||||
expect(mergeTop([a, b], 0)).toEqual([])
|
||||
})
|
||||
|
||||
it('breaks ties by name ascending', () => {
|
||||
const a = [{ name: 'zeta', hits: 5 }, { name: 'alpha', hits: 5 }]
|
||||
const b = [{ name: 'mid', hits: 5 }]
|
||||
expect(mergeTop([a, b], 10).map((e) => e.name)).toEqual(['alpha', 'mid', 'zeta'])
|
||||
})
|
||||
|
||||
it('keeps the first non-empty domain and ORs rateLimited', () => {
|
||||
const a = [{ name: 'c1', domain: '', hits: 1 }]
|
||||
const b = [{ name: 'c1', domain: 'first.lan', hits: 1, rateLimited: true }]
|
||||
const c = [{ name: 'c1', domain: 'second.lan', hits: 1 }]
|
||||
expect(mergeTop([a, b, c])).toEqual([{ name: 'c1', domain: 'first.lan', hits: 3, rateLimited: true }])
|
||||
})
|
||||
|
||||
it('ignores missing lists and does not mutate the inputs', () => {
|
||||
const a = [{ name: 'a', hits: 1 }]
|
||||
expect(mergeTop([a, undefined, a])).toEqual([{ name: 'a', hits: 2 }])
|
||||
expect(a).toEqual([{ name: 'a', hits: 1 }])
|
||||
expect(mergeTop([])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeTopResponses', () => {
|
||||
it('merges the getTop shape under the key the servers used', () => {
|
||||
const merged = mergeTopResponses([
|
||||
{ topClients: [{ name: 'x', hits: 3 }] },
|
||||
{ topClients: [{ name: 'x', hits: 4 }, { name: 'y', hits: 1 }] },
|
||||
], 1)
|
||||
expect(merged).toEqual({ topClients: [{ name: 'x', hits: 7 }] })
|
||||
})
|
||||
|
||||
it('works for domains and blocked domains', () => {
|
||||
expect(mergeTopResponses([{ topDomains: [{ name: 'a', hits: 1 }] }, { topDomains: [{ name: 'a', hits: 1 }] }]))
|
||||
.toEqual({ topDomains: [{ name: 'a', hits: 2 }] })
|
||||
expect(mergeTopResponses([{ topBlockedDomains: [{ name: 'b', hits: 1 }] }]))
|
||||
.toEqual({ topBlockedDomains: [{ name: 'b', hits: 1 }] })
|
||||
})
|
||||
})
|
||||
|
||||
/* Fixtures in the shape of `logs/query` (see dev/mock-api.mjs). */
|
||||
function entry(timestamp: string, qname: string): QueryLogEntry {
|
||||
return {
|
||||
rowNumber: 1, timestamp, clientIpAddress: '192.168.10.14', protocol: 'Udp', responseType: 'Cached',
|
||||
responseRtt: 1.5, rcode: 'NoError', qname, qtype: 'A', qclass: 'IN', answer: '1.1.1.1',
|
||||
}
|
||||
}
|
||||
|
||||
function page(entries: QueryLogEntry[], totalEntries = entries.length): QueryLogPage {
|
||||
return { pageNumber: 1, totalPages: 1, totalEntries, entries }
|
||||
}
|
||||
|
||||
describe('mergeQueryLogs', () => {
|
||||
const dns = page([entry('2026-01-01T10:00:03Z', 'a.com'), entry('2026-01-01T10:00:01Z', 'c.com')])
|
||||
const ndns = page([entry('2026-01-01T10:00:04Z', 'b.com'), entry('2026-01-01T10:00:02Z', 'd.com')])
|
||||
const pages = [{ node: 'dns', page: dns }, { node: 'ndns', page: ndns }]
|
||||
|
||||
it('sorts newest first by default and tags each entry with its server', () => {
|
||||
const merged = mergeQueryLogs(pages, { pageNumber: 1, entriesPerPage: 25 })
|
||||
expect(merged.entries.map((e) => e.qname)).toEqual(['b.com', 'a.com', 'd.com', 'c.com'])
|
||||
expect(merged.entries.map((e) => e.node)).toEqual(['ndns', 'dns', 'ndns', 'dns'])
|
||||
expect(merged.entries[0]).toMatchObject({ clientIpAddress: '192.168.10.14', answer: '1.1.1.1', responseRtt: 1.5 })
|
||||
})
|
||||
|
||||
it('sorts oldest first when descending is false', () => {
|
||||
const merged = mergeQueryLogs(pages, { pageNumber: 1, entriesPerPage: 25, descending: false })
|
||||
expect(merged.entries.map((e) => e.qname)).toEqual(['c.com', 'd.com', 'a.com', 'b.com'])
|
||||
})
|
||||
|
||||
it('renumbers rows from 1 and sums the totals', () => {
|
||||
const merged = mergeQueryLogs(
|
||||
[{ node: 'dns', page: page(dns.entries, 120) }, { node: 'ndns', page: page(ndns.entries, 31) }],
|
||||
{ pageNumber: 1, entriesPerPage: 25 },
|
||||
)
|
||||
expect(merged.entries.map((e) => e.rowNumber)).toEqual([1, 2, 3, 4])
|
||||
expect(merged.totalEntries).toBe(151)
|
||||
expect(merged.totalPages).toBe(7)
|
||||
expect(merged.pageNumber).toBe(1)
|
||||
})
|
||||
|
||||
it('cuts page N from the merged head of every server and numbers it from its offset', () => {
|
||||
// Each server sent its first 2 x 2 entries; page 2 is the 3rd and 4th of the merged list.
|
||||
const merged = mergeQueryLogs(pages, { pageNumber: 2, entriesPerPage: 2 })
|
||||
expect(merged.pageNumber).toBe(2)
|
||||
expect(merged.entries.map((e) => e.qname)).toEqual(['d.com', 'c.com'])
|
||||
expect(merged.entries.map((e) => e.rowNumber)).toEqual([3, 4])
|
||||
})
|
||||
|
||||
it('a page past the end is empty', () => {
|
||||
expect(mergeQueryLogs(pages, { pageNumber: 3, entriesPerPage: 2 }).entries).toEqual([])
|
||||
})
|
||||
|
||||
it('cuts to entriesPerPage', () => {
|
||||
const merged = mergeQueryLogs(pages, { pageNumber: 1, entriesPerPage: 3 })
|
||||
expect(merged.entries.map((e) => e.qname)).toEqual(['b.com', 'a.com', 'd.com'])
|
||||
expect(merged.totalEntries).toBe(4)
|
||||
expect(merged.totalPages).toBe(2)
|
||||
})
|
||||
|
||||
it('keeps server order for equal timestamps', () => {
|
||||
const same = [
|
||||
{ node: 'dns', page: page([entry('2026-01-01T10:00:00Z', 'x.com')]) },
|
||||
{ node: 'ndns', page: page([entry('2026-01-01T10:00:00Z', 'y.com')]) },
|
||||
]
|
||||
expect(mergeQueryLogs(same, { pageNumber: 1, entriesPerPage: 10 }).entries.map((e) => e.node)).toEqual(['dns', 'ndns'])
|
||||
})
|
||||
|
||||
it('handles empty pages', () => {
|
||||
const merged = mergeQueryLogs([{ node: 'dns', page: page([]) }], { pageNumber: 1, entriesPerPage: 25 })
|
||||
expect(merged).toEqual({ pageNumber: 1, totalPages: 0, totalEntries: 0, entries: [] })
|
||||
})
|
||||
|
||||
it('does not mutate the input entries', () => {
|
||||
mergeQueryLogs(pages, { pageNumber: 2, entriesPerPage: 2 })
|
||||
expect(dns.entries[0].node).toBeUndefined()
|
||||
expect(dns.entries[0].rowNumber).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeSettled', () => {
|
||||
it('returns the data of the servers that answered', () => {
|
||||
const results: Settled<number>[] = [{ node: 'dns', ok: true, data: 1 }, { node: 'ndns', ok: true, data: 2 }]
|
||||
expect(mergeSettled(results)).toEqual({ data: [1, 2], warnings: [] })
|
||||
})
|
||||
|
||||
it('warns about an unreachable server and keeps the other one', () => {
|
||||
const results: Settled<number>[] = [
|
||||
{ node: 'dns', ok: true, data: 1 },
|
||||
{ node: 'ndns', ok: false, error: 'timeout' },
|
||||
]
|
||||
expect(mergeSettled(results)).toEqual({ data: [1], warnings: ['ndns: timeout'] })
|
||||
})
|
||||
|
||||
it('throws naming every server when all failed', () => {
|
||||
const results: Settled<number>[] = [
|
||||
{ node: 'dns', ok: false, error: 'refused' },
|
||||
{ node: 'ndns', ok: false, error: 'timeout' },
|
||||
]
|
||||
expect(() => mergeSettled(results)).toThrow(/dns: refused.*ndns: timeout/)
|
||||
})
|
||||
})
|
||||
+260
@@ -0,0 +1,260 @@
|
||||
/*
|
||||
Pure merge functions of the hub: they take the `response` objects of several
|
||||
Technitium servers and return the one a single server would have sent, so the
|
||||
console can show "All servers" without knowing there are two.
|
||||
|
||||
No imports, no I/O: it runs under `node --experimental-strip-types` and is tested
|
||||
on its own. The types are declared here and mirror `src/api/dashboard.ts` and
|
||||
`src/api/logs.ts` on purpose, so the hub never pulls the console's sources in.
|
||||
*/
|
||||
|
||||
export interface ChartDataset {
|
||||
label: string
|
||||
data: number[]
|
||||
backgroundColor?: string | string[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface ChartData {
|
||||
labelFormat?: string
|
||||
labels: string[]
|
||||
datasets: ChartDataset[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface TopEntry {
|
||||
name: string
|
||||
hits: number
|
||||
domain?: string
|
||||
rateLimited?: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface DashboardStats {
|
||||
stats: Record<string, number>
|
||||
mainChartData?: ChartData
|
||||
queryResponseChartData?: ChartData
|
||||
queryTypeChartData?: ChartData
|
||||
protocolTypeChartData?: ChartData
|
||||
topClients?: TopEntry[]
|
||||
topDomains?: TopEntry[]
|
||||
topBlockedDomains?: TopEntry[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface QueryLogEntry {
|
||||
rowNumber: number
|
||||
timestamp: string
|
||||
node?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface QueryLogPage {
|
||||
pageNumber: number
|
||||
totalPages: number
|
||||
totalEntries: number
|
||||
entries: QueryLogEntry[]
|
||||
}
|
||||
|
||||
export type Settled<T> = { node: string; ok: true; data: T } | { node: string; ok: false; error: string }
|
||||
|
||||
const TOP_KEYS = ['topClients', 'topDomains', 'topBlockedDomains'] as const
|
||||
const BREAKDOWN_KEYS = ['queryResponseChartData', 'queryTypeChartData', 'protocolTypeChartData'] as const
|
||||
|
||||
/*
|
||||
Charts. A chart is a list of labels and datasets whose `data` lines up with the
|
||||
labels. Merging means: build the merged label list, then for each dataset (matched
|
||||
by its `label`) add each server's value into the slot of its label. A label a
|
||||
server lacks counts as 0.
|
||||
|
||||
`ordered` is for the time series: the merged labels keep chronological order, new
|
||||
labels are slipped in before the next label both lists share. Without it (the
|
||||
breakdowns) the labels of the first server keep their order and new ones are
|
||||
appended.
|
||||
*/
|
||||
function mergeLabels(charts: ChartData[], ordered: boolean): string[] {
|
||||
const labels: string[] = [...charts[0].labels]
|
||||
for (const chart of charts.slice(1)) {
|
||||
if (!ordered) {
|
||||
for (const label of chart.labels) if (!labels.includes(label)) labels.push(label)
|
||||
continue
|
||||
}
|
||||
// Labels before the first shared one go right before it; with none shared, all are appended.
|
||||
const anchor = chart.labels.findIndex((label) => labels.includes(label))
|
||||
if (anchor < 0) {
|
||||
labels.push(...chart.labels)
|
||||
continue
|
||||
}
|
||||
let pos = labels.indexOf(chart.labels[anchor])
|
||||
for (const label of chart.labels) {
|
||||
const at = labels.indexOf(label)
|
||||
if (at >= 0) {
|
||||
pos = at + 1
|
||||
} else {
|
||||
labels.splice(pos, 0, label)
|
||||
pos += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return labels
|
||||
}
|
||||
|
||||
function mergeChart(charts: (ChartData | undefined)[], ordered: boolean): ChartData | undefined {
|
||||
const present = charts.filter((c): c is ChartData => c != null && Array.isArray(c.labels))
|
||||
if (present.length === 0) return undefined
|
||||
|
||||
const labels = mergeLabels(present, ordered)
|
||||
const slot = new Map(labels.map((label, i) => [label, i]))
|
||||
const merged = new Map<string, ChartDataset>()
|
||||
|
||||
for (const chart of present) {
|
||||
for (const dataset of chart.datasets ?? []) {
|
||||
let target = merged.get(dataset.label)
|
||||
if (target === undefined) {
|
||||
// The other properties (colours...) come from the first server that has the dataset.
|
||||
target = { ...dataset, data: labels.map(() => 0) }
|
||||
merged.set(dataset.label, target)
|
||||
}
|
||||
const into = target
|
||||
chart.labels.forEach((label, i) => {
|
||||
const value = dataset.data?.[i]
|
||||
if (typeof value === 'number') into.data[slot.get(label) as number] += value
|
||||
})
|
||||
}
|
||||
}
|
||||
return { ...present[0], labels, datasets: [...merged.values()] }
|
||||
}
|
||||
|
||||
/*
|
||||
Top lists: hits are summed by name, ordered by hits (highest first) then by name,
|
||||
and cut to `limit`, which defaults to the longest input list. For a client, the
|
||||
first non-empty `domain` is kept and `rateLimited` is true if any server said so.
|
||||
*/
|
||||
export function mergeTop(lists: (TopEntry[] | undefined)[], limit?: number): TopEntry[] {
|
||||
const present = lists.filter((l): l is TopEntry[] => Array.isArray(l))
|
||||
const byName = new Map<string, TopEntry>()
|
||||
for (const list of present) {
|
||||
for (const entry of list) {
|
||||
const seen = byName.get(entry.name)
|
||||
if (seen === undefined) {
|
||||
byName.set(entry.name, { ...entry })
|
||||
continue
|
||||
}
|
||||
seen.hits += entry.hits
|
||||
if (!seen.domain && entry.domain) seen.domain = entry.domain
|
||||
if (entry.rateLimited) seen.rateLimited = true
|
||||
}
|
||||
}
|
||||
const cut = limit ?? Math.max(0, ...present.map((l) => l.length))
|
||||
return [...byName.values()]
|
||||
.sort((a, b) => b.hits - a.hits || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
|
||||
.slice(0, cut)
|
||||
}
|
||||
|
||||
/* The `dashboard/stats/getTop` response is `{ topClients | topDomains | topBlockedDomains: [...] }`. */
|
||||
export function mergeTopResponses(
|
||||
responses: Record<string, TopEntry[] | undefined>[],
|
||||
limit?: number,
|
||||
): Record<string, TopEntry[]> {
|
||||
for (const key of TOP_KEYS) {
|
||||
if (responses.some((r) => Array.isArray(r[key]))) return { [key]: mergeTop(responses.map((r) => r[key]), limit) }
|
||||
}
|
||||
return { topDomains: [] }
|
||||
}
|
||||
|
||||
/* A counter as a number: a number, or a numeric string some servers send. Anything else is not a counter. */
|
||||
function counter(value: unknown): number | null {
|
||||
if (typeof value === 'number') return Number.isFinite(value) ? value : null
|
||||
if (typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value))) return Number(value)
|
||||
return null
|
||||
}
|
||||
|
||||
/* What a server holds, as the "Server" panel of the Dashboard counts it. */
|
||||
const HELD = ['zones', 'cachedEntries', 'allowedZones', 'blockedZones', 'allowListZones', 'blockListZones']
|
||||
|
||||
/*
|
||||
`dashboard/stats/get`. Every numeric counter in `stats` is summed. That includes
|
||||
`totalClients` (distinct clients), which over-counts a client seen by both servers:
|
||||
the hub has no way to know the overlap from the totals alone.
|
||||
*/
|
||||
export function mergeStats(responses: DashboardStats[]): DashboardStats {
|
||||
if (responses.length === 0) throw new Error('Nothing to merge.')
|
||||
const first = responses[0]
|
||||
|
||||
const stats: Record<string, number> = {}
|
||||
for (const r of responses) {
|
||||
for (const [key, value] of Object.entries(r.stats ?? {})) {
|
||||
const n = counter(value)
|
||||
if (n !== null) stats[key] = (stats[key] ?? 0) + n
|
||||
}
|
||||
}
|
||||
/* The Dashboard's "Server" panel counts what each server holds (zones, cache, allow and block
|
||||
lists). Those are sums too, and a server that left one out counts as none, so the field is
|
||||
never missing from the total. */
|
||||
for (const key of HELD) stats[key] ??= 0
|
||||
|
||||
const merged: DashboardStats = { ...first, stats }
|
||||
const main = mergeChart(responses.map((r) => r.mainChartData), true)
|
||||
if (main) merged.mainChartData = main
|
||||
for (const key of BREAKDOWN_KEYS) {
|
||||
const chart = mergeChart(responses.map((r) => r[key]), false)
|
||||
if (chart) merged[key] = chart
|
||||
}
|
||||
for (const key of TOP_KEYS) {
|
||||
if (responses.some((r) => Array.isArray(r[key]))) merged[key] = mergeTop(responses.map((r) => r[key]))
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
/*
|
||||
`logs/query`. The pages are what each server answered; the entries are sorted by
|
||||
timestamp together, each tagged with the server it came from. To build page N the
|
||||
caller asks every server for ITS first N x `entriesPerPage` entries (page 1 of that
|
||||
size): page N of the merged list can only be cut from the merged head of all of them.
|
||||
The hub keeps the slice of page N. `rowNumber` continues from the page's offset, as a
|
||||
single server numbers it.
|
||||
*/
|
||||
export function mergeQueryLogs(
|
||||
pages: { node: string; page: QueryLogPage }[],
|
||||
opts: { pageNumber: number; entriesPerPage: number; descending?: boolean },
|
||||
): QueryLogPage {
|
||||
const descending = opts.descending !== false
|
||||
const entries: QueryLogEntry[] = []
|
||||
let totalEntries = 0
|
||||
for (const { node, page } of pages) {
|
||||
totalEntries += page.totalEntries
|
||||
for (const entry of page.entries) entries.push({ ...entry, node })
|
||||
}
|
||||
|
||||
// Array.prototype.sort is stable: equal timestamps keep the order of the servers.
|
||||
const time = (e: QueryLogEntry) => {
|
||||
const t = Date.parse(e.timestamp)
|
||||
return Number.isNaN(t) ? 0 : t
|
||||
}
|
||||
entries.sort((a, b) => {
|
||||
const diff = time(a) - time(b)
|
||||
return descending ? -diff : diff
|
||||
})
|
||||
|
||||
const offset = (opts.pageNumber - 1) * opts.entriesPerPage
|
||||
return {
|
||||
pageNumber: opts.pageNumber,
|
||||
totalPages: Math.ceil(totalEntries / opts.entriesPerPage),
|
||||
totalEntries,
|
||||
entries: entries
|
||||
.slice(offset, offset + opts.entriesPerPage)
|
||||
.map((entry, i) => ({ ...entry, rowNumber: offset + i + 1 })),
|
||||
}
|
||||
}
|
||||
|
||||
/* Settled results of the parallel calls: the data that arrived plus a warning per unreachable server. */
|
||||
export function mergeSettled<T>(results: Settled<T>[]): { data: T[]; warnings: string[] } {
|
||||
const data: T[] = []
|
||||
const warnings: string[] = []
|
||||
for (const result of results) {
|
||||
if (result.ok) data.push(result.data)
|
||||
else warnings.push(`${result.node}: ${result.error}`)
|
||||
}
|
||||
if (data.length === 0) throw new Error(`No server answered. ${warnings.join('; ')}`.trim())
|
||||
return { data, warnings }
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
A small multipart/form-data reader for the app zip uploads, and a body reader with a size
|
||||
limit. No dependencies; the whole body stays in memory (the limit is 20 MB).
|
||||
*/
|
||||
import type http from 'node:http'
|
||||
|
||||
export const MAX_UPLOAD = 20 * 1024 * 1024
|
||||
|
||||
export class TooLarge extends Error {
|
||||
constructor(limit: number) {
|
||||
super(`The upload is larger than ${Math.floor(limit / (1024 * 1024))} MB.`)
|
||||
this.name = 'TooLarge'
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Reads the request body, counting bytes as they arrive; throws `TooLarge` above `limit`.
|
||||
Past the limit nothing more is kept, but the rest is still read (up to DRAIN more bytes), so the
|
||||
client gets its 413 instead of a reset connection.
|
||||
*/
|
||||
const DRAIN = 32 * 1024 * 1024
|
||||
|
||||
export async function readLimited(req: http.IncomingMessage, limit: number = MAX_UPLOAD): Promise<Buffer> {
|
||||
const declared = Number(req.headers['content-length'])
|
||||
if (Number.isFinite(declared) && declared > limit + DRAIN) throw new TooLarge(limit)
|
||||
const chunks: Buffer[] = []
|
||||
let size = 0
|
||||
for await (const chunk of req) {
|
||||
size += (chunk as Buffer).length
|
||||
if (size > limit + DRAIN) throw new TooLarge(limit)
|
||||
if (size <= limit) chunks.push(chunk as Buffer)
|
||||
}
|
||||
if (size > limit) throw new TooLarge(limit)
|
||||
return Buffer.concat(chunks)
|
||||
}
|
||||
|
||||
export interface UploadedFile {
|
||||
field: string
|
||||
filename: string
|
||||
type: string
|
||||
data: Buffer
|
||||
}
|
||||
|
||||
export interface Multipart {
|
||||
fields: Record<string, string>
|
||||
files: UploadedFile[]
|
||||
}
|
||||
|
||||
export function boundaryOf(contentType: string): string | null {
|
||||
const match = /boundary=(?:"([^"]+)"|([^;\s]+))/i.exec(contentType)
|
||||
return match === null ? null : (match[1] ?? match[2])
|
||||
}
|
||||
|
||||
const CRLF2 = Buffer.from('\r\n\r\n')
|
||||
|
||||
/* Throws on anything that is not a well-formed multipart body. */
|
||||
export function parseMultipart(body: Buffer, contentType: string): Multipart {
|
||||
const boundary = boundaryOf(contentType)
|
||||
if (boundary === null || !/^multipart\/form-data/i.test(contentType)) throw new Error('Expected multipart/form-data with a boundary.')
|
||||
const delimiter = Buffer.from(`--${boundary}`)
|
||||
const result: Multipart = { fields: {}, files: [] }
|
||||
|
||||
let at = body.indexOf(delimiter)
|
||||
if (at < 0) throw new Error('The multipart body has no boundary.')
|
||||
for (;;) {
|
||||
at += delimiter.length
|
||||
// "--" after a delimiter closes the body.
|
||||
if (body[at] === 0x2d && body[at + 1] === 0x2d) return result
|
||||
if (body[at] !== 0x0d || body[at + 1] !== 0x0a) throw new Error('Malformed multipart body.')
|
||||
at += 2
|
||||
const headerEnd = body.indexOf(CRLF2, at)
|
||||
if (headerEnd < 0) throw new Error('Malformed multipart part.')
|
||||
const headers = body.subarray(at, headerEnd).toString('utf8')
|
||||
const start = headerEnd + CRLF2.length
|
||||
// The part ends at the next CRLF + delimiter, so binary content can hold anything else.
|
||||
const next = body.indexOf(Buffer.concat([Buffer.from('\r\n'), delimiter]), start)
|
||||
if (next < 0) throw new Error('The multipart body is not terminated.')
|
||||
const data = body.subarray(start, next)
|
||||
|
||||
const disposition = /^content-disposition:\s*form-data;(.*)$/im.exec(headers)?.[1] ?? ''
|
||||
const name = /\bname="([^"]*)"/i.exec(disposition)?.[1]
|
||||
const filename = /\bfilename="([^"]*)"/i.exec(disposition)?.[1]
|
||||
if (name === undefined) throw new Error('A multipart part has no name.')
|
||||
if (filename === undefined) result.fields[name] = data.toString('utf8')
|
||||
else {
|
||||
const type = /^content-type:\s*(.+)$/im.exec(headers)?.[1].trim() ?? 'application/octet-stream'
|
||||
result.files.push({ field: name, filename, type, data: Buffer.from(data) })
|
||||
}
|
||||
at = next + 2
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
// @vitest-environment node
|
||||
/*
|
||||
Stage 8, profiles: making one from the hub's baselines, what a viewer of it may see, export and
|
||||
import, the preview, and the order and the stop of an apply, on two fake servers that keep their
|
||||
apps, configs, settings and lists.
|
||||
*/
|
||||
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { fake, startHub, ROLES, type Call, type Fake, type TestHub } from './fake-servers.ts'
|
||||
import { cleanProfile } from './profiles.ts'
|
||||
import { writeBaseline, writeListsBaseline, writeSettingsBaseline } from './store.ts'
|
||||
|
||||
interface Box {
|
||||
apps: Record<string, string>
|
||||
configs: Record<string, string>
|
||||
store: { name: string; version: string }[]
|
||||
settings: Record<string, unknown>
|
||||
blocked: string[]
|
||||
allowed: string[]
|
||||
failSettings: boolean
|
||||
}
|
||||
|
||||
const boxes = new Map<Fake, Box>()
|
||||
const urlOf = (f: Fake, name: string): string => `${f.url}/store/${name}.zip`
|
||||
|
||||
function install(f: Fake, box: Box): void {
|
||||
boxes.set(f, box)
|
||||
f.on('apps/list', () => ({ response: { apps: Object.entries(box.apps).map(([name, version]) => ({ name, version })) } }))
|
||||
f.on('apps/listStoreApps', () => ({ response: { storeApps: box.store.map((a) => ({ ...a, url: urlOf(f, a.name) })) } }))
|
||||
f.on('apps/downloadAndInstall', (c: Call) => {
|
||||
const name = c.form.get('name') ?? c.query.get('name') ?? ''
|
||||
const url = c.form.get('url') ?? c.query.get('url') ?? ''
|
||||
const entry = box.store.find((a) => a.name === name)
|
||||
if (entry === undefined || url !== urlOf(f, name)) return { error: 'The app was not found in the store.' }
|
||||
box.apps[name] = entry.version
|
||||
return { response: {} }
|
||||
})
|
||||
f.on('apps/config/get', (c: Call) => ({ response: { config: box.configs[c.query.get('name') ?? ''] ?? null } }))
|
||||
f.on('apps/config/set', (c: Call) => {
|
||||
box.configs[c.form.get('name') ?? c.query.get('name') ?? ''] = c.form.get('config') ?? ''
|
||||
return { response: {} }
|
||||
})
|
||||
f.on('settings/get', () => ({ response: box.settings }))
|
||||
f.on('settings/set', (c: Call) => {
|
||||
if (box.failSettings) return { error: 'The settings were refused.' }
|
||||
for (const [k, v] of c.form) {
|
||||
if (k === 'tsigKeys') {
|
||||
const cells = v.split('|')
|
||||
box.settings.tsigKeys = v === 'false' ? [] : [{ keyName: cells[0], sharedSecret: cells[1], algorithmName: cells[2] }]
|
||||
} else if (k in box.settings) box.settings[k] = v
|
||||
}
|
||||
return { response: {} }
|
||||
})
|
||||
for (const list of ['blocked', 'allowed'] as const) {
|
||||
f.on(`${list}/export`, () => ({ text: box[list].join('\n') + '\n' }))
|
||||
f.on(`${list}/add`, (c: Call) => {
|
||||
box[list].push(c.form.get('domain') ?? '')
|
||||
return { response: {} }
|
||||
})
|
||||
f.on(`${list}/delete`, (c: Call) => {
|
||||
box[list] = box[list].filter((d) => d !== c.form.get('domain'))
|
||||
return { response: {} }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let a: Fake
|
||||
let b: Fake
|
||||
let hub: TestHub
|
||||
const boxA = (): Box => boxes.get(a)!
|
||||
const boxB = (): Box => boxes.get(b)!
|
||||
const order = (f: Fake): string[] => f.calls.filter((c) => c.method === 'POST').map((c) => c.path)
|
||||
|
||||
const CONFIG = JSON.stringify({ ttl: 30 })
|
||||
const settingsOf = (over: Record<string, unknown> = {}): Record<string, unknown> => ({
|
||||
dnsServerDomain: 'a.example',
|
||||
blockingType: 'NxDomain',
|
||||
tsigKeys: [{ keyName: 'k1', sharedSecret: 'REALSECRET', algorithmName: 'hmac-sha256' }],
|
||||
...over,
|
||||
})
|
||||
|
||||
function baselines(): void {
|
||||
writeBaseline(hub.dataDir, { app: 'Blocker', text: CONFIG, updatedAt: '2026-01-01T00:00:00Z', updatedBy: 't' })
|
||||
writeSettingsBaseline(hub.dataDir, { settings: settingsOf(), updatedAt: '2026-01-01T00:00:00Z', updatedBy: 't' })
|
||||
writeListsBaseline(hub.dataDir, { blocked: ['ads.example'], allowed: ['ok.example'], updatedAt: '2026-01-01T00:00:00Z', updatedBy: 't' })
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
a = await fake()
|
||||
b = await fake()
|
||||
hub = await startHub(a, b)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
hub.close()
|
||||
a.server.close()
|
||||
b.server.close()
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
a.calls = []
|
||||
b.calls = []
|
||||
install(a, {
|
||||
apps: { Blocker: '2.0', Zipped: '1.0' },
|
||||
configs: { Blocker: CONFIG },
|
||||
store: [{ name: 'Blocker', version: '2.0' }],
|
||||
settings: settingsOf(),
|
||||
blocked: ['ads.example'],
|
||||
allowed: ['ok.example'],
|
||||
failSettings: false,
|
||||
})
|
||||
// b is a fresh server: no apps, different settings, a missing domain, an extra one.
|
||||
install(b, {
|
||||
apps: {},
|
||||
configs: {},
|
||||
store: [{ name: 'Blocker', version: '2.0' }],
|
||||
settings: settingsOf({ dnsServerDomain: 'b.example', blockingType: 'AnyAddress', tsigKeys: [] }),
|
||||
blocked: ['mine.example'],
|
||||
allowed: ['ok.example'],
|
||||
failSettings: false,
|
||||
})
|
||||
baselines()
|
||||
for (const f of readdirSync(path.join(hub.dataDir)).filter((n) => n === 'profiles')) void f
|
||||
await hub.call('admin', 'hub/profiles/delete', { name: 'Fresh' })
|
||||
})
|
||||
|
||||
const snapshot = (over: Record<string, string> = {}) =>
|
||||
hub.call('admin', 'hub/profiles/snapshot', { name: 'Fresh', apps: 'Blocker', settings: 'true', lists: 'true', ...over })
|
||||
|
||||
describe('making a profile', () => {
|
||||
it('copies the baselines at that moment: store apps with version and config, shared settings fields, lists', async () => {
|
||||
const done = await snapshot()
|
||||
expect(done.code).toBe(200)
|
||||
expect(done.body.status).toBe('ok')
|
||||
const file = JSON.parse(readFileSync(path.join(hub.dataDir, 'profiles', 'Fresh.json'), 'utf8'))
|
||||
expect(file.apps).toEqual([{ name: 'Blocker', version: '2.0', config: CONFIG }])
|
||||
// The host field stays out; the real secret is kept, as the baseline file has it.
|
||||
expect(file.settings.dnsServerDomain).toBeUndefined()
|
||||
expect(file.settings.tsigKeys[0].sharedSecret).toBe('REALSECRET')
|
||||
expect(file.lists).toEqual({ blocked: ['ads.example'], allowed: ['ok.example'] })
|
||||
expect(file.updatedBy).toBe('admin')
|
||||
// Later changes of the baselines do not change the profile.
|
||||
writeBaseline(hub.dataDir, { app: 'Blocker', text: '{"ttl":1}', updatedAt: 'x', updatedBy: 't' })
|
||||
const again = JSON.parse(readFileSync(path.join(hub.dataDir, 'profiles', 'Fresh.json'), 'utf8'))
|
||||
expect(again.apps[0].config).toBe(CONFIG)
|
||||
})
|
||||
|
||||
it('lets the admin choose the parts, and refuses an empty choice', async () => {
|
||||
expect((await snapshot({ settings: 'false', lists: 'false' })).body.status).toBe('ok')
|
||||
const file = JSON.parse(readFileSync(path.join(hub.dataDir, 'profiles', 'Fresh.json'), 'utf8'))
|
||||
expect(file.settings).toBeNull()
|
||||
expect(file.lists).toBeNull()
|
||||
const none = await snapshot({ apps: '', settings: 'false', lists: 'false' })
|
||||
expect(none.code).toBe(400)
|
||||
expect(none.body.errorMessage).toMatch(/at least one part/)
|
||||
})
|
||||
|
||||
it('refuses an app that is a zip, one that is installed nowhere, and a bad name', async () => {
|
||||
const zip = await snapshot({ apps: 'Zipped' })
|
||||
expect(zip.code).toBe(400)
|
||||
expect(zip.body.errorMessage).toMatch(/store listing/)
|
||||
expect((await snapshot({ apps: 'Ghost' })).body.errorMessage).toMatch(/not installed/)
|
||||
expect((await snapshot({ name: '../evil' })).code).toBe(400)
|
||||
expect(existsSync(path.join(hub.dataDir, 'profiles', 'Fresh.json'))).toBe(false)
|
||||
})
|
||||
|
||||
it('replaces a profile of the same name and lists it with counts only', async () => {
|
||||
await snapshot()
|
||||
await snapshot({ lists: 'false' })
|
||||
const list = await hub.call('admin', 'hub/profiles/list', {}, 'GET')
|
||||
expect(list.body.response.profiles).toMatchObject([{ name: 'Fresh', apps: 1, configs: 1, lists: null }])
|
||||
expect(list.body.response.profiles[0].settings).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('viewing a profile', () => {
|
||||
it('masks secrets and never returns an app config', async () => {
|
||||
await snapshot()
|
||||
const got = await hub.call('admin', 'hub/profiles/get', { name: 'Fresh' }, 'GET')
|
||||
const text = JSON.stringify(got.body)
|
||||
expect(text).not.toContain('REALSECRET')
|
||||
expect(text).toContain('<secret>')
|
||||
expect(text).not.toContain('"ttl"')
|
||||
expect(got.body.response.apps).toEqual([{ name: 'Blocker', version: '2.0', hasConfig: true, bytes: CONFIG.length }])
|
||||
expect(got.body.response.lists).toEqual({ blocked: 1, allowed: 1 })
|
||||
})
|
||||
|
||||
it('answers an unknown profile with an error', async () => {
|
||||
expect((await hub.call('admin', 'hub/profiles/get', { name: 'Nope' }, 'GET')).code).toBe(400)
|
||||
})
|
||||
})
|
||||
|
||||
describe('roles', () => {
|
||||
it('refuses every profile endpoint to every role but admin, and answers none of them from a server', async () => {
|
||||
const reads = ['list', 'get', 'export', 'preview']
|
||||
const writes = ['snapshot', 'delete', 'import', 'apply']
|
||||
for (const role of ROLES.filter((r) => r !== 'admin')) {
|
||||
for (const p of reads) expect((await hub.call(role, `hub/profiles/${p}`, { name: 'Fresh', targets: 'b' }, 'GET')).code).toBe(403)
|
||||
for (const p of writes) expect((await hub.call(role, `hub/profiles/${p}`, { name: 'Fresh', targets: 'b', data: '{}' })).code).toBe(403)
|
||||
}
|
||||
// An unlisted profile path is refused, not forwarded.
|
||||
expect((await hub.call('admin', 'hub/profiles/nothing', {}, 'GET')).code).toBe(403)
|
||||
expect(a.calls).toEqual([])
|
||||
expect(b.calls).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('export and import', () => {
|
||||
it('exports with the real secret, and imports it dry first, then for real', async () => {
|
||||
await snapshot()
|
||||
const res = await fetch(`${hub.base}/api/hub/profiles/export`, { headers: { authorization: `Bearer ${hub.tokens.admin}` } })
|
||||
expect(res.headers.get('content-disposition')).toMatch(/hub-profiles-/)
|
||||
const text = await res.text()
|
||||
expect(text).toContain('REALSECRET')
|
||||
const file = JSON.parse(text)
|
||||
expect(file).toMatchObject({ hub: 'technitium-hub', type: 'profiles', version: 1 })
|
||||
|
||||
await hub.call('admin', 'hub/profiles/delete', { name: 'Fresh' })
|
||||
const dry = await hub.call('admin', 'hub/profiles/import', { data: text, apply: 'false' })
|
||||
expect(dry.body.response).toMatchObject({ applied: false, profiles: [{ name: 'Fresh', status: 'new', apps: 1, settings: true, lists: true }] })
|
||||
expect(existsSync(path.join(hub.dataDir, 'profiles', 'Fresh.json'))).toBe(false)
|
||||
const done = await hub.call('admin', 'hub/profiles/import', { data: text, apply: 'true' })
|
||||
expect(done.body.response.applied).toBe(true)
|
||||
const again = await hub.call('admin', 'hub/profiles/import', { data: text, apply: 'true' })
|
||||
expect(again.body.response.profiles[0].status).toBe('same')
|
||||
expect(JSON.stringify(await hub.audit())).not.toContain('REALSECRET')
|
||||
})
|
||||
|
||||
it('refuses a file of another kind, a placeholder where a secret should be, and local fields are dropped', async () => {
|
||||
const bad = await hub.call('admin', 'hub/profiles/import', { data: JSON.stringify({ hub: 'technitium-hub', version: 1, apps: [] }) })
|
||||
expect(bad.code).toBe(400)
|
||||
const masked = { hub: 'technitium-hub', type: 'profiles', version: 1, profiles: [{ name: 'M', apps: [], settings: { tsigKeys: [{ keyName: 'k', sharedSecret: '<secret>' }] }, lists: null }] }
|
||||
const refused = await hub.call('admin', 'hub/profiles/import', { data: JSON.stringify(masked) })
|
||||
expect(refused.code).toBe(400)
|
||||
expect(refused.body.errorMessage).toMatch(/<secret>/)
|
||||
expect(() => cleanProfile({ name: 'x y', apps: [], settings: { dnsServerDomain: 'h', blockingType: 'NxDomain' }, lists: null }).settings).not.toThrow()
|
||||
expect(cleanProfile({ name: 'x y', apps: [], settings: { dnsServerDomain: 'h', blockingType: 'NxDomain' }, lists: null }).settings).toEqual({ blockingType: 'NxDomain' })
|
||||
})
|
||||
|
||||
it('accepts a body above the usual limit for an admin', async () => {
|
||||
const big = JSON.stringify({ hub: 'technitium-hub', type: 'profiles', version: 1, profiles: [{ name: 'Big', apps: [{ name: 'X', version: '1', config: JSON.stringify({ pad: 'x'.repeat(200_000) }) }], settings: null, lists: null }] })
|
||||
expect(big.length).toBeGreaterThan(64 * 1024)
|
||||
const done = await hub.call('admin', 'hub/profiles/import', { data: big, apply: 'false' })
|
||||
expect(done.body.response.profiles[0]).toMatchObject({ name: 'Big', status: 'new' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('preview', () => {
|
||||
it('says what each part would change on a target, and writes nothing', async () => {
|
||||
await snapshot()
|
||||
const got = await hub.call('admin', 'hub/profiles/preview', { name: 'Fresh', targets: 'b,a' })
|
||||
expect(got.code).toBe(200)
|
||||
const [tb, ta] = got.body.response.targets
|
||||
expect(tb.server).toBe('b')
|
||||
expect(tb.apps).toEqual([{ name: 'Blocker', state: 'install', profileVersion: '2.0', storeVersion: '2.0' }])
|
||||
expect(tb.configs).toEqual([{ name: 'Blocker', state: 'after-install' }])
|
||||
expect(tb.settings.fields).toContain('blockingType')
|
||||
expect(tb.settings.secretFields).toContain('tsigKeys')
|
||||
expect(JSON.stringify(got.body)).not.toContain('REALSECRET')
|
||||
expect(tb.lists.blocked.missing).toBe(1)
|
||||
expect(ta.apps).toEqual([{ name: 'Blocker', state: 'present', profileVersion: '2.0', installedVersion: '2.0' }])
|
||||
expect(ta.configs).toEqual([{ name: 'Blocker', state: 'same' }])
|
||||
expect(ta.settings.unchanged).toBe(true)
|
||||
expect(order(a).concat(order(b))).toEqual([])
|
||||
})
|
||||
|
||||
it('marks an installed app whose version differs, and an app the store does not offer', async () => {
|
||||
await snapshot()
|
||||
boxB().apps.Blocker = '1.0'
|
||||
const one = await hub.call('admin', 'hub/profiles/preview', { name: 'Fresh', targets: 'b' })
|
||||
expect(one.body.response.targets[0].apps[0]).toMatchObject({ state: 'version-differs', installedVersion: '1.0', profileVersion: '2.0' })
|
||||
boxB().apps = {}
|
||||
boxB().store = []
|
||||
const two = await hub.call('admin', 'hub/profiles/preview', { name: 'Fresh', targets: 'b' })
|
||||
expect(two.body.response.targets[0].apps[0].state).toBe('not-in-store')
|
||||
})
|
||||
})
|
||||
|
||||
describe('apply', () => {
|
||||
it('runs the steps in order on a fresh server: install, config, settings, lists', async () => {
|
||||
await snapshot()
|
||||
const done = await hub.call('admin', 'hub/profiles/apply', { name: 'Fresh', targets: 'b' })
|
||||
expect(done.body.response.results[0]).toMatchObject({ server: 'b', ok: true })
|
||||
expect(done.body.response.results[0].steps.map((s: Record<string, string>) => [s.step, s.status])).toEqual([
|
||||
['apps', 'ok'],
|
||||
['configs', 'ok'],
|
||||
['settings', 'ok'],
|
||||
['lists', 'ok'],
|
||||
])
|
||||
expect(order(b)).toEqual(['apps/downloadAndInstall', 'apps/config/set', 'settings/set', 'blocked/add'])
|
||||
expect(boxB().apps.Blocker).toBe('2.0')
|
||||
expect(JSON.parse(boxB().configs.Blocker)).toEqual({ ttl: 30 })
|
||||
expect(boxB().settings.blockingType).toBe('NxDomain')
|
||||
// The host field was left alone, and the extra domain stays unless asked.
|
||||
expect(boxB().settings.dnsServerDomain).toBe('b.example')
|
||||
expect(boxB().blocked).toEqual(['mine.example', 'ads.example'])
|
||||
// The old config of a server that had one goes to the history; b had none, so nothing is claimed.
|
||||
expect(order(a)).toEqual([])
|
||||
})
|
||||
|
||||
it('takes the install URL from the target own store, never from the request', async () => {
|
||||
await snapshot()
|
||||
await hub.call('admin', 'hub/profiles/apply', { name: 'Fresh', targets: 'b', url: 'https://evil.example/x.zip' })
|
||||
const install = b.calls.find((c) => c.path === 'apps/downloadAndInstall')!
|
||||
expect(install.query.get('url') ?? install.form.get('url')).toBe(urlOf(b, 'Blocker'))
|
||||
expect(a.calls.some((c) => c.path === 'apps/downloadAndInstall')).toBe(false)
|
||||
// Each server is asked for its own listing, with its own read token.
|
||||
expect(b.calls.find((c) => c.path === 'apps/listStoreApps')!.token).toBe('read-b')
|
||||
})
|
||||
|
||||
it('removes extra domains only when asked', async () => {
|
||||
await snapshot()
|
||||
await hub.call('admin', 'hub/profiles/apply', { name: 'Fresh', targets: 'b', removeExtras: 'true' })
|
||||
expect(boxB().blocked).toEqual(['ads.example'])
|
||||
})
|
||||
|
||||
it('stops the later steps of a server when one fails, says so, and leaves the other server alone', async () => {
|
||||
await snapshot()
|
||||
boxB().failSettings = true
|
||||
boxA().settings.blockingType = 'AnyAddress'
|
||||
const done = await hub.call('admin', 'hub/profiles/apply', { name: 'Fresh', targets: 'a,b' })
|
||||
const [ra, rb] = done.body.response.results
|
||||
expect(ra).toMatchObject({ server: 'a', ok: true })
|
||||
expect(rb).toMatchObject({ server: 'b', ok: false })
|
||||
expect(rb.message).toMatch(/Stopped at settings/)
|
||||
expect(rb.steps.map((s: Record<string, string>) => [s.step, s.status])).toEqual([
|
||||
['apps', 'ok'],
|
||||
['configs', 'ok'],
|
||||
['settings', 'failed'],
|
||||
['lists', 'not-run'],
|
||||
])
|
||||
expect(order(b)).not.toContain('blocked/add')
|
||||
const audit = await hub.audit()
|
||||
const lines = audit.filter((e) => e.action === 'profiles.apply' && e.server === 'b').slice(0, 3)
|
||||
expect(lines.map((e) => [e.outcome, e.detail?.split(':')[0]])).toEqual([['failed', 'settings'], ['ok', 'configs'], ['ok', 'apps']])
|
||||
expect(JSON.stringify(audit)).not.toContain('REALSECRET')
|
||||
})
|
||||
|
||||
it('retries from the failed step without redoing the earlier ones', async () => {
|
||||
await snapshot()
|
||||
boxB().failSettings = true
|
||||
await hub.call('admin', 'hub/profiles/apply', { name: 'Fresh', targets: 'b' })
|
||||
b.calls = []
|
||||
boxB().failSettings = false
|
||||
const again = await hub.call('admin', 'hub/profiles/apply', { name: 'Fresh', targets: 'b', from: JSON.stringify({ b: 'settings' }) })
|
||||
const r = again.body.response.results[0]
|
||||
expect(r.ok).toBe(true)
|
||||
expect(r.steps.map((s: Record<string, string>) => [s.step, s.status])).toEqual([
|
||||
['apps', 'skipped'],
|
||||
['configs', 'skipped'],
|
||||
['settings', 'ok'],
|
||||
['lists', 'ok'],
|
||||
])
|
||||
expect(order(b)).toEqual(['settings/set', 'blocked/add'])
|
||||
})
|
||||
|
||||
it('fails a server with no admin token on its first step, and refuses an unknown server or profile', async () => {
|
||||
await snapshot()
|
||||
const unknown = await hub.call('admin', 'hub/profiles/apply', { name: 'Fresh', targets: 'zzz' })
|
||||
expect(unknown.code).toBe(400)
|
||||
expect((await hub.call('admin', 'hub/profiles/apply', { name: 'Nope', targets: 'b' })).code).toBe(400)
|
||||
expect((await hub.call('admin', 'hub/profiles/apply', { name: 'Fresh', targets: '' })).code).toBe(400)
|
||||
})
|
||||
|
||||
it('deletes a profile', async () => {
|
||||
await snapshot()
|
||||
expect((await hub.call('admin', 'hub/profiles/delete', { name: 'Fresh' })).body.response).toEqual({ deleted: true })
|
||||
expect((await hub.call('admin', 'hub/profiles/delete', { name: 'Fresh' })).code).toBe(400)
|
||||
expect((await hub.call('admin', 'hub/profiles/list', {}, 'GET')).body.response.profiles).toEqual([])
|
||||
})
|
||||
})
|
||||
+510
@@ -0,0 +1,510 @@
|
||||
/*
|
||||
Profiles (stage 8): a named bundle of what a server should have, kept in `${HUB_DATA}/profiles/`,
|
||||
to bring a new or reset server in line in one go.
|
||||
|
||||
A profile holds, as the admin chose when it was made from the hub's current baselines:
|
||||
|
||||
apps store apps only (never a zip): the name and the version they had, and for each the hub's
|
||||
baseline config at that moment
|
||||
settings the SHARED fields of the settings baseline (host fields never travel), secrets as the
|
||||
baseline file keeps them
|
||||
lists the custom Blocked and Allowed lists baseline
|
||||
|
||||
Applying a profile to a server runs four steps in order, and a failed step stops the later ones for
|
||||
that server (the other servers go on):
|
||||
|
||||
apps install the apps that are missing, from THAT server's own store listing (apps-write.ts)
|
||||
configs the profile's config of each app plus that server's own local values; the old config
|
||||
goes to the history first (apps-config.ts)
|
||||
settings the changed shared fields, through the same checks as a per-server settings/set
|
||||
lists the missing domains; extras only when asked
|
||||
|
||||
`from` restarts a server at a given step (the retry of a failed run), so what already worked is not
|
||||
done again. Everything here is for admins. A view never carries a secret and never an app config.
|
||||
*/
|
||||
import { existsSync, readFileSync, readdirSync, rmSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import type { HubConfig, HubServer } from './config.ts'
|
||||
import { MAX_CONFIG, looksLikeJson, parseJson, type Diff } from './apps-sync.ts'
|
||||
import { UserError, compareVersions, type AppsConfig } from './apps-config.ts'
|
||||
import type { AppsWriter } from './apps-write.ts'
|
||||
import { isFieldName, isBuiltInLocal, MAX_LIST, SECRET_PLACEHOLDER, maskSettings } from './settings-sync.ts'
|
||||
import type { SettingsConfig } from './settings-config.ts'
|
||||
import { listBaselineApps, readBaseline, readListsBaseline, readSettingsBaseline, safeName, writeAtomic } from './store.ts'
|
||||
import type { Upstream } from './upstream.ts'
|
||||
|
||||
export const STEPS = ['apps', 'configs', 'settings', 'lists'] as const
|
||||
export type Step = (typeof STEPS)[number]
|
||||
|
||||
export interface ProfileApp {
|
||||
name: string
|
||||
/** The version the app had when the profile was made. */
|
||||
version: string
|
||||
/** The hub's baseline config of the app then; null when it had none. */
|
||||
config: string | null
|
||||
}
|
||||
|
||||
export interface Profile {
|
||||
name: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
updatedBy: string
|
||||
apps: ProfileApp[]
|
||||
settings: Record<string, unknown> | null
|
||||
lists: { blocked: string[]; allowed: string[] } | null
|
||||
}
|
||||
|
||||
export interface StepResult {
|
||||
step: Step
|
||||
/* `skipped`: done by an earlier run. `not-run`: an earlier step failed. */
|
||||
status: 'ok' | 'failed' | 'skipped' | 'not-run'
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface ApplyResult {
|
||||
server: string
|
||||
ok: boolean
|
||||
message: string
|
||||
steps: StepResult[]
|
||||
}
|
||||
|
||||
const NAME = /^[A-Za-z0-9][A-Za-z0-9 ._-]{0,59}$/
|
||||
const MAX_PROFILES = 50
|
||||
const MAX_APPS = 100
|
||||
const MAX_SETTINGS_BYTES = 1024 * 1024
|
||||
const reason = (e: unknown): string => (e instanceof Error ? e.message : 'failed')
|
||||
const isObject = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === 'object' && !Array.isArray(v)
|
||||
|
||||
const hasPlaceholder = (value: unknown): boolean => JSON.stringify(value).includes(JSON.stringify(SECRET_PLACEHOLDER))
|
||||
|
||||
/* The stored shape, checked field by field. Used for files on disk and for imports. */
|
||||
export function cleanProfile(raw: unknown, fallbackName?: string): Profile {
|
||||
if (!isObject(raw)) throw new UserError('A profile must be an object.')
|
||||
const name = typeof raw.name === 'string' ? raw.name : (fallbackName ?? '')
|
||||
if (!NAME.test(name)) throw new UserError('A profile name uses letters, digits, spaces and . _ - (up to 60, starting with a letter or digit).')
|
||||
if (!Array.isArray(raw.apps) || raw.apps.length > MAX_APPS) throw new UserError(`"${name}": the apps must be a list of at most ${MAX_APPS}.`)
|
||||
const seen = new Set<string>()
|
||||
const apps = raw.apps.map((a): ProfileApp => {
|
||||
if (!isObject(a) || typeof a.name !== 'string' || a.name === '' || a.name.length > 200 || seen.has(a.name)) throw new UserError(`"${name}": an app has no valid, unique name.`)
|
||||
seen.add(a.name)
|
||||
const version = typeof a.version === 'string' ? a.version.slice(0, 40) : ''
|
||||
const config = typeof a.config === 'string' ? a.config : null
|
||||
if (config !== null) {
|
||||
if (config.length > MAX_CONFIG) throw new UserError(`"${name}": the config of "${a.name}" is larger than 1 MB.`)
|
||||
if (looksLikeJson(config) && !parseJson(config).ok) throw new UserError(`"${name}": the config of "${a.name}" is not valid JSON.`)
|
||||
}
|
||||
return { name: a.name, version, config }
|
||||
})
|
||||
let settings: Profile['settings'] = null
|
||||
if (raw.settings !== null && raw.settings !== undefined) {
|
||||
if (!isObject(raw.settings)) throw new UserError(`"${name}": the settings must be an object.`)
|
||||
if (JSON.stringify(raw.settings).length > MAX_SETTINGS_BYTES) throw new UserError(`"${name}": the settings are larger than 1 MB.`)
|
||||
if (hasPlaceholder(raw.settings)) throw new UserError(`"${name}": the settings hold ${SECRET_PLACEHOLDER} where a secret should be. Export the profile from the hub that has it.`)
|
||||
settings = {}
|
||||
for (const [k, v] of Object.entries(raw.settings)) {
|
||||
if (!isFieldName(k)) throw new UserError(`"${name}": "${k.slice(0, 40)}" is not a setting name.`)
|
||||
// Host fields never travel.
|
||||
if (!isBuiltInLocal(k)) settings[k] = v
|
||||
}
|
||||
}
|
||||
let lists: Profile['lists'] = null
|
||||
if (raw.lists !== null && raw.lists !== undefined) {
|
||||
const l = raw.lists as { blocked?: unknown; allowed?: unknown }
|
||||
if (!isObject(raw.lists) || !Array.isArray(l.blocked) || !Array.isArray(l.allowed)) throw new UserError(`"${name}": the lists need a blocked and an allowed array.`)
|
||||
const clean = (v: unknown[]): string[] => {
|
||||
if (v.length > MAX_LIST || v.some((d) => typeof d !== 'string' || d.length > 253)) throw new UserError(`"${name}": a list is longer than ${MAX_LIST} domains or holds a bad entry.`)
|
||||
return [...new Set(v as string[])].sort()
|
||||
}
|
||||
lists = { blocked: clean(l.blocked), allowed: clean(l.allowed) }
|
||||
}
|
||||
const text = (v: unknown): string => (typeof v === 'string' ? v : '')
|
||||
return { name, createdAt: text(raw.createdAt), updatedAt: text(raw.updatedAt), updatedBy: text(raw.updatedBy), apps, settings, lists }
|
||||
}
|
||||
|
||||
const shape = (p: Profile): string => JSON.stringify({ apps: p.apps, settings: p.settings, lists: p.lists })
|
||||
|
||||
export class Profiles {
|
||||
#config: HubConfig
|
||||
#upstream: Upstream
|
||||
#appsConfig: AppsConfig
|
||||
#settings: SettingsConfig
|
||||
#writer: AppsWriter
|
||||
#now: () => number
|
||||
|
||||
constructor(config: HubConfig, upstream: Upstream, appsConfig: AppsConfig, settings: SettingsConfig, writer: AppsWriter, now: () => number = Date.now) {
|
||||
this.#config = config
|
||||
this.#upstream = upstream
|
||||
this.#appsConfig = appsConfig
|
||||
this.#settings = settings
|
||||
this.#writer = writer
|
||||
this.#now = now
|
||||
}
|
||||
|
||||
#dir(): string {
|
||||
return path.join(this.#config.dataDir, 'profiles')
|
||||
}
|
||||
|
||||
#file(name: string): string {
|
||||
return path.join(this.#dir(), `${safeName(name)}.json`)
|
||||
}
|
||||
|
||||
#read(name: string): Profile | null {
|
||||
const file = this.#file(name)
|
||||
if (!existsSync(file)) return null
|
||||
try {
|
||||
const p = cleanProfile(JSON.parse(readFileSync(file, 'utf8')))
|
||||
return p.name === name ? p : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
#all(): Profile[] {
|
||||
if (!existsSync(this.#dir())) return []
|
||||
const out: Profile[] = []
|
||||
for (const f of readdirSync(this.#dir())) {
|
||||
if (!f.endsWith('.json')) continue
|
||||
try {
|
||||
out.push(cleanProfile(JSON.parse(readFileSync(path.join(this.#dir(), f), 'utf8'))))
|
||||
} catch {
|
||||
// A file that is not a profile is left alone.
|
||||
}
|
||||
}
|
||||
return out.sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
#require(name: string): Profile {
|
||||
const found = this.#read(name)
|
||||
if (found === null) throw new UserError(`There is no profile named "${name.slice(0, 60)}".`)
|
||||
return found
|
||||
}
|
||||
|
||||
#write(p: Profile): void {
|
||||
writeAtomic(this.#file(p.name), JSON.stringify(p, null, 2) + '\n')
|
||||
}
|
||||
|
||||
/* What the console lists: counts, no config and no value. */
|
||||
list(): { name: string; apps: number; configs: number; settings: number | null; lists: { blocked: number; allowed: number } | null; updatedAt: string; updatedBy: string }[] {
|
||||
return this.#all().map((p) => ({
|
||||
name: p.name,
|
||||
apps: p.apps.length,
|
||||
configs: p.apps.filter((a) => a.config !== null).length,
|
||||
settings: p.settings === null ? null : Object.keys(p.settings).length,
|
||||
lists: p.lists === null ? null : { blocked: p.lists.blocked.length, allowed: p.lists.allowed.length },
|
||||
updatedAt: p.updatedAt,
|
||||
updatedBy: p.updatedBy,
|
||||
}))
|
||||
}
|
||||
|
||||
/* One profile for the console: secrets masked, no app config text. */
|
||||
get(name: string): unknown {
|
||||
const p = this.#require(name)
|
||||
return {
|
||||
name: p.name,
|
||||
createdAt: p.createdAt,
|
||||
updatedAt: p.updatedAt,
|
||||
updatedBy: p.updatedBy,
|
||||
apps: p.apps.map((a) => ({ name: a.name, version: a.version, hasConfig: a.config !== null, bytes: a.config === null ? 0 : a.config.length })),
|
||||
settings: p.settings === null ? null : maskSettings(p.settings),
|
||||
lists: p.lists === null ? null : { blocked: p.lists.blocked.length, allowed: p.lists.allowed.length },
|
||||
}
|
||||
}
|
||||
|
||||
delete(name: string): void {
|
||||
this.#require(name)
|
||||
rmSync(this.#file(name), { force: true })
|
||||
}
|
||||
|
||||
/* ---- Making one from the hub's current baselines ---- */
|
||||
|
||||
async #installedVersions(): Promise<Map<string, string>> {
|
||||
const versions = new Map<string, string>()
|
||||
let answered = 0
|
||||
await Promise.all(
|
||||
this.#config.servers.map(async (s) => {
|
||||
try {
|
||||
this.#upstream.invalidate(s.id)
|
||||
const list = (await this.#upstream.call(s, 'apps/list')) as { apps?: { name: string; version?: string }[] } | null
|
||||
answered++
|
||||
for (const a of list?.apps ?? []) {
|
||||
const have = versions.get(a.name)
|
||||
const v = typeof a.version === 'string' ? a.version : ''
|
||||
if (have === undefined || compareVersions(v, have) > 0) versions.set(a.name, v)
|
||||
}
|
||||
} catch {
|
||||
// A server that does not answer adds nothing.
|
||||
}
|
||||
}),
|
||||
)
|
||||
if (answered === 0) throw new UserError('No server answered, so the installed apps cannot be read.')
|
||||
return versions
|
||||
}
|
||||
|
||||
async #storeNames(): Promise<Set<string>> {
|
||||
const names = new Set<string>()
|
||||
let answered = 0
|
||||
await Promise.all(
|
||||
this.#config.servers.map(async (s) => {
|
||||
try {
|
||||
const r = (await this.#upstream.call(s, 'apps/listStoreApps')) as { storeApps?: { name: string }[] } | null
|
||||
answered++
|
||||
for (const a of r?.storeApps ?? []) names.add(a.name)
|
||||
} catch {
|
||||
// Same.
|
||||
}
|
||||
}),
|
||||
)
|
||||
if (answered === 0) throw new UserError('No server answered, so the store listing cannot be read.')
|
||||
return names
|
||||
}
|
||||
|
||||
/*
|
||||
Makes or replaces a profile from the hub's current baselines. `apps`: the names to take (undefined
|
||||
takes every app that has a baseline). Settings and lists are taken whole when asked.
|
||||
*/
|
||||
async snapshot(name: string, parts: { apps?: string[]; settings: boolean; lists: boolean }, user: string): Promise<{ replaced: boolean; detail: string }> {
|
||||
if (!NAME.test(name)) throw new UserError('A profile name uses letters, digits, spaces and . _ - (up to 60, starting with a letter or digit).')
|
||||
const appNames = parts.apps ?? listBaselineApps(this.#config.dataDir)
|
||||
if (appNames.length === 0 && !parts.settings && !parts.lists) throw new UserError('Choose at least one part: apps, settings or lists.')
|
||||
if (appNames.length > MAX_APPS) throw new UserError(`At most ${MAX_APPS} apps in a profile.`)
|
||||
const existing = this.#read(name)
|
||||
if (existing === null && this.#all().length >= MAX_PROFILES) throw new UserError(`At most ${MAX_PROFILES} profiles.`)
|
||||
|
||||
let apps: ProfileApp[] = []
|
||||
if (appNames.length > 0) {
|
||||
const [versions, store] = await Promise.all([this.#installedVersions(), this.#storeNames()])
|
||||
apps = [...new Set(appNames)].map((app) => {
|
||||
const version = versions.get(app)
|
||||
if (version === undefined) throw new UserError(`"${app}" is not installed on any server, so its version is not known.`)
|
||||
if (!store.has(app)) throw new UserError(`"${app}" is not in the store listing. A profile holds store apps only, not apps installed from a file.`)
|
||||
return { name: app, version, config: readBaseline(this.#config.dataDir, app)?.text ?? null }
|
||||
})
|
||||
}
|
||||
let settings: Profile['settings'] = null
|
||||
if (parts.settings) {
|
||||
const baseline = readSettingsBaseline(this.#config.dataDir)
|
||||
if (baseline === null) throw new UserError('There is no settings baseline yet. Accept one from a server first.')
|
||||
const extra = this.#settings.extraLocal()
|
||||
settings = Object.fromEntries(Object.entries(baseline.settings).filter(([k]) => !isBuiltInLocal(k) && !extra.includes(k)))
|
||||
}
|
||||
let lists: Profile['lists'] = null
|
||||
if (parts.lists) {
|
||||
const baseline = readListsBaseline(this.#config.dataDir)
|
||||
if (baseline === null) throw new UserError('There is no lists baseline yet. Accept one from a server first.')
|
||||
lists = { blocked: [...baseline.blocked], allowed: [...baseline.allowed] }
|
||||
}
|
||||
const at = new Date(this.#now()).toISOString()
|
||||
this.#write({ name, createdAt: existing?.createdAt || at, updatedAt: at, updatedBy: user, apps, settings, lists })
|
||||
return {
|
||||
replaced: existing !== null,
|
||||
detail: `${existing === null ? 'created' : 'replaced'}: ${apps.length} app(s), settings ${settings === null ? 'no' : Object.keys(settings).length + ' fields'}, lists ${lists === null ? 'no' : 'yes'}`,
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Export and import ---- */
|
||||
|
||||
exportFile(name?: string): { hub: string; type: string; version: number; exportedAt: string; profiles: Profile[] } {
|
||||
const profiles = name === undefined || name === '' ? this.#all() : [this.#require(name)]
|
||||
return { hub: 'technitium-hub', type: 'profiles', version: 1, exportedAt: new Date(this.#now()).toISOString(), profiles }
|
||||
}
|
||||
|
||||
importFile(raw: string, apply: boolean, user: string): { profiles: { name: string; status: 'new' | 'changed' | 'same'; apps: number; settings: boolean; lists: boolean }[]; applied: boolean } {
|
||||
const parsed = parseJson(raw)
|
||||
if (!parsed.ok) throw new UserError('The file is not valid JSON.')
|
||||
const file = parsed.value as { hub?: unknown; type?: unknown; version?: unknown; profiles?: unknown }
|
||||
if (file === null || typeof file !== 'object' || file.hub !== 'technitium-hub' || file.type !== 'profiles') throw new UserError('This is not a profiles file of the hub.')
|
||||
if (file.version !== 1) throw new UserError('This file has a version the hub does not know.')
|
||||
if (!Array.isArray(file.profiles) || file.profiles.length > MAX_PROFILES) throw new UserError(`The file has no list of profiles, or has more than ${MAX_PROFILES}.`)
|
||||
const plan: { profile: Profile; status: 'new' | 'changed' | 'same' }[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const item of file.profiles) {
|
||||
const profile = cleanProfile(item)
|
||||
if (seen.has(profile.name)) throw new UserError(`"${profile.name}" is in the file twice.`)
|
||||
seen.add(profile.name)
|
||||
const old = this.#read(profile.name)
|
||||
plan.push({ profile, status: old === null ? 'new' : shape(old) === shape(profile) ? 'same' : 'changed' })
|
||||
}
|
||||
const total = this.#all().filter((p) => !seen.has(p.name)).length + plan.length
|
||||
if (total > MAX_PROFILES) throw new UserError(`That would make more than ${MAX_PROFILES} profiles.`)
|
||||
if (apply) {
|
||||
const at = new Date(this.#now()).toISOString()
|
||||
for (const p of plan) {
|
||||
if (p.status === 'same') continue
|
||||
const old = this.#read(p.profile.name)
|
||||
this.#write({ ...p.profile, createdAt: old?.createdAt || at, updatedAt: at, updatedBy: user })
|
||||
}
|
||||
}
|
||||
return { profiles: plan.map((p) => ({ name: p.profile.name, status: p.status, apps: p.profile.apps.length, settings: p.profile.settings !== null, lists: p.profile.lists !== null })), applied: apply }
|
||||
}
|
||||
|
||||
/* ---- Preview ---- */
|
||||
|
||||
#server(id: string): HubServer {
|
||||
const found = this.#config.servers.find((s) => s.id === id)
|
||||
if (found === undefined) throw new UserError(`Unknown server: ${id}.`)
|
||||
return found
|
||||
}
|
||||
|
||||
#targets(ids: string[]): HubServer[] {
|
||||
const unique = [...new Set(ids)]
|
||||
if (unique.length === 0) throw new UserError('Choose at least one server.')
|
||||
return unique.map((id) => this.#server(id))
|
||||
}
|
||||
|
||||
async #look(server: HubServer): Promise<{ installed: Map<string, string>; store: Map<string, string> }> {
|
||||
this.#upstream.invalidate(server.id)
|
||||
const list = (await this.#upstream.call(server, 'apps/list')) as { apps?: { name: string; version?: string }[] } | null
|
||||
const store = (await this.#upstream.call(server, 'apps/listStoreApps')) as { storeApps?: { name: string; version?: string }[] } | null
|
||||
return {
|
||||
installed: new Map((list?.apps ?? []).map((a) => [a.name, typeof a.version === 'string' ? a.version : ''])),
|
||||
store: new Map((store?.storeApps ?? []).map((a) => [a.name, typeof a.version === 'string' ? a.version : ''])),
|
||||
}
|
||||
}
|
||||
|
||||
/* What applying the profile would change on each target. Nothing is written. */
|
||||
async preview(name: string, ids: string[], removeExtras: boolean): Promise<{ profile: string; targets: unknown[] }> {
|
||||
const profile = this.#require(name)
|
||||
const targets = this.#targets(ids)
|
||||
const out = await Promise.all(
|
||||
targets.map(async (s) => {
|
||||
const writable = s.adminToken !== undefined
|
||||
const base = { server: s.id, writable }
|
||||
try {
|
||||
const { installed, store } = await this.#look(s)
|
||||
const apps = profile.apps.map((a) => {
|
||||
const have = installed.get(a.name)
|
||||
if (have === undefined) {
|
||||
const offered = store.get(a.name)
|
||||
return offered === undefined
|
||||
? { name: a.name, state: 'not-in-store' as const, profileVersion: a.version }
|
||||
: { name: a.name, state: 'install' as const, profileVersion: a.version, storeVersion: offered }
|
||||
}
|
||||
return { name: a.name, state: have === a.version ? ('present' as const) : ('version-differs' as const), profileVersion: a.version, installedVersion: have }
|
||||
})
|
||||
const configs = await Promise.all(
|
||||
profile.apps
|
||||
.filter((a) => a.config !== null)
|
||||
.map(async (a) => {
|
||||
if (!installed.has(a.name)) return { name: a.name, state: 'after-install' as const }
|
||||
try {
|
||||
const p = (await this.#appsConfig.preview(a.name, [s.id], a.config as string)).targets[0]
|
||||
if (!p.ok) return { name: a.name, state: 'unreadable' as const, error: p.error }
|
||||
return p.unchanged ? { name: a.name, state: 'same' as const } : { name: a.name, state: 'differs' as const, changes: p.changes as Diff }
|
||||
} catch (e) {
|
||||
return { name: a.name, state: 'unreadable' as const, error: reason(e) }
|
||||
}
|
||||
}),
|
||||
)
|
||||
let settings: unknown = null
|
||||
if (profile.settings !== null) {
|
||||
const p = (await this.#settings.preview([s.id], undefined, { settings: profile.settings, updatedAt: '', updatedBy: '' })).targets[0]
|
||||
settings = p.ok ? { fields: p.changes.changes.map((c) => c.path), secretFields: p.secretFields, unchanged: p.unchanged, changes: p.changes, warnings: p.warnings } : { error: p.error }
|
||||
}
|
||||
let lists: unknown = null
|
||||
if (profile.lists !== null) {
|
||||
const p = (await this.#settings.listsPreview([s.id], removeExtras, { ...profile.lists, updatedAt: '', updatedBy: '' })).targets[0]
|
||||
lists = p.ok ? { blocked: p.blocked, allowed: p.allowed, status: p.status, tooMany: p.tooMany } : { error: p.error }
|
||||
}
|
||||
return { ...base, ok: true, apps, configs, settings, lists, warnings: writable ? [] : ['The hub has no admin token for this server, so it cannot write to it.'] }
|
||||
} catch (e) {
|
||||
return { ...base, ok: false, error: reason(e), apps: [], configs: [], settings: null, lists: null, warnings: [] }
|
||||
}
|
||||
}),
|
||||
)
|
||||
return { profile: profile.name, targets: out }
|
||||
}
|
||||
|
||||
/* ---- Apply ---- */
|
||||
|
||||
async #run(profile: Profile, s: HubServer, step: Step, removeExtras: boolean): Promise<{ ok: boolean; message: string }> {
|
||||
const failed = (message: string) => ({ ok: false, message })
|
||||
if (step === 'apps') {
|
||||
this.#upstream.invalidate(s.id)
|
||||
const list = (await this.#upstream.call(s, 'apps/list')) as { apps?: { name: string }[] } | null
|
||||
const have = new Set((list?.apps ?? []).map((a) => a.name))
|
||||
const installed: string[] = []
|
||||
for (const a of profile.apps) {
|
||||
if (have.has(a.name)) continue
|
||||
const outcome = await this.#writer.runMany('install-store', a.name, [s.id])
|
||||
if ('error' in outcome) return failed(`${a.name}: ${outcome.error}`)
|
||||
const r = outcome.results[0]
|
||||
if (!r.ok) return failed(`${a.name}: ${r.message ?? 'could not be installed'}`)
|
||||
installed.push(a.name)
|
||||
}
|
||||
return { ok: true, message: installed.length === 0 ? 'Every app is already installed.' : `Installed ${installed.join(', ')}.` }
|
||||
}
|
||||
if (step === 'configs') {
|
||||
let written = 0
|
||||
let same = 0
|
||||
for (const a of profile.apps) {
|
||||
if (a.config === null) continue
|
||||
const done = await this.#appsConfig.apply(a.name, [s.id], {}, a.config)
|
||||
const r = done.results[0]
|
||||
if (!r.ok) return failed(`${a.name}: ${r.message ?? 'failed'}`)
|
||||
if (/nothing was written/.test(r.message ?? '')) same++
|
||||
else written++
|
||||
}
|
||||
return { ok: true, message: `${written} config(s) applied, ${same} already in sync.` }
|
||||
}
|
||||
if (step === 'settings') {
|
||||
const done = await this.#settings.apply([s.id], {}, undefined, { settings: profile.settings ?? {}, updatedAt: '', updatedBy: '' })
|
||||
const r = done.results[0]
|
||||
return { ok: r.ok, message: r.message ?? '' }
|
||||
}
|
||||
const done = await this.#settings.listsApply([s.id], removeExtras, { ...(profile.lists ?? { blocked: [], allowed: [] }), updatedAt: '', updatedBy: '' })
|
||||
const r = done.results[0]
|
||||
return { ok: r.ok, message: r.message ?? '' }
|
||||
}
|
||||
|
||||
#stepsOf(profile: Profile): Step[] {
|
||||
return STEPS.filter((step) => (step === 'apps' ? profile.apps.length > 0 : step === 'configs' ? profile.apps.some((a) => a.config !== null) : step === 'settings' ? profile.settings !== null : profile.lists !== null))
|
||||
}
|
||||
|
||||
async #applyOne(profile: Profile, s: HubServer, from: Step | undefined, removeExtras: boolean): Promise<ApplyResult> {
|
||||
const steps: StepResult[] = []
|
||||
const plan = this.#stepsOf(profile)
|
||||
if (plan.length === 0) return { server: s.id, ok: true, message: 'The profile has nothing to apply.', steps }
|
||||
if (s.adminToken === undefined) {
|
||||
return { server: s.id, ok: false, message: 'no admin token configured', steps: plan.map((step, i) => ({ step, status: i === 0 ? ('failed' as const) : ('not-run' as const), message: i === 0 ? 'no admin token configured' : 'An earlier step failed.' })) }
|
||||
}
|
||||
const start = from === undefined ? 0 : Math.max(0, plan.indexOf(from))
|
||||
let stopped: Step | null = null
|
||||
for (const [i, step] of plan.entries()) {
|
||||
if (stopped !== null) {
|
||||
steps.push({ step, status: 'not-run', message: 'An earlier step failed.' })
|
||||
continue
|
||||
}
|
||||
if (i < start) {
|
||||
steps.push({ step, status: 'skipped', message: 'Done by an earlier run.' })
|
||||
continue
|
||||
}
|
||||
let outcome: { ok: boolean; message: string }
|
||||
try {
|
||||
outcome = await this.#run(profile, s, step, removeExtras)
|
||||
} catch (e) {
|
||||
outcome = { ok: false, message: reason(e) }
|
||||
}
|
||||
steps.push({ step, status: outcome.ok ? 'ok' : 'failed', message: outcome.message })
|
||||
if (!outcome.ok) stopped = step
|
||||
}
|
||||
return {
|
||||
server: s.id,
|
||||
ok: stopped === null,
|
||||
message: stopped === null ? 'Profile applied.' : `Stopped at ${stopped}: ${steps.find((x) => x.step === stopped)?.message ?? 'failed'}`.slice(0, 300),
|
||||
steps,
|
||||
}
|
||||
}
|
||||
|
||||
/* Several servers go on in parallel; the steps of one server go one after the other. */
|
||||
async apply(name: string, ids: string[], options: { removeExtras: boolean; from: Record<string, string> }): Promise<{ results: ApplyResult[] }> {
|
||||
const profile = this.#require(name)
|
||||
const targets = this.#targets(ids)
|
||||
const results = await Promise.all(
|
||||
targets.map((s) => {
|
||||
const from = (STEPS as readonly string[]).includes(options.from[s.id]) ? (options.from[s.id] as Step) : undefined
|
||||
return this.#applyOne(profile, s, from, options.removeExtras)
|
||||
}),
|
||||
)
|
||||
return { results }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
Login rate limiting. Failures are counted per client address and per user name: after
|
||||
MAX_FAILURES within WINDOW_MS, further attempts are refused for LOCK_MS. A success clears
|
||||
the user's counter. In memory; `sweep` drops what has expired.
|
||||
*/
|
||||
export const MAX_FAILURES = 5
|
||||
export const WINDOW_MS = 15 * 60_000
|
||||
export const LOCK_MS = 15 * 60_000
|
||||
|
||||
interface Entry {
|
||||
failures: number[]
|
||||
lockedUntil: number
|
||||
}
|
||||
|
||||
const userKey = (user: string): string => user.trim().toLowerCase().slice(0, 128)
|
||||
|
||||
export class LoginLimiter {
|
||||
#now: () => number
|
||||
#ips = new Map<string, Entry>()
|
||||
#users = new Map<string, Entry>()
|
||||
|
||||
constructor(now: () => number = Date.now) {
|
||||
this.#now = now
|
||||
}
|
||||
|
||||
/** Milliseconds left of a lock on this address or this user, or 0 when attempts are allowed. */
|
||||
lockedFor(ip: string, user: string): number {
|
||||
const now = this.#now()
|
||||
const until = Math.max(this.#ips.get(ip)?.lockedUntil ?? 0, this.#users.get(userKey(user))?.lockedUntil ?? 0)
|
||||
return until > now ? until - now : 0
|
||||
}
|
||||
|
||||
failure(ip: string, user: string): void {
|
||||
this.#record(this.#ips, ip)
|
||||
this.#record(this.#users, userKey(user))
|
||||
this.sweep()
|
||||
}
|
||||
|
||||
/** A good login clears the user's counter (the address keeps its own). */
|
||||
success(user: string): void {
|
||||
this.#users.delete(userKey(user))
|
||||
}
|
||||
|
||||
#record(map: Map<string, Entry>, key: string): void {
|
||||
const now = this.#now()
|
||||
const entry = map.get(key) ?? { failures: [], lockedUntil: 0 }
|
||||
entry.failures = entry.failures.filter((t) => now - t < WINDOW_MS)
|
||||
entry.failures.push(now)
|
||||
if (entry.failures.length >= MAX_FAILURES) {
|
||||
entry.lockedUntil = now + LOCK_MS
|
||||
entry.failures = []
|
||||
}
|
||||
map.set(key, entry)
|
||||
}
|
||||
|
||||
sweep(): void {
|
||||
const now = this.#now()
|
||||
for (const map of [this.#ips, this.#users]) {
|
||||
for (const [key, entry] of map) {
|
||||
const recent = entry.failures.some((t) => now - t < WINDOW_MS)
|
||||
if (entry.lockedUntil <= now && !recent) map.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Technitium-like message for a locked attempt. */
|
||||
export const lockedMessage = (ms: number): string => {
|
||||
const minutes = Math.max(1, Math.ceil(ms / 60_000))
|
||||
return `Too many failed logins. Try again in ${minutes} minute${minutes === 1 ? '' : 's'}.`
|
||||
}
|
||||
+244
@@ -0,0 +1,244 @@
|
||||
/*
|
||||
What each `/api/<path>` does and who may call it. `kind` says what it touches:
|
||||
`read` (no change), `write-one` (changes one server) and `write-all` (changes every server).
|
||||
A path that is not listed is refused. The app writes need `apps.write`. `hub/servers/run` (blocking, Blocked/Allowed and cache on several
|
||||
servers at once) needs `operate`.
|
||||
|
||||
The sections a server is managed from (stage 5) are listed by what the console calls. A
|
||||
write there goes to ONE server (`node=<id>`) with that server's admin token. Capabilities:
|
||||
cache and Blocked/Allowed writes, the temporary blocking switch and the list update need
|
||||
`operate`; zones (records and DNSSEC too), settings and DHCP need `admin`.
|
||||
|
||||
Left out on purpose, so they are refused: everything under `admin/` and `user/` (users,
|
||||
groups, sessions, SSO, LDAP, tokens: those stay in each server's own console), `logs/delete`,
|
||||
`logs/deleteAll`, `dashboard/stats/deleteAll`, `settings/backup` and `settings/restore` (a
|
||||
backup holds the users and their tokens), and `zones/permissions/*` (they name users).
|
||||
|
||||
Not in the table: `status`, `user/login`, `user/session/get`, `user/logout` and
|
||||
`user/createSingleUseToken` (the hub's own, for downloads), which the server answers itself
|
||||
before the table is consulted.
|
||||
*/
|
||||
import type { Capability } from './users.ts'
|
||||
|
||||
export type RouteKind = 'read' | 'write-one' | 'write-all'
|
||||
|
||||
export interface Route {
|
||||
kind: RouteKind
|
||||
capability: Capability
|
||||
/* A read whose answer is a file, not JSON: it is passed through (see `Upstream.raw`). */
|
||||
raw?: boolean
|
||||
/* A read that has no merger: without `node=<id>` it is refused, not answered by the first server. */
|
||||
perServer?: boolean
|
||||
/* A write that may carry a file or a text body (a zone import). */
|
||||
upload?: boolean
|
||||
}
|
||||
|
||||
const read = (capability: Capability = 'read'): Route => ({ kind: 'read', capability })
|
||||
const writeOne = (capability: Capability): Route => ({ kind: 'write-one', capability })
|
||||
const writeAll = (capability: Capability): Route => ({ kind: 'write-all', capability })
|
||||
/* A read that only makes sense for one server. */
|
||||
const readOne = (): Route => ({ kind: 'read', capability: 'read', perServer: true })
|
||||
/* A download: one server's file. */
|
||||
const download = (): Route => ({ kind: 'read', capability: 'read', perServer: true, raw: true })
|
||||
const upload = (capability: Capability): Route => ({ ...writeOne(capability), upload: true })
|
||||
|
||||
export const ROUTES: Record<string, Route> = {
|
||||
'dashboard/stats/get': read(),
|
||||
'dashboard/stats/getTop': read(),
|
||||
'dashboard/metrics/json': read(),
|
||||
'apps/list': read(),
|
||||
'logs/query': read(),
|
||||
'logs/list': readOne(),
|
||||
'settings/get': read(),
|
||||
'user/checkForUpdate': read(),
|
||||
'admin/cluster/state': read(),
|
||||
// Zones. Reads need a server; writes need `admin`.
|
||||
'zones/list': readOne(),
|
||||
'zones/catalogs/list': readOne(),
|
||||
'zones/options/get': readOne(),
|
||||
'zones/records/get': readOne(),
|
||||
'zones/dnssec/properties/get': readOne(),
|
||||
'zones/dnssec/viewDS': readOne(),
|
||||
'zones/export': download(),
|
||||
'zones/create': upload('admin'),
|
||||
'zones/import': upload('admin'),
|
||||
'zones/delete': writeOne('admin'),
|
||||
'zones/enable': writeOne('admin'),
|
||||
'zones/disable': writeOne('admin'),
|
||||
'zones/resync': writeOne('admin'),
|
||||
'zones/clone': writeOne('admin'),
|
||||
'zones/convert': writeOne('admin'),
|
||||
'zones/options/set': writeOne('admin'),
|
||||
'zones/records/add': writeOne('admin'),
|
||||
'zones/records/update': writeOne('admin'),
|
||||
'zones/records/delete': writeOne('admin'),
|
||||
'zones/dnssec/sign': writeOne('admin'),
|
||||
'zones/dnssec/unsign': writeOne('admin'),
|
||||
'zones/dnssec/properties/addPrivateKey': writeOne('admin'),
|
||||
'zones/dnssec/properties/updatePrivateKey': writeOne('admin'),
|
||||
'zones/dnssec/properties/deletePrivateKey': writeOne('admin'),
|
||||
'zones/dnssec/properties/publishAllPrivateKeys': writeOne('admin'),
|
||||
'zones/dnssec/properties/activateKskDnsKey': writeOne('admin'),
|
||||
'zones/dnssec/properties/rolloverDnsKey': writeOne('admin'),
|
||||
'zones/dnssec/properties/retireDnsKey': writeOne('admin'),
|
||||
'zones/dnssec/properties/convertToNSEC': writeOne('admin'),
|
||||
'zones/dnssec/properties/convertToNSEC3': writeOne('admin'),
|
||||
'zones/dnssec/properties/updateNSEC3Params': writeOne('admin'),
|
||||
'zones/dnssec/properties/updateDnsKeyTtl': writeOne('admin'),
|
||||
// Cache.
|
||||
'cache/list': readOne(),
|
||||
'cache/flush': writeOne('operate'),
|
||||
'cache/delete': writeOne('operate'),
|
||||
// Blocked and Allowed.
|
||||
'allowed/list': readOne(),
|
||||
'blocked/list': readOne(),
|
||||
'allowed/export': download(),
|
||||
'blocked/export': download(),
|
||||
'allowed/add': writeOne('operate'),
|
||||
'allowed/delete': writeOne('operate'),
|
||||
'allowed/flush': writeOne('operate'),
|
||||
'allowed/import': writeOne('operate'),
|
||||
'blocked/add': writeOne('operate'),
|
||||
'blocked/delete': writeOne('operate'),
|
||||
'blocked/flush': writeOne('operate'),
|
||||
'blocked/import': writeOne('operate'),
|
||||
// DHCP.
|
||||
'dhcp/leases/list': readOne(),
|
||||
'dhcp/scopes/list': readOne(),
|
||||
'dhcp/scopes/get': readOne(),
|
||||
'dhcp/scopes/set': writeOne('admin'),
|
||||
'dhcp/scopes/enable': writeOne('admin'),
|
||||
'dhcp/scopes/disable': writeOne('admin'),
|
||||
'dhcp/scopes/delete': writeOne('admin'),
|
||||
'dhcp/leases/remove': writeOne('admin'),
|
||||
'dhcp/leases/convertToReserved': writeOne('admin'),
|
||||
'dhcp/leases/convertToDynamic': writeOne('admin'),
|
||||
// DNS Client. `import=true` turns a resolve into a zone write: see `effectiveRoute`.
|
||||
'dnsClient/resolve': readOne(),
|
||||
// Settings. `settings/get` (above) is not `perServer`: the header's blocking mark reads it without a node.
|
||||
'settings/getTsigKeyNames': readOne(),
|
||||
'settings/set': writeOne('admin'), // see `effectiveRoute` for the blocking switch
|
||||
'settings/temporaryDisableBlocking': writeOne('operate'),
|
||||
'settings/forceUpdateBlockLists': writeOne('operate'),
|
||||
// Logs (no delete).
|
||||
'logs/download': download(),
|
||||
'logs/export': download(),
|
||||
// The hub's own namespace; never forwarded to a server.
|
||||
'hub/audit/list': read('audit.read'),
|
||||
'hub/apps/matrix': read(),
|
||||
'apps/listStoreApps': read(),
|
||||
// Writes. They go to a server's ADMIN token only; see apps-write.ts.
|
||||
'apps/downloadAndInstall': writeOne('apps.write'),
|
||||
'apps/downloadAndUpdate': writeOne('apps.write'),
|
||||
'apps/uninstall': writeOne('apps.write'),
|
||||
'apps/install': writeOne('apps.write'),
|
||||
'apps/update': writeOne('apps.write'),
|
||||
// Config sync (apps-config.ts). Compare shows live configs to anyone who can read; the rest needs apps.write.
|
||||
'apps/config/get': read(),
|
||||
'apps/config/set': writeOne('apps.write'),
|
||||
'hub/apps/config/compare': read(),
|
||||
'hub/apps/config/preview': read('apps.write'),
|
||||
'hub/apps/overrides/current': read('apps.write'),
|
||||
'hub/apps/baseline/import': writeAll('apps.write'),
|
||||
'hub/apps/baseline/save': writeAll('apps.write'),
|
||||
'hub/apps/overrides/save': writeAll('apps.write'),
|
||||
'hub/apps/config/apply': writeAll('apps.write'),
|
||||
// History, drift, and moving baselines between hubs (apps-config.ts, drift.ts).
|
||||
'hub/apps/history/list': read(),
|
||||
'hub/apps/history/get': read(),
|
||||
'hub/apps/history/diff': read(),
|
||||
'hub/apps/history/rollback': writeAll('apps.write'),
|
||||
'hub/apps/baseline/restore': writeAll('apps.write'),
|
||||
'hub/apps/drift/accept': writeAll('apps.write'),
|
||||
// Profiles (profiles.ts): bundles of apps, settings and lists. They hold settings secrets, so everything needs admin.
|
||||
'hub/profiles/list': read('admin'),
|
||||
'hub/profiles/get': read('admin'),
|
||||
'hub/profiles/export': read('admin'),
|
||||
'hub/profiles/preview': read('admin'),
|
||||
'hub/profiles/snapshot': writeAll('admin'),
|
||||
'hub/profiles/delete': writeAll('admin'),
|
||||
'hub/profiles/import': writeAll('admin'),
|
||||
'hub/profiles/apply': writeAll('admin'),
|
||||
'hub/webhook/status': read('admin'),
|
||||
'hub/webhook/test': writeAll('admin'),
|
||||
'hub/drift/get': read(),
|
||||
'hub/drift/check': writeAll('apps.write'),
|
||||
// Configs can hold secrets, so the file of all baselines needs apps.write even to read.
|
||||
'hub/apps/export': read('apps.write'),
|
||||
'hub/apps/import': writeAll('apps.write'),
|
||||
'hub/apps/run': writeAll('apps.write'),
|
||||
'hub/apps/upload': writeAll('apps.write'),
|
||||
// The same change on several servers, outside Apps (servers-run.ts). One endpoint with a fixed list of actions.
|
||||
'hub/servers/run': writeAll('operate'),
|
||||
// Zones across the servers (zones-sync.ts). The merged list is zone names and serials only.
|
||||
'hub/zones/matrix': read(),
|
||||
'hub/zones/copy/preview': read('admin'),
|
||||
'hub/zones/copy': writeAll('admin'),
|
||||
'hub/zones/secondary/preview': read('admin'),
|
||||
'hub/zones/secondary': writeAll('admin'),
|
||||
// Settings and blocking lists across the servers (settings-config.ts). Settings carry secrets, so even reads need admin.
|
||||
'hub/settings/compare': read('admin'),
|
||||
'hub/settings/preview': read('admin'),
|
||||
'hub/settings/history/list': read('admin'),
|
||||
'hub/settings/history/get': read('admin'),
|
||||
'hub/settings/baseline/import': writeAll('admin'),
|
||||
'hub/settings/baseline/save': writeAll('admin'),
|
||||
'hub/settings/overrides/save': writeAll('admin'),
|
||||
'hub/settings/apply': writeAll('admin'),
|
||||
// The domains in an Advanced Blocking app's block lists, counted by the hub (app-lists.ts). One server's app.
|
||||
'hub/blocking/app-lists/count': readOne(),
|
||||
'hub/blocking/lists/compare': read('admin'),
|
||||
'hub/blocking/lists/preview': read('admin'),
|
||||
'hub/blocking/lists/baseline/import': writeAll('admin'),
|
||||
'hub/blocking/lists/apply': writeAll('admin'),
|
||||
}
|
||||
|
||||
export function routeFor(apiPath: string): Route | undefined {
|
||||
return Object.hasOwn(ROUTES, apiPath) ? ROUTES[apiPath] : undefined
|
||||
}
|
||||
|
||||
/*
|
||||
The route for THIS call. `dnsClient/resolve` reads, but with `import=true` it also imports the
|
||||
answer into a zone of the server: that variant is a zone write (`admin`, one server). Any value
|
||||
but an absent or `false` one counts, so a spelling the server might still accept does not slip
|
||||
through as a read.
|
||||
*/
|
||||
export function effectiveRoute(apiPath: string, params: Record<string, string>): Route | undefined {
|
||||
if (apiPath === 'dnsClient/resolve') {
|
||||
const flag = (params.import ?? '').trim().toLowerCase()
|
||||
if (flag !== '' && flag !== 'false') return writeOne('admin')
|
||||
}
|
||||
/*
|
||||
The blocking switch is blocking, not server settings: a `settings/set` that carries nothing but
|
||||
`enableBlocking` needs `operate`, as the temporary switch does. Any other field in the call
|
||||
makes it the usual `admin` write. (`node` and `token` are the hub's own.)
|
||||
*/
|
||||
if (apiPath === 'settings/set') {
|
||||
const fields = Object.keys(params).filter((k) => k !== 'node' && k !== 'token')
|
||||
if (fields.length === 1 && fields[0] === 'enableBlocking') return writeOne('operate')
|
||||
}
|
||||
return routeFor(apiPath)
|
||||
}
|
||||
|
||||
/*
|
||||
The largest body a signed-in caller may send to a path outside `hub/apps/`. The servers read the
|
||||
rest with the small default, so a long TXT record or a block list import has to be listed here.
|
||||
*/
|
||||
const MB = 1024 * 1024
|
||||
const LARGE_BODIES: Record<string, number> = {
|
||||
'settings/set': 2 * MB,
|
||||
'zones/records/add': 2 * MB,
|
||||
'zones/records/update': 2 * MB,
|
||||
'zones/options/set': 2 * MB,
|
||||
'dhcp/scopes/set': 2 * MB,
|
||||
'hub/settings/baseline/save': 2 * MB,
|
||||
'hub/settings/overrides/save': 2 * MB,
|
||||
'hub/profiles/import': 5 * MB,
|
||||
'allowed/import': 5 * MB,
|
||||
'blocked/import': 5 * MB,
|
||||
}
|
||||
|
||||
export const largeBodyLimit = (apiPath: string): number | undefined =>
|
||||
Object.hasOwn(LARGE_BODIES, apiPath) ? LARGE_BODIES[apiPath] : undefined
|
||||
|
||||
export const isHubPath = (apiPath: string): boolean => apiPath.startsWith('hub/')
|
||||
@@ -0,0 +1,376 @@
|
||||
// @vitest-environment node
|
||||
import http from 'node:http'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { parseConfig } from './config.ts'
|
||||
import { createHub } from './server.ts'
|
||||
import { Audit } from './audit.ts'
|
||||
import { LoginLimiter, LOCK_MS, lockedMessage } from './ratelimit.ts'
|
||||
import { ROUTES, routeFor } from './routes.ts'
|
||||
import { run } from './users-cli.ts'
|
||||
import {
|
||||
Users,
|
||||
addUser,
|
||||
can,
|
||||
hashPassword,
|
||||
parseUsersFile,
|
||||
readUsersFile,
|
||||
requires,
|
||||
setDisabled,
|
||||
setPassword,
|
||||
setRole,
|
||||
verifyPassword,
|
||||
writeUsersFile,
|
||||
type Role,
|
||||
} from './users.ts'
|
||||
|
||||
let dir: string
|
||||
let servers: http.Server[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(path.join(tmpdir(), 'hub-sec-'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const s of servers) s.close()
|
||||
servers = []
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const env = (over: Record<string, string> = {}) => ({
|
||||
HUB_SERVERS: 'a=http://127.0.0.1:1',
|
||||
A_TOKEN: 'token-a',
|
||||
A_ADMIN_TOKEN: 'admin-secret-a',
|
||||
HUB_DATA: dir,
|
||||
HUB_STATIC: dir,
|
||||
...over,
|
||||
})
|
||||
|
||||
async function start(over: Record<string, string> = {}): Promise<string> {
|
||||
const hub = createHub(parseConfig(env(over)))
|
||||
servers.push(hub)
|
||||
await new Promise<void>((resolve) => hub.listen(0, '127.0.0.1', resolve))
|
||||
return `http://127.0.0.1:${(hub.address() as AddressInfo).port}`
|
||||
}
|
||||
|
||||
async function call(base: string, apiPath: string, params: Record<string, string> = {}, token?: string, headers: Record<string, string> = {}) {
|
||||
const headerSet: Record<string, string> = { ...headers }
|
||||
if (token) headerSet.authorization = `Bearer ${token}`
|
||||
const isLogin = apiPath === 'user/login'
|
||||
const res = await fetch(`${base}/api/${apiPath}${isLogin ? '' : `?${new URLSearchParams(params)}`}`, {
|
||||
method: isLogin ? 'POST' : 'GET',
|
||||
headers: isLogin ? { ...headerSet, 'content-type': 'application/x-www-form-urlencoded' } : headerSet,
|
||||
body: isLogin ? new URLSearchParams(params).toString() : undefined,
|
||||
})
|
||||
return { code: res.status, body: (await res.json()) as Record<string, any> }
|
||||
}
|
||||
|
||||
const login = (base: string, user: string, pass: string, headers: Record<string, string> = {}) =>
|
||||
call(base, 'user/login', { user, pass }, undefined, headers)
|
||||
|
||||
function seed(...rows: [string, Role, string][]): void {
|
||||
let users = readUsersFile(dir) ?? []
|
||||
for (const [name, role, pass] of rows) users = addUser(users, name, role, pass)
|
||||
writeUsersFile(dir, users)
|
||||
}
|
||||
|
||||
describe('roles and capabilities', () => {
|
||||
it('ranks the roles and answers capabilities', () => {
|
||||
expect(requires('admin', 'operator')).toBe(true)
|
||||
expect(requires('viewer', 'operator')).toBe(false)
|
||||
expect(requires('apps-admin', 'operator')).toBe(true)
|
||||
expect(can('viewer', 'read')).toBe(true)
|
||||
expect(can('viewer', 'operate')).toBe(false)
|
||||
expect(can('operator', 'operate')).toBe(true)
|
||||
expect(can('operator', 'apps.write')).toBe(false)
|
||||
expect(can('apps-admin', 'apps.write')).toBe(true)
|
||||
expect(can('apps-admin', 'audit.read')).toBe(false)
|
||||
for (const c of ['read', 'apps.write', 'operate', 'audit.read', 'admin'] as const) expect(can('admin', c)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('passwords and the users file', () => {
|
||||
it('hashes with scrypt and verifies', async () => {
|
||||
const hash = hashPassword('correct horse')
|
||||
expect(hash).toMatch(/^scrypt\$16384\$8\$1\$[A-Za-z0-9+/=]+\$[A-Za-z0-9+/=]+$/)
|
||||
expect(hash.split('$')[5].length).toBeGreaterThan(80)
|
||||
expect(await verifyPassword('correct horse', hash)).toBe(true)
|
||||
expect(await verifyPassword('wrong', hash)).toBe(false)
|
||||
expect(await verifyPassword('x', 'not a hash')).toBe(false)
|
||||
expect(hashPassword('correct horse')).not.toBe(hash)
|
||||
})
|
||||
|
||||
it('parses and rejects bad files', () => {
|
||||
const hash = hashPassword('longenough')
|
||||
const ok = parseUsersFile(JSON.stringify({ users: [{ name: 'Alice', role: 'admin', hash, disabled: true }] }))
|
||||
expect(ok).toEqual([{ name: 'Alice', role: 'admin', hash, disabled: true }])
|
||||
expect(() => parseUsersFile('nope')).toThrow(/JSON/)
|
||||
expect(() => parseUsersFile('{}')).toThrow(/users/)
|
||||
expect(() => parseUsersFile(JSON.stringify({ users: [{ name: 'a', role: 'root', hash }] }))).toThrow(/role/)
|
||||
expect(() => parseUsersFile(JSON.stringify({ users: [{ name: 'a', role: 'admin', hash: 'x' }] }))).toThrow(/hash/)
|
||||
expect(() =>
|
||||
parseUsersFile(JSON.stringify({ users: [{ name: 'a', role: 'admin', hash }, { name: 'A', role: 'viewer', hash }] })),
|
||||
).toThrow(/twice/)
|
||||
})
|
||||
|
||||
it('edits users (the functions the CLI calls)', async () => {
|
||||
let users = addUser([], 'bob', 'viewer', 'password1')
|
||||
expect(() => addUser(users, 'BOB', 'viewer', 'password1')).toThrow(/exists/)
|
||||
expect(() => addUser(users, 'x', 'root', 'password1')).toThrow(/role/)
|
||||
expect(() => addUser(users, 'x', 'viewer', 'short')).toThrow(/8/)
|
||||
users = setRole(users, 'Bob', 'operator')
|
||||
expect(users[0].role).toBe('operator')
|
||||
const before = users[0].hash
|
||||
users = setPassword(users, 'bob', 'password2')
|
||||
expect(users[0].hash).not.toBe(before)
|
||||
expect(await verifyPassword('password2', users[0].hash)).toBe(true)
|
||||
users = setDisabled(users, 'bob', true)
|
||||
expect(users[0].disabled).toBe(true)
|
||||
users = setDisabled(users, 'bob', false)
|
||||
expect('disabled' in users[0]).toBe(false)
|
||||
expect(() => setRole(users, 'nobody', 'viewer')).toThrow(/No user/)
|
||||
})
|
||||
|
||||
it('writes atomically, leaving no temp file', () => {
|
||||
writeUsersFile(dir, addUser([], 'bob', 'viewer', 'password1'))
|
||||
const file = path.join(dir, 'users.json')
|
||||
expect(readUsersFile(dir)?.[0].name).toBe('bob')
|
||||
expect(existsSync(`${file}.${process.pid}.tmp`)).toBe(false)
|
||||
if (process.platform !== 'win32') expect(statSync(file).mode & 0o777).toBe(0o600)
|
||||
})
|
||||
|
||||
it('runs CLI commands', async () => {
|
||||
const pw = async () => 'password1'
|
||||
expect(await run(['list'], dir, pw)).toEqual(['No users.'])
|
||||
await run(['add', 'alice', 'apps-admin'], dir, pw)
|
||||
await run(['role', 'alice', 'operator'], dir, pw)
|
||||
await run(['disable', 'alice'], dir, pw)
|
||||
expect(await run(['list'], dir, pw)).toEqual(['alice\toperator\tdisabled'])
|
||||
await run(['enable', 'alice'], dir, pw)
|
||||
await run(['passwd', 'alice'], dir, async () => 'password2')
|
||||
expect(await verifyPassword('password2', readUsersFile(dir)![0].hash)).toBe(true)
|
||||
await expect(run(['bogus'], dir, pw)).rejects.toThrow(/Usage/)
|
||||
await expect(run(['add', 'bob'], dir, pw)).rejects.toThrow(/role/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('bootstrap', () => {
|
||||
it('uses the environment user as admin when there is no file, and does not write it', async () => {
|
||||
const users = Users.load(dir, { user: 'Root', password: 'secret' })
|
||||
expect(existsSync(path.join(dir, 'users.json'))).toBe(false)
|
||||
expect(users.notice).toMatch(/HUB_USER/)
|
||||
expect((await users.authenticate('root', 'secret'))?.role).toBe('admin')
|
||||
expect(await users.authenticate('root', 'nope')).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores the environment user when the file exists', async () => {
|
||||
seed(['alice', 'viewer', 'password1'])
|
||||
const users = Users.load(dir, { user: 'root', password: 'secret' })
|
||||
expect(users.notice).toMatch(/ignored/)
|
||||
expect(await users.authenticate('root', 'secret')).toBeNull()
|
||||
expect((await users.authenticate('ALICE', 'password1'))?.role).toBe('viewer')
|
||||
})
|
||||
|
||||
it('refuses to start with neither', () => {
|
||||
expect(() => Users.load(dir, null)).toThrow(/No users/)
|
||||
expect(() => createHub(parseConfig(env()))).toThrow(/No users/)
|
||||
})
|
||||
|
||||
it('config reads no file and takes the optional variables', () => {
|
||||
const c = parseConfig(env({ HUB_TRUST_PROXY: '1' }))
|
||||
expect(c.bootstrap).toBeNull()
|
||||
expect(c.dataDir).toBe(dir)
|
||||
expect(c.trustProxy).toBe(true)
|
||||
expect(c.servers[0].adminToken).toBe('admin-secret-a')
|
||||
expect(parseConfig({ ...env(), HUB_DATA: '' }).dataDir).toBe('/data')
|
||||
expect(parseConfig(env()).trustProxy).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rate limit', () => {
|
||||
it('locks after 5 failures, expires, and a success resets the user', () => {
|
||||
let now = 1_000_000
|
||||
const limiter = new LoginLimiter(() => now)
|
||||
for (let i = 0; i < 4; i++) limiter.failure('1.1.1.1', 'Bob')
|
||||
expect(limiter.lockedFor('1.1.1.1', 'bob')).toBe(0)
|
||||
limiter.success('bob')
|
||||
for (let i = 0; i < 4; i++) limiter.failure('2.2.2.2', 'bob')
|
||||
expect(limiter.lockedFor('2.2.2.2', 'bob')).toBe(0)
|
||||
limiter.failure('2.2.2.2', 'bob')
|
||||
expect(limiter.lockedFor('3.3.3.3', 'BOB')).toBe(LOCK_MS)
|
||||
expect(limiter.lockedFor('2.2.2.2', 'other')).toBe(LOCK_MS)
|
||||
now += LOCK_MS + 1
|
||||
expect(limiter.lockedFor('2.2.2.2', 'bob')).toBe(0)
|
||||
expect(lockedMessage(LOCK_MS)).toBe('Too many failed logins. Try again in 15 minutes.')
|
||||
expect(lockedMessage(1000)).toBe('Too many failed logins. Try again in 1 minute.')
|
||||
})
|
||||
|
||||
it('forgets failures older than the window', () => {
|
||||
let now = 0
|
||||
const limiter = new LoginLimiter(() => now)
|
||||
for (let i = 0; i < 4; i++) limiter.failure('ip', 'u')
|
||||
now += 16 * 60_000
|
||||
limiter.failure('ip', 'u')
|
||||
expect(limiter.lockedFor('ip', 'u')).toBe(0)
|
||||
})
|
||||
|
||||
it('locks the hub login with 429 and the message, even for the right password', async () => {
|
||||
seed(['alice', 'admin', 'password1'])
|
||||
const base = await start()
|
||||
for (let i = 0; i < 5; i++) expect((await login(base, 'alice', 'bad')).body.status).toBe('error')
|
||||
const locked = await login(base, 'alice', 'password1')
|
||||
expect(locked.code).toBe(429)
|
||||
expect(locked.body).toEqual({ status: 'error', errorMessage: 'Too many failed logins. Try again in 15 minutes.' })
|
||||
})
|
||||
|
||||
it('counts X-Forwarded-For only when the proxy is trusted', async () => {
|
||||
seed(['alice', 'admin', 'password1'])
|
||||
// Untrusted: the header is ignored, so five attempts from one socket lock that socket.
|
||||
const plain = await start()
|
||||
for (let i = 0; i < 5; i++) await login(plain, `u${i}`, 'bad', { 'x-forwarded-for': `9.9.9.${i}` })
|
||||
expect((await login(plain, 'alice', 'password1', { 'x-forwarded-for': '8.8.8.8' })).code).toBe(429)
|
||||
// Trusted: each forwarded address has its own count.
|
||||
const trusted = await start({ HUB_TRUST_PROXY: '1' })
|
||||
for (let i = 0; i < 5; i++) await login(trusted, `u${i}`, 'bad', { 'x-forwarded-for': `9.9.9.${i}` })
|
||||
expect((await login(trusted, 'alice', 'password1', { 'x-forwarded-for': '8.8.8.8, 10.0.0.1' })).code).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
describe('audit', () => {
|
||||
it('appends JSON lines, lists newest first and filters with before', () => {
|
||||
let now = Date.parse('2026-01-01T00:00:00Z')
|
||||
const audit = new Audit(dir, { now: () => now })
|
||||
for (const action of ['one', 'two', 'three']) {
|
||||
audit.log({ user: 'u', ip: 'i', action, outcome: 'ok' })
|
||||
now += 1000
|
||||
}
|
||||
const lines = readFileSync(path.join(dir, 'audit.log'), 'utf8').trim().split('\n')
|
||||
expect(lines).toHaveLength(3)
|
||||
expect(JSON.parse(lines[0])).toMatchObject({ user: 'u', ip: 'i', action: 'one', outcome: 'ok' })
|
||||
expect(audit.list().map((e) => e.action)).toEqual(['three', 'two', 'one'])
|
||||
expect(audit.list(1).map((e) => e.action)).toEqual(['three'])
|
||||
expect(audit.list(200, '2026-01-01T00:00:02.000Z').map((e) => e.action)).toEqual(['two', 'one'])
|
||||
})
|
||||
|
||||
it('rotates once the file is too big and keeps one old file', () => {
|
||||
const audit = new Audit(dir, { maxBytes: 600 })
|
||||
for (let i = 0; i < 12; i++) audit.log({ user: 'u', ip: 'i', action: `a${i}`, outcome: 'ok' })
|
||||
expect(existsSync(path.join(dir, 'audit.log.1'))).toBe(true)
|
||||
expect(existsSync(path.join(dir, 'audit.log.2'))).toBe(false)
|
||||
expect(statSync(path.join(dir, 'audit.log')).size).toBeLessThanOrEqual(600)
|
||||
expect(audit.list(1)[0].action).toBe('a11')
|
||||
})
|
||||
|
||||
it('falls back to memory when the folder is not writable', () => {
|
||||
const file = path.join(dir, 'blocker')
|
||||
writeFileSync(file, 'x')
|
||||
const err = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const audit = new Audit(path.join(file, 'sub'))
|
||||
audit.log({ user: 'u', ip: 'i', action: 'x', outcome: 'ok' })
|
||||
audit.log({ user: 'u', ip: 'i', action: 'y', outcome: 'ok' })
|
||||
expect(err).toHaveBeenCalledTimes(1)
|
||||
err.mockRestore()
|
||||
expect(audit.inMemory).toBe(true)
|
||||
expect(audit.list().map((e) => e.action)).toEqual(['y', 'x'])
|
||||
})
|
||||
|
||||
it('records login, logout and refusals without passwords or tokens, and is for audit.read only', async () => {
|
||||
seed(['alice', 'admin', 'password1'], ['vic', 'viewer', 'password2'])
|
||||
const base = await start()
|
||||
await login(base, 'alice', 'wrong-password-xyz')
|
||||
const adminLogin = await login(base, 'alice', 'password1')
|
||||
const admin = adminLogin.body.token as string
|
||||
expect(adminLogin.body.hubRole).toBe('admin')
|
||||
const viewer = (await login(base, 'vic', 'password2')).body.token as string
|
||||
|
||||
expect((await call(base, 'admin/users/delete', { user: 'x', node: 'a' }, admin)).code).toBe(403)
|
||||
expect((await call(base, 'hub/audit/list', {}, viewer)).code).toBe(403)
|
||||
await call(base, 'user/logout', {}, viewer)
|
||||
|
||||
const list = await call(base, 'hub/audit/list', { limit: '50' }, admin)
|
||||
expect(list.body.status).toBe('ok')
|
||||
const entries = list.body.response.entries as Record<string, string>[]
|
||||
const actions = entries.map((e) => `${e.action}:${e.outcome}`)
|
||||
expect(actions).toEqual(expect.arrayContaining(['login:failed', 'login:ok', 'refused-write:refused', 'denied-read:refused', 'logout:ok']))
|
||||
const refused = entries.find((e) => e.action === 'refused-write')
|
||||
expect(refused).toMatchObject({ user: 'alice', role: 'admin', target: 'admin/users/delete', server: 'a' })
|
||||
const ts = entries.map((e) => e.ts)
|
||||
expect([...ts].sort().reverse()).toEqual(ts)
|
||||
|
||||
const raw = readFileSync(path.join(dir, 'audit.log'), 'utf8')
|
||||
for (const secret of ['wrong-password-xyz', 'password1', 'password2', admin, viewer, 'token-a', 'admin-secret-a']) {
|
||||
expect(raw).not.toContain(secret)
|
||||
}
|
||||
})
|
||||
|
||||
it('logs a locked attempt', async () => {
|
||||
seed(['alice', 'admin', 'password1'])
|
||||
const base = await start()
|
||||
for (let i = 0; i < 6; i++) await login(base, 'alice', 'bad')
|
||||
const lines = readFileSync(path.join(dir, 'audit.log'), 'utf8').trim().split('\n').map((l) => JSON.parse(l))
|
||||
expect(lines.filter((l) => l.outcome === 'failed')).toHaveLength(5)
|
||||
expect(lines[5]).toMatchObject({ action: 'login', outcome: 'refused', detail: 'locked' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('routes table', () => {
|
||||
it('lists every old read endpoint as a read and hub/audit/list as audit.read', () => {
|
||||
for (const p of [
|
||||
'dashboard/stats/get', 'dashboard/stats/getTop', 'dashboard/metrics/json', 'apps/list', 'logs/query',
|
||||
'settings/get', 'user/checkForUpdate', 'admin/cluster/state',
|
||||
]) expect(ROUTES[p]).toEqual({ kind: 'read', capability: 'read' })
|
||||
expect(routeFor('hub/audit/list')).toEqual({ kind: 'read', capability: 'audit.read' })
|
||||
expect(routeFor('logs/delete')).toBeUndefined()
|
||||
expect(routeFor('constructor')).toBeUndefined()
|
||||
expect(routeFor('hub/nothing')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('sessions by role', () => {
|
||||
it('puts hubRole in the body and in info', async () => {
|
||||
seed(['vic', 'viewer', 'password2'], ['op', 'operator', 'password3'])
|
||||
const base = await start()
|
||||
const a = await login(base, 'vic', 'password2')
|
||||
expect(a.body.hubRole).toBe('viewer')
|
||||
expect(a.body.info.hubRole).toBe('viewer')
|
||||
expect(a.body.info.permissions.Dashboard.canView).toBe(true)
|
||||
const b = await call(base, 'user/session/get', {}, (await login(base, 'op', 'password3')).body.token)
|
||||
expect(b.body.hubRole).toBe('operator')
|
||||
expect(b.body.info.hubRole).toBe('operator')
|
||||
})
|
||||
|
||||
it('drops the sessions of a disabled user and refuses a new login', async () => {
|
||||
seed(['alice', 'admin', 'password1'], ['vic', 'viewer', 'password2'])
|
||||
const base = await start()
|
||||
const vic = (await login(base, 'vic', 'password2')).body.token as string
|
||||
const alice = (await login(base, 'alice', 'password1')).body.token as string
|
||||
expect((await call(base, 'user/session/get', {}, vic)).body.status).toBe('ok')
|
||||
|
||||
// The file changes: vic is disabled, alice is untouched.
|
||||
const users = setDisabled(readUsersFile(dir)!, 'vic', true)
|
||||
writeUsersFile(dir, users)
|
||||
const future = new Date(Date.now() + 5000)
|
||||
const { utimesSync } = await import('node:fs')
|
||||
utimesSync(path.join(dir, 'users.json'), future, future)
|
||||
|
||||
expect((await call(base, 'user/session/get', {}, vic)).body.status).toBe('invalid-token')
|
||||
expect((await call(base, 'user/session/get', {}, alice)).body.status).toBe('ok')
|
||||
expect((await login(base, 'vic', 'password2')).body.status).toBe('error')
|
||||
})
|
||||
|
||||
it('drops sessions when the role or password changes', async () => {
|
||||
seed(['alice', 'admin', 'password1'])
|
||||
const base = await start()
|
||||
const t = (await login(base, 'alice', 'password1')).body.token as string
|
||||
writeUsersFile(dir, setRole(readUsersFile(dir)!, 'alice', 'viewer'))
|
||||
const { utimesSync } = await import('node:fs')
|
||||
const future = new Date(Date.now() + 5000)
|
||||
utimesSync(path.join(dir, 'users.json'), future, future)
|
||||
expect((await call(base, 'user/session/get', {}, t)).body.status).toBe('invalid-token')
|
||||
const again = await login(base, 'ALICE', 'password1')
|
||||
expect(again.body.hubRole).toBe('viewer')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,600 @@
|
||||
// @vitest-environment node
|
||||
/*
|
||||
Stage 5: managing one chosen server from the hub (zones, cache, blocking, DNS client, settings,
|
||||
DHCP, logs). The servers are fakes that record how they were called.
|
||||
*/
|
||||
import http from 'node:http'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { parseConfig } from './config.ts'
|
||||
import { createHub } from './server.ts'
|
||||
import { addUser, writeUsersFile } from './users.ts'
|
||||
import { webServiceChanges, redactSettings, safeTarget } from './guards.ts'
|
||||
import { effectiveRoute, routeFor } from './routes.ts'
|
||||
|
||||
interface Call {
|
||||
path: string
|
||||
method: string
|
||||
token: string
|
||||
/* The URL's query alone. */
|
||||
query: URLSearchParams
|
||||
/* The form body, when it is one. */
|
||||
form: URLSearchParams
|
||||
contentType: string
|
||||
body: Buffer
|
||||
}
|
||||
|
||||
interface Fake {
|
||||
server: http.Server
|
||||
url: string
|
||||
calls: Call[]
|
||||
settings: Record<string, unknown>
|
||||
down: boolean
|
||||
failWith: string | null
|
||||
}
|
||||
|
||||
const SETTINGS = {
|
||||
dnsServerDomain: 'a.example',
|
||||
webServiceLocalAddresses: ['0.0.0.0', '[::]'],
|
||||
webServiceHttpPort: 5380,
|
||||
webServiceEnableTls: false,
|
||||
webServiceTlsCertificatePath: null,
|
||||
webServiceTlsCertificatePassword: '************',
|
||||
tsigKeys: [{ keyName: 'k', sharedSecret: 'TOPSECRET', algorithmName: 'hmac-sha256' }],
|
||||
proxy: { type: 'Http', address: 'p', port: 3128, username: 'u', password: 'PROXYPASS', bypass: [] },
|
||||
dnsTlsCertificatePassword: 'CERTPASS',
|
||||
enableBlocking: true,
|
||||
}
|
||||
|
||||
function fake(): Promise<Fake> {
|
||||
return new Promise((resolve) => {
|
||||
const f: Fake = { server: http.createServer(), url: '', calls: [], settings: { ...SETTINGS }, down: false, failWith: null }
|
||||
f.server.on('request', (req, res) => {
|
||||
const chunks: Buffer[] = []
|
||||
req.on('data', (c: Buffer) => chunks.push(c))
|
||||
req.on('end', () => {
|
||||
if (f.down) return req.socket.destroy()
|
||||
const url = new URL(req.url ?? '/', 'http://fake')
|
||||
const apiPath = url.pathname.replace(/^\/api\//, '')
|
||||
const body = Buffer.concat(chunks)
|
||||
const contentType = String(req.headers['content-type'] ?? '')
|
||||
const form = new URLSearchParams(contentType.includes('x-www-form-urlencoded') ? body.toString('utf8') : '')
|
||||
f.calls.push({ path: apiPath, method: req.method ?? '', token: url.searchParams.get('token') ?? '', query: url.searchParams, form, contentType, body })
|
||||
if (f.failWith !== null && req.method === 'POST') {
|
||||
res.setHeader('content-type', 'application/json')
|
||||
return res.end(JSON.stringify({ status: 'error', errorMessage: f.failWith }))
|
||||
}
|
||||
if (apiPath === 'zones/export' || apiPath === 'logs/download' || apiPath === 'blocked/export') {
|
||||
if (url.searchParams.get('zone') === 'missing.example') {
|
||||
res.setHeader('content-type', 'application/json')
|
||||
return res.end(JSON.stringify({ status: 'error', errorMessage: 'No such zone: missing.example' }))
|
||||
}
|
||||
res.writeHead(200, { 'content-type': 'text/plain', 'content-disposition': `attachment; filename="${apiPath.replace('/', '-')}.txt"` })
|
||||
return res.end(`line one\nline two (${apiPath})\n`)
|
||||
}
|
||||
res.setHeader('content-type', 'application/json')
|
||||
const ok = (response: unknown = {}) => res.end(JSON.stringify({ status: 'ok', response }))
|
||||
if (apiPath === 'settings/get') return ok(f.settings)
|
||||
if (apiPath === 'zones/list') return ok({ zones: [{ name: 'example.com' }], pageNumber: 1, totalPages: 1 })
|
||||
ok({ done: apiPath })
|
||||
})
|
||||
})
|
||||
f.server.listen(0, '127.0.0.1', () => {
|
||||
f.url = `http://127.0.0.1:${(f.server.address() as AddressInfo).port}`
|
||||
resolve(f)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
let a: Fake
|
||||
let b: Fake
|
||||
let dataDir: string
|
||||
let staticDir: string
|
||||
let hub: http.Server | undefined
|
||||
let base: string
|
||||
const tokens: Record<string, string> = {}
|
||||
const ROLES = ['viewer', 'operator', 'apps-admin', 'admin']
|
||||
|
||||
async function start(extra: Record<string, string> = {}): Promise<void> {
|
||||
hub?.close()
|
||||
hub = createHub(
|
||||
parseConfig({
|
||||
HUB_SERVERS: `a=${a.url},b=${b.url}`,
|
||||
A_TOKEN: 'read-a',
|
||||
B_TOKEN: 'read-b',
|
||||
A_ADMIN_TOKEN: 'admin-a',
|
||||
B_ADMIN_TOKEN: 'admin-b',
|
||||
HUB_DATA: dataDir,
|
||||
HUB_STATIC: staticDir,
|
||||
HUB_CACHE_SECONDS: '0',
|
||||
HUB_TIMEOUT_MS: '2000',
|
||||
...extra,
|
||||
}),
|
||||
)
|
||||
await new Promise<void>((resolve) => hub!.listen(0, '127.0.0.1', resolve))
|
||||
base = `http://127.0.0.1:${(hub.address() as AddressInfo).port}`
|
||||
for (const role of ROLES) {
|
||||
const res = await fetch(`${base}/api/user/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: `user=${role}&pass=password-${role}`,
|
||||
})
|
||||
tokens[role] = ((await res.json()) as { token: string }).token
|
||||
}
|
||||
}
|
||||
|
||||
interface Init {
|
||||
params?: Record<string, string>
|
||||
method?: string
|
||||
body?: string | FormData
|
||||
headers?: Record<string, string>
|
||||
auth?: boolean
|
||||
}
|
||||
|
||||
async function api(role: string, apiPath: string, init: Init = {}) {
|
||||
const query = new URLSearchParams(init.params ?? {})
|
||||
const headers: Record<string, string> = { ...(init.auth === false ? {} : { authorization: `Bearer ${tokens[role]}` }), ...(init.headers ?? {}) }
|
||||
const res = await fetch(`${base}/api/${apiPath}?${query}`, { method: init.method ?? (init.body === undefined ? 'GET' : 'POST'), headers, body: init.body })
|
||||
const text = await res.text()
|
||||
let json: Record<string, any> = {}
|
||||
try {
|
||||
json = JSON.parse(text) as Record<string, any>
|
||||
} catch {
|
||||
// A file.
|
||||
}
|
||||
return { code: res.status, body: json, text, headers: res.headers }
|
||||
}
|
||||
|
||||
const posts = (f: Fake) => f.calls.filter((c) => c.method === 'POST')
|
||||
const form = (params: Record<string, string>) => ({
|
||||
body: new URLSearchParams(params).toString(),
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
})
|
||||
|
||||
async function auditEntries(): Promise<Record<string, string>[]> {
|
||||
return (await api('admin', 'hub/audit/list', { params: { limit: '200' } })).body.response.entries
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
a = await fake()
|
||||
b = await fake()
|
||||
dataDir = mkdtempSync(path.join(tmpdir(), 'hub-s5-data-'))
|
||||
staticDir = mkdtempSync(path.join(tmpdir(), 'hub-s5-static-'))
|
||||
let users = addUser([], 'viewer', 'viewer', 'password-viewer')
|
||||
users = addUser(users, 'operator', 'operator', 'password-operator')
|
||||
users = addUser(users, 'apps-admin', 'apps-admin', 'password-apps-admin')
|
||||
users = addUser(users, 'admin', 'admin', 'password-admin')
|
||||
writeUsersFile(dataDir, users)
|
||||
await start()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
for (const f of [a, b]) {
|
||||
f.calls = []
|
||||
f.down = false
|
||||
f.failWith = null
|
||||
f.settings = { ...SETTINGS }
|
||||
}
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
hub?.close()
|
||||
a.server.close()
|
||||
b.server.close()
|
||||
rmSync(dataDir, { recursive: true, force: true })
|
||||
rmSync(staticDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/* One write of each area, with the lowest capability that may make it. */
|
||||
const OPERATE = [
|
||||
['cache/flush', {}],
|
||||
['cache/delete', { domain: 'x.example' }],
|
||||
['blocked/add', { domain: 'ads.example' }],
|
||||
['allowed/delete', { domain: 'ok.example' }],
|
||||
['settings/temporaryDisableBlocking', { minutes: '5' }],
|
||||
['settings/forceUpdateBlockLists', {}],
|
||||
// The blocking switch alone is blocking, not server settings (routes.ts `effectiveRoute`).
|
||||
['settings/set', { enableBlocking: 'false' }],
|
||||
] as const
|
||||
const ADMIN = [
|
||||
['zones/records/add', { zone: 'example.com', domain: 'www.example.com', type: 'A', ipAddress: '192.0.2.1' }],
|
||||
['zones/delete', { zone: 'example.com' }],
|
||||
['zones/dnssec/sign', { zone: 'example.com', algorithm: 'ECDSA' }],
|
||||
['dhcp/scopes/enable', { name: 'Default' }],
|
||||
['settings/set', { dnsServerDomain: 'x.example' }],
|
||||
// The switch plus any other field is a settings change.
|
||||
['settings/set', { enableBlocking: 'true', dnsServerDomain: 'x.example' }],
|
||||
] as const
|
||||
|
||||
describe('who may write what', () => {
|
||||
it('refuses a viewer on every write, without calling a server', async () => {
|
||||
for (const [p, params] of [...OPERATE, ...ADMIN]) {
|
||||
const r = await api('viewer', p, { params: { node: 'a', ...params } })
|
||||
expect(r.code, p).toBe(403)
|
||||
expect(r.body.errorMessage).toBe('Your role does not allow this.')
|
||||
}
|
||||
expect(posts(a)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('lets an operator operate (cache, blocking) but not change zones, settings or DHCP', async () => {
|
||||
for (const [p, params] of OPERATE) expect((await api('operator', p, { params: { node: 'a', ...params } })).body.status, p).toBe('ok')
|
||||
for (const [p, params] of ADMIN) expect((await api('operator', p, { params: { node: 'a', ...params } })).code, p).toBe(403)
|
||||
expect(posts(a).map((c) => c.path)).toEqual(OPERATE.map(([p]) => p))
|
||||
})
|
||||
|
||||
it('lets an apps-admin do neither', async () => {
|
||||
for (const [p, params] of [...OPERATE, ...ADMIN]) expect((await api('apps-admin', p, { params: { node: 'a', ...params } })).code, p).toBe(403)
|
||||
expect(posts(a)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('lets an admin do all of it', async () => {
|
||||
for (const [p, params] of [...OPERATE, ...ADMIN]) expect((await api('admin', p, { params: { node: 'a', ...params } })).body.status, p).toBe('ok')
|
||||
expect(posts(a)).toHaveLength(OPERATE.length + ADMIN.length)
|
||||
})
|
||||
|
||||
it('tells the console what each role may modify', async () => {
|
||||
const info = async (role: string) => (await api(role, 'user/session/get')).body.info.permissions
|
||||
const viewer = await info('viewer')
|
||||
const operator = await info('operator')
|
||||
const admin = await info('admin')
|
||||
expect(viewer.Zones).toEqual({ canView: true, canModify: false, canDelete: false })
|
||||
expect(operator.Zones.canModify).toBe(false)
|
||||
expect(operator.Cache).toEqual({ canView: true, canModify: true, canDelete: true })
|
||||
expect(operator.Blocked.canModify).toBe(true)
|
||||
expect(operator.Settings.canModify).toBe(false)
|
||||
expect(admin.Zones).toEqual({ canView: true, canModify: true, canDelete: true })
|
||||
expect(admin.DhcpServer).toEqual({ canView: true, canModify: true, canDelete: true })
|
||||
// Backup and restore, deleting logs and stats: nobody.
|
||||
for (const p of [viewer, operator, admin]) {
|
||||
expect(p.Logs).toEqual({ canView: true, canModify: false, canDelete: false })
|
||||
expect(p.Dashboard.canDelete).toBe(false)
|
||||
expect(p.Settings.canDelete).toBe(false)
|
||||
expect(p.Administration.canView).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('what stays refused', () => {
|
||||
const REFUSED = [
|
||||
'admin/users/list', 'admin/users/create', 'admin/sso/set', 'admin/ldap/set', 'admin/groups/list', 'admin/sessions/createToken',
|
||||
'user/createToken', 'user/changePassword', 'user/profile/get',
|
||||
'logs/delete', 'logs/deleteAll', 'dashboard/stats/deleteAll',
|
||||
'settings/restore', 'settings/backup', 'zones/permissions/get', 'zones/permissions/set',
|
||||
]
|
||||
|
||||
it('answers "read-only" to each, even to an admin, and calls no server', async () => {
|
||||
for (const p of REFUSED) {
|
||||
const r = await api('admin', p, { params: { node: 'a' }, method: 'POST', body: '' })
|
||||
expect(r.code, p).toBe(403)
|
||||
expect(r.body.errorMessage, p).toBe('The hub is read-only.')
|
||||
}
|
||||
expect(a.calls).toHaveLength(0)
|
||||
const refused = (await auditEntries()).filter((e) => e.action === 'refused-write')
|
||||
expect(refused.map((e) => e.target)).toEqual(expect.arrayContaining(['logs/delete', 'settings/backup']))
|
||||
})
|
||||
})
|
||||
|
||||
describe('choosing the server', () => {
|
||||
it('refuses a write with no node, the aggregate or an unknown node (400) and calls no server', async () => {
|
||||
for (const node of [undefined, '', 'cluster', 'zzz']) {
|
||||
const r = await api('admin', 'cache/flush', { params: node === undefined ? {} : { node } })
|
||||
expect(r.code).toBe(400)
|
||||
expect(r.body.errorMessage).toBe(node === 'zzz' ? 'Unknown node: zzz' : 'Choose a server: this change is made on one server at a time.')
|
||||
}
|
||||
expect(a.calls).toHaveLength(0)
|
||||
expect(b.calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('refuses a server the hub has no admin token for (403), and audits it', async () => {
|
||||
await start({ B_ADMIN_TOKEN: '' })
|
||||
const r = await api('admin', 'cache/flush', { params: { node: 'b' } })
|
||||
expect(r.code).toBe(403)
|
||||
expect(r.body.errorMessage).toMatch(/no admin token/)
|
||||
expect(b.calls).toHaveLength(0)
|
||||
expect((await api('admin', 'cache/flush', { params: { node: 'a' } })).body.status).toBe('ok')
|
||||
expect((await auditEntries()).find((e) => e.detail === 'no admin token')).toMatchObject({ server: 'b', outcome: 'refused' })
|
||||
await start()
|
||||
})
|
||||
|
||||
it('writes only to the chosen server', async () => {
|
||||
await api('admin', 'cache/flush', { params: { node: 'b' } })
|
||||
expect(posts(a)).toHaveLength(0)
|
||||
expect(posts(b)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('refuses a read that has no merger when no server is named', async () => {
|
||||
for (const p of ['zones/list', 'cache/list', 'blocked/list', 'dhcp/scopes/list', 'logs/list', 'dnsClient/resolve', 'zones/export']) {
|
||||
const r = await api('viewer', p)
|
||||
expect(r.code, p).toBe(400)
|
||||
expect(r.body.errorMessage).toBe('Choose a server: this is read from one server at a time.')
|
||||
expect((await api('viewer', p, { params: { node: 'cluster' } })).code, p).toBe(400)
|
||||
}
|
||||
expect(a.calls).toHaveLength(0)
|
||||
const one = await api('viewer', 'zones/list', { params: { node: 'a', pageNumber: '1' } })
|
||||
expect(one.body.response.zones[0].name).toBe('example.com')
|
||||
expect(a.calls[0]).toMatchObject({ method: 'GET', token: 'read-a' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('how the hub calls the server', () => {
|
||||
it('sends the parameters in a form body and leaves only the token in the query', async () => {
|
||||
const r = await api('admin', 'zones/records/add', {
|
||||
params: { node: 'a', zone: 'example.com' },
|
||||
...form({ domain: 'www.example.com', type: 'TXT', text: 'v=spf1 secret-value' }),
|
||||
})
|
||||
expect(r.body.status).toBe('ok')
|
||||
const [call] = posts(a)
|
||||
expect(call.path).toBe('zones/records/add')
|
||||
expect(call.token).toBe('admin-a')
|
||||
expect([...call.query.keys()]).toEqual(['token'])
|
||||
expect(call.contentType).toContain('application/x-www-form-urlencoded')
|
||||
expect(Object.fromEntries(call.form)).toEqual({ zone: 'example.com', domain: 'www.example.com', type: 'TXT', text: 'v=spf1 secret-value' })
|
||||
})
|
||||
|
||||
it('forwards a GET the console sent as a POST', async () => {
|
||||
await api('admin', 'zones/enable', { params: { node: 'a', zone: 'example.com' } })
|
||||
expect(posts(a)[0].form.get('zone')).toBe('example.com')
|
||||
expect(posts(a)[0].form.has('node')).toBe(false)
|
||||
})
|
||||
|
||||
it('never forwards the caller token or node, and uses the admin token alone', async () => {
|
||||
await api('admin', 'cache/flush', { params: { node: 'a', token: 'evil' } })
|
||||
const call = posts(a)[0]
|
||||
expect(call.token).toBe('admin-a')
|
||||
expect(call.form.has('token')).toBe(false)
|
||||
expect(call.form.has('node')).toBe(false)
|
||||
})
|
||||
|
||||
it('relays the server refusal as an error naming the server', async () => {
|
||||
a.failWith = 'Zone already exists.'
|
||||
const r = await api('admin', 'zones/create', { params: { node: 'a', zone: 'example.com', type: 'Primary' }, method: 'POST' })
|
||||
expect(r.code).toBe(200)
|
||||
expect(r.body).toEqual({ status: 'error', errorMessage: 'a: Zone already exists.' })
|
||||
})
|
||||
|
||||
it('says so when the server does not answer', async () => {
|
||||
a.down = true
|
||||
const r = await api('admin', 'cache/flush', { params: { node: 'a' } })
|
||||
expect(r.body.status).toBe('error')
|
||||
expect(r.body.errorMessage).toMatch(/^a did not answer/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the audit line', () => {
|
||||
it('has the path, the server, one identifier and the outcome, and no value', async () => {
|
||||
await api('operator', 'blocked/add', { params: { node: 'a' }, ...form({ domain: 'ads.example', note: 'PRIVATE-NOTE' }) })
|
||||
await api('admin', 'zones/records/add', { params: { node: 'b' }, ...form({ zone: 'example.com', domain: 'www.example.com', type: 'TXT', text: 'SECRET-TXT' }) })
|
||||
await api('admin', 'settings/set', { params: { node: 'a' }, ...form({ tsigKeys: 'k|TOPSECRET|hmac-sha256', proxyPassword: 'PROXYPASS2' }) })
|
||||
b.failWith = 'Denied for SECRET-TXT.'
|
||||
await api('admin', 'zones/delete', { params: { node: 'b', zone: 'example.com' } })
|
||||
const entries = await auditEntries()
|
||||
const mine = entries.filter((e) => /^(blocked|zones|settings)\./.test(e.action))
|
||||
expect(mine.find((e) => e.action === 'blocked.add')).toMatchObject({ user: 'operator', role: 'operator', server: 'a', target: 'ads.example', outcome: 'ok' })
|
||||
expect(mine.find((e) => e.action === 'zones.records.add')).toMatchObject({ user: 'admin', server: 'b', target: 'example.com', outcome: 'ok' })
|
||||
expect(mine.find((e) => e.action === 'settings.set')).toMatchObject({ server: 'a', outcome: 'ok' })
|
||||
expect(mine.find((e) => e.action === 'zones.delete')).toMatchObject({ outcome: 'failed', detail: 'refused by the server' })
|
||||
expect(mine.find((e) => e.action === 'blocked.add')?.ip).toBeTruthy()
|
||||
const raw = readFileSync(path.join(dataDir, 'audit.log'), 'utf8')
|
||||
for (const value of ['PRIVATE-NOTE', 'SECRET-TXT', 'TOPSECRET', 'PROXYPASS2', 'admin-a', 'admin-b', 'ads.example"}']) expect(raw).not.toContain(value)
|
||||
})
|
||||
})
|
||||
|
||||
describe('settings/set', () => {
|
||||
const unchanged = {
|
||||
webServiceLocalAddresses: '0.0.0.0,[::]',
|
||||
webServiceHttpPort: '5380',
|
||||
webServiceEnableTls: 'false',
|
||||
webServiceTlsCertificatePath: '',
|
||||
webServiceTlsCertificatePassword: '************',
|
||||
}
|
||||
|
||||
it('passes a save that carries the web service block unchanged, as the console sends it', async () => {
|
||||
const r = await api('admin', 'settings/set', { params: { node: 'a' }, ...form({ ...unchanged, enableBlocking: 'false', dnsServerDomain: 'a2.example' }) })
|
||||
expect(r.body.status).toBe('ok')
|
||||
expect(r.body.server).toBe('a')
|
||||
expect(posts(a)[0].form.get('enableBlocking')).toBe('false')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['webServiceHttpPort', '5381'],
|
||||
['webServiceLocalAddresses', '127.0.0.1'],
|
||||
['webServiceEnableTls', 'true'],
|
||||
['webServiceTlsCertificatePassword', 'new-pass'],
|
||||
])('refuses a changed %s and forwards nothing', async (name, value) => {
|
||||
const r = await api('admin', 'settings/set', { params: { node: 'a' }, ...form({ ...unchanged, [name]: value, enableBlocking: 'false' }) })
|
||||
expect(r.code).toBe(400)
|
||||
expect(r.body.errorMessage).toBe("Change the web service settings in that server's own console.")
|
||||
expect(posts(a)).toHaveLength(0)
|
||||
const entry = (await auditEntries()).find((e) => e.action === 'settings.set' && e.outcome === 'refused')
|
||||
expect(entry?.detail).toContain(name)
|
||||
expect(entry?.detail).not.toContain(value)
|
||||
})
|
||||
|
||||
it('refuses user, SSO and LDAP fields whatever their value', async () => {
|
||||
for (const field of ['ssoEnabled', 'ldapServer', 'userName', 'authSessionTimeout']) {
|
||||
const r = await api('admin', 'settings/set', { params: { node: 'a' }, ...form({ [field]: 'x' }) })
|
||||
expect(r.code, field).toBe(400)
|
||||
}
|
||||
expect(posts(a)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not save when the current settings cannot be read first', async () => {
|
||||
a.down = true
|
||||
const r = await api('admin', 'settings/set', { params: { node: 'a' }, ...form({ enableBlocking: 'false' }) })
|
||||
expect(r.body.status).toBe('error')
|
||||
expect(r.body.errorMessage).toMatch(/nothing was changed/)
|
||||
})
|
||||
|
||||
it('compares as the console writes (lists, numbers, booleans)', () => {
|
||||
const current = { webServiceLocalAddresses: ['0.0.0.0', '[::]'], webServiceHttpPort: 5380, webServiceEnableTls: false, webServiceRealIpHeader: 'X-Real-IP', other: 'x' }
|
||||
expect(webServiceChanges({ webServiceLocalAddresses: '[::]\n0.0.0.0', webServiceHttpPort: '5380', webServiceEnableTls: 'False', webServiceRealIpHeader: 'X-Real-IP', other: 'changed' }, current)).toEqual([])
|
||||
expect(webServiceChanges({ webServiceHttpPort: '' }, current)).toEqual(['webServiceHttpPort'])
|
||||
expect(webServiceChanges({ webServiceLocalAddresses: 'false' }, current)).toEqual(['webServiceLocalAddresses'])
|
||||
expect(webServiceChanges({ webServiceUnknownField: 'x' }, current)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('settings/get', () => {
|
||||
it('blanks the secrets for every role but admin', async () => {
|
||||
for (const role of ['viewer', 'operator', 'apps-admin']) {
|
||||
const r = await api(role, 'settings/get', { params: { node: 'a' } })
|
||||
expect(r.body.status).toBe('ok')
|
||||
const s = r.body.response
|
||||
expect(s.tsigKeys).toEqual([{ keyName: 'k', sharedSecret: '', algorithmName: 'hmac-sha256' }])
|
||||
expect(s.proxy).toMatchObject({ address: 'p', username: 'u', password: '' })
|
||||
expect(s.dnsTlsCertificatePassword).toBe('')
|
||||
expect(s.webServiceTlsCertificatePassword).toBe('')
|
||||
expect(s.enableBlocking).toBe(true)
|
||||
expect(r.text).not.toMatch(/TOPSECRET|PROXYPASS|CERTPASS/)
|
||||
}
|
||||
const admin = (await api('admin', 'settings/get', { params: { node: 'a' } })).body.response
|
||||
expect(admin.tsigKeys[0].sharedSecret).toBe('TOPSECRET')
|
||||
expect(admin.proxy.password).toBe('PROXYPASS')
|
||||
})
|
||||
|
||||
it('also blanks the answer given without a node (the first server)', async () => {
|
||||
const r = await api('viewer', 'settings/get')
|
||||
expect(r.body.response.tsigKeys[0].sharedSecret).toBe('')
|
||||
})
|
||||
|
||||
it('does not change what it is given', () => {
|
||||
const input = { tsigKeys: [{ sharedSecret: 's' }], proxy: null }
|
||||
expect(redactSettings(input)).toEqual({ tsigKeys: [{ sharedSecret: '' }], proxy: null })
|
||||
expect(input.tsigKeys[0].sharedSecret).toBe('s')
|
||||
expect(redactSettings(null)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('dnsClient/resolve', () => {
|
||||
it('is a plain read, but with import it is a zone write needing admin', async () => {
|
||||
expect(effectiveRoute('dnsClient/resolve', {})).toMatchObject({ kind: 'read', capability: 'read' })
|
||||
expect(effectiveRoute('dnsClient/resolve', { import: 'false' })?.kind).toBe('read')
|
||||
for (const flag of ['true', 'True', ' TRUE ', '1', 'yes']) {
|
||||
expect(effectiveRoute('dnsClient/resolve', { import: flag })).toMatchObject({ kind: 'write-one', capability: 'admin' })
|
||||
}
|
||||
|
||||
expect((await api('viewer', 'dnsClient/resolve', { params: { node: 'a', domain: 'example.com', type: 'A' } })).body.status).toBe('ok')
|
||||
expect(a.calls[0]).toMatchObject({ method: 'GET', token: 'read-a' })
|
||||
for (const role of ['viewer', 'operator', 'apps-admin']) {
|
||||
expect((await api(role, 'dnsClient/resolve', { params: { node: 'a', domain: 'example.com', import: 'true' } })).code, role).toBe(403)
|
||||
}
|
||||
expect(posts(a)).toHaveLength(0)
|
||||
expect((await api('admin', 'dnsClient/resolve', { params: { node: 'a', domain: 'example.com', import: 'true' } })).body.status).toBe('ok')
|
||||
expect(posts(a)[0]).toMatchObject({ token: 'admin-a' })
|
||||
expect(posts(a)[0].form.get('import')).toBe('true')
|
||||
})
|
||||
})
|
||||
|
||||
describe('downloads', () => {
|
||||
it('passes a file through with the reader token', async () => {
|
||||
const r = await api('viewer', 'zones/export', { params: { node: 'a', zone: 'example.com' } })
|
||||
expect(r.code).toBe(200)
|
||||
expect(r.text).toBe('line one\nline two (zones/export)\n')
|
||||
expect(r.headers.get('content-type')).toBe('text/plain')
|
||||
expect(r.headers.get('content-disposition')).toContain('attachment')
|
||||
expect(r.headers.get('x-content-type-options')).toBe('nosniff')
|
||||
expect(a.calls[0]).toMatchObject({ method: 'GET', token: 'read-a' })
|
||||
})
|
||||
|
||||
it('turns the server error envelope into the hub one', async () => {
|
||||
const r = await api('viewer', 'zones/export', { params: { node: 'a', zone: 'missing.example' } })
|
||||
expect(r.body).toEqual({ status: 'error', errorMessage: 'a: No such zone: missing.example' })
|
||||
})
|
||||
|
||||
it('opens a download with a single-use token, once', async () => {
|
||||
const issued = await api('viewer', 'user/createSingleUseToken')
|
||||
const once = issued.body.response.token as string
|
||||
expect(once).toMatch(/^[0-9a-f]{64}$/)
|
||||
const first = await api('viewer', 'logs/download', { auth: false, params: { node: 'a', fileName: '2026-01-01', token: once } })
|
||||
expect(first.text).toContain('logs/download')
|
||||
const second = await api('viewer', 'logs/download', { auth: false, params: { node: 'a', fileName: '2026-01-01', token: once } })
|
||||
expect(second.body.status).toBe('invalid-token')
|
||||
})
|
||||
|
||||
it('does not accept a single-use token for anything but a download', async () => {
|
||||
const once = (await api('admin', 'user/createSingleUseToken')).body.response.token as string
|
||||
const r = await api('admin', 'cache/flush', { auth: false, params: { node: 'a', token: once } })
|
||||
expect(r.body.status).toBe('invalid-token')
|
||||
expect(posts(a)).toHaveLength(0)
|
||||
expect((await api('admin', 'zones/list', { auth: false, params: { node: 'a', token: once } })).body.status).toBe('invalid-token')
|
||||
})
|
||||
|
||||
it('gives no single-use token without a session', async () => {
|
||||
expect((await api('viewer', 'user/createSingleUseToken', { auth: false })).body.status).toBe('invalid-token')
|
||||
})
|
||||
})
|
||||
|
||||
describe('uploads and large bodies', () => {
|
||||
it('forwards a pasted zone file as text/plain, with the options in the query', async () => {
|
||||
const r = await api('admin', 'zones/import', {
|
||||
params: { node: 'a', zone: 'example.com', overwrite: 'true' },
|
||||
body: '$ORIGIN example.com.\n@ IN SOA ns hostmaster 1 2 3 4 5\n',
|
||||
headers: { 'content-type': 'text/plain' },
|
||||
})
|
||||
expect(r.body.status).toBe('ok')
|
||||
const call = posts(a)[0]
|
||||
expect(call.token).toBe('admin-a')
|
||||
expect(call.contentType).toContain('text/plain')
|
||||
expect(call.body.toString()).toContain('$ORIGIN example.com.')
|
||||
expect(call.query.get('zone')).toBe('example.com')
|
||||
expect(call.query.get('overwrite')).toBe('true')
|
||||
})
|
||||
|
||||
it('forwards a zone file sent as multipart, and a create with no body', async () => {
|
||||
const data = new FormData()
|
||||
data.append('fileImportZone', new Blob(['zone text'], { type: 'text/plain' }), 'example.com.zone')
|
||||
const r = await api('admin', 'zones/create', { params: { node: 'b', zone: 'example.com', type: 'Primary' }, body: data })
|
||||
expect(r.body.status).toBe('ok')
|
||||
const call = posts(b)[0]
|
||||
expect(call.contentType).toContain('multipart/form-data')
|
||||
expect(call.body.toString()).toContain('zone text')
|
||||
expect(call.body.toString()).toContain('name="fileImportZone"')
|
||||
expect(call.query.get('zone')).toBe('example.com')
|
||||
|
||||
await api('admin', 'zones/create', { params: { node: 'a', zone: 'plain.example', type: 'Primary' }, method: 'POST' })
|
||||
expect(posts(a)[0].form.get('zone')).toBe('plain.example')
|
||||
})
|
||||
|
||||
it('does not take an upload from a role that may not write zones', async () => {
|
||||
const r = await api('operator', 'zones/import', { params: { node: 'a', zone: 'example.com' }, body: 'x', headers: { 'content-type': 'text/plain' } })
|
||||
expect(r.code).toBe(403)
|
||||
expect(posts(a)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('takes a long TXT record from a signed-in caller, and a small limit from anyone else', async () => {
|
||||
const text = 'v=spf1 ' + 'a'.repeat(200_000)
|
||||
const r = await api('admin', 'zones/records/add', { params: { node: 'a' }, ...form({ zone: 'example.com', domain: 'www.example.com', type: 'TXT', text }) })
|
||||
expect(r.body.status).toBe('ok')
|
||||
expect(posts(a)[0].form.get('text')).toBe(text)
|
||||
|
||||
// Not signed in: the small default applies (the hub drops the connection), and nothing reaches the server.
|
||||
const anon = await api('admin', 'zones/records/add', { auth: false, params: { node: 'a' }, ...form({ zone: 'example.com', text }) }).catch(() => null)
|
||||
expect(anon?.body.status).not.toBe('ok')
|
||||
expect(posts(a)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('takes a block list import of a few MB', async () => {
|
||||
const list = Array.from({ length: 150_000 }, (_, i) => `d${i}.example`).join(',')
|
||||
const r = await api('operator', 'blocked/import', { params: { node: 'a' }, ...form({ blockedZones: list }) })
|
||||
expect(r.body.status).toBe('ok')
|
||||
expect(posts(a)[0].form.get('blockedZones')).toBe(list)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the table', () => {
|
||||
it('has every area and none of the refused paths', () => {
|
||||
expect(routeFor('zones/records/add')).toMatchObject({ kind: 'write-one', capability: 'admin' })
|
||||
expect(routeFor('cache/flush')).toMatchObject({ kind: 'write-one', capability: 'operate' })
|
||||
expect(routeFor('settings/temporaryDisableBlocking')).toMatchObject({ kind: 'write-one', capability: 'operate' })
|
||||
expect(routeFor('settings/get')).toEqual({ kind: 'read', capability: 'read' })
|
||||
expect(routeFor('zones/export')).toMatchObject({ kind: 'read', raw: true, perServer: true })
|
||||
for (const p of ['logs/delete', 'logs/deleteAll', 'dashboard/stats/deleteAll', 'settings/backup', 'settings/restore', 'admin/users/list', 'user/createToken']) {
|
||||
expect(routeFor(p), p).toBeUndefined()
|
||||
}
|
||||
expect(safeTarget({ zone: 'example.com', text: 'x' })).toBe('example.com')
|
||||
expect(safeTarget({ text: 'x' })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,346 @@
|
||||
// @vitest-environment node
|
||||
import http from 'node:http'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { parseConfig } from './config.ts'
|
||||
import { createHub } from './server.ts'
|
||||
|
||||
/* A fake Technitium: answers `canned(path, query)` and records every request. */
|
||||
interface Fake {
|
||||
server: http.Server
|
||||
url: string
|
||||
calls: { path: string; query: URLSearchParams }[]
|
||||
canned: (path: string, query: URLSearchParams) => unknown
|
||||
down: boolean
|
||||
}
|
||||
|
||||
function fakeUpstream(canned: Fake['canned']): Promise<Fake> {
|
||||
return new Promise((resolve) => {
|
||||
const fake: Fake = { server: http.createServer(), url: '', calls: [], canned, down: false }
|
||||
fake.server.on('request', (req, res) => {
|
||||
if (fake.down) {
|
||||
req.socket.destroy()
|
||||
return
|
||||
}
|
||||
const url = new URL(req.url ?? '/', 'http://fake')
|
||||
const apiPath = url.pathname.replace(/^\/api\//, '')
|
||||
fake.calls.push({ path: apiPath, query: url.searchParams })
|
||||
res.setHeader('content-type', 'application/json')
|
||||
res.end(JSON.stringify({ status: 'ok', response: fake.canned(apiPath, url.searchParams) }))
|
||||
})
|
||||
fake.server.listen(0, '127.0.0.1', () => {
|
||||
fake.url = `http://127.0.0.1:${(fake.server.address() as AddressInfo).port}`
|
||||
resolve(fake)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const stats = (queries: number) => ({
|
||||
stats: { totalQueries: queries, totalBlocked: queries / 10 },
|
||||
mainChartData: { labels: ['a', 'b'], datasets: [{ label: 'Total', data: [queries, queries] }] },
|
||||
topDomains: [{ name: 'x.example', hits: queries }],
|
||||
})
|
||||
|
||||
function logPage(prefix: string, stamps: string[], total: number) {
|
||||
return {
|
||||
pageNumber: 1,
|
||||
totalPages: 1,
|
||||
totalEntries: total,
|
||||
entries: stamps.map((timestamp, i) => ({ rowNumber: i + 1, timestamp, qname: `${prefix}${i}.example` })),
|
||||
}
|
||||
}
|
||||
|
||||
function canned(queries: number, stamps: string[]): Fake['canned'] {
|
||||
return (apiPath, query) => {
|
||||
switch (apiPath) {
|
||||
case 'dashboard/stats/get':
|
||||
return stats(queries)
|
||||
case 'dashboard/stats/getTop':
|
||||
return { topDomains: [{ name: 'x.example', hits: queries }, { name: `only-${queries}.example`, hits: 1 }] }
|
||||
case 'logs/query':
|
||||
return logPage(`q${queries}-`, stamps.slice(0, Number(query.get('entriesPerPage') ?? 25)), stamps.length)
|
||||
case 'apps/list':
|
||||
return { apps: [{ name: 'Shared' }, { name: `App ${queries}` }] }
|
||||
case 'settings/get':
|
||||
return { enableBlocking: true, from: queries }
|
||||
default:
|
||||
return {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let a: Fake
|
||||
let b: Fake
|
||||
let hub: http.Server
|
||||
let base: string
|
||||
let staticDir: string
|
||||
let dataDir: string
|
||||
|
||||
function makeConfig(over: Record<string, string> = {}) {
|
||||
return parseConfig({
|
||||
HUB_SERVERS: `a=${a.url},b=${b.url}`,
|
||||
A_TOKEN: 'token-a',
|
||||
B_TOKEN: 'token-b',
|
||||
HUB_USER: 'admin',
|
||||
HUB_PASSWORD: 'secret',
|
||||
HUB_STATIC: staticDir,
|
||||
HUB_DATA: dataDir,
|
||||
HUB_CACHE_SECONDS: '15',
|
||||
HUB_TIMEOUT_MS: '2000',
|
||||
...over,
|
||||
})
|
||||
}
|
||||
|
||||
async function listen(server: http.Server): Promise<string> {
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||||
return `http://127.0.0.1:${(server.address() as AddressInfo).port}`
|
||||
}
|
||||
|
||||
async function call(apiPath: string, params: Record<string, string> = {}, token?: string, method = 'GET') {
|
||||
const query = new URLSearchParams(params)
|
||||
const url = `${base}/api/${apiPath}${method === 'GET' && params ? `?${query}` : ''}`
|
||||
const init: RequestInit = { method, headers: token ? { authorization: `Bearer ${token}` } : {} }
|
||||
if (method === 'POST') {
|
||||
init.body = query.toString()
|
||||
init.headers = { ...init.headers, 'content-type': 'application/x-www-form-urlencoded' }
|
||||
}
|
||||
const res = await fetch(url, init)
|
||||
return { code: res.status, body: (await res.json()) as Record<string, any> }
|
||||
}
|
||||
|
||||
async function login(): Promise<string> {
|
||||
const { body } = await call('user/login', { user: 'admin', pass: 'secret', includeInfo: 'true' }, undefined, 'POST')
|
||||
return body.token as string
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
a = await fakeUpstream(canned(100, ['2026-01-01T10:00:04Z', '2026-01-01T10:00:02Z']))
|
||||
b = await fakeUpstream(canned(300, ['2026-01-01T10:00:03Z', '2026-01-01T10:00:01Z']))
|
||||
dataDir = mkdtempSync(path.join(tmpdir(), 'hub-data-'))
|
||||
staticDir = mkdtempSync(path.join(tmpdir(), 'hub-static-'))
|
||||
writeFileSync(path.join(staticDir, 'index.html'), '<html>root</html>')
|
||||
mkdirSync(path.join(staticDir, 'logs', 'query-logs'), { recursive: true })
|
||||
writeFileSync(path.join(staticDir, 'logs', 'index.html'), '<html>logs</html>')
|
||||
mkdirSync(path.join(staticDir, 'assets'))
|
||||
writeFileSync(path.join(staticDir, 'assets', 'app.js'), 'export {}')
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
a.server.close()
|
||||
b.server.close()
|
||||
rmSync(staticDir, { recursive: true, force: true })
|
||||
rmSync(dataDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
a.down = false
|
||||
b.down = false
|
||||
hub?.close()
|
||||
hub = createHub(makeConfig())
|
||||
base = await listen(hub)
|
||||
a.calls.length = 0
|
||||
b.calls.length = 0
|
||||
})
|
||||
|
||||
describe('config', () => {
|
||||
it('refuses to start with half the credentials or without a token', () => {
|
||||
const env = { HUB_SERVERS: 'a=http://x', A_TOKEN: 't' }
|
||||
expect(() => parseConfig({ ...env, HUB_USER: 'u' })).toThrow(/HUB_USER/)
|
||||
expect(() => parseConfig({ ...env, HUB_USER: 'u', HUB_PASSWORD: 'p', A_TOKEN: '' })).toThrow(/A_TOKEN/)
|
||||
})
|
||||
|
||||
it('derives the token variable from the id and applies defaults', () => {
|
||||
const c = parseConfig({ HUB_SERVERS: 'my-dns.1=http://x/', MY_DNS_1_TOKEN: 't', HUB_USER: 'u', HUB_PASSWORD: 'p' })
|
||||
expect(c.servers).toEqual([{ id: 'my-dns.1', url: 'http://x', token: 't' }])
|
||||
expect([c.port, c.cacheSeconds, c.timeoutMs, c.domain, c.staticDir]).toEqual([8080, 15, 8000, 'hub', './dist'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('session', () => {
|
||||
it('logs in and describes the servers as cluster nodes', async () => {
|
||||
const token = await login()
|
||||
expect(token).toMatch(/^[0-9a-f]{64}$/)
|
||||
const { body } = await call('user/session/get', {}, token)
|
||||
expect(body.status).toBe('ok')
|
||||
expect(body.info.hub).toBe(true)
|
||||
expect(body.info.clusterInitialized).toBe(true)
|
||||
expect(body.info.clusterNodes).toEqual([{ name: 'a', type: 'Primary' }, { name: 'b', type: 'Secondary' }])
|
||||
expect(body.info.permissions.Dashboard.canView).toBe(true)
|
||||
expect(body.info.permissions.Logs.canView).toBe(true)
|
||||
expect(body.info.permissions.Zones).toEqual({ canView: true, canModify: true, canDelete: true })
|
||||
expect(body.info.permissions.Administration).toEqual({ canView: false, canModify: false, canDelete: false })
|
||||
})
|
||||
|
||||
it('refuses a wrong password', async () => {
|
||||
const { body } = await call('user/login', { user: 'admin', pass: 'nope' }, undefined, 'POST')
|
||||
expect(body.status).toBe('error')
|
||||
expect(body.token).toBeUndefined()
|
||||
})
|
||||
|
||||
it('answers invalid-token for a missing, unknown or logged-out token', async () => {
|
||||
expect((await call('dashboard/stats/get')).body.status).toBe('invalid-token')
|
||||
expect((await call('dashboard/stats/get', {}, 'bogus')).body.status).toBe('invalid-token')
|
||||
const token = await login()
|
||||
await call('user/logout', {}, token)
|
||||
expect((await call('user/session/get', {}, token)).body.status).toBe('invalid-token')
|
||||
})
|
||||
|
||||
it('accepts the token as a parameter too', async () => {
|
||||
const token = await login()
|
||||
expect((await call('user/checkForUpdate', { token })).body.response.updateAvailable).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('read-only', () => {
|
||||
it('refuses everything that is not a listed read', async () => {
|
||||
const token = await login()
|
||||
const { code, body } = await call('admin/users/delete', { user: 'x' }, token, 'POST')
|
||||
expect(code).toBe(403)
|
||||
expect(body).toEqual({ status: 'error', errorMessage: 'The hub is read-only.' })
|
||||
expect(a.calls).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('single node', () => {
|
||||
it('forwards to that server with its token and without node', async () => {
|
||||
const token = await login()
|
||||
const { body } = await call('dashboard/stats/get', { type: 'LastDay', node: 'b' }, token)
|
||||
expect(body.response.stats.totalQueries).toBe(300)
|
||||
expect(a.calls).toHaveLength(0)
|
||||
expect(b.calls).toHaveLength(1)
|
||||
expect(b.calls[0].query.get('token')).toBe('token-b')
|
||||
expect(b.calls[0].query.has('node')).toBe(false)
|
||||
expect(b.calls[0].query.get('type')).toBe('LastDay')
|
||||
expect(body.hubWarnings).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reports an unreachable server by its id', async () => {
|
||||
const token = await login()
|
||||
a.down = true
|
||||
const { body } = await call('settings/get', { node: 'a' }, token)
|
||||
expect(body.status).toBe('error')
|
||||
expect(body.errorMessage).toMatch(/^a did not answer: /)
|
||||
expect(body.errorMessage).not.toContain('token-a')
|
||||
})
|
||||
|
||||
it('rejects an unknown node', async () => {
|
||||
const token = await login()
|
||||
expect((await call('settings/get', { node: 'zzz' }, token)).body.status).toBe('error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('aggregate', () => {
|
||||
it('sums the stats of every server', async () => {
|
||||
const token = await login()
|
||||
for (const node of [undefined, 'cluster']) {
|
||||
const params: Record<string, string> = { type: 'LastDay' }
|
||||
if (node) params.node = node
|
||||
const { body } = await call('dashboard/stats/get', params, token)
|
||||
expect(body.response.stats.totalQueries).toBe(400)
|
||||
expect(body.response.mainChartData.datasets[0].data).toEqual([400, 400])
|
||||
expect(body.hubWarnings).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
it('merges the top lists by name', async () => {
|
||||
const token = await login()
|
||||
const { body } = await call('dashboard/stats/getTop', { type: 'LastDay', statsType: 'TopDomains', limit: '2' }, token)
|
||||
expect(body.response.topDomains).toEqual([
|
||||
{ name: 'x.example', hits: 400 },
|
||||
{ name: 'only-100.example', hits: 1 },
|
||||
])
|
||||
})
|
||||
|
||||
it('merges query logs across servers with node tags and pages', async () => {
|
||||
const token = await login()
|
||||
const page1 = await call('logs/query', { pageNumber: '1', entriesPerPage: '3', descendingOrder: 'true' }, token)
|
||||
expect(page1.body.response.totalEntries).toBe(4)
|
||||
expect(page1.body.response.totalPages).toBe(2)
|
||||
expect(page1.body.response.entries.map((e: any) => [e.node, e.timestamp])).toEqual([
|
||||
['a', '2026-01-01T10:00:04Z'],
|
||||
['b', '2026-01-01T10:00:03Z'],
|
||||
['a', '2026-01-01T10:00:02Z'],
|
||||
])
|
||||
// Each server was asked for the head of the list that page N needs.
|
||||
expect(a.calls[0].query.get('entriesPerPage')).toBe('3')
|
||||
expect(a.calls[0].query.get('pageNumber')).toBe('1')
|
||||
|
||||
a.calls.length = 0
|
||||
const page2 = await call('logs/query', { pageNumber: '2', entriesPerPage: '3', descendingOrder: 'true' }, token)
|
||||
expect(a.calls[0].query.get('entriesPerPage')).toBe('6')
|
||||
expect(page2.body.response.entries.map((e: any) => [e.node, e.rowNumber])).toEqual([['b', 4]])
|
||||
|
||||
const asc = await call('logs/query', { pageNumber: '1', entriesPerPage: '2', descendingOrder: 'false' }, token)
|
||||
expect(asc.body.response.entries.map((e: any) => e.timestamp)).toEqual(['2026-01-01T10:00:01Z', '2026-01-01T10:00:02Z'])
|
||||
})
|
||||
|
||||
it('unions the apps by name and asks the first server for other reads', async () => {
|
||||
const token = await login()
|
||||
const apps = await call('apps/list', {}, token)
|
||||
expect(apps.body.response.apps.map((x: any) => x.name)).toEqual(['Shared', 'App 100', 'App 300'])
|
||||
const settings = await call('settings/get', {}, token)
|
||||
expect(settings.body.response.from).toBe(100)
|
||||
expect(b.calls).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('serves a repeated call from the cache', async () => {
|
||||
const token = await login()
|
||||
await call('dashboard/stats/get', { type: 'LastDay' }, token)
|
||||
await call('dashboard/stats/get', { type: 'LastDay' }, token)
|
||||
expect(a.calls).toHaveLength(1)
|
||||
await call('dashboard/stats/get', { type: 'LastWeek' }, token)
|
||||
expect(a.calls).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('answers with the other server and a warning when one is down', async () => {
|
||||
const token = await login()
|
||||
b.down = true
|
||||
const { body } = await call('dashboard/stats/get', { type: 'Warn' }, token)
|
||||
expect(body.status).toBe('ok')
|
||||
expect(body.response.stats.totalQueries).toBe(100)
|
||||
expect(body.hubWarnings).toHaveLength(1)
|
||||
expect(body.hubWarnings[0]).toMatch(/^b: /)
|
||||
})
|
||||
|
||||
it('is an error when every server is down', async () => {
|
||||
const token = await login()
|
||||
a.down = true
|
||||
b.down = true
|
||||
const { body } = await call('dashboard/stats/get', { type: 'Dead' }, token)
|
||||
expect(body.status).toBe('error')
|
||||
expect(body.errorMessage).toMatch(/No server answered/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('static files', () => {
|
||||
it('serves files and the index of a folder, with the console headers on html', async () => {
|
||||
const root = await fetch(`${base}/`)
|
||||
expect(await root.text()).toBe('<html>root</html>')
|
||||
expect(root.headers.get('content-security-policy')).toBe("default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'")
|
||||
expect(root.headers.get('x-content-type-options')).toBe('nosniff')
|
||||
|
||||
const logs = await fetch(`${base}/logs/`)
|
||||
expect(await logs.text()).toBe('<html>logs</html>')
|
||||
|
||||
const js = await fetch(`${base}/assets/app.js`)
|
||||
expect(js.headers.get('content-type')).toMatch(/^text\/javascript/)
|
||||
})
|
||||
|
||||
it('redirects a folder without the slash and 404s the rest', async () => {
|
||||
const redirect = await fetch(`${base}/logs`, { redirect: 'manual' })
|
||||
expect(redirect.status).toBe(301)
|
||||
expect(redirect.headers.get('location')).toBe('/logs/')
|
||||
expect((await fetch(`${base}/nope.js`)).status).toBe(404)
|
||||
// A folder with no index of its own is not a route.
|
||||
expect((await fetch(`${base}/assets`, { redirect: 'manual' })).status).toBe(404)
|
||||
expect((await fetch(`${base}/..%2f..%2fetc/passwd`)).status).toBeGreaterThanOrEqual(400)
|
||||
})
|
||||
|
||||
it('answers the api under a sub path', async () => {
|
||||
const res = await fetch(`${base}/dashboard/api/status`)
|
||||
expect(((await res.json()) as { status: string }).status).toBe('ok')
|
||||
})
|
||||
})
|
||||
+1117
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,354 @@
|
||||
// @vitest-environment node
|
||||
/*
|
||||
Stage 6: the same change on several servers at once, outside Apps (`hub/servers/run`). The servers
|
||||
are fakes that record how they were called.
|
||||
*/
|
||||
import http from 'node:http'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { parseConfig } from './config.ts'
|
||||
import { createHub } from './server.ts'
|
||||
import { addUser, writeUsersFile } from './users.ts'
|
||||
import { MAX_MINUTES, MAX_LINES, SERVER_ACTIONS, cleanDomain, cleanUrls, planAction } from './servers-run.ts'
|
||||
import { routeFor } from './routes.ts'
|
||||
|
||||
interface Call {
|
||||
path: string
|
||||
method: string
|
||||
token: string
|
||||
query: URLSearchParams
|
||||
form: URLSearchParams
|
||||
}
|
||||
|
||||
interface Fake {
|
||||
server: http.Server
|
||||
url: string
|
||||
calls: Call[]
|
||||
failWith: string | null
|
||||
down: boolean
|
||||
}
|
||||
|
||||
function fake(): Promise<Fake> {
|
||||
return new Promise((resolve) => {
|
||||
const f: Fake = { server: http.createServer(), url: '', calls: [], failWith: null, down: false }
|
||||
f.server.on('request', (req, res) => {
|
||||
const chunks: Buffer[] = []
|
||||
req.on('data', (c: Buffer) => chunks.push(c))
|
||||
req.on('end', () => {
|
||||
if (f.down) return req.socket.destroy()
|
||||
const url = new URL(req.url ?? '/', 'http://fake')
|
||||
const contentType = String(req.headers['content-type'] ?? '')
|
||||
const form = new URLSearchParams(contentType.includes('x-www-form-urlencoded') ? Buffer.concat(chunks).toString('utf8') : '')
|
||||
f.calls.push({ path: url.pathname.replace(/^\/api\//, ''), method: req.method ?? '', token: url.searchParams.get('token') ?? '', query: url.searchParams, form })
|
||||
res.setHeader('content-type', 'application/json')
|
||||
if (f.failWith !== null && req.method === 'POST') return res.end(JSON.stringify({ status: 'error', errorMessage: f.failWith }))
|
||||
res.end(JSON.stringify({ status: 'ok', response: {} }))
|
||||
})
|
||||
})
|
||||
f.server.listen(0, '127.0.0.1', () => {
|
||||
f.url = `http://127.0.0.1:${(f.server.address() as AddressInfo).port}`
|
||||
resolve(f)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
let a: Fake
|
||||
let b: Fake
|
||||
let dataDir: string
|
||||
let staticDir: string
|
||||
let hub: http.Server | undefined
|
||||
let base: string
|
||||
const tokens: Record<string, string> = {}
|
||||
const ROLES = ['viewer', 'operator', 'apps-admin', 'admin']
|
||||
|
||||
async function start(extra: Record<string, string> = {}): Promise<void> {
|
||||
hub?.close()
|
||||
hub = createHub(
|
||||
parseConfig({
|
||||
HUB_SERVERS: `a=${a.url},b=${b.url}`,
|
||||
A_TOKEN: 'read-a',
|
||||
B_TOKEN: 'read-b',
|
||||
A_ADMIN_TOKEN: 'admin-a',
|
||||
B_ADMIN_TOKEN: 'admin-b',
|
||||
HUB_DATA: dataDir,
|
||||
HUB_STATIC: staticDir,
|
||||
HUB_CACHE_SECONDS: '0',
|
||||
HUB_TIMEOUT_MS: '2000',
|
||||
...extra,
|
||||
}),
|
||||
)
|
||||
await new Promise<void>((resolve) => hub!.listen(0, '127.0.0.1', resolve))
|
||||
base = `http://127.0.0.1:${(hub.address() as AddressInfo).port}`
|
||||
for (const role of ROLES) {
|
||||
const res = await fetch(`${base}/api/user/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: `user=${role}&pass=password-${role}`,
|
||||
})
|
||||
tokens[role] = ((await res.json()) as { token: string }).token
|
||||
}
|
||||
}
|
||||
|
||||
async function run(role: string, params: Record<string, string>) {
|
||||
const res = await fetch(`${base}/api/hub/servers/run`, {
|
||||
method: 'POST',
|
||||
headers: { authorization: `Bearer ${tokens[role]}`, 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams(params).toString(),
|
||||
})
|
||||
return { code: res.status, body: (await res.json()) as Record<string, any> }
|
||||
}
|
||||
|
||||
const writes = (f: Fake) => f.calls.filter((c) => c.method === 'POST')
|
||||
|
||||
async function auditEntries(): Promise<Record<string, string>[]> {
|
||||
const res = await fetch(`${base}/api/hub/audit/list?limit=200`, { headers: { authorization: `Bearer ${tokens.admin}` } })
|
||||
return ((await res.json()) as { response: { entries: Record<string, string>[] } }).response.entries
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
a = await fake()
|
||||
b = await fake()
|
||||
dataDir = mkdtempSync(path.join(tmpdir(), 'hub-s6-data-'))
|
||||
staticDir = mkdtempSync(path.join(tmpdir(), 'hub-s6-static-'))
|
||||
let users = addUser([], 'viewer', 'viewer', 'password-viewer')
|
||||
users = addUser(users, 'operator', 'operator', 'password-operator')
|
||||
users = addUser(users, 'apps-admin', 'apps-admin', 'password-apps-admin')
|
||||
users = addUser(users, 'admin', 'admin', 'password-admin')
|
||||
writeUsersFile(dataDir, users)
|
||||
await start()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
for (const f of [a, b]) {
|
||||
f.calls = []
|
||||
f.failWith = null
|
||||
f.down = false
|
||||
}
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
hub?.close()
|
||||
a.server.close()
|
||||
b.server.close()
|
||||
rmSync(dataDir, { recursive: true, force: true })
|
||||
rmSync(staticDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('the plan of an action', () => {
|
||||
const plan = (action: string, raw: Record<string, string> = {}) => {
|
||||
const out = planAction(action, raw)
|
||||
if ('error' in out) throw new Error(out.error)
|
||||
return out.plan
|
||||
}
|
||||
|
||||
it('maps each action to its Technitium call and sends only what it needs', () => {
|
||||
expect(plan('blocked.add', { domain: 'Ads.Example.' })).toMatchObject({ path: 'blocked/add', params: { domain: 'Ads.Example' } })
|
||||
expect(plan('blocked.delete', { domain: 'ads.example' })).toMatchObject({ path: 'blocked/delete', params: { domain: 'ads.example' } })
|
||||
expect(plan('allowed.add', { domain: 'ok.example' })).toMatchObject({ path: 'allowed/add' })
|
||||
expect(plan('allowed.delete', { domain: 'ok.example' })).toMatchObject({ path: 'allowed/delete' })
|
||||
expect(plan('blocking.disable', { minutes: '15' })).toMatchObject({ path: 'settings/temporaryDisableBlocking', params: { minutes: '15' }, target: 'minutes=15' })
|
||||
expect(plan('blocking.enable')).toMatchObject({ path: 'settings/set', params: { enableBlocking: 'true' } })
|
||||
expect(plan('blocking.updateLists')).toMatchObject({ path: 'settings/forceUpdateBlockLists', params: {} })
|
||||
expect(plan('blocking.setListUrls', { urls: 'https://a.example/x\nhttp://b.example/y' })).toMatchObject({
|
||||
path: 'settings/set',
|
||||
params: { blockListUrls: 'https://a.example/x,http://b.example/y' },
|
||||
target: 'urls=2',
|
||||
})
|
||||
expect(plan('cache.flush')).toMatchObject({ path: 'cache/flush', params: {} })
|
||||
expect(plan('cache.delete', { domain: 'x.example' })).toMatchObject({ path: 'cache/delete', params: { domain: 'x.example' } })
|
||||
expect(SERVER_ACTIONS).toHaveLength(10)
|
||||
})
|
||||
|
||||
it('refuses an action that is not on the list', () => {
|
||||
for (const bad of [undefined, '', 'settings.set', 'zones/delete', 'cache.flush ', '__proto__']) expect(planAction(bad, {})).toHaveProperty('error')
|
||||
})
|
||||
|
||||
it('checks the domain', () => {
|
||||
expect(cleanDomain('example.com')).toBe('example.com')
|
||||
expect(cleanDomain('*.example.com')).toBe('*.example.com')
|
||||
for (const bad of ['', ' ', 'a b.example', 'a..example', '-a.example', 'a.example/x', 'a.example?x=1', 'a,b.example', 'a'.repeat(64) + '.example', ('a.'.repeat(130)) + 'com']) {
|
||||
expect(cleanDomain(bad)).toBeNull()
|
||||
expect(planAction('blocked.add', { domain: bad })).toHaveProperty('error')
|
||||
}
|
||||
})
|
||||
|
||||
it('checks the minutes', () => {
|
||||
for (const bad of [undefined, '', '0', '-5', '1.5', 'abc', '5m', String(MAX_MINUTES + 1), '99999999']) {
|
||||
expect(planAction('blocking.disable', { minutes: bad })).toHaveProperty('error')
|
||||
}
|
||||
expect(planAction('blocking.disable', { minutes: String(MAX_MINUTES) })).toHaveProperty('plan')
|
||||
})
|
||||
|
||||
it('checks the lines', () => {
|
||||
for (const bad of ['', ' \n ', 'ftp://a.example/x', '!ftp://a.example/x', '! javascript:alert(1)', 'not a url', 'https://a.example/x,y', '# a, b', 'https://a.example/a b', '#' + 'x'.repeat(300)]) {
|
||||
expect(cleanUrls(bad), bad).toHaveProperty('error')
|
||||
}
|
||||
expect(cleanUrls(Array.from({ length: MAX_LINES + 1 }, (_, i) => `https://a.example/${i}`).join('\n'))).toHaveProperty('error')
|
||||
})
|
||||
|
||||
it('takes the grammar of the lists: allow lines, switched-off lines, headings, file addresses', () => {
|
||||
const text = '# Privacy\nhttps://a.example/hosts\n!https://b.example/allow.txt\n#https://c.example/off\n\nfile:///etc/extra.txt\r\n http://d.example/x \n'
|
||||
expect(cleanUrls(text)).toEqual({
|
||||
urls: ['# Privacy', 'https://a.example/hosts', '!https://b.example/allow.txt', '#https://c.example/off', 'file:///etc/extra.txt', 'http://d.example/x'],
|
||||
})
|
||||
})
|
||||
|
||||
it('sends the same blockListUrls the console Settings save sends (the lines joined with commas, in order)', () => {
|
||||
const lines = ['# Privacy', 'https://a.example/hosts', '!https://b.example/allow.txt', '#https://c.example/off', '# Ads', 'https://d.example/x']
|
||||
const out = planAction('blocking.setListUrls', { urls: lines.join('\n') })
|
||||
if ('error' in out) throw new Error(out.error)
|
||||
// cleanList in src/screens/settings/model.ts: newlines to commas, repeated commas collapsed, none at the ends.
|
||||
expect(out.plan.params).toEqual({ blockListUrls: lines.join(',') })
|
||||
expect(out.plan.target).toBe('urls=6')
|
||||
})
|
||||
})
|
||||
|
||||
describe('who may run it', () => {
|
||||
it('is a write-all route for operate', () => {
|
||||
expect(routeFor('hub/servers/run')).toMatchObject({ kind: 'write-all', capability: 'operate' })
|
||||
})
|
||||
|
||||
it('refuses a viewer and an apps admin, calling no server', async () => {
|
||||
for (const role of ['viewer', 'apps-admin']) {
|
||||
const r = await run(role, { action: 'cache.flush', targets: 'a,b' })
|
||||
expect(r.code).toBe(403)
|
||||
}
|
||||
expect(writes(a)).toHaveLength(0)
|
||||
expect(writes(b)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('lets an operator and an admin run it', async () => {
|
||||
for (const role of ['operator', 'admin']) {
|
||||
const r = await run(role, { action: 'cache.flush', targets: 'a,b' })
|
||||
expect(r.code).toBe(200)
|
||||
expect(r.body.response.results).toHaveLength(2)
|
||||
}
|
||||
})
|
||||
|
||||
it('needs a POST', async () => {
|
||||
const res = await fetch(`${base}/api/hub/servers/run?action=cache.flush&targets=a`, { headers: { authorization: `Bearer ${tokens.operator}` } })
|
||||
expect(res.status).toBe(405)
|
||||
expect(writes(a)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('running it', () => {
|
||||
it('sends each action to each server with its admin token, parameters in the body', async () => {
|
||||
const cases: [Record<string, string>, string, Record<string, string>][] = [
|
||||
[{ action: 'blocked.add', domain: 'ads.example' }, 'blocked/add', { domain: 'ads.example' }],
|
||||
[{ action: 'blocked.delete', domain: 'ads.example' }, 'blocked/delete', { domain: 'ads.example' }],
|
||||
[{ action: 'allowed.add', domain: 'ok.example' }, 'allowed/add', { domain: 'ok.example' }],
|
||||
[{ action: 'allowed.delete', domain: 'ok.example' }, 'allowed/delete', { domain: 'ok.example' }],
|
||||
[{ action: 'blocking.disable', minutes: '15' }, 'settings/temporaryDisableBlocking', { minutes: '15' }],
|
||||
[{ action: 'blocking.enable' }, 'settings/set', { enableBlocking: 'true' }],
|
||||
[{ action: 'blocking.updateLists' }, 'settings/forceUpdateBlockLists', {}],
|
||||
[{ action: 'blocking.setListUrls', urls: 'https://a.example/x\nhttps://b.example/y' }, 'settings/set', { blockListUrls: 'https://a.example/x,https://b.example/y' }],
|
||||
[{ action: 'cache.flush' }, 'cache/flush', {}],
|
||||
[{ action: 'cache.delete', domain: 'x.example' }, 'cache/delete', { domain: 'x.example' }],
|
||||
]
|
||||
for (const [params, upstreamPath, sent] of cases) {
|
||||
a.calls = []
|
||||
b.calls = []
|
||||
const r = await run('operator', { ...params, targets: 'a,b' })
|
||||
expect(r.code, params.action).toBe(200)
|
||||
expect(r.body.response.results.map((x: { server: string; ok: boolean }) => [x.server, x.ok])).toEqual([['a', true], ['b', true]])
|
||||
for (const [f, token] of [[a, 'admin-a'], [b, 'admin-b']] as const) {
|
||||
const call = writes(f).filter((c) => c.path !== 'settings/get')
|
||||
expect(call, params.action).toHaveLength(1)
|
||||
expect(call[0].path).toBe(upstreamPath)
|
||||
expect(call[0].token).toBe(token)
|
||||
expect(Object.fromEntries(call[0].form)).toEqual(sent)
|
||||
// Nothing but the token in the URL.
|
||||
expect([...call[0].query.keys()]).toEqual(['token'])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('sends only the one setting, and never reads or writes anything else', async () => {
|
||||
await run('operator', { action: 'blocking.setListUrls', urls: 'https://a.example/x', targets: 'a' })
|
||||
expect(a.calls.map((c) => c.path)).toEqual(['settings/set'])
|
||||
expect([...(a.calls[0].form.keys())]).toEqual(['blockListUrls'])
|
||||
})
|
||||
|
||||
it('shows a partial failure per server, never a bare ok', async () => {
|
||||
b.failWith = 'Access was denied to user x for Blocked.'
|
||||
const r = await run('operator', { action: 'blocked.add', domain: 'ads.example', targets: 'a,b' })
|
||||
expect(r.code).toBe(200)
|
||||
const results = r.body.response.results as { server: string; ok: boolean; message: string }[]
|
||||
expect(results.find((x) => x.server === 'a')).toMatchObject({ ok: true })
|
||||
expect(results.find((x) => x.server === 'b')).toMatchObject({ ok: false })
|
||||
expect(results.find((x) => x.server === 'b')?.message).toContain('Access was denied')
|
||||
expect(results.every((x) => typeof x.message === 'string' && x.message !== '')).toBe(true)
|
||||
})
|
||||
|
||||
it('reports a server that does not answer, and still runs the others', async () => {
|
||||
b.down = true
|
||||
const r = await run('operator', { action: 'cache.flush', targets: 'a,b' })
|
||||
const results = r.body.response.results as { server: string; ok: boolean }[]
|
||||
expect(results.map((x) => [x.server, x.ok])).toEqual([['a', true], ['b', false]])
|
||||
expect(writes(a)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('fails only the target that has no admin token, and one that is unknown refuses the request', async () => {
|
||||
await start({ A_ADMIN_TOKEN: '' })
|
||||
for (const f of [a, b]) f.calls = []
|
||||
const r = await run('operator', { action: 'cache.flush', targets: 'a,b' })
|
||||
expect(r.code).toBe(200)
|
||||
const results = r.body.response.results as { server: string; ok: boolean; message: string }[]
|
||||
expect(results.find((x) => x.server === 'a')).toMatchObject({ ok: false, message: 'no admin token configured' })
|
||||
expect(results.find((x) => x.server === 'b')).toMatchObject({ ok: true })
|
||||
expect(writes(a)).toHaveLength(0)
|
||||
expect(writes(b)).toHaveLength(1)
|
||||
const unknown = await run('operator', { action: 'cache.flush', targets: 'a,nope' })
|
||||
expect(unknown.code).toBe(400)
|
||||
expect(writes(a)).toHaveLength(0)
|
||||
await start()
|
||||
})
|
||||
|
||||
it('refuses empty targets and a bad parameter, calling no server', async () => {
|
||||
const bad: Record<string, string>[] = [
|
||||
{ action: 'cache.flush', targets: '' },
|
||||
{ action: 'cache.flush' },
|
||||
{ action: 'cache.flush', targets: ' , ' },
|
||||
{ action: 'blocked.add', domain: 'a b', targets: 'a' },
|
||||
{ action: 'blocking.disable', minutes: '0', targets: 'a' },
|
||||
{ action: 'blocking.setListUrls', urls: 'ftp://x.example', targets: 'a' },
|
||||
{ action: 'settings.set', targets: 'a' },
|
||||
]
|
||||
for (const params of bad) {
|
||||
const r = await run('operator', params)
|
||||
expect(r.code, JSON.stringify(params)).toBe(400)
|
||||
expect(r.body.status).toBe('error')
|
||||
}
|
||||
expect(a.calls).toHaveLength(0)
|
||||
expect(b.calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('runs on a server once even when it is named twice', async () => {
|
||||
await run('operator', { action: 'cache.flush', targets: 'a,a' })
|
||||
expect(writes(a)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the audit log', () => {
|
||||
it('has one line per server with the target, and never the URL list', async () => {
|
||||
b.failWith = 'Disk full.'
|
||||
await run('operator', { action: 'blocking.setListUrls', urls: 'https://secret.example/token123/list.txt\nhttps://x.example/y', targets: 'a,b' })
|
||||
await run('operator', { action: 'blocked.add', domain: 'ads.example', targets: 'a,b' })
|
||||
await run('operator', { action: 'blocking.disable', minutes: '15', targets: 'a' })
|
||||
await run('operator', { action: 'cache.flush', targets: '' })
|
||||
const entries = (await auditEntries()).filter((e) => e.action?.startsWith('servers.'))
|
||||
const lists = entries.filter((e) => e.action === 'servers.blocking.setListUrls')
|
||||
expect(lists.map((e) => [e.server, e.outcome, e.target])).toEqual(expect.arrayContaining([['a', 'ok', 'urls=2'], ['b', 'failed', 'urls=2']]))
|
||||
expect(entries.filter((e) => e.action === 'servers.blocked.add').map((e) => [e.server, e.target])).toEqual(expect.arrayContaining([['a', 'ads.example'], ['b', 'ads.example']]))
|
||||
expect(entries.find((e) => e.action === 'servers.blocking.disable')).toMatchObject({ server: 'a', target: 'minutes=15', outcome: 'ok', user: 'operator' })
|
||||
expect(entries.find((e) => e.action === 'servers.cache.flush')).toMatchObject({ outcome: 'refused' })
|
||||
const text = JSON.stringify(entries)
|
||||
expect(text).not.toContain('secret.example')
|
||||
expect(text).not.toContain('token123')
|
||||
expect(text).not.toContain('admin-a')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,183 @@
|
||||
/*
|
||||
Actions on several servers at once, outside Apps (stage 6): Blocked and Allowed, the temporary
|
||||
blocking switch, the block lists, and the cache. One endpoint, `hub/servers/run`, takes a fixed
|
||||
`action` and the action's own parameters, checks them HERE, and runs the one matching Technitium
|
||||
call on every chosen server, in parallel, with that server's admin token.
|
||||
|
||||
* `planAction` is pure: the allowlist, the validation, and the path and parameters to send.
|
||||
* `ServersRunner` runs a plan on targets. A server that is unknown or has no admin token fails
|
||||
on its own; the others still run. The answer is one result per server, never a bare "ok".
|
||||
|
||||
The block list URLs are part of blocking, so `blocking.setListUrls` needs `operate` like the rest,
|
||||
not `admin` like the per-server `settings/set`. Only `blockListUrls` is sent, so nothing else of
|
||||
the server's settings is touched, and the plan still goes through the checks of guards.ts.
|
||||
*/
|
||||
import type { HubConfig } from './config.ts'
|
||||
import { isForbiddenSettingsField } from './guards.ts'
|
||||
import { Upstream } from './upstream.ts'
|
||||
|
||||
export const SERVER_ACTIONS = [
|
||||
'blocked.add',
|
||||
'blocked.delete',
|
||||
'allowed.add',
|
||||
'allowed.delete',
|
||||
'blocking.disable',
|
||||
'blocking.enable',
|
||||
'blocking.updateLists',
|
||||
'blocking.setListUrls',
|
||||
'cache.flush',
|
||||
'cache.delete',
|
||||
] as const
|
||||
|
||||
export type ServerAction = (typeof SERVER_ACTIONS)[number]
|
||||
|
||||
export interface TargetResult {
|
||||
server: string
|
||||
ok: boolean
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface Plan {
|
||||
action: ServerAction
|
||||
/* The Technitium endpoint. */
|
||||
path: string
|
||||
/* Exactly what is sent (a form body). */
|
||||
params: Record<string, string>
|
||||
/* What the audit line may say about it: a domain, `minutes=15`, `urls=3`. Never the URLs. */
|
||||
target?: string
|
||||
/* The sentence for a server where it worked. */
|
||||
done: string
|
||||
}
|
||||
|
||||
export const MAX_MINUTES = 1440
|
||||
const MAX_URL_LENGTH = 2000
|
||||
|
||||
const LABEL = '[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?'
|
||||
const DOMAIN = new RegExp(`^(?:\\*\\.)?(?:${LABEL}\\.)*${LABEL}$`, 'i')
|
||||
|
||||
/* A domain as the hub accepts it (a trailing dot is dropped), or null. */
|
||||
export function cleanDomain(value: string | undefined): string | null {
|
||||
const domain = (value ?? '').trim().replace(/\.$/, '')
|
||||
return domain.length > 0 && domain.length <= 253 && DOMAIN.test(domain) ? domain : null
|
||||
}
|
||||
|
||||
/*
|
||||
The lines of a block list request, one per line, in the grammar of the servers and of the console's
|
||||
Lists tab (src/screens/blocking/list-lines.ts): `<url>` a block list, `!<url>` an allow list,
|
||||
`#...` a switched-off list or a comment (a group heading), free text. Each URL is http, https or
|
||||
file. Blank lines go. They travel as the console's own Settings save sends them: joined with a
|
||||
comma. A comma cannot be told from the separator, so a line that has one is refused, not split.
|
||||
*/
|
||||
export const MAX_LINES = 300
|
||||
export const MAX_COMMENT = 200
|
||||
const SCHEMES = new Set(['http:', 'https:', 'file:'])
|
||||
|
||||
export function cleanUrls(value: string | undefined): { urls: string[] } | { error: string } {
|
||||
const lines = (value ?? '')
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l !== '')
|
||||
if (lines.length === 0) return { error: 'Give at least one block list URL.' }
|
||||
if (lines.length > MAX_LINES) return { error: `At most ${MAX_LINES} lines.` }
|
||||
for (const line of lines) {
|
||||
if (line.includes(',')) return { error: `A line cannot contain a comma: ${line.slice(0, 60)}` }
|
||||
if (line.startsWith('#')) {
|
||||
if (line.length > MAX_COMMENT) return { error: 'A comment line is too long.' }
|
||||
continue
|
||||
}
|
||||
if (line.length > MAX_URL_LENGTH) return { error: 'A block list URL is too long.' }
|
||||
const text = line.startsWith('!') ? line.slice(1).trim() : line
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(text)
|
||||
} catch {
|
||||
return { error: `Not a valid http, https or file address: ${line.slice(0, 60)}` }
|
||||
}
|
||||
if (!SCHEMES.has(url.protocol) || /\s/.test(text)) return { error: `Not a valid http, https or file address: ${line.slice(0, 60)}` }
|
||||
}
|
||||
return { urls: lines }
|
||||
}
|
||||
|
||||
export function planAction(action: string | undefined, raw: Record<string, string | undefined>): { error: string } | { plan: Plan } {
|
||||
if (action === undefined || !(SERVER_ACTIONS as readonly string[]).includes(action)) {
|
||||
return { error: `action must be one of: ${SERVER_ACTIONS.join(', ')}.` }
|
||||
}
|
||||
const kind = action as ServerAction
|
||||
switch (kind) {
|
||||
case 'blocked.add':
|
||||
case 'blocked.delete':
|
||||
case 'allowed.add':
|
||||
case 'allowed.delete':
|
||||
case 'cache.delete': {
|
||||
const domain = cleanDomain(raw.domain)
|
||||
if (domain === null) return { error: 'A valid domain name is required.' }
|
||||
const [list, verb] = kind.split('.')
|
||||
const path = list === 'cache' ? 'cache/delete' : `${list}/${verb}`
|
||||
const done =
|
||||
kind === 'cache.delete' ? 'Deleted from the cache.' : `${verb === 'add' ? 'Added to' : 'Removed from'} ${list === 'blocked' ? 'Blocked' : 'Allowed'}.`
|
||||
return { plan: { action: kind, path, params: { domain }, target: domain, done } }
|
||||
}
|
||||
case 'blocking.disable': {
|
||||
const text = (raw.minutes ?? '').trim()
|
||||
const minutes = /^\d{1,5}$/.test(text) ? Number(text) : 0
|
||||
if (minutes < 1 || minutes > MAX_MINUTES) return { error: `minutes must be a whole number from 1 to ${MAX_MINUTES}.` }
|
||||
return {
|
||||
plan: { action: kind, path: 'settings/temporaryDisableBlocking', params: { minutes: String(minutes) }, target: `minutes=${minutes}`, done: `Blocking disabled for ${minutes} minute(s).` },
|
||||
}
|
||||
}
|
||||
case 'blocking.enable':
|
||||
return settingsPlan(kind, { enableBlocking: 'true' }, undefined, 'Blocking enabled.')
|
||||
case 'blocking.setListUrls': {
|
||||
const cleaned = cleanUrls(raw.urls)
|
||||
if ('error' in cleaned) return cleaned
|
||||
return settingsPlan(kind, { blockListUrls: cleaned.urls.join(',') }, `urls=${cleaned.urls.length}`, `Block list URLs replaced (${cleaned.urls.length} line(s)).`)
|
||||
}
|
||||
case 'blocking.updateLists':
|
||||
return { plan: { action: kind, path: 'settings/forceUpdateBlockLists', params: {}, done: 'Block list update started.' } }
|
||||
case 'cache.flush':
|
||||
return { plan: { action: kind, path: 'cache/flush', params: {}, done: 'Cache flushed.' } }
|
||||
}
|
||||
}
|
||||
|
||||
/* A `settings/set` of one field. The same checks as the per-server route, applied to what the hub itself builds. */
|
||||
function settingsPlan(action: ServerAction, params: Record<string, string>, target: string | undefined, done: string): { error: string } | { plan: Plan } {
|
||||
if (Object.keys(params).some((k) => isForbiddenSettingsField(k) || k.startsWith('webService'))) return { error: 'That setting is not changed from the hub.' }
|
||||
return { plan: { action, path: 'settings/set', params, target, done } }
|
||||
}
|
||||
|
||||
const reason = (e: unknown): string => (e instanceof Error ? e.message : 'failed')
|
||||
|
||||
export class ServersRunner {
|
||||
#config: HubConfig
|
||||
#upstream: Upstream
|
||||
|
||||
constructor(config: HubConfig, upstream: Upstream) {
|
||||
this.#config = config
|
||||
this.#upstream = upstream
|
||||
}
|
||||
|
||||
/*
|
||||
The plan on every target, in parallel. Returns `{ error }` when the request itself is refused
|
||||
(no targets, an unknown server), else one result per target.
|
||||
*/
|
||||
async run(plan: Plan, targets: string[]): Promise<{ error: string } | { results: TargetResult[] }> {
|
||||
const known = new Map(this.#config.servers.map((s) => [s.id, s]))
|
||||
const unique = [...new Set(targets)]
|
||||
if (unique.length === 0) return { error: 'Choose at least one server.' }
|
||||
const unknown = unique.filter((t) => !known.has(t))
|
||||
if (unknown.length > 0) return { error: `Unknown server: ${unknown.join(', ')}.` }
|
||||
const results = await Promise.all(
|
||||
unique.map(async (id): Promise<TargetResult> => {
|
||||
const server = known.get(id)!
|
||||
if (server.adminToken === undefined) return { server: id, ok: false, message: 'no admin token configured' }
|
||||
try {
|
||||
await this.#upstream.write(server, plan.path, plan.params)
|
||||
return { server: id, ok: true, message: plan.done }
|
||||
} catch (e) {
|
||||
return { server: id, ok: false, message: reason(e) }
|
||||
}
|
||||
}),
|
||||
)
|
||||
return { results }
|
||||
}
|
||||
}
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
/*
|
||||
The hub's own sign-in. Sessions are kept in memory with an idle expiry; each remembers who
|
||||
signed in and what that user looked like (role, hash, disabled flag) at that moment, so a
|
||||
change to the users file ends the sessions of the users it touched. `info` is the object
|
||||
the console reads from `user/login` and `user/session/get` (flat, as the real server sends it).
|
||||
*/
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { HUB_VERSION, type HubConfig } from './config.ts'
|
||||
import { can, signature, type Capability, type Role, type UserRecord, type Users } from './users.ts'
|
||||
|
||||
export const IDLE_MS = 30 * 60_000
|
||||
/* A download link carries a token that works once, within this time. */
|
||||
export const SINGLE_USE_MS = 60_000
|
||||
|
||||
export interface Identity {
|
||||
user: string
|
||||
role: Role
|
||||
}
|
||||
|
||||
interface Session extends Identity {
|
||||
last: number
|
||||
sig: string
|
||||
}
|
||||
|
||||
interface SingleUse extends Identity {
|
||||
until: number
|
||||
sig: string
|
||||
}
|
||||
|
||||
/*
|
||||
Every key `src/app/sections.ts` reads, spelled out: the console hides a section only
|
||||
when `canView === false`, so a missing key would leave it visible. About has no permission
|
||||
and is always there. Administration (users, SSO, LDAP of each server) is not offered.
|
||||
|
||||
What a role may change comes from its capabilities, per key. Settings never get `canDelete`
|
||||
(that is the backup and restore, which the hub does not forward), nor do the Logs and the
|
||||
Dashboard (deleting logs and stats stays in each server's own console). DNS Client needs
|
||||
`admin` to modify because its Import writes a zone. The blocking switch and the block list update are blocking,
|
||||
so behind a hub the console asks for Blocked/Allowed Modify there (which an operator has), not for Settings Modify (which
|
||||
only an admin has). Saving the list URLs of one server stays a settings change.
|
||||
*/
|
||||
const PERMISSION_KEYS = [
|
||||
'Dashboard', 'Zones', 'Cache', 'Allowed', 'Blocked', 'Apps', 'DnsClient', 'Settings', 'DhcpServer', 'Administration', 'Logs',
|
||||
]
|
||||
const VIEWABLE = new Set(['Dashboard', 'Logs', 'Apps', 'Zones', 'Cache', 'Allowed', 'Blocked', 'DnsClient', 'Settings', 'DhcpServer'])
|
||||
const MODIFIES: Record<string, Capability> = {
|
||||
Apps: 'apps.write', Zones: 'admin', Cache: 'operate', Allowed: 'operate', Blocked: 'operate',
|
||||
DnsClient: 'admin', Settings: 'admin', DhcpServer: 'admin',
|
||||
}
|
||||
const DELETES: Record<string, Capability> = {
|
||||
Apps: 'apps.write', Zones: 'admin', Cache: 'operate', Allowed: 'operate', Blocked: 'operate', DhcpServer: 'admin',
|
||||
}
|
||||
|
||||
export class Sessions {
|
||||
#config: HubConfig
|
||||
#users: Users
|
||||
#now: () => number
|
||||
#started: string
|
||||
#seen = new Map<string, Session>()
|
||||
#single = new Map<string, SingleUse>()
|
||||
|
||||
#drift: () => number
|
||||
|
||||
constructor(config: HubConfig, users: Users, now: () => number = Date.now, drift: () => number = () => 0) {
|
||||
this.#drift = drift
|
||||
this.#config = config
|
||||
this.#users = users
|
||||
this.#now = now
|
||||
this.#started = new Date(now()).toISOString()
|
||||
}
|
||||
|
||||
/** Opens a session for a user already authenticated by `Users.authenticate`. */
|
||||
open(user: UserRecord): string {
|
||||
this.#sweep()
|
||||
const token = randomBytes(32).toString('hex')
|
||||
this.#seen.set(token, { user: user.name, role: user.role, last: this.#now(), sig: signature(user) })
|
||||
return token
|
||||
}
|
||||
|
||||
/* The session's identity while it is valid (extending it), else null. A user who was removed,
|
||||
disabled or edited since sign-in ends the session. */
|
||||
touch(token: string | null | undefined): Identity | null {
|
||||
if (!token) return null
|
||||
const session = this.#seen.get(token)
|
||||
if (session === undefined) return null
|
||||
const record = this.#users.find(session.user)
|
||||
if (this.#now() - session.last > IDLE_MS || record === undefined || signature(record) !== session.sig) {
|
||||
this.#seen.delete(token)
|
||||
return null
|
||||
}
|
||||
session.last = this.#now()
|
||||
return { user: session.user, role: session.role }
|
||||
}
|
||||
|
||||
logout(token: string): void {
|
||||
this.#seen.delete(token)
|
||||
}
|
||||
|
||||
/* A token for one download (the console opens it in a new tab, which has no Authorization header). */
|
||||
openSingleUse(token: string): string | null {
|
||||
const session = this.#seen.get(token)
|
||||
if (session === undefined) return null
|
||||
const once = randomBytes(32).toString('hex')
|
||||
this.#single.set(once, { user: session.user, role: session.role, sig: session.sig, until: this.#now() + SINGLE_USE_MS })
|
||||
return once
|
||||
}
|
||||
|
||||
/* The identity behind a single-use token, which is spent by asking; null when it is unknown, used,
|
||||
expired, or its user was removed or edited since. */
|
||||
takeSingleUse(token: string | null | undefined): Identity | null {
|
||||
if (!token) return null
|
||||
const once = this.#single.get(token)
|
||||
if (once === undefined) return null
|
||||
this.#single.delete(token)
|
||||
const record = this.#users.find(once.user)
|
||||
if (this.#now() > once.until || record === undefined || signature(record) !== once.sig) return null
|
||||
return { user: once.user, role: once.role }
|
||||
}
|
||||
|
||||
#sweep(): void {
|
||||
const now = this.#now()
|
||||
for (const [token, session] of this.#seen) if (now - session.last > IDLE_MS) this.#seen.delete(token)
|
||||
for (const [token, once] of this.#single) if (now > once.until) this.#single.delete(token)
|
||||
}
|
||||
|
||||
info(role: Role) {
|
||||
const { servers, domain } = this.#config
|
||||
return {
|
||||
version: HUB_VERSION,
|
||||
uptimestamp: this.#started,
|
||||
dnsServerDomain: domain,
|
||||
permissions: Object.fromEntries(
|
||||
PERMISSION_KEYS.map((key) => [
|
||||
key,
|
||||
{
|
||||
canView: VIEWABLE.has(key),
|
||||
canModify: key in MODIFIES && can(role, MODIFIES[key]),
|
||||
canDelete: key in DELETES && can(role, DELETES[key]),
|
||||
},
|
||||
]),
|
||||
),
|
||||
/* The console offers its node selector when the cluster is initialised. The
|
||||
first server plays the Primary (`primaryNodeName` looks for one). */
|
||||
clusterInitialized: true,
|
||||
clusterDomain: domain,
|
||||
clusterNodes: servers.map((s, i) => ({ name: s.id, type: i === 0 ? 'Primary' : 'Secondary' })),
|
||||
defaultRecordTtl: 3600,
|
||||
defaultNsRecordTtl: 3600,
|
||||
defaultSoaRecordTtl: 900,
|
||||
useSoaSerialDateScheme: true,
|
||||
dnssecValidation: true,
|
||||
hub: true,
|
||||
hubRole: role,
|
||||
/* The servers the hub holds an admin token for: the only ones a write can reach. */
|
||||
/* How many servers differ from their baseline now: the console marks the header with it. */
|
||||
hubDrift: this.#drift(),
|
||||
hubWritable: servers.filter((s) => s.adminToken !== undefined).map((s) => s.id),
|
||||
}
|
||||
}
|
||||
|
||||
/** The flat body of `user/login` and `user/session/get`. */
|
||||
body(token: string, identity: Identity) {
|
||||
return {
|
||||
status: 'ok',
|
||||
token,
|
||||
username: identity.user,
|
||||
displayName: identity.user,
|
||||
type: 'Local',
|
||||
isSsoUser: false,
|
||||
totpEnabled: false,
|
||||
hubRole: identity.role,
|
||||
info: this.info(identity.role),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,586 @@
|
||||
/*
|
||||
Settings and blocking compare across the servers (stage 7): the baseline kept in the hub, the
|
||||
fields that stay local, a preview, an apply with history and read-back, and the custom Blocked and
|
||||
Allowed lists compared as sets.
|
||||
|
||||
The settings are read with each server's ADMIN token when the hub has one (a read-only token may
|
||||
see blanked secrets, and a baseline imported from those would blank them elsewhere). Apply sends
|
||||
ONLY the shared fields that differ, through the same checks as a per-server `settings/set`
|
||||
(guards.ts), after saving the server's settings to the history, and reads them back.
|
||||
|
||||
Nothing here puts a secret value, or a hash of one, in an answer; the audit lines carry field
|
||||
names only (see settings-sync.ts).
|
||||
*/
|
||||
import { createHash } from 'node:crypto'
|
||||
import type { HubConfig, HubServer } from './config.ts'
|
||||
import { WEB_SERVICE_MESSAGE, isForbiddenSettingsField, webServiceChanges } from './guards.ts'
|
||||
import {
|
||||
BLOCKING_FIELDS,
|
||||
LOCAL_DEFAULTS,
|
||||
MAX_DELETES,
|
||||
MAX_LIST,
|
||||
changedFields,
|
||||
compareSettings,
|
||||
diffSets,
|
||||
hasSecret,
|
||||
isBuiltInLocal,
|
||||
isFieldName,
|
||||
maskSettings,
|
||||
maskValue,
|
||||
normalize,
|
||||
parseDomains,
|
||||
prepare,
|
||||
restoreSecrets,
|
||||
sameValue,
|
||||
toParams,
|
||||
type Comparison,
|
||||
} from './settings-sync.ts'
|
||||
import { cleanDomain } from './servers-run.ts'
|
||||
import { UserError } from './apps-config.ts'
|
||||
import type { FieldChange } from './apps-sync.ts'
|
||||
import { MAX_CONFIG } from './apps-sync.ts'
|
||||
import {
|
||||
listHistoryIn,
|
||||
readHistoryIn,
|
||||
readListsBaseline,
|
||||
readSettingsBaseline,
|
||||
readSettingsOverrides,
|
||||
settingsHistoryDir,
|
||||
writeHistoryIn,
|
||||
writeListsBaseline,
|
||||
writeSettingsBaseline,
|
||||
writeSettingsOverrides,
|
||||
type HistoryEntry,
|
||||
type ListsBaseline,
|
||||
type SettingsBaseline,
|
||||
} from './store.ts'
|
||||
import { Upstream } from './upstream.ts'
|
||||
import { readCapped } from './zones-sync.ts'
|
||||
|
||||
const reason = (e: unknown): string => (e instanceof Error ? e.message : 'failed')
|
||||
const hashOf = (value: unknown): string => createHash('sha256').update(JSON.stringify(value)).digest('hex').slice(0, 16)
|
||||
|
||||
export const MAX_ADDS = 1000
|
||||
const LIST_TIMEOUT_MS = 30_000
|
||||
const LIST_BYTES = 8 * 1024 * 1024
|
||||
|
||||
export interface TargetResult {
|
||||
server: string
|
||||
ok: boolean
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface SettingsTargetPreview {
|
||||
server: string
|
||||
ok: boolean
|
||||
error?: string
|
||||
writable: boolean
|
||||
/* Tells the apply whether the server changed since this preview. Built from masked values. */
|
||||
currentHash: string
|
||||
/* `{kind:'json'}` so the console's field table draws it as it does a config. */
|
||||
changes: { kind: 'json'; changes: FieldChange[] }
|
||||
/* Fields whose change is a secret: shown as "secret changed", never as values. */
|
||||
secretFields: string[]
|
||||
unchanged: boolean
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
export interface ListsServerView {
|
||||
id: string
|
||||
ok: boolean
|
||||
error?: string
|
||||
writable: boolean
|
||||
blocked: { count: number; missing: number; extra: number; missingSample: string[]; extraSample: string[] }
|
||||
allowed: { count: number; missing: number; extra: number; missingSample: string[]; extraSample: string[] }
|
||||
status: 'same' | 'differs' | 'unknown'
|
||||
}
|
||||
|
||||
const SAMPLE = 100
|
||||
|
||||
export class SettingsConfig {
|
||||
#config: HubConfig
|
||||
#upstream: Upstream
|
||||
#now: () => number
|
||||
|
||||
constructor(config: HubConfig, upstream: Upstream, now: () => number = Date.now) {
|
||||
this.#config = config
|
||||
this.#upstream = upstream
|
||||
this.#now = now
|
||||
}
|
||||
|
||||
#server(id: string): HubServer {
|
||||
const found = this.#config.servers.find((s) => s.id === id)
|
||||
if (found === undefined) throw new UserError(`Unknown server: ${id}.`)
|
||||
return found
|
||||
}
|
||||
|
||||
#targets(ids: string[]): HubServer[] {
|
||||
const unique = [...new Set(ids)]
|
||||
if (unique.length === 0) throw new UserError('Choose at least one server.')
|
||||
return unique.map((id) => this.#server(id))
|
||||
}
|
||||
|
||||
/* Fresh `settings/get`, with the admin token when there is one. */
|
||||
async #read(server: HubServer): Promise<unknown> {
|
||||
this.#upstream.invalidate(server.id)
|
||||
return this.#upstream.call(server, 'settings/get', {}, server.adminToken ?? server.token)
|
||||
}
|
||||
|
||||
async #readAll(): Promise<Record<string, { settings: unknown } | { error: string }>> {
|
||||
const entries = await Promise.all(
|
||||
this.#config.servers.map(async (s): Promise<[string, { settings: unknown } | { error: string }]> => {
|
||||
try {
|
||||
return [s.id, { settings: await this.#read(s) }]
|
||||
} catch (e) {
|
||||
return [s.id, { error: reason(e) }]
|
||||
}
|
||||
}),
|
||||
)
|
||||
return Object.fromEntries(entries)
|
||||
}
|
||||
|
||||
getBaseline(): SettingsBaseline | null {
|
||||
return readSettingsBaseline(this.#config.dataDir)
|
||||
}
|
||||
|
||||
#extraLocal(): string[] {
|
||||
return readSettingsOverrides(this.#config.dataDir).paths
|
||||
}
|
||||
|
||||
/* The baseline as the console may see it: secrets masked. */
|
||||
baselineView(): { settings: Record<string, unknown>; importedFrom?: string; updatedAt: string; updatedBy: string } | null {
|
||||
const b = this.getBaseline()
|
||||
return b === null ? null : { ...b, settings: maskSettings(b.settings) }
|
||||
}
|
||||
|
||||
async compare(): Promise<unknown> {
|
||||
const baseline = this.getBaseline()
|
||||
const extra = this.#extraLocal()
|
||||
const live = await this.#readAll()
|
||||
const comparison: Comparison = compareSettings({ baseline: baseline?.settings ?? null, live, extraLocal: extra })
|
||||
return {
|
||||
baseline: this.baselineView(),
|
||||
localFields: { builtIn: [...LOCAL_DEFAULTS], builtInPatterns: ['webService*', 'user*, sso*, ldap*, auth*'], extra },
|
||||
blockingFields: [...BLOCKING_FIELDS],
|
||||
servers: comparison.servers.map((s) => ({ ...s, writable: this.#config.servers.find((x) => x.id === s.id)?.adminToken !== undefined })),
|
||||
fields: comparison.fields,
|
||||
withBaseline: comparison.withBaseline,
|
||||
}
|
||||
}
|
||||
|
||||
/* Writes the baseline; the one it replaces goes to the history first. */
|
||||
#setBaseline(settings: Record<string, unknown>, importedFrom: string | undefined, user: string): void {
|
||||
const old = this.getBaseline()
|
||||
if (old !== null && JSON.stringify(old.settings) !== JSON.stringify(settings)) {
|
||||
try {
|
||||
writeHistoryIn(settingsHistoryDir(this.#config.dataDir), 'baseline', JSON.stringify(old.settings), 'before-save', this.#now())
|
||||
} catch (e) {
|
||||
throw new UserError(`The old baseline could not be saved first (${reason(e)}); nothing was changed.`)
|
||||
}
|
||||
}
|
||||
writeSettingsBaseline(this.#config.dataDir, {
|
||||
settings,
|
||||
...(importedFrom === undefined ? {} : { importedFrom }),
|
||||
updatedAt: new Date(this.#now()).toISOString(),
|
||||
updatedBy: user,
|
||||
})
|
||||
}
|
||||
|
||||
/* Takes a server's settings as the baseline; with `group=blocking` only its blocking fields replace the baseline's. */
|
||||
async importBaseline(serverId: string, group: string | undefined, user: string): Promise<{ detail: string }> {
|
||||
const server = this.#server(serverId)
|
||||
let raw: unknown
|
||||
try {
|
||||
raw = await this.#read(server)
|
||||
} catch (e) {
|
||||
throw new UserError(`${serverId}: ${reason(e)}`)
|
||||
}
|
||||
const read = normalize(raw)
|
||||
if (Object.keys(read).length === 0) throw new UserError(`${serverId} sent no settings.`)
|
||||
let next = read
|
||||
if (group === 'blocking') {
|
||||
const old = this.getBaseline()?.settings ?? {}
|
||||
const kept: Record<string, unknown> = { ...old }
|
||||
for (const name of BLOCKING_FIELDS) if (Object.hasOwn(read, name)) kept[name] = read[name]
|
||||
next = kept
|
||||
} else if (group !== undefined && group !== '') throw new UserError('group can only be "blocking".')
|
||||
this.#setBaseline(next, serverId, user)
|
||||
return { detail: `from ${serverId}${group === 'blocking' ? ' (blocking fields)' : ''}, ${Object.keys(next).length} fields` }
|
||||
}
|
||||
|
||||
/* The baseline from the editor's JSON. A secret shown as the placeholder keeps the stored one. */
|
||||
saveBaseline(textBody: string, user: string): { detail: string } {
|
||||
if (textBody.length > MAX_CONFIG) throw new UserError('The baseline is larger than 1 MB.')
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(textBody)
|
||||
} catch (e) {
|
||||
throw new UserError(`The baseline is not valid JSON: ${reason(e)}`)
|
||||
}
|
||||
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) throw new UserError('The baseline must be a JSON object of settings.')
|
||||
const named = Object.keys(parsed as object)
|
||||
const bad = named.find((k) => !isFieldName(k))
|
||||
if (bad !== undefined) throw new UserError(`"${bad.slice(0, 40)}" is not a setting name.`)
|
||||
let settings: Record<string, unknown>
|
||||
try {
|
||||
settings = restoreSecrets(normalize(parsed), this.getBaseline()?.settings ?? null)
|
||||
} catch (e) {
|
||||
throw new UserError(reason(e))
|
||||
}
|
||||
this.#setBaseline(settings, this.getBaseline()?.importedFrom, user)
|
||||
return { detail: `${Object.keys(settings).length} fields` }
|
||||
}
|
||||
|
||||
saveOverrides(pathsRaw: string[]): { detail: string } {
|
||||
const seen = new Set<string>()
|
||||
if (pathsRaw.length > 50) throw new UserError('At most 50 extra local fields.')
|
||||
for (const p of pathsRaw) {
|
||||
if (!isFieldName(p)) throw new UserError(`"${p.slice(0, 40)}" is not a setting name.`)
|
||||
if (seen.has(p)) throw new UserError(`"${p}" is listed twice.`)
|
||||
seen.add(p)
|
||||
}
|
||||
writeSettingsOverrides(this.#config.dataDir, { paths: pathsRaw })
|
||||
return { detail: `${pathsRaw.length} extra local field(s)` }
|
||||
}
|
||||
|
||||
/* ---- Preview and apply ---- */
|
||||
|
||||
#fingerprint(current: unknown, baseline: Record<string, unknown>, extra: string[]): string {
|
||||
const { settings } = prepare(current)
|
||||
const shared = Object.keys(settings)
|
||||
.filter((k) => !isBuiltInLocal(k) && !extra.includes(k) && Object.hasOwn(baseline, k))
|
||||
.sort()
|
||||
.map((k) => [k, maskValue(k, settings[k])])
|
||||
return hashOf(shared)
|
||||
}
|
||||
|
||||
async preview(ids: string[], only?: readonly string[], override?: SettingsBaseline): Promise<{ targets: SettingsTargetPreview[] }> {
|
||||
const targets = this.#targets(ids)
|
||||
const baseline = override ?? this.getBaseline()
|
||||
if (baseline === null) throw new UserError('There is no settings baseline yet. Accept one from a server first.')
|
||||
const extra = this.#extraLocal()
|
||||
const base = normalize(baseline.settings)
|
||||
const out = await Promise.all(
|
||||
targets.map(async (s): Promise<SettingsTargetPreview> => {
|
||||
const empty = { kind: 'json' as const, changes: [] }
|
||||
const writable = s.adminToken !== undefined
|
||||
let current: unknown
|
||||
try {
|
||||
current = await this.#read(s)
|
||||
} catch (e) {
|
||||
return { server: s.id, ok: false, error: reason(e), writable, currentHash: '', changes: empty, secretFields: [], unchanged: false, warnings: [] }
|
||||
}
|
||||
const names = changedFields(baseline.settings, current, extra, only)
|
||||
const live = prepare(current)
|
||||
const warnings: string[] = []
|
||||
if (!writable) warnings.push('The hub has no admin token for this server, so it cannot write to it.')
|
||||
if (live.paused) warnings.push('Blocking is paused for a while on this server; its blocking switch is left alone.')
|
||||
const changes: FieldChange[] = names.map((name) => {
|
||||
const [a, b] = [maskValue(name, live.settings[name]), maskValue(name, base[name])]
|
||||
return { path: name, type: a === undefined ? 'added' : b === undefined ? 'removed' : 'changed', a, b }
|
||||
})
|
||||
return {
|
||||
server: s.id,
|
||||
ok: true,
|
||||
writable,
|
||||
currentHash: this.#fingerprint(current, base, extra),
|
||||
changes: { kind: 'json', changes },
|
||||
secretFields: names.filter(hasSecret),
|
||||
unchanged: names.length === 0,
|
||||
warnings,
|
||||
}
|
||||
}),
|
||||
)
|
||||
return { targets: out }
|
||||
}
|
||||
|
||||
async #applyOne(s: HubServer, baseline: SettingsBaseline, extra: string[], expect: string | undefined, only?: readonly string[]): Promise<{ result: TargetResult; detail?: string }> {
|
||||
const fail = (message: string): { result: TargetResult } => ({ result: { server: s.id, ok: false, message } })
|
||||
if (s.adminToken === undefined) return fail('no admin token configured')
|
||||
try {
|
||||
let current: unknown
|
||||
try {
|
||||
current = await this.#read(s)
|
||||
} catch (e) {
|
||||
return fail(reason(e))
|
||||
}
|
||||
const base = normalize(baseline.settings)
|
||||
if (expect !== undefined && expect !== this.#fingerprint(current, base, extra)) return fail('the settings changed on the server since the preview; run the preview again')
|
||||
const names = changedFields(baseline.settings, current, extra, only)
|
||||
if (names.length === 0) return { result: { server: s.id, ok: true, message: 'Already in sync; nothing was written.' } }
|
||||
|
||||
const params: Record<string, string> = {}
|
||||
for (const name of names) {
|
||||
const encoded = toParams(name, base[name])
|
||||
if ('error' in encoded) return fail(`${name}: ${encoded.error}; nothing was changed`)
|
||||
Object.assign(params, encoded.params)
|
||||
}
|
||||
// The same checks as a per-server settings/set (guards.ts).
|
||||
if (Object.keys(params).some((k) => isForbiddenSettingsField(k) || k.startsWith('webService'))) return fail('a setting the hub does not change was in the list; nothing was changed')
|
||||
const web = webServiceChanges(params, current)
|
||||
if (web.length > 0) return fail(`${WEB_SERVICE_MESSAGE} nothing was changed`)
|
||||
|
||||
try {
|
||||
writeHistoryIn(settingsHistoryDir(this.#config.dataDir), s.id, JSON.stringify(normalize(current)), 'before-apply', this.#now())
|
||||
} catch (e) {
|
||||
return fail(`the old settings could not be saved first (${reason(e)}); nothing was changed`)
|
||||
}
|
||||
await this.#upstream.write(s, 'settings/set', params)
|
||||
const detail = names.join(', ')
|
||||
let after: unknown
|
||||
try {
|
||||
after = prepare(await this.#read(s)).settings
|
||||
} catch (e) {
|
||||
return { result: { server: s.id, ok: false, message: `the server accepted the change but could not be read back (${reason(e)})` }, detail }
|
||||
}
|
||||
const wrong = names.filter((n) => !sameValue(n, base[n], (after as Record<string, unknown>)[n]))
|
||||
if (wrong.length > 0) return { result: { server: s.id, ok: false, message: `the server accepted the change but reads back different values for: ${wrong.join(', ')}` }, detail }
|
||||
return { result: { server: s.id, ok: true, message: `Applied and verified (${names.length} field${names.length === 1 ? '' : 's'}).` }, detail }
|
||||
} catch (e) {
|
||||
return fail(reason(e))
|
||||
}
|
||||
}
|
||||
|
||||
/* `override` is a baseline that is not the hub's own (a profile's settings). */
|
||||
async apply(ids: string[], expect: Record<string, string>, only?: readonly string[], override?: SettingsBaseline): Promise<{ results: TargetResult[]; details: Record<string, string> }> {
|
||||
const targets = this.#targets(ids)
|
||||
const baseline = override ?? this.getBaseline()
|
||||
if (baseline === null) throw new UserError('There is no settings baseline yet. Accept one from a server first.')
|
||||
const extra = this.#extraLocal()
|
||||
const done = await Promise.all(targets.map((s) => this.#applyOne(s, baseline, extra, expect[s.id], only)))
|
||||
const details: Record<string, string> = {}
|
||||
for (const d of done) if (d.detail !== undefined) details[d.result.server] = d.detail
|
||||
return { results: done.map((d) => d.result), details }
|
||||
}
|
||||
|
||||
/* The extra local field names (for the profiles, which keep shared fields only). */
|
||||
extraLocal(): string[] {
|
||||
return this.#extraLocal()
|
||||
}
|
||||
|
||||
/*
|
||||
For the drift check: which servers differ from the settings baseline and from the lists baseline.
|
||||
Nothing is read for a baseline that does not exist. Field names only, never a value.
|
||||
*/
|
||||
async driftFindings(): Promise<{ kind: 'settings' | 'lists'; server: string; status: 'differs' | 'unreadable'; fields: string[] }[]> {
|
||||
const out: { kind: 'settings' | 'lists'; server: string; status: 'differs' | 'unreadable'; fields: string[] }[] = []
|
||||
const baseline = this.getBaseline()
|
||||
const lists = this.getListsBaseline()
|
||||
if (baseline === null && lists === null) return out
|
||||
const extra = this.#extraLocal()
|
||||
await Promise.all(
|
||||
this.#config.servers.map(async (s) => {
|
||||
if (baseline !== null) {
|
||||
try {
|
||||
const fields = changedFields(baseline.settings, await this.#read(s), extra)
|
||||
if (fields.length > 0) out.push({ kind: 'settings', server: s.id, status: 'differs', fields })
|
||||
} catch {
|
||||
out.push({ kind: 'settings', server: s.id, status: 'unreadable', fields: [] })
|
||||
}
|
||||
}
|
||||
if (lists !== null) {
|
||||
try {
|
||||
const live = await this.#readLists(s)
|
||||
const fields = (['blocked', 'allowed'] as const).filter((l) => {
|
||||
const d = diffSets(lists[l], live[l])
|
||||
return d.missing.length + d.extra.length > 0
|
||||
})
|
||||
if (fields.length > 0) out.push({ kind: 'lists', server: s.id, status: 'differs', fields })
|
||||
} catch {
|
||||
out.push({ kind: 'lists', server: s.id, status: 'unreadable', fields: [] })
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
return out
|
||||
}
|
||||
|
||||
/* ---- History: list and view (no rollback; apply the baseline again, or accept an old one by hand) ---- */
|
||||
|
||||
historyList(): HistoryEntry[] {
|
||||
return listHistoryIn(settingsHistoryDir(this.#config.dataDir))
|
||||
}
|
||||
|
||||
historyGet(id: string): { entry: HistoryEntry; settings?: Record<string, unknown>; lists?: { blocked: number; allowed: number } } {
|
||||
const entry = this.historyList().find((e) => e.id === id)
|
||||
const text = entry === undefined ? null : readHistoryIn(settingsHistoryDir(this.#config.dataDir), id)
|
||||
if (entry === undefined || text === null) throw new UserError('There is no such history entry.')
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(text)
|
||||
} catch {
|
||||
throw new UserError('That history entry cannot be read.')
|
||||
}
|
||||
if (entry.kind === 'before-lists') {
|
||||
const p = parsed as { blocked?: unknown[]; allowed?: unknown[] }
|
||||
return { entry, lists: { blocked: p.blocked?.length ?? 0, allowed: p.allowed?.length ?? 0 } }
|
||||
}
|
||||
return { entry, settings: maskSettings(normalize(parsed)) }
|
||||
}
|
||||
|
||||
/* ---- Blocked and Allowed lists ---- */
|
||||
|
||||
async #readList(server: HubServer, list: 'blocked' | 'allowed'): Promise<string[]> {
|
||||
const file = await this.#upstream.raw(server, `${list}/export`, {})
|
||||
return parseDomains(await readCapped(file, LIST_BYTES, LIST_TIMEOUT_MS, `The ${list} list`))
|
||||
}
|
||||
|
||||
async #readLists(server: HubServer): Promise<{ blocked: string[]; allowed: string[] }> {
|
||||
const [blocked, allowed] = await Promise.all([this.#readList(server, 'blocked'), this.#readList(server, 'allowed')])
|
||||
return { blocked, allowed }
|
||||
}
|
||||
|
||||
getListsBaseline(): ListsBaseline | null {
|
||||
return readListsBaseline(this.#config.dataDir)
|
||||
}
|
||||
|
||||
async listsCompare(): Promise<unknown> {
|
||||
const baseline = this.getListsBaseline()
|
||||
const reads = await Promise.all(
|
||||
this.#config.servers.map(async (s) => {
|
||||
try {
|
||||
return { s, lists: await this.#readLists(s), error: undefined }
|
||||
} catch (e) {
|
||||
return { s, lists: null, error: reason(e) }
|
||||
}
|
||||
}),
|
||||
)
|
||||
const reference = baseline ?? reads.find((r) => r.lists !== null)?.lists ?? null
|
||||
const refId = baseline === null ? reads.find((r) => r.lists !== null)?.s.id : undefined
|
||||
const view = (live: string[], ref: string[], own: boolean) => {
|
||||
const d = own ? { missing: [], extra: [] } : diffSets(ref, live)
|
||||
return { count: live.length, missing: d.missing.length, extra: d.extra.length, missingSample: d.missing.slice(0, SAMPLE), extraSample: d.extra.slice(0, SAMPLE) }
|
||||
}
|
||||
const servers: ListsServerView[] = reads.map((r) => {
|
||||
const writable = r.s.adminToken !== undefined
|
||||
if (r.lists === null || reference === null) {
|
||||
const none = { count: 0, missing: 0, extra: 0, missingSample: [], extraSample: [] }
|
||||
return { id: r.s.id, ok: false, error: r.error, writable, blocked: none, allowed: none, status: 'unknown' }
|
||||
}
|
||||
const own = r.s.id === refId
|
||||
const blocked = view(r.lists.blocked, reference.blocked, own)
|
||||
const allowed = view(r.lists.allowed, reference.allowed, own)
|
||||
const differs = blocked.missing + blocked.extra + allowed.missing + allowed.extra > 0
|
||||
return { id: r.s.id, ok: true, writable, blocked, allowed, status: differs ? 'differs' : 'same' }
|
||||
})
|
||||
// Without a baseline the first server is the reference; it differs when any other does.
|
||||
if (baseline === null) {
|
||||
const first = servers.find((s) => s.id === refId)
|
||||
if (first !== undefined && servers.some((s) => s.id !== first.id && s.status === 'differs')) first.status = 'differs'
|
||||
}
|
||||
return {
|
||||
baseline: baseline === null ? null : { blocked: baseline.blocked.length, allowed: baseline.allowed.length, importedFrom: baseline.importedFrom, updatedAt: baseline.updatedAt, updatedBy: baseline.updatedBy },
|
||||
withBaseline: baseline !== null,
|
||||
servers,
|
||||
}
|
||||
}
|
||||
|
||||
async listsImport(serverId: string, user: string): Promise<{ detail: string }> {
|
||||
const server = this.#server(serverId)
|
||||
let lists: { blocked: string[]; allowed: string[] }
|
||||
try {
|
||||
lists = await this.#readLists(server)
|
||||
} catch (e) {
|
||||
throw new UserError(`${serverId}: ${reason(e)}`)
|
||||
}
|
||||
if (lists.blocked.length > MAX_LIST || lists.allowed.length > MAX_LIST) {
|
||||
throw new UserError(`A list is longer than ${MAX_LIST} domains. The hub compares custom lists, not whole block lists.`)
|
||||
}
|
||||
writeListsBaseline(this.#config.dataDir, { ...lists, importedFrom: serverId, updatedAt: new Date(this.#now()).toISOString(), updatedBy: user })
|
||||
return { detail: `from ${serverId}: ${lists.blocked.length} blocked, ${lists.allowed.length} allowed` }
|
||||
}
|
||||
|
||||
async listsPreview(ids: string[], removeExtras: boolean, override?: ListsBaseline): Promise<{ targets: (ListsServerView & { tooMany?: string })[] }> {
|
||||
const targets = this.#targets(ids)
|
||||
const baseline = override ?? this.getListsBaseline()
|
||||
if (baseline === null) throw new UserError('There is no lists baseline yet. Accept one from a server first.')
|
||||
const out = await Promise.all(
|
||||
targets.map(async (s) => {
|
||||
const writable = s.adminToken !== undefined
|
||||
const none = { count: 0, missing: 0, extra: 0, missingSample: [], extraSample: [] }
|
||||
try {
|
||||
const live = await this.#readLists(s)
|
||||
const mk = (l: string[], b: string[]) => {
|
||||
const d = diffSets(b, l)
|
||||
return { count: l.length, missing: d.missing.length, extra: d.extra.length, missingSample: d.missing.slice(0, SAMPLE), extraSample: d.extra.slice(0, SAMPLE) }
|
||||
}
|
||||
const blocked = mk(live.blocked, baseline.blocked)
|
||||
const allowed = mk(live.allowed, baseline.allowed)
|
||||
const adds = blocked.missing + allowed.missing
|
||||
const deletes = removeExtras ? blocked.extra + allowed.extra : 0
|
||||
const tooMany = adds > MAX_ADDS ? `${adds} domains to add; at most ${MAX_ADDS} at a time.` : deletes > MAX_DELETES ? `${deletes} domains to remove; at most ${MAX_DELETES} at a time.` : undefined
|
||||
return { id: s.id, ok: true, writable, blocked, allowed, status: adds + (removeExtras ? blocked.extra + allowed.extra : 0) > 0 ? ('differs' as const) : ('same' as const), ...(tooMany === undefined ? {} : { tooMany }) }
|
||||
} catch (e) {
|
||||
return { id: s.id, ok: false, error: reason(e), writable, blocked: none, allowed: none, status: 'unknown' as const }
|
||||
}
|
||||
}),
|
||||
)
|
||||
return { targets: out }
|
||||
}
|
||||
|
||||
/* Adds the missing domains and, only when asked, deletes the extras; one call per domain, a few at a time. */
|
||||
async #applyLists(s: HubServer, baseline: ListsBaseline, removeExtras: boolean): Promise<{ result: TargetResult; detail?: string }> {
|
||||
const fail = (message: string): { result: TargetResult } => ({ result: { server: s.id, ok: false, message } })
|
||||
if (s.adminToken === undefined) return fail('no admin token configured')
|
||||
try {
|
||||
const live = await this.#readLists(s)
|
||||
const plan = (['blocked', 'allowed'] as const).map((list) => {
|
||||
const d = diffSets(baseline[list], live[list])
|
||||
return { list, add: d.missing, remove: removeExtras ? d.extra : [] }
|
||||
})
|
||||
const adds = plan.reduce((n, p) => n + p.add.length, 0)
|
||||
const removals = plan.reduce((n, p) => n + p.remove.length, 0)
|
||||
if (adds + removals === 0) return { result: { server: s.id, ok: true, message: 'Already in sync; nothing was written.' } }
|
||||
if (adds > MAX_ADDS) return fail(`${adds} domains to add; at most ${MAX_ADDS} at a time, so nothing was changed`)
|
||||
if (removals > MAX_DELETES) return fail(`${removals} domains to remove; at most ${MAX_DELETES} at a time, so nothing was changed`)
|
||||
const calls: { path: string; domain: string }[] = []
|
||||
let invalid = 0
|
||||
for (const p of plan) {
|
||||
for (const d of p.add) (cleanDomain(d) === null ? invalid++ : calls.push({ path: `${p.list}/add`, domain: d }))
|
||||
for (const d of p.remove) (cleanDomain(d) === null ? invalid++ : calls.push({ path: `${p.list}/delete`, domain: d }))
|
||||
}
|
||||
if (removals > 0) {
|
||||
try {
|
||||
writeHistoryIn(settingsHistoryDir(this.#config.dataDir), s.id, JSON.stringify(live), 'before-lists', this.#now())
|
||||
} catch (e) {
|
||||
return fail(`the old lists could not be saved first (${reason(e)}); nothing was changed`)
|
||||
}
|
||||
}
|
||||
let failed = 0
|
||||
let firstError = ''
|
||||
for (let i = 0; i < calls.length; i += 10) {
|
||||
await Promise.all(
|
||||
calls.slice(i, i + 10).map(async (c) => {
|
||||
try {
|
||||
await this.#upstream.write(s, c.path, { domain: c.domain })
|
||||
} catch (e) {
|
||||
failed++
|
||||
if (firstError === '') firstError = reason(e)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
const detail = `${adds} to add, ${removals} to remove`
|
||||
if (failed > 0 || invalid > 0) {
|
||||
return { result: { server: s.id, ok: false, message: `${failed} change(s) failed${firstError === '' ? '' : ` (${firstError})`}${invalid > 0 ? `, ${invalid} domain(s) skipped as invalid` : ''}` }, detail }
|
||||
}
|
||||
const after = await this.#readLists(s)
|
||||
const left = (['blocked', 'allowed'] as const).reduce((n, l) => n + diffSets(baseline[l], after[l]).missing.length + (removeExtras ? diffSets(baseline[l], after[l]).extra.length : 0), 0)
|
||||
if (left > 0) return { result: { server: s.id, ok: false, message: `the changes were accepted but ${left} difference(s) remain when read back` }, detail }
|
||||
return { result: { server: s.id, ok: true, message: `Added ${adds}, removed ${removals}.` }, detail }
|
||||
} catch (e) {
|
||||
return fail(reason(e))
|
||||
}
|
||||
}
|
||||
|
||||
async listsApply(ids: string[], removeExtras: boolean, override?: ListsBaseline): Promise<{ results: TargetResult[]; details: Record<string, string> }> {
|
||||
const targets = this.#targets(ids)
|
||||
const baseline = override ?? this.getListsBaseline()
|
||||
if (baseline === null) throw new UserError('There is no lists baseline yet. Accept one from a server first.')
|
||||
const done = await Promise.all(targets.map((s) => this.#applyLists(s, baseline, removeExtras)))
|
||||
const details: Record<string, string> = {}
|
||||
for (const d of done) if (d.detail !== undefined) details[d.result.server] = d.detail
|
||||
return { results: done.map((d) => d.result), details }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,630 @@
|
||||
// @vitest-environment node
|
||||
/*
|
||||
Stage 7, settings and blocking: the pure rules (local, ignored and secret fields, the encoding for
|
||||
settings/set) and the endpoints against fake servers that keep their settings and lists.
|
||||
*/
|
||||
import { existsSync, readdirSync, readFileSync, rmSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { fake, startHub, ROLES, type Call, type Fake, type TestHub } from './fake-servers.ts'
|
||||
import {
|
||||
SECRET_PLACEHOLDER,
|
||||
changedFields,
|
||||
compareSettings,
|
||||
diffSets,
|
||||
isBuiltInLocal,
|
||||
maskSettings,
|
||||
normalize,
|
||||
parseDomains,
|
||||
prepare,
|
||||
restoreSecrets,
|
||||
sameValue,
|
||||
toParams,
|
||||
} from './settings-sync.ts'
|
||||
import { routeFor } from './routes.ts'
|
||||
|
||||
const TSIG = 'TSIG-SECRET-9f3a'
|
||||
const PROXY = 'PROXY-PASS-77c1'
|
||||
const CERT = 'CERT-PASS-4b2d'
|
||||
|
||||
function settingsOf(over: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
version: '15.5',
|
||||
uptimestamp: '2026-01-01T00:00:00Z',
|
||||
clusterInitialized: false,
|
||||
dnsServerDomain: 'a.example.net',
|
||||
dnsServerLocalEndPoints: ['0.0.0.0:53'],
|
||||
dnsServerIPv4SourceAddresses: null,
|
||||
webServiceHttpPort: 5380,
|
||||
webServiceTlsCertificatePassword: CERT,
|
||||
dnsTlsCertificatePath: '/etc/a.pfx',
|
||||
dnsTlsCertificatePassword: CERT,
|
||||
logFolder: 'logs-a',
|
||||
defaultRecordTtl: 3600,
|
||||
dnssecValidation: true,
|
||||
recursion: 'AllowOnlyForPrivateNetworks',
|
||||
forwarders: ['1.1.1.1', '9.9.9.9'],
|
||||
qpmPrefixLimitsIPv4: [{ prefix: 32, udpLimit: 600, tcpLimit: 600 }],
|
||||
tsigKeys: [{ keyName: 'transfer.key', sharedSecret: TSIG, algorithmName: 'hmac-sha256' }],
|
||||
proxy: null,
|
||||
enableBlocking: true,
|
||||
temporaryDisableBlockingTill: null,
|
||||
blockingType: 'NxDomain',
|
||||
blockingAnswerTtl: 30,
|
||||
blockListUrls: ['https://a.example/hosts', '# Privacy'],
|
||||
blockListNextUpdatedOn: '2026-02-01T00:00:00Z',
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
interface Box {
|
||||
settings: Record<string, unknown>
|
||||
blocked: string[]
|
||||
allowed: string[]
|
||||
/* The settings a read answers with after a write, to pretend the server ignored the change. */
|
||||
ignoreWrites: boolean
|
||||
}
|
||||
|
||||
const boxes = new Map<Fake, Box>()
|
||||
|
||||
function decode(old: unknown, value: string): unknown {
|
||||
if (typeof old === 'boolean') return value === 'true'
|
||||
if (typeof old === 'number') return Number(value)
|
||||
if (Array.isArray(old)) return value === 'false' ? [] : value.split(',')
|
||||
return value
|
||||
}
|
||||
|
||||
function install(f: Fake, box: Box): void {
|
||||
boxes.set(f, box)
|
||||
f.on('settings/get', () => ({ response: box.settings }))
|
||||
f.on('settings/set', (c: Call) => {
|
||||
if (box.ignoreWrites) return { response: box.settings }
|
||||
for (const [k, v] of c.form) {
|
||||
if (k === 'tsigKeys') {
|
||||
box.settings.tsigKeys = v === 'false' ? [] : (() => {
|
||||
const cells = v.split('|')
|
||||
const keys = []
|
||||
for (let i = 0; i < cells.length; i += 3) keys.push({ keyName: cells[i], sharedSecret: cells[i + 1], algorithmName: cells[i + 2] })
|
||||
return keys
|
||||
})()
|
||||
} else if (k === 'qpmPrefixLimitsIPv4') {
|
||||
const cells = v.split('|')
|
||||
box.settings[k] = v === 'false' ? [] : [{ prefix: Number(cells[0]), udpLimit: Number(cells[1]), tcpLimit: Number(cells[2]) }]
|
||||
} else if (k === 'proxyType') {
|
||||
box.settings.proxy = v === 'none' ? null : { type: v, address: c.form.get('proxyAddress'), port: Number(c.form.get('proxyPort')), username: c.form.get('proxyUsername'), password: c.form.get('proxyPassword'), bypass: [] }
|
||||
} else if (!k.startsWith('proxy') && k in box.settings) box.settings[k] = decode(box.settings[k], v)
|
||||
}
|
||||
return { response: box.settings }
|
||||
})
|
||||
for (const list of ['blocked', 'allowed'] as const) {
|
||||
f.on(`${list}/export`, () => ({ text: box[list].join('\n') + '\n' }))
|
||||
f.on(`${list}/add`, (c) => {
|
||||
box[list].push(c.form.get('domain') ?? '')
|
||||
return { response: {} }
|
||||
})
|
||||
f.on(`${list}/delete`, (c) => {
|
||||
box[list] = box[list].filter((d) => d !== c.form.get('domain'))
|
||||
return { response: {} }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let a: Fake
|
||||
let b: Fake
|
||||
let hub: TestHub
|
||||
|
||||
const boxB = (): Box => boxes.get(b)!
|
||||
const writes = (f: Fake): Call[] => f.calls.filter((c) => c.method === 'POST')
|
||||
|
||||
beforeAll(async () => {
|
||||
a = await fake()
|
||||
b = await fake()
|
||||
hub = await startHub(a, b)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
hub.close()
|
||||
a.server.close()
|
||||
b.server.close()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
a.calls = []
|
||||
b.calls = []
|
||||
a.down = false
|
||||
b.down = false
|
||||
install(a, { settings: settingsOf(), blocked: ['ads.example', 'both.example'], allowed: ['ok.example'], ignoreWrites: false })
|
||||
install(
|
||||
b,
|
||||
{
|
||||
settings: settingsOf({
|
||||
dnsServerDomain: 'b.example.net',
|
||||
logFolder: 'logs-b',
|
||||
webServiceHttpPort: 5381,
|
||||
uptimestamp: '2026-03-03T00:00:00Z',
|
||||
defaultRecordTtl: 7200,
|
||||
forwarders: ['8.8.8.8'],
|
||||
blockingType: 'CustomAddress',
|
||||
}),
|
||||
blocked: ['both.example', 'only-b.example'],
|
||||
allowed: ['ok.example', 'extra-allowed.example'],
|
||||
ignoreWrites: false,
|
||||
},
|
||||
)
|
||||
// A baseline and its history from an earlier test must not leak into the next one.
|
||||
for (const dir of ['settings', 'blocking']) rmSync(path.join(hub.dataDir, dir), { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('which fields count', () => {
|
||||
it('treats the host identity, certificates, paths and every webService field as local', () => {
|
||||
for (const n of ['dnsServerDomain', 'dnsServerLocalEndPoints', 'dnsServerIPv4SourceAddresses', 'dnsServerIPv6SourceAddresses', 'webServiceHttpPort', 'webServiceTlsCertificatePath', 'dnsTlsCertificatePath', 'dnsTlsCertificatePassword', 'logFolder', 'ssoEnabled', 'ldapServer']) {
|
||||
expect(isBuiltInLocal(n), n).toBe(true)
|
||||
}
|
||||
for (const n of ['forwarders', 'defaultRecordTtl', 'blockListUrls', 'tsigKeys']) expect(isBuiltInLocal(n), n).toBe(false)
|
||||
})
|
||||
|
||||
it('drops the read-only and runtime fields', () => {
|
||||
const n = normalize(settingsOf())
|
||||
for (const gone of ['version', 'uptimestamp', 'clusterInitialized', 'temporaryDisableBlockingTill', 'blockListNextUpdatedOn']) expect(n).not.toHaveProperty(gone)
|
||||
expect(n).toHaveProperty('forwarders')
|
||||
})
|
||||
|
||||
it('leaves the blocking switch alone while blocking is paused', () => {
|
||||
const p = prepare(settingsOf({ enableBlocking: false, temporaryDisableBlockingTill: '2026-12-01T00:00:00Z' }))
|
||||
expect(p.paused).toBe(true)
|
||||
expect(p.settings).not.toHaveProperty('enableBlocking')
|
||||
})
|
||||
|
||||
it('compares by meaning: absent, null, empty text and an empty list are the same, key order does not count', () => {
|
||||
expect(sameValue('x', null, '')).toBe(true)
|
||||
expect(sameValue('x', [], null)).toBe(true)
|
||||
expect(sameValue('x', { a: 1, b: 2 }, { b: 2, a: 1 })).toBe(true)
|
||||
expect(sameValue('x', ['a', 'b'], ['b', 'a'])).toBe(false)
|
||||
expect(sameValue('proxy', null, { type: 'None', address: 'x' })).toBe(true)
|
||||
expect(sameValue('x', 5, '5')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('comparing', () => {
|
||||
const live = (x: Record<string, unknown>, y: Record<string, unknown>) => ({ a: { settings: settingsOf(x) }, b: { settings: settingsOf(y) } })
|
||||
|
||||
it('says same, differs and local-only against a baseline', () => {
|
||||
const baseline = settingsOf()
|
||||
const out = compareSettings({
|
||||
baseline,
|
||||
live: live({}, { dnsServerDomain: 'b.example.net', defaultRecordTtl: 1 }),
|
||||
extraLocal: [],
|
||||
})
|
||||
const by = Object.fromEntries(out.servers.map((s) => [s.id, s]))
|
||||
expect(by.a.status).toBe('same')
|
||||
expect(by.b).toMatchObject({ status: 'differs', differing: 1, localDiffering: 1 })
|
||||
expect(out.fields.map((f) => f.name)).toEqual(['defaultRecordTtl', 'dnsServerDomain'])
|
||||
|
||||
const onlyLocal = compareSettings({ baseline, live: live({}, { dnsServerDomain: 'b.example.net' }), extraLocal: [] })
|
||||
expect(onlyLocal.servers.find((s) => s.id === 'b')!.status).toBe('local-only')
|
||||
})
|
||||
|
||||
it('compares with the first server when there is no baseline', () => {
|
||||
const out = compareSettings({ baseline: null, live: live({}, { forwarders: ['8.8.8.8'] }), extraLocal: [] })
|
||||
expect(out.withBaseline).toBe(false)
|
||||
expect(out.fields.map((f) => f.name)).toEqual(['forwarders'])
|
||||
expect(out.servers.every((s) => s.status === 'differs')).toBe(true)
|
||||
})
|
||||
|
||||
it('marks an unreachable server unknown', () => {
|
||||
const out = compareSettings({ baseline: settingsOf(), live: { a: { settings: settingsOf() }, b: { error: 'timed out' } }, extraLocal: [] })
|
||||
expect(out.servers.find((s) => s.id === 'b')).toMatchObject({ ok: false, status: 'unknown', error: 'timed out' })
|
||||
})
|
||||
|
||||
it('counts an extra local field as local, not as a difference', () => {
|
||||
const out = compareSettings({ baseline: settingsOf(), live: live({}, { defaultRecordTtl: 1 }), extraLocal: ['defaultRecordTtl'] })
|
||||
expect(out.servers.find((s) => s.id === 'b')!.status).toBe('local-only')
|
||||
})
|
||||
|
||||
it('never puts a secret value in a row, and says only that a secret differs', () => {
|
||||
const other = [{ keyName: 'transfer.key', sharedSecret: 'DIFFERENT-SECRET', algorithmName: 'hmac-sha256' }]
|
||||
const out = compareSettings({ baseline: settingsOf(), live: live({}, { tsigKeys: other, proxy: { type: 'Http', address: 'p', port: 1, username: 'u', password: PROXY, bypass: [] } }), extraLocal: [] })
|
||||
const text = JSON.stringify(out)
|
||||
for (const secret of [TSIG, PROXY, CERT, 'DIFFERENT-SECRET']) expect(text).not.toContain(secret)
|
||||
expect(out.fields.find((f) => f.name === 'tsigKeys')).toMatchObject({ secret: true, cells: { b: { differs: true, secretChanged: true } } })
|
||||
})
|
||||
|
||||
it('masks every secret of a settings object', () => {
|
||||
const text = JSON.stringify(maskSettings(settingsOf({ proxy: { type: 'Http', address: 'p', port: 1, username: 'u', password: PROXY, bypass: [] } })))
|
||||
for (const secret of [TSIG, PROXY, CERT]) expect(text).not.toContain(secret)
|
||||
expect(text).toContain(SECRET_PLACEHOLDER)
|
||||
})
|
||||
})
|
||||
|
||||
describe('what an apply would send', () => {
|
||||
it('lists only shared fields that differ and that both sides have', () => {
|
||||
const baseline = settingsOf({ defaultRecordTtl: 1, webServiceHttpPort: 1, dnsServerDomain: 'x', onlyInBaseline: true })
|
||||
expect(changedFields(baseline, settingsOf({ defaultRecordTtl: 2 }), [])).toEqual(['defaultRecordTtl'])
|
||||
expect(changedFields(baseline, settingsOf({ defaultRecordTtl: 2 }), ['defaultRecordTtl'])).toEqual([])
|
||||
expect(changedFields(baseline, settingsOf({ defaultRecordTtl: 2, blockingType: 'x' }), [], ['blockingType'])).toEqual(['blockingType'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('encoding for settings/set', () => {
|
||||
const one = (name: string, value: unknown) => {
|
||||
const r = toParams(name, value)
|
||||
if ('error' in r) throw new Error(r.error)
|
||||
return r.params
|
||||
}
|
||||
|
||||
it('encodes flags, numbers and text', () => {
|
||||
expect(one('dnssecValidation', true)).toEqual({ dnssecValidation: 'true' })
|
||||
expect(one('blockingAnswerTtl', 30)).toEqual({ blockingAnswerTtl: '30' })
|
||||
expect(one('recursion', 'Allow')).toEqual({ recursion: 'Allow' })
|
||||
expect(one('eDnsClientSubnetIpv4Override', null)).toEqual({ eDnsClientSubnetIpv4Override: '' })
|
||||
})
|
||||
|
||||
it('joins lists with commas and sends an empty one as false', () => {
|
||||
expect(one('forwarders', ['1.1.1.1', '9.9.9.9'])).toEqual({ forwarders: '1.1.1.1,9.9.9.9' })
|
||||
expect(one('forwarders', [])).toEqual({ forwarders: 'false' })
|
||||
expect(one('blockListUrls', ['https://a.example/x', '# Privacy'])).toEqual({ blockListUrls: 'https://a.example/x,# Privacy' })
|
||||
})
|
||||
|
||||
it('refuses an entry the server would split', () => {
|
||||
expect(toParams('blockListUrls', ['https://a.example/x,y'])).toHaveProperty('error')
|
||||
})
|
||||
|
||||
it('sends tsigKeys and the QPM limits as pipe-joined rows, and false when empty', () => {
|
||||
expect(one('tsigKeys', [{ keyName: 'k', sharedSecret: 's', algorithmName: 'hmac-sha256' }])).toEqual({ tsigKeys: 'k|s|hmac-sha256' })
|
||||
expect(one('tsigKeys', [])).toEqual({ tsigKeys: 'false' })
|
||||
expect(one('qpmPrefixLimitsIPv4', [{ prefix: 32, udpLimit: 600, tcpLimit: 500 }, { prefix: 24, udpLimit: 1, tcpLimit: 2 }])).toEqual({ qpmPrefixLimitsIPv4: '32|600|500|24|1|2' })
|
||||
expect(one('qpmPrefixLimitsIPv6', [])).toEqual({ qpmPrefixLimitsIPv6: 'false' })
|
||||
expect(toParams('tsigKeys', [{ keyName: 'k|x', sharedSecret: 's', algorithmName: 'a' }])).toHaveProperty('error')
|
||||
})
|
||||
|
||||
it('sends the proxy flat, and only its type when there is none', () => {
|
||||
expect(one('proxy', null)).toEqual({ proxyType: 'none' })
|
||||
expect(one('proxy', { type: 'None', address: 'x' })).toEqual({ proxyType: 'none' })
|
||||
expect(one('proxy', { type: 'Socks5', address: 'p.example', port: 1080, username: 'u', password: 'pw', bypass: ['127.0.0.1', 'lan'] })).toEqual({
|
||||
proxyType: 'socks5',
|
||||
proxyAddress: 'p.example',
|
||||
proxyPort: '1080',
|
||||
proxyUsername: 'u',
|
||||
proxyPassword: 'pw',
|
||||
proxyBypass: '127.0.0.1,lan',
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses a name that is not a setting', () => {
|
||||
expect(toParams('a=b&c', 1)).toHaveProperty('error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('secrets coming back from the baseline editor', () => {
|
||||
const old = normalize(settingsOf())
|
||||
|
||||
it('keeps the stored secret behind a placeholder, and takes a typed one', () => {
|
||||
const kept = restoreSecrets({ tsigKeys: [{ keyName: 'transfer.key', sharedSecret: SECRET_PLACEHOLDER, algorithmName: 'x' }] }, old)
|
||||
expect((kept.tsigKeys as { sharedSecret: string }[])[0].sharedSecret).toBe(TSIG)
|
||||
const typed = restoreSecrets({ tsigKeys: [{ keyName: 'transfer.key', sharedSecret: 'NEW', algorithmName: 'x' }] }, old)
|
||||
expect((typed.tsigKeys as { sharedSecret: string }[])[0].sharedSecret).toBe('NEW')
|
||||
})
|
||||
|
||||
it('refuses a placeholder with nothing to stand for', () => {
|
||||
expect(() => restoreSecrets({ tsigKeys: [{ keyName: 'new.key', sharedSecret: SECRET_PLACEHOLDER, algorithmName: 'x' }] }, old)).toThrow(/transfer|new\.key/)
|
||||
expect(() => restoreSecrets({ proxy: { type: 'Http', password: SECRET_PLACEHOLDER } }, old)).toThrow(/proxy/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('lists', () => {
|
||||
it('parses an export and compares as sets', () => {
|
||||
expect(parseDomains('B.example.\n# note\n\na.example\r\na.example\n')).toEqual(['a.example', 'b.example'])
|
||||
expect(diffSets(['a', 'b'], ['b', 'c'])).toEqual({ missing: ['a'], extra: ['c'] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('routes and roles', () => {
|
||||
const PATHS = [
|
||||
'hub/settings/compare',
|
||||
'hub/settings/preview',
|
||||
'hub/settings/history/list',
|
||||
'hub/settings/history/get',
|
||||
'hub/settings/baseline/import',
|
||||
'hub/settings/baseline/save',
|
||||
'hub/settings/overrides/save',
|
||||
'hub/settings/apply',
|
||||
'hub/blocking/lists/compare',
|
||||
'hub/blocking/lists/preview',
|
||||
'hub/blocking/lists/baseline/import',
|
||||
'hub/blocking/lists/apply',
|
||||
]
|
||||
|
||||
it('needs admin on every endpoint, reads included', () => {
|
||||
for (const p of PATHS) expect(routeFor(p)?.capability, p).toBe('admin')
|
||||
for (const p of PATHS.filter((x) => /import|save|apply/.test(x))) expect(routeFor(p)?.kind, p).toBe('write-all')
|
||||
})
|
||||
|
||||
it('refuses the other roles without touching a server', async () => {
|
||||
for (const role of ROLES.filter((r) => r !== 'admin')) {
|
||||
for (const p of PATHS) expect((await hub.call(role, p, { server: 'a', targets: 'a', text: '{}' })).code, `${role} ${p}`).toBe(403)
|
||||
}
|
||||
expect(a.calls).toHaveLength(0)
|
||||
expect(b.calls).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hub/settings', () => {
|
||||
const importFrom = (server: string, extra: Record<string, string> = {}) => hub.call('admin', 'hub/settings/baseline/import', { server, ...extra })
|
||||
const historyFiles = (): string[] => {
|
||||
const dir = path.join(hub.dataDir, 'settings', 'history')
|
||||
return existsSync(dir) ? readdirSync(dir) : []
|
||||
}
|
||||
|
||||
it('compares the servers with each other before there is a baseline', async () => {
|
||||
const r = await hub.call('admin', 'hub/settings/compare', {}, 'GET')
|
||||
expect(r.body.response.withBaseline).toBe(false)
|
||||
expect(r.body.response.baseline).toBeNull()
|
||||
const names = r.body.response.fields.map((f: { name: string }) => f.name)
|
||||
expect(names).toContain('defaultRecordTtl')
|
||||
expect(names).toContain('dnsServerDomain')
|
||||
expect(names).not.toContain('uptimestamp')
|
||||
expect(r.body.response.localFields.builtIn).toContain('dnsServerDomain')
|
||||
})
|
||||
|
||||
it('accepts a server as the baseline, then reports b as differing and a as same', async () => {
|
||||
expect((await importFrom('a')).body.status).toBe('ok')
|
||||
const r = await hub.call('admin', 'hub/settings/compare', {}, 'GET')
|
||||
const by = Object.fromEntries(r.body.response.servers.map((s: { id: string }) => [s.id, s]))
|
||||
expect(by.a.status).toBe('same')
|
||||
expect(by.b.status).toBe('differs')
|
||||
expect(by.b.differing).toBe(3)
|
||||
expect(r.body.response.baseline.importedFrom).toBe('a')
|
||||
// The import read with the admin token.
|
||||
expect(a.calls.find((c) => c.path === 'settings/get')!.token).toBe('admin-a')
|
||||
})
|
||||
|
||||
it('imports only the blocking fields with group=blocking', async () => {
|
||||
await importFrom('a')
|
||||
boxB().settings.blockingType = 'AnyAddress'
|
||||
boxB().settings.defaultRecordTtl = 5
|
||||
await importFrom('b', { group: 'blocking' })
|
||||
const r = await hub.call('admin', 'hub/settings/compare', {}, 'GET')
|
||||
const rows = r.body.response.fields.map((f: { name: string }) => f.name)
|
||||
// The baseline now has b's blocking type and a's TTL: a differs in blocking, b in TTL.
|
||||
expect(rows).toContain('blockingType')
|
||||
expect(r.body.response.baseline.settings.blockingType).toBe('AnyAddress')
|
||||
expect(r.body.response.baseline.settings.defaultRecordTtl).toBe(3600)
|
||||
})
|
||||
|
||||
it('previews the fields that would change per target, and writes nothing', async () => {
|
||||
await importFrom('a')
|
||||
a.calls = []
|
||||
b.calls = []
|
||||
const r = await hub.call('admin', 'hub/settings/preview', { targets: 'a,b' })
|
||||
const [pa, pb] = r.body.response.targets
|
||||
expect(pa).toMatchObject({ server: 'a', ok: true, unchanged: true })
|
||||
expect(pb.unchanged).toBe(false)
|
||||
expect(pb.changes.kind).toBe('json')
|
||||
expect(pb.changes.changes.map((c: { path: string }) => c.path)).toEqual(['blockingType', 'defaultRecordTtl', 'forwarders'])
|
||||
expect(writes(a)).toHaveLength(0)
|
||||
expect(writes(b)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('applies only the changed shared fields, never a local or webService field, and saves the old settings first', async () => {
|
||||
await importFrom('a')
|
||||
b.calls = []
|
||||
const preview = await hub.call('admin', 'hub/settings/preview', { targets: 'b' })
|
||||
const expectHash = { b: preview.body.response.targets[0].currentHash }
|
||||
const r = await hub.call('admin', 'hub/settings/apply', { targets: 'b', expect: JSON.stringify(expectHash) })
|
||||
expect(r.body.response.results[0]).toMatchObject({ server: 'b', ok: true })
|
||||
expect(r.body.response.results[0].message).toMatch(/verified \(3 fields\)/)
|
||||
const set = writes(b).filter((c) => c.path === 'settings/set')
|
||||
expect(set).toHaveLength(1)
|
||||
expect(set[0].token).toBe('admin-b')
|
||||
expect(Object.fromEntries(set[0].form)).toEqual({ blockingType: 'NxDomain', defaultRecordTtl: '3600', forwarders: '1.1.1.1,9.9.9.9' })
|
||||
expect(Object.keys(Object.fromEntries(set[0].form)).some((k) => k.startsWith('webService') || k === 'dnsServerDomain' || k === 'logFolder')).toBe(false)
|
||||
expect(boxB().settings.dnsServerDomain).toBe('b.example.net')
|
||||
// The history entry holds b's settings from before the change.
|
||||
const files = historyFiles()
|
||||
expect(files).toHaveLength(1)
|
||||
expect(files[0]).toMatch(/__b__before-apply\.txt$/)
|
||||
expect(JSON.parse(readFileSync(path.join(hub.dataDir, 'settings', 'history', files[0]), 'utf8')).defaultRecordTtl).toBe(7200)
|
||||
})
|
||||
|
||||
it('limits an apply to the blocking fields with group=blocking', async () => {
|
||||
await importFrom('a')
|
||||
b.calls = []
|
||||
const r = await hub.call('admin', 'hub/settings/apply', { targets: 'b', group: 'blocking' })
|
||||
expect(r.body.response.results[0].ok).toBe(true)
|
||||
expect(Object.keys(Object.fromEntries(writes(b)[0].form))).toEqual(['blockingType'])
|
||||
expect(boxB().settings.defaultRecordTtl).toBe(7200)
|
||||
})
|
||||
|
||||
it('refuses to apply when the server changed since the preview', async () => {
|
||||
await importFrom('a')
|
||||
const preview = await hub.call('admin', 'hub/settings/preview', { targets: 'b' })
|
||||
boxB().settings.defaultRecordTtl = 99
|
||||
b.calls = []
|
||||
const r = await hub.call('admin', 'hub/settings/apply', { targets: 'b', expect: JSON.stringify({ b: preview.body.response.targets[0].currentHash }) })
|
||||
expect(r.body.response.results[0]).toMatchObject({ ok: false })
|
||||
expect(r.body.response.results[0].message).toMatch(/changed on the server since the preview/)
|
||||
expect(writes(b)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('reports a server that accepts the write but reads back something else', async () => {
|
||||
await importFrom('a')
|
||||
boxB().ignoreWrites = true
|
||||
const r = await hub.call('admin', 'hub/settings/apply', { targets: 'b' })
|
||||
expect(r.body.response.results[0].ok).toBe(false)
|
||||
expect(r.body.response.results[0].message).toMatch(/reads back different values for: blockingType, defaultRecordTtl, forwarders/)
|
||||
})
|
||||
|
||||
it('reports an unreachable target and a rejected write per server', async () => {
|
||||
await importFrom('a')
|
||||
b.on('settings/set', () => ({ error: 'Access was denied.' }))
|
||||
const r = await hub.call('admin', 'hub/settings/apply', { targets: 'b' })
|
||||
expect(r.body.response.results[0]).toMatchObject({ server: 'b', ok: false, message: 'Access was denied.' })
|
||||
b.down = true
|
||||
const down = await hub.call('admin', 'hub/settings/apply', { targets: 'b' })
|
||||
expect(down.body.response.results[0]).toMatchObject({ server: 'b', ok: false })
|
||||
})
|
||||
|
||||
it('leaves the blocking switch alone while blocking is paused on the server', async () => {
|
||||
await importFrom('a')
|
||||
boxB().settings.enableBlocking = false
|
||||
boxB().settings.temporaryDisableBlockingTill = '2026-12-01T00:00:00Z'
|
||||
b.calls = []
|
||||
await hub.call('admin', 'hub/settings/apply', { targets: 'b' })
|
||||
expect(Object.fromEntries(writes(b)[0].form)).not.toHaveProperty('enableBlocking')
|
||||
})
|
||||
|
||||
it('does not send a field the server does not report', async () => {
|
||||
await importFrom('a')
|
||||
delete boxB().settings.forwarders
|
||||
b.calls = []
|
||||
await hub.call('admin', 'hub/settings/apply', { targets: 'b' })
|
||||
expect(Object.fromEntries(writes(b)[0].form)).not.toHaveProperty('forwarders')
|
||||
})
|
||||
|
||||
it('treats a field added to the local list as not to be sent', async () => {
|
||||
await importFrom('a')
|
||||
expect((await hub.call('admin', 'hub/settings/overrides/save', { paths: 'defaultRecordTtl' })).body.status).toBe('ok')
|
||||
b.calls = []
|
||||
await hub.call('admin', 'hub/settings/apply', { targets: 'b' })
|
||||
expect(Object.fromEntries(writes(b)[0].form)).not.toHaveProperty('defaultRecordTtl')
|
||||
expect((await hub.call('admin', 'hub/settings/overrides/save', { paths: 'bad name' })).code).toBe(400)
|
||||
await hub.call('admin', 'hub/settings/overrides/save', { paths: '' })
|
||||
})
|
||||
|
||||
it('keeps a secret behind the placeholder when the baseline is saved from the editor', async () => {
|
||||
await importFrom('a')
|
||||
const shown = (await hub.call('admin', 'hub/settings/compare', {}, 'GET')).body.response.baseline.settings
|
||||
expect(shown.tsigKeys[0].sharedSecret).toBe(SECRET_PLACEHOLDER)
|
||||
const saved = await hub.call('admin', 'hub/settings/baseline/save', { text: JSON.stringify({ ...shown, defaultRecordTtl: 1800 }) })
|
||||
expect(saved.body.status).toBe('ok')
|
||||
const stored = JSON.parse(readFileSync(path.join(hub.dataDir, 'settings', 'baseline.json'), 'utf8'))
|
||||
expect(stored.settings.tsigKeys[0].sharedSecret).toBe(TSIG)
|
||||
expect(stored.settings.defaultRecordTtl).toBe(1800)
|
||||
// The old baseline went to the history.
|
||||
expect(historyFiles().some((f) => f.endsWith('__baseline__before-save.txt'))).toBe(true)
|
||||
const bad = await hub.call('admin', 'hub/settings/baseline/save', { text: JSON.stringify({ tsigKeys: [{ keyName: 'other', sharedSecret: SECRET_PLACEHOLDER, algorithmName: 'x' }] }) })
|
||||
expect(bad.code).toBe(400)
|
||||
expect((await hub.call('admin', 'hub/settings/baseline/save', { text: '{nope' })).code).toBe(400)
|
||||
})
|
||||
|
||||
it('lists and shows history without a secret', async () => {
|
||||
await importFrom('a')
|
||||
await hub.call('admin', 'hub/settings/apply', { targets: 'b' })
|
||||
const list = await hub.call('admin', 'hub/settings/history/list', {}, 'GET')
|
||||
expect(list.body.response.entries.length).toBeGreaterThan(0)
|
||||
const entry = list.body.response.entries.find((e: { server: string }) => e.server === 'b')
|
||||
const got = await hub.call('admin', 'hub/settings/history/get', { id: entry.id }, 'GET')
|
||||
expect(got.body.response.settings.defaultRecordTtl).toBe(7200)
|
||||
expect(JSON.stringify(got.body)).not.toContain(TSIG)
|
||||
expect((await hub.call('admin', 'hub/settings/history/get', { id: '../../users.json' }, 'GET')).code).toBe(400)
|
||||
})
|
||||
|
||||
it('never lets a secret into any response or into the audit log', async () => {
|
||||
boxB().settings.tsigKeys = [{ keyName: 'transfer.key', sharedSecret: 'B-DIFFERENT-SECRET', algorithmName: 'hmac-sha256' }]
|
||||
boxB().settings.proxy = { type: 'Http', address: 'p.example', port: 8080, username: 'u', password: PROXY, bypass: [] }
|
||||
const bodies: string[] = []
|
||||
const keep = async (p: Promise<{ body: unknown }>) => bodies.push(JSON.stringify((await p).body))
|
||||
await keep(hub.call('admin', 'hub/settings/compare', {}, 'GET'))
|
||||
await keep(hub.call('admin', 'hub/settings/baseline/import', { server: 'a' }))
|
||||
await keep(hub.call('admin', 'hub/settings/compare', {}, 'GET'))
|
||||
await keep(hub.call('admin', 'hub/settings/preview', { targets: 'a,b' }))
|
||||
await keep(hub.call('admin', 'hub/settings/apply', { targets: 'b' }))
|
||||
await keep(hub.call('admin', 'hub/settings/history/list', {}, 'GET'))
|
||||
const list = await hub.call('admin', 'hub/settings/history/list', {}, 'GET')
|
||||
for (const e of list.body.response.entries) await keep(hub.call('admin', 'hub/settings/history/get', { id: e.id }, 'GET'))
|
||||
// And what the existing settings/get gives a viewer.
|
||||
const viewer = await hub.call('viewer', 'settings/get', { node: 'b' }, 'GET')
|
||||
bodies.push(JSON.stringify(viewer.body))
|
||||
const audit = JSON.stringify(await hub.audit())
|
||||
for (const secret of [TSIG, PROXY, CERT, 'B-DIFFERENT-SECRET']) {
|
||||
for (const body of bodies) expect(body).not.toContain(secret)
|
||||
expect(audit).not.toContain(secret)
|
||||
}
|
||||
// The apply did send the baseline's TSIG key to b; that travels to the server, not to the console.
|
||||
expect(JSON.stringify(writes(b).map((c) => [...c.form]))).toContain(TSIG)
|
||||
})
|
||||
|
||||
it('writes audit lines with field names and no values', async () => {
|
||||
await importFrom('a')
|
||||
await hub.call('admin', 'hub/settings/apply', { targets: 'b' })
|
||||
const line = (await hub.audit()).find((e) => e.action === 'settings.apply' && e.server === 'b')!
|
||||
expect(line.outcome).toBe('ok')
|
||||
expect(line.detail).toMatch(/fields: blockingType, defaultRecordTtl, forwarders/)
|
||||
expect(line.detail).not.toContain('8.8.8.8')
|
||||
})
|
||||
})
|
||||
|
||||
describe('hub/blocking/lists', () => {
|
||||
const importFrom = (server: string) => hub.call('admin', 'hub/blocking/lists/baseline/import', { server })
|
||||
|
||||
it('compares as sets, per server: missing and extra', async () => {
|
||||
await importFrom('a')
|
||||
const r = await hub.call('admin', 'hub/blocking/lists/compare', {}, 'GET')
|
||||
const by = Object.fromEntries(r.body.response.servers.map((s: { id: string }) => [s.id, s]))
|
||||
expect(by.a.status).toBe('same')
|
||||
expect(by.b.status).toBe('differs')
|
||||
expect(by.b.blocked).toMatchObject({ count: 2, missing: 1, extra: 1, missingSample: ['ads.example'], extraSample: ['only-b.example'] })
|
||||
expect(by.b.allowed).toMatchObject({ missing: 0, extra: 1, extraSample: ['extra-allowed.example'] })
|
||||
})
|
||||
|
||||
it('compares with the first server before there is a baseline', async () => {
|
||||
const r = await hub.call('admin', 'hub/blocking/lists/compare', {}, 'GET')
|
||||
expect(r.body.response.withBaseline).toBe(false)
|
||||
expect(r.body.response.servers.map((s: { status: string }) => s.status)).toEqual(['differs', 'differs'])
|
||||
})
|
||||
|
||||
it('marks an unreachable server unknown', async () => {
|
||||
await importFrom('a')
|
||||
b.down = true
|
||||
const r = await hub.call('admin', 'hub/blocking/lists/compare', {}, 'GET')
|
||||
expect(r.body.response.servers.find((s: { id: string }) => s.id === 'b')).toMatchObject({ ok: false, status: 'unknown' })
|
||||
})
|
||||
|
||||
it('adds the missing domains and leaves the extras unless asked', async () => {
|
||||
await importFrom('a')
|
||||
const r = await hub.call('admin', 'hub/blocking/lists/apply', { targets: 'b' })
|
||||
expect(r.body.response.results[0]).toMatchObject({ server: 'b', ok: true })
|
||||
// Extras remain: they are not what the user asked to change.
|
||||
expect(boxB().blocked.sort()).toEqual(['ads.example', 'both.example', 'only-b.example'])
|
||||
expect(writes(b).some((c) => c.path.endsWith('/delete'))).toBe(false)
|
||||
})
|
||||
|
||||
it('removes the extras only when the user asked', async () => {
|
||||
await importFrom('a')
|
||||
b.calls = []
|
||||
const r = await hub.call('admin', 'hub/blocking/lists/apply', { targets: 'b', removeExtras: 'true' })
|
||||
expect(r.body.response.results[0]).toMatchObject({ server: 'b', ok: true })
|
||||
expect(boxB().blocked.sort()).toEqual(['ads.example', 'both.example'])
|
||||
expect(boxB().allowed).toEqual(['ok.example'])
|
||||
expect(writes(b).every((c) => c.token === 'admin-b')).toBe(true)
|
||||
// Before deleting, the server's lists went to the history.
|
||||
const dir = path.join(hub.dataDir, 'settings', 'history')
|
||||
expect(readdirSync(dir).some((f) => f.endsWith('__b__before-lists.txt'))).toBe(true)
|
||||
})
|
||||
|
||||
it('previews the counts and warns when a change is too large', async () => {
|
||||
await importFrom('a')
|
||||
const r = await hub.call('admin', 'hub/blocking/lists/preview', { targets: 'b', removeExtras: 'true' })
|
||||
expect(r.body.response.targets[0]).toMatchObject({ id: 'b', ok: true, status: 'differs' })
|
||||
boxB().blocked = Array.from({ length: 1200 }, (_, i) => `d${i}.example`)
|
||||
const big = await hub.call('admin', 'hub/blocking/lists/preview', { targets: 'b', removeExtras: 'true' })
|
||||
expect(big.body.response.targets[0].tooMany).toMatch(/at most/)
|
||||
const applied = await hub.call('admin', 'hub/blocking/lists/apply', { targets: 'b', removeExtras: 'true' })
|
||||
expect(applied.body.response.results[0].ok).toBe(false)
|
||||
})
|
||||
|
||||
it('is a no-op when the lists already match, and reports a rejected write', async () => {
|
||||
await importFrom('a')
|
||||
const same = await hub.call('admin', 'hub/blocking/lists/apply', { targets: 'a' })
|
||||
expect(same.body.response.results[0].message).toMatch(/Already in sync/)
|
||||
b.on('blocked/add', () => ({ error: 'Access was denied.' }))
|
||||
const r = await hub.call('admin', 'hub/blocking/lists/apply', { targets: 'b' })
|
||||
expect(r.body.response.results[0].ok).toBe(false)
|
||||
expect(r.body.response.results[0].message).toMatch(/failed/)
|
||||
})
|
||||
|
||||
it('refuses to apply without a baseline', async () => {
|
||||
rmSync(path.join(hub.dataDir, 'blocking', 'baseline.json'), { force: true })
|
||||
expect((await hub.call('admin', 'hub/blocking/lists/apply', { targets: 'b' })).code).toBe(400)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,385 @@
|
||||
/*
|
||||
Pure functions behind the settings and blocking compare (stage 7): which fields are never
|
||||
compared, which stay local to a server, how two settings are compared, how a field is encoded for
|
||||
`settings/set`, and how secrets are kept out of every view. No I/O here.
|
||||
|
||||
Three kinds of field:
|
||||
|
||||
* IGNORED: runtime or read-only (`version`, `uptimestamp`, cluster info, the temporary blocking
|
||||
switch, the next list update). Never compared, never sent.
|
||||
* LOCAL: the identity and the bind addresses of the host, certificates and paths, and every
|
||||
`webService*` field. Compared across servers for information, never sent by an apply. The
|
||||
built-in list cannot be shortened; more fields can be added (`overrides.json`).
|
||||
* everything else is SHARED: compared with the baseline and sent by an apply when it differs.
|
||||
|
||||
Secrets (TSIG shared secrets and the forward proxy password, plus the two certificate passwords,
|
||||
which are local anyway) are compared inside the hub by their real value, but no view ever carries
|
||||
a value or a hash of one: a view shows `SECRET_PLACEHOLDER`, and the row says only that the secret
|
||||
differs.
|
||||
*/
|
||||
import { isForbiddenSettingsField } from './guards.ts'
|
||||
|
||||
export const SECRET_PLACEHOLDER = '<secret>'
|
||||
|
||||
/* Read-only or runtime: never compared. */
|
||||
const IGNORED = new Set([
|
||||
'version',
|
||||
'uptimestamp',
|
||||
'clusterInitialized',
|
||||
'clusterNodes',
|
||||
'temporaryDisableBlockingTill',
|
||||
'blockListNextUpdatedOn',
|
||||
])
|
||||
|
||||
export const isIgnoredField = (name: string): boolean => IGNORED.has(name) || /^cluster/i.test(name)
|
||||
|
||||
/*
|
||||
Local by default, and why:
|
||||
dnsServerDomain, dnsServerLocalEndPoints, dnsServerIPv4SourceAddresses,
|
||||
dnsServerIPv6SourceAddresses the identity of the host and the addresses it binds
|
||||
webService* how the hub itself reaches the server (guards.ts refuses them anyway)
|
||||
dnsTlsCertificatePath, dnsTlsCertificatePassword the certificate file of this host
|
||||
dnsOverHttpUnixSocket, dnsOverHttpsUnixSocket, enableDnsOverHttpUnixSocket,
|
||||
enableDnsOverHttpsUnixSocket sockets of this host
|
||||
dnsReverseProxyNetworkACL the address of the reverse proxy in front of this host
|
||||
logFolder a path on this host
|
||||
*/
|
||||
export const LOCAL_DEFAULTS = [
|
||||
'dnsServerDomain',
|
||||
'dnsServerLocalEndPoints',
|
||||
'dnsServerIPv4SourceAddresses',
|
||||
'dnsServerIPv6SourceAddresses',
|
||||
'dnsTlsCertificatePath',
|
||||
'dnsTlsCertificatePassword',
|
||||
'dnsOverHttpUnixSocket',
|
||||
'dnsOverHttpsUnixSocket',
|
||||
'enableDnsOverHttpUnixSocket',
|
||||
'enableDnsOverHttpsUnixSocket',
|
||||
'dnsReverseProxyNetworkACL',
|
||||
'logFolder',
|
||||
] as const
|
||||
|
||||
const LOCAL_SET = new Set<string>(LOCAL_DEFAULTS)
|
||||
|
||||
/* True for a field that is local whatever the overrides say: the built-in list, `webService*`, and anything user, SSO or LDAP. */
|
||||
export const isBuiltInLocal = (name: string): boolean => LOCAL_SET.has(name) || name.startsWith('webService') || isForbiddenSettingsField(name)
|
||||
|
||||
/* The fields of the Blocking group (the rest are General). */
|
||||
export const BLOCKING_FIELDS = [
|
||||
'enableBlocking',
|
||||
'allowTxtBlockingReport',
|
||||
'blockingBypassList',
|
||||
'blockingType',
|
||||
'customBlockingAddresses',
|
||||
'blockingAnswerTtl',
|
||||
'blockListUrls',
|
||||
'blockListUpdateIntervalHours',
|
||||
] as const
|
||||
|
||||
const BLOCKING_SET = new Set<string>(BLOCKING_FIELDS)
|
||||
export const groupOf = (name: string): 'blocking' | 'general' => (BLOCKING_SET.has(name) ? 'blocking' : 'general')
|
||||
|
||||
const PASSWORD_FIELDS = new Set(['webServiceTlsCertificatePassword', 'dnsTlsCertificatePassword'])
|
||||
const FIELD_NAME = /^[A-Za-z][A-Za-z0-9]{0,59}$/
|
||||
|
||||
export const isFieldName = (name: string): boolean => FIELD_NAME.test(name)
|
||||
|
||||
type Settings = Record<string, unknown>
|
||||
|
||||
const isObject = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === 'object' && !Array.isArray(v)
|
||||
|
||||
/* `settings/get` without the ignored fields. */
|
||||
export function normalize(raw: unknown): Settings {
|
||||
const out: Settings = {}
|
||||
if (!isObject(raw)) return out
|
||||
for (const [k, v] of Object.entries(raw)) if (isFieldName(k) && !isIgnoredField(k)) out[k] = v
|
||||
return out
|
||||
}
|
||||
|
||||
/* Whether a field carries a secret anywhere inside it. */
|
||||
export const hasSecret = (name: string): boolean => name === 'tsigKeys' || name === 'proxy' || PASSWORD_FIELDS.has(name)
|
||||
|
||||
/* A copy of a field's value that is safe to send out: every secret replaced by the placeholder. */
|
||||
export function maskValue(name: string, value: unknown): unknown {
|
||||
if (PASSWORD_FIELDS.has(name)) return typeof value === 'string' && value !== '' ? SECRET_PLACEHOLDER : value
|
||||
if (name === 'tsigKeys' && Array.isArray(value)) {
|
||||
return value.map((k) => (isObject(k) ? { ...k, sharedSecret: typeof k.sharedSecret === 'string' && k.sharedSecret !== '' ? SECRET_PLACEHOLDER : k.sharedSecret } : k))
|
||||
}
|
||||
if (name === 'proxy' && isObject(value)) return { ...value, password: typeof value.password === 'string' && value.password !== '' ? SECRET_PLACEHOLDER : value.password }
|
||||
return value
|
||||
}
|
||||
|
||||
export function maskSettings(settings: Settings): Settings {
|
||||
return Object.fromEntries(Object.entries(settings).map(([k, v]) => [k, maskValue(k, v)]))
|
||||
}
|
||||
|
||||
/* ---- Comparing ---- */
|
||||
|
||||
/* A value reduced to what it means: no key order, no difference between absent, null, empty text and an empty list. */
|
||||
function canon(name: string, value: unknown): unknown {
|
||||
if (value === undefined || value === null || value === '') return null
|
||||
if (typeof value === 'string') return value.trim()
|
||||
if (Array.isArray(value)) return value.length === 0 ? null : value.map((v) => canon('', v))
|
||||
if (isObject(value)) {
|
||||
// A proxy of type None carries no other setting.
|
||||
if (name === 'proxy' && String(value.type ?? '').toLowerCase() === 'none') return null
|
||||
return Object.fromEntries(Object.keys(value).sort().map((k) => [k, canon('', value[k])]))
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export const sameValue = (name: string, a: unknown, b: unknown): boolean => JSON.stringify(canon(name, a)) === JSON.stringify(canon(name, b))
|
||||
|
||||
/*
|
||||
Settings ready for comparing: normalised, and without `enableBlocking` while blocking is paused
|
||||
for a while (it is off for now, not by configuration).
|
||||
*/
|
||||
export function prepare(raw: unknown): { settings: Settings; paused: boolean } {
|
||||
const settings = normalize(raw)
|
||||
const paused = isObject(raw) && typeof raw.temporaryDisableBlockingTill === 'string' && raw.temporaryDisableBlockingTill !== ''
|
||||
if (paused) delete settings.enableBlocking
|
||||
return { settings, paused }
|
||||
}
|
||||
|
||||
export interface Cell {
|
||||
value: unknown
|
||||
differs: boolean
|
||||
/* The values look alike once masked: only a secret differs. */
|
||||
secretChanged?: boolean
|
||||
}
|
||||
|
||||
export interface FieldRow {
|
||||
name: string
|
||||
group: 'blocking' | 'general'
|
||||
local: boolean
|
||||
secret: boolean
|
||||
/* The baseline's value (masked), when comparing with a baseline. */
|
||||
expected?: unknown
|
||||
cells: Record<string, Cell>
|
||||
}
|
||||
|
||||
export interface ServerSummary {
|
||||
id: string
|
||||
ok: boolean
|
||||
error?: string
|
||||
/* same, differs, local-only (only local fields differ) or unknown (the server did not answer). */
|
||||
status: 'same' | 'differs' | 'local-only' | 'unknown'
|
||||
differing: number
|
||||
localDiffering: number
|
||||
paused: boolean
|
||||
}
|
||||
|
||||
export interface Comparison {
|
||||
/* True when compared with a baseline, false when the servers are compared with the first that answered. */
|
||||
withBaseline: boolean
|
||||
servers: ServerSummary[]
|
||||
fields: FieldRow[]
|
||||
}
|
||||
|
||||
export interface CompareInput {
|
||||
baseline: Settings | null
|
||||
/** Raw `settings/get` per server; null when it did not answer. */
|
||||
live: Record<string, { settings: unknown } | { error: string }>
|
||||
extraLocal: string[]
|
||||
}
|
||||
|
||||
export function compareSettings({ baseline, live, extraLocal }: CompareInput): Comparison {
|
||||
const isLocal = (name: string): boolean => isBuiltInLocal(name) || extraLocal.includes(name)
|
||||
const base = baseline === null ? null : normalize(baseline)
|
||||
const prepared: Record<string, { settings: Settings; paused: boolean }> = {}
|
||||
const summaries: ServerSummary[] = []
|
||||
for (const [id, answer] of Object.entries(live)) {
|
||||
if ('error' in answer) summaries.push({ id, ok: false, error: answer.error, status: 'unknown', differing: 0, localDiffering: 0, paused: false })
|
||||
else {
|
||||
prepared[id] = prepare(answer.settings)
|
||||
summaries.push({ id, ok: true, status: 'same', differing: 0, localDiffering: 0, paused: prepared[id].paused })
|
||||
}
|
||||
}
|
||||
const ids = Object.keys(prepared)
|
||||
const reference = base ?? (ids.length > 0 ? prepared[ids[0]].settings : null)
|
||||
const names = new Set<string>()
|
||||
if (base !== null) for (const k of Object.keys(base)) names.add(k)
|
||||
for (const id of ids) for (const k of Object.keys(prepared[id].settings)) names.add(k)
|
||||
|
||||
const rows: FieldRow[] = []
|
||||
for (const name of [...names].sort()) {
|
||||
const local = isLocal(name)
|
||||
const holders = ids.filter((id) => Object.hasOwn(prepared[id].settings, name))
|
||||
const cells: Record<string, Cell> = {}
|
||||
let any = false
|
||||
if (local) {
|
||||
// Compared with the baseline's value when it has one, else with the first server that has the field.
|
||||
const useBase = base !== null && Object.hasOwn(base, name)
|
||||
const ref = useBase ? base[name] : holders.length > 0 ? prepared[holders[0]].settings[name] : undefined
|
||||
for (const id of holders) cells[id] = { value: maskValue(name, prepared[id].settings[name]), differs: !sameValue(name, ref, prepared[id].settings[name]) }
|
||||
any = holders.some((id) => cells[id].differs)
|
||||
if (any && !useBase) for (const id of holders) cells[id].differs = true
|
||||
} else {
|
||||
if (reference === null || !Object.hasOwn(reference, name)) continue
|
||||
for (const id of holders) {
|
||||
const differs = !sameValue(name, reference[name], prepared[id].settings[name])
|
||||
const secretChanged = differs && hasSecret(name) && JSON.stringify(maskValue(name, canon(name, reference[name]))) === JSON.stringify(maskValue(name, canon(name, prepared[id].settings[name])))
|
||||
cells[id] = { value: maskValue(name, prepared[id].settings[name]), differs, ...(secretChanged ? { secretChanged: true } : {}) }
|
||||
if (differs) any = true
|
||||
}
|
||||
// Without a baseline the reference itself is one side of the difference.
|
||||
if (any && base === null) cells[ids[0]].differs = true
|
||||
}
|
||||
if (!any) continue
|
||||
rows.push({
|
||||
name,
|
||||
group: groupOf(name),
|
||||
local,
|
||||
secret: hasSecret(name),
|
||||
...(base !== null && !local ? { expected: maskValue(name, base[name]) } : {}),
|
||||
cells,
|
||||
})
|
||||
}
|
||||
for (const s of summaries) {
|
||||
if (!s.ok) continue
|
||||
s.differing = rows.filter((r) => !r.local && r.cells[s.id]?.differs === true && (base !== null || s.id !== ids[0])).length
|
||||
s.localDiffering = rows.filter((r) => r.local && r.cells[s.id]?.differs === true).length
|
||||
s.status = s.differing > 0 ? 'differs' : s.localDiffering > 0 ? 'local-only' : 'same'
|
||||
}
|
||||
// Without a baseline the first server is the reference: it differs only if another does.
|
||||
if (base === null && ids.length > 0) {
|
||||
const first = summaries.find((s) => s.id === ids[0])!
|
||||
const others = summaries.some((s) => s.ok && s.id !== first.id && s.differing > 0)
|
||||
first.differing = rows.filter((r) => !r.local).length
|
||||
first.status = others ? 'differs' : first.localDiffering > 0 ? 'local-only' : 'same'
|
||||
}
|
||||
return { withBaseline: base !== null, servers: summaries, fields: rows }
|
||||
}
|
||||
|
||||
/*
|
||||
The shared fields an apply would send to a server: in the baseline and reported by the server,
|
||||
different, not local, not forbidden. Sorted. A field only one side has is left alone.
|
||||
*/
|
||||
export function changedFields(baseline: unknown, current: unknown, extraLocal: string[], only?: readonly string[]): string[] {
|
||||
const base = normalize(baseline)
|
||||
const { settings } = prepare(current)
|
||||
const out: string[] = []
|
||||
for (const name of Object.keys(base)) {
|
||||
if (isBuiltInLocal(name) || extraLocal.includes(name)) continue
|
||||
if (only !== undefined && !only.includes(name)) continue
|
||||
if (!Object.hasOwn(settings, name)) continue
|
||||
if (!sameValue(name, base[name], settings[name])) out.push(name)
|
||||
}
|
||||
return out.sort()
|
||||
}
|
||||
|
||||
/* ---- Encoding for `settings/set` (the same as the console's own save, model.ts `buildBody`) ---- */
|
||||
|
||||
const text = (v: unknown): string => (v === null || v === undefined ? '' : String(v))
|
||||
|
||||
function table(rows: unknown, keys: string[]): string | null {
|
||||
if (!Array.isArray(rows)) return rows === null || rows === undefined ? 'false' : null
|
||||
const cells: string[] = []
|
||||
for (const row of rows) {
|
||||
if (!isObject(row)) return null
|
||||
for (const k of keys) {
|
||||
const cell = text(row[k])
|
||||
if (cell.includes('|')) return null
|
||||
cells.push(cell)
|
||||
}
|
||||
}
|
||||
return cells.length === 0 ? 'false' : cells.join('|')
|
||||
}
|
||||
|
||||
/* The form parameters for one field, or an error that says why it cannot be sent. */
|
||||
export function toParams(name: string, value: unknown): { params: Record<string, string> } | { error: string } {
|
||||
if (!isFieldName(name)) return { error: `"${name}" is not a setting name.` }
|
||||
if (name === 'proxy') {
|
||||
if (!isObject(value) || String(value.type ?? '').toLowerCase() === 'none' || String(value.type ?? '') === '') return { params: { proxyType: 'none' } }
|
||||
const bypass = Array.isArray(value.bypass) ? value.bypass.map(String) : []
|
||||
if (bypass.some((b) => b.includes(','))) return { error: 'A proxy bypass entry contains a comma.' }
|
||||
return {
|
||||
params: {
|
||||
proxyType: String(value.type).toLowerCase(),
|
||||
proxyAddress: text(value.address),
|
||||
proxyPort: text(value.port),
|
||||
proxyUsername: text(value.username),
|
||||
proxyPassword: text(value.password),
|
||||
proxyBypass: bypass.join(','),
|
||||
},
|
||||
}
|
||||
}
|
||||
if (name === 'tsigKeys') {
|
||||
const t = table(value, ['keyName', 'sharedSecret', 'algorithmName'])
|
||||
return t === null ? { error: 'A TSIG key cannot be sent (a "|" in it, or a malformed list).' } : { params: { tsigKeys: t } }
|
||||
}
|
||||
if (name === 'qpmPrefixLimitsIPv4' || name === 'qpmPrefixLimitsIPv6') {
|
||||
const t = table(value, ['prefix', 'udpLimit', 'tcpLimit'])
|
||||
return t === null ? { error: `${name} cannot be sent (a malformed list).` } : { params: { [name]: t } }
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const items = value.map((v) => text(v).trim()).filter((v) => v !== '')
|
||||
if (items.some((i) => i.includes(',') || i.includes('\n'))) return { error: `An entry of ${name} contains a comma or a line break, which the server takes as a separator.` }
|
||||
return { params: { [name]: items.length === 0 ? 'false' : items.join(',') } }
|
||||
}
|
||||
if (isObject(value)) return { error: `${name} has a shape the hub does not send.` }
|
||||
if (typeof value === 'boolean' || typeof value === 'number') return { params: { [name]: String(value) } }
|
||||
return { params: { [name]: text(value) } }
|
||||
}
|
||||
|
||||
/* ---- Baseline editing ---- */
|
||||
|
||||
/*
|
||||
A baseline coming back from the editor shows secrets as the placeholder. Each placeholder stands for
|
||||
the secret the stored baseline already has (a TSIG key is found by its name); anything else is a new
|
||||
secret and replaces it. A placeholder with nothing to stand for is refused, so a save can never
|
||||
write the placeholder over a real key.
|
||||
*/
|
||||
export function restoreSecrets(next: Settings, old: Settings | null): Settings {
|
||||
const out: Settings = structuredClone(next)
|
||||
const fail = (what: string): never => {
|
||||
throw new Error(`The secret of ${what} is shown as ${SECRET_PLACEHOLDER} but the stored baseline has none to keep. Type the value or remove the entry.`)
|
||||
}
|
||||
for (const name of PASSWORD_FIELDS) {
|
||||
if (out[name] === SECRET_PLACEHOLDER) {
|
||||
const was = old?.[name]
|
||||
out[name] = typeof was === 'string' ? was : fail(name)
|
||||
}
|
||||
}
|
||||
if (Array.isArray(out.tsigKeys)) {
|
||||
const oldKeys = Array.isArray(old?.tsigKeys) ? (old.tsigKeys as unknown[]).filter(isObject) : []
|
||||
out.tsigKeys = out.tsigKeys.map((k: unknown) => {
|
||||
if (!isObject(k) || k.sharedSecret !== SECRET_PLACEHOLDER) return k
|
||||
const was = oldKeys.find((o) => o.keyName === k.keyName)
|
||||
return { ...k, sharedSecret: typeof was?.sharedSecret === 'string' ? was.sharedSecret : fail(`the TSIG key ${text(k.keyName)}`) }
|
||||
})
|
||||
}
|
||||
if (isObject(out.proxy) && out.proxy.password === SECRET_PLACEHOLDER) {
|
||||
const was = isObject(old?.proxy) ? old.proxy.password : undefined
|
||||
out.proxy = { ...out.proxy, password: typeof was === 'string' ? was : fail('the proxy') }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/* ---- Blocked and Allowed lists ---- */
|
||||
|
||||
export const MAX_LIST = 5000
|
||||
export const MAX_DELETES = 500
|
||||
export const IMPORT_CHUNK = 500
|
||||
|
||||
/* The domains of an export: lower case, no trailing dot, no blank or `#` line, once each, sorted. */
|
||||
export function parseDomains(textBody: string): string[] {
|
||||
const set = new Set<string>()
|
||||
for (const line of textBody.split(/\r?\n/)) {
|
||||
const t = line.trim().toLowerCase().replace(/\.$/, '')
|
||||
if (t !== '' && !t.startsWith('#')) set.add(t)
|
||||
}
|
||||
return [...set].sort()
|
||||
}
|
||||
|
||||
export interface SetDiff {
|
||||
/* In the baseline, not on the server. */
|
||||
missing: string[]
|
||||
/* On the server, not in the baseline. */
|
||||
extra: string[]
|
||||
}
|
||||
|
||||
export function diffSets(baseline: readonly string[], live: readonly string[]): SetDiff {
|
||||
const [b, l] = [new Set(baseline), new Set(live)]
|
||||
return { missing: baseline.filter((d) => !l.has(d)), extra: live.filter((d) => !b.has(d)) }
|
||||
}
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
/*
|
||||
What the hub keeps per app under `${HUB_DATA}/apps/<app>/`:
|
||||
|
||||
baseline.json the desired config, as TEXT (some configs are not JSON)
|
||||
overrides.json the fields that stay local to each server, and their values
|
||||
history/ the config a server had before the hub changed it (50 files kept)
|
||||
|
||||
Every write is a temp file and a rename, mode 0600: these files hold configs.
|
||||
*/
|
||||
import { createHash } from 'node:crypto'
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
export const KEEP_HISTORY = 50
|
||||
|
||||
/* A folder name for an app: letters, digits and . _ - only, with a short hash when anything had to change. */
|
||||
export function safeName(name: string): string {
|
||||
const cleaned = name.replace(/[^A-Za-z0-9._-]/g, '_').replace(/\.{2,}/g, '_').replace(/^\.+/, '_').slice(0, 80)
|
||||
if (cleaned === name && cleaned !== '') return cleaned
|
||||
return `${cleaned === '' ? '_' : cleaned}-${createHash('sha256').update(name).digest('hex').slice(0, 6)}`
|
||||
}
|
||||
|
||||
export const appDir = (dataDir: string, app: string): string => path.join(dataDir, 'apps', safeName(app))
|
||||
|
||||
export function writeAtomic(file: string, text: string): void {
|
||||
mkdirSync(path.dirname(file), { recursive: true })
|
||||
const temp = `${file}.${process.pid}.tmp`
|
||||
try {
|
||||
writeFileSync(temp, text, { mode: 0o600 })
|
||||
renameSync(temp, file)
|
||||
} catch (e) {
|
||||
rmSync(temp, { force: true })
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
export interface Baseline {
|
||||
app: string
|
||||
text: string
|
||||
importedFrom?: string
|
||||
updatedAt: string
|
||||
updatedBy: string
|
||||
}
|
||||
|
||||
export interface Overrides {
|
||||
/** Dot paths (`a.b.0.c`) of the fields that are not synchronised. */
|
||||
paths: string[]
|
||||
/** server -> path -> value */
|
||||
values: Record<string, Record<string, unknown>>
|
||||
}
|
||||
|
||||
export const EMPTY_OVERRIDES: Overrides = { paths: [], values: {} }
|
||||
|
||||
function readJson(file: string): unknown {
|
||||
if (!existsSync(file)) return null
|
||||
try {
|
||||
return JSON.parse(readFileSync(file, 'utf8'))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function readBaseline(dataDir: string, app: string): Baseline | null {
|
||||
const data = readJson(path.join(appDir(dataDir, app), 'baseline.json')) as Partial<Baseline> | null
|
||||
if (data === null || typeof data.text !== 'string') return null
|
||||
return {
|
||||
app,
|
||||
text: data.text,
|
||||
...(typeof data.importedFrom === 'string' ? { importedFrom: data.importedFrom } : {}),
|
||||
updatedAt: String(data.updatedAt ?? ''),
|
||||
updatedBy: String(data.updatedBy ?? ''),
|
||||
}
|
||||
}
|
||||
|
||||
export function writeBaseline(dataDir: string, baseline: Baseline): void {
|
||||
writeAtomic(path.join(appDir(dataDir, baseline.app), 'baseline.json'), JSON.stringify(baseline, null, 2) + '\n')
|
||||
}
|
||||
|
||||
export function readOverrides(dataDir: string, app: string): Overrides {
|
||||
const data = readJson(path.join(appDir(dataDir, app), 'overrides.json')) as Partial<Overrides> | null
|
||||
if (data === null || !Array.isArray(data.paths)) return { paths: [], values: {} }
|
||||
const values = data.values !== null && typeof data.values === 'object' ? data.values : {}
|
||||
return { paths: data.paths.filter((p): p is string => typeof p === 'string'), values }
|
||||
}
|
||||
|
||||
export function writeOverrides(dataDir: string, app: string, overrides: Overrides): void {
|
||||
writeAtomic(path.join(appDir(dataDir, app), 'overrides.json'), JSON.stringify(overrides, null, 2) + '\n')
|
||||
}
|
||||
|
||||
export type HistoryKind = 'before-uninstall' | 'before-apply' | 'before-set' | 'before-save' | 'before-lists'
|
||||
|
||||
export interface HistoryEntry {
|
||||
/** The file name; the only handle `readHistory` accepts. */
|
||||
id: string
|
||||
at: string
|
||||
/** A server id, or `baseline` for an earlier text of the baseline. */
|
||||
server: string
|
||||
kind: string
|
||||
size: number
|
||||
hash: string
|
||||
}
|
||||
|
||||
const HISTORY_FILE = /^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2}\.\d{3}Z)__(.+)__(before-[a-z-]+)\.txt$/
|
||||
|
||||
const historyDir = (dataDir: string, app: string): string => path.join(appDir(dataDir, app), 'history')
|
||||
|
||||
export const listHistory = (dataDir: string, app: string): HistoryEntry[] => listHistoryIn(historyDir(dataDir, app))
|
||||
|
||||
/* Newest first. Never the text. `dir` is a history folder. */
|
||||
export function listHistoryIn(dir: string): HistoryEntry[] {
|
||||
if (!existsSync(dir)) return []
|
||||
const entries: HistoryEntry[] = []
|
||||
for (const name of readdirSync(dir)) {
|
||||
const m = HISTORY_FILE.exec(name)
|
||||
if (m === null) continue
|
||||
try {
|
||||
const text = readFileSync(path.join(dir, name), 'utf8')
|
||||
entries.push({
|
||||
id: name,
|
||||
at: `${m[1]}T${m[2]}:${m[3]}:${m[4]}`,
|
||||
server: m[5],
|
||||
kind: m[6],
|
||||
size: Buffer.byteLength(text),
|
||||
hash: createHash('sha256').update(text).digest('hex').slice(0, 16),
|
||||
})
|
||||
} catch {
|
||||
// A file that vanished while listing is skipped.
|
||||
}
|
||||
}
|
||||
return entries.sort((a, b) => (a.id < b.id ? 1 : a.id > b.id ? -1 : 0))
|
||||
}
|
||||
|
||||
/* The text of one entry. `id` has to be a name the listing returns, so nothing outside the folder can be read. */
|
||||
export const readHistory = (dataDir: string, app: string, id: string): string | null => readHistoryIn(historyDir(dataDir, app), id)
|
||||
|
||||
export function readHistoryIn(dir: string, id: string): string | null {
|
||||
const known = listHistoryIn(dir).find((e) => e.id === id)
|
||||
return known === undefined ? null : readFileSync(path.join(dir, known.id), 'utf8')
|
||||
}
|
||||
|
||||
/* The apps that have a baseline, by the name stored in the file. */
|
||||
export function listBaselineApps(dataDir: string): string[] {
|
||||
const root = path.join(dataDir, 'apps')
|
||||
if (!existsSync(root)) return []
|
||||
const apps: string[] = []
|
||||
for (const dir of readdirSync(root)) {
|
||||
try {
|
||||
if (!statSync(path.join(root, dir)).isDirectory()) continue
|
||||
const data = readJson(path.join(root, dir, 'baseline.json')) as Partial<Baseline> | null
|
||||
if (data !== null && typeof data.app === 'string' && typeof data.text === 'string') apps.push(data.app)
|
||||
} catch {
|
||||
// Not an app folder.
|
||||
}
|
||||
}
|
||||
return apps.sort((a, b) => a.localeCompare(b))
|
||||
}
|
||||
|
||||
/*
|
||||
Saves a server's config as it was before the hub changed it. The name starts with the time, so
|
||||
sorting the names sorts the files by age; the oldest beyond KEEP_HISTORY are deleted.
|
||||
*/
|
||||
export function writeHistory(
|
||||
dataDir: string,
|
||||
app: string,
|
||||
server: string,
|
||||
text: string,
|
||||
kind: HistoryKind,
|
||||
now: number,
|
||||
): string {
|
||||
return writeHistoryIn(historyDir(dataDir, app), server, text, kind, now)
|
||||
}
|
||||
|
||||
export function writeHistoryIn(dir: string, server: string, text: string, kind: HistoryKind, now: number): string {
|
||||
const stamp = new Date(now).toISOString().replace(/:/g, '-')
|
||||
const file = path.join(dir, `${stamp}__${safeName(server)}__${kind}.txt`)
|
||||
writeAtomic(file, text)
|
||||
const files = readdirSync(dir).filter((f) => f.endsWith('.txt')).sort()
|
||||
for (const old of files.slice(0, Math.max(0, files.length - KEEP_HISTORY))) rmSync(path.join(dir, old), { force: true })
|
||||
return file
|
||||
}
|
||||
|
||||
/* ---- Settings and blocking lists (stage 7): `${HUB_DATA}/settings/` and `${HUB_DATA}/blocking/` ---- */
|
||||
|
||||
export const settingsDir = (dataDir: string): string => path.join(dataDir, 'settings')
|
||||
export const settingsHistoryDir = (dataDir: string): string => path.join(settingsDir(dataDir), 'history')
|
||||
export const blockingDir = (dataDir: string): string => path.join(dataDir, 'blocking')
|
||||
|
||||
export interface SettingsBaseline {
|
||||
/** The settings as `settings/get` gave them, secrets included (the file is 0600). */
|
||||
settings: Record<string, unknown>
|
||||
importedFrom?: string
|
||||
updatedAt: string
|
||||
updatedBy: string
|
||||
}
|
||||
|
||||
/* Extra local fields, on top of the built-in ones. Values are read live from the servers, not stored. */
|
||||
export interface SettingsOverrides {
|
||||
paths: string[]
|
||||
}
|
||||
|
||||
export function readSettingsBaseline(dataDir: string): SettingsBaseline | null {
|
||||
const data = readJson(path.join(settingsDir(dataDir), 'baseline.json')) as Partial<SettingsBaseline> | null
|
||||
if (data === null || data.settings === null || typeof data.settings !== 'object' || Array.isArray(data.settings)) return null
|
||||
return {
|
||||
settings: data.settings,
|
||||
...(typeof data.importedFrom === 'string' ? { importedFrom: data.importedFrom } : {}),
|
||||
updatedAt: String(data.updatedAt ?? ''),
|
||||
updatedBy: String(data.updatedBy ?? ''),
|
||||
}
|
||||
}
|
||||
|
||||
export const writeSettingsBaseline = (dataDir: string, baseline: SettingsBaseline): void =>
|
||||
writeAtomic(path.join(settingsDir(dataDir), 'baseline.json'), JSON.stringify(baseline, null, 2) + '\n')
|
||||
|
||||
export function readSettingsOverrides(dataDir: string): SettingsOverrides {
|
||||
const data = readJson(path.join(settingsDir(dataDir), 'overrides.json')) as Partial<SettingsOverrides> | null
|
||||
return { paths: Array.isArray(data?.paths) ? data.paths.filter((p): p is string => typeof p === 'string') : [] }
|
||||
}
|
||||
|
||||
export const writeSettingsOverrides = (dataDir: string, overrides: SettingsOverrides): void =>
|
||||
writeAtomic(path.join(settingsDir(dataDir), 'overrides.json'), JSON.stringify(overrides, null, 2) + '\n')
|
||||
|
||||
export interface ListsBaseline {
|
||||
blocked: string[]
|
||||
allowed: string[]
|
||||
importedFrom?: string
|
||||
updatedAt: string
|
||||
updatedBy: string
|
||||
}
|
||||
|
||||
export function readListsBaseline(dataDir: string): ListsBaseline | null {
|
||||
const data = readJson(path.join(blockingDir(dataDir), 'baseline.json')) as Partial<ListsBaseline> | null
|
||||
if (data === null || !Array.isArray(data.blocked) || !Array.isArray(data.allowed)) return null
|
||||
const strings = (v: unknown[]): string[] => v.filter((x): x is string => typeof x === 'string')
|
||||
return {
|
||||
blocked: strings(data.blocked),
|
||||
allowed: strings(data.allowed),
|
||||
...(typeof data.importedFrom === 'string' ? { importedFrom: data.importedFrom } : {}),
|
||||
updatedAt: String(data.updatedAt ?? ''),
|
||||
updatedBy: String(data.updatedBy ?? ''),
|
||||
}
|
||||
}
|
||||
|
||||
export const writeListsBaseline = (dataDir: string, baseline: ListsBaseline): void =>
|
||||
writeAtomic(path.join(blockingDir(dataDir), 'baseline.json'), JSON.stringify(baseline, null, 2) + '\n')
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "../node_modules/.tmp/tsconfig.hub.tsbuildinfo",
|
||||
"target": "esnext",
|
||||
"lib": ["ESNext"],
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* The hub runs under `node --experimental-strip-types`. */
|
||||
"module": "nodenext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["*.ts"]
|
||||
}
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
/*
|
||||
Calls to the real Technitium servers: `GET <url>/api/<path>?<params>&token=<token>`.
|
||||
Successful answers are cached for a few seconds, so a dashboard that refreshes every
|
||||
minute (and several people looking at it) does not load the servers.
|
||||
*/
|
||||
import type { HubConfig, HubServer } from './config.ts'
|
||||
|
||||
/*
|
||||
`unreachable`: no answer (network, timeout, not JSON). `rejected`: the server answered
|
||||
with a status other than `ok`. The hub never relays the server's `invalid-token`, or
|
||||
the console would sign the hub's own user out because a hub token expired.
|
||||
*/
|
||||
export class UpstreamError extends Error {
|
||||
kind: 'unreachable' | 'rejected'
|
||||
constructor(kind: 'unreachable' | 'rejected', message: string) {
|
||||
super(message)
|
||||
this.name = 'UpstreamError'
|
||||
this.kind = kind
|
||||
}
|
||||
}
|
||||
|
||||
export type Params = Record<string, string>
|
||||
|
||||
/* What `raw` hands back: the headers worth passing on and the body as a stream. */
|
||||
export interface RawAnswer {
|
||||
contentType: string
|
||||
disposition?: string
|
||||
body: ReadableStream<Uint8Array> | null
|
||||
/* Stops the download (the caller went away). */
|
||||
abort: () => void
|
||||
}
|
||||
|
||||
interface Cached {
|
||||
until: number
|
||||
response: unknown
|
||||
}
|
||||
|
||||
const MAX_CACHED = 500
|
||||
|
||||
/* An app download or upload can take a while. */
|
||||
const WRITE_TIMEOUT_MS = 60_000
|
||||
|
||||
export class Upstream {
|
||||
#config: HubConfig
|
||||
#fetch: typeof fetch
|
||||
#cache = new Map<string, Cached>()
|
||||
#now: () => number
|
||||
|
||||
constructor(config: HubConfig, fetchImpl: typeof fetch = fetch, now: () => number = Date.now) {
|
||||
this.#config = config
|
||||
this.#fetch = fetchImpl
|
||||
this.#now = now
|
||||
}
|
||||
|
||||
/** The `response` of the server's envelope, or an `UpstreamError`. */
|
||||
async call(server: HubServer, path: string, params: Params = {}, token: string = server.token): Promise<unknown> {
|
||||
const sorted = Object.entries(params).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
||||
const key = `${server.id}\n${path}\n${new URLSearchParams(sorted).toString()}`
|
||||
const hit = this.#cache.get(key)
|
||||
if (hit !== undefined && hit.until > this.#now()) return hit.response
|
||||
|
||||
const response = await this.#exec(server, path, new URLSearchParams(sorted), token, { method: 'GET' }, this.#config.timeoutMs)
|
||||
if (this.#config.cacheSeconds > 0) {
|
||||
if (this.#cache.size >= MAX_CACHED) this.#prune()
|
||||
this.#cache.set(key, { until: this.#now() + this.#config.cacheSeconds * 1000, response })
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
/*
|
||||
A write: POST with the server's ADMIN token and never the read one, never cached. Whatever
|
||||
the outcome, what the hub remembers of this server is dropped afterwards, so the next read
|
||||
shows the state the write left.
|
||||
|
||||
The parameters travel in a form body (`application/x-www-form-urlencoded`) and only `token`
|
||||
stays in the query, so values (a record's data, a TSIG secret) do not end up in a proxy's
|
||||
access log. The server reads query or form alike. A `body` that is already a form gets the
|
||||
parameters added to it. Two bodies cannot be a form, so there the parameters stay in the
|
||||
query: a `FormData` (a multipart upload, the app zip or a zone file) and a string (a zone
|
||||
file as `text/plain`).
|
||||
*/
|
||||
async write(server: HubServer, path: string, params: Params = {}, body?: FormData | URLSearchParams | string): Promise<unknown> {
|
||||
if (server.adminToken === undefined) throw new UpstreamError('rejected', 'no admin token configured')
|
||||
let query = new URLSearchParams()
|
||||
const init: RequestInit = { method: 'POST' }
|
||||
if (body === undefined || body instanceof URLSearchParams) {
|
||||
const form = new URLSearchParams(body)
|
||||
for (const [k, v] of Object.entries(params)) form.set(k, v)
|
||||
init.body = form
|
||||
} else {
|
||||
query = new URLSearchParams(params)
|
||||
init.body = body
|
||||
if (typeof body === 'string') init.headers = { 'content-type': 'text/plain; charset=utf-8' }
|
||||
}
|
||||
try {
|
||||
return await this.#exec(server, path, query, server.adminToken, init, Math.max(this.#config.timeoutMs, WRITE_TIMEOUT_MS))
|
||||
} finally {
|
||||
this.invalidate(server.id)
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
A file the server answers with (a zone export, a log, a block list): read with the READ token,
|
||||
never cached. A JSON answer is the usual envelope, so an error is raised as for any call; any
|
||||
other answer is handed back as it is, to be streamed to the caller. The timeout covers the wait
|
||||
for the headers only: a big log may take longer to arrive.
|
||||
*/
|
||||
async raw(server: HubServer, path: string, params: Params = {}): Promise<RawAnswer> {
|
||||
const query = new URLSearchParams(params)
|
||||
query.set('token', server.token)
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), this.#config.timeoutMs)
|
||||
let res: Response
|
||||
try {
|
||||
res = await this.#fetch(`${server.url}/api/${path}?${query.toString()}`, { method: 'GET', signal: controller.signal })
|
||||
} catch (e) {
|
||||
throw new UpstreamError('unreachable', this.#describe(e, [server.token], this.#config.timeoutMs))
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
const type = res.headers.get('content-type') ?? 'application/octet-stream'
|
||||
if (/json/i.test(type)) {
|
||||
let envelope: { status?: string; response?: unknown; errorMessage?: string }
|
||||
try {
|
||||
envelope = (await res.json()) as typeof envelope
|
||||
} catch (e) {
|
||||
throw new UpstreamError('unreachable', this.#describe(e, [server.token], this.#config.timeoutMs))
|
||||
}
|
||||
throw envelope?.status === 'ok' ? new UpstreamError('rejected', 'it sent JSON where a file was expected') : this.#rejection(envelope)
|
||||
}
|
||||
if (!res.ok) throw new UpstreamError('rejected', `status ${res.status}`)
|
||||
return {
|
||||
contentType: type,
|
||||
disposition: res.headers.get('content-disposition') ?? undefined,
|
||||
body: res.body,
|
||||
abort: () => controller.abort(),
|
||||
}
|
||||
}
|
||||
|
||||
/** Forget every cached answer of one server. */
|
||||
invalidate(serverId: string): void {
|
||||
const prefix = `${serverId}\n`
|
||||
for (const key of this.#cache.keys()) if (key.startsWith(prefix)) this.#cache.delete(key)
|
||||
}
|
||||
|
||||
async #exec(server: HubServer, path: string, query: URLSearchParams, token: string, init: RequestInit, timeoutMs: number): Promise<unknown> {
|
||||
query.set('token', token)
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
||||
|
||||
let envelope: { status?: string; response?: unknown; errorMessage?: string }
|
||||
try {
|
||||
const res = await this.#fetch(`${server.url}/api/${path}?${query.toString()}`, {
|
||||
...init,
|
||||
signal: controller.signal,
|
||||
headers: { accept: 'application/json', ...(init.headers as Record<string, string> | undefined) },
|
||||
})
|
||||
envelope = (await res.json()) as typeof envelope
|
||||
} catch (e) {
|
||||
throw new UpstreamError('unreachable', this.#describe(e, [server.token, token], timeoutMs))
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
|
||||
if (envelope?.status !== 'ok') throw this.#rejection(envelope)
|
||||
return envelope.response
|
||||
}
|
||||
|
||||
/* The error for an envelope that is not `ok`. */
|
||||
#rejection(envelope: { status?: string; errorMessage?: string } | undefined): UpstreamError {
|
||||
const why =
|
||||
envelope?.status === 'invalid-token'
|
||||
? 'its API token was refused'
|
||||
: (envelope?.errorMessage ?? `status ${String(envelope?.status)}`)
|
||||
return new UpstreamError('rejected', why)
|
||||
}
|
||||
|
||||
#prune(): void {
|
||||
const now = this.#now()
|
||||
for (const [key, entry] of this.#cache) if (entry.until <= now) this.#cache.delete(key)
|
||||
// Still full of live entries: drop the oldest inserted.
|
||||
while (this.#cache.size >= MAX_CACHED) this.#cache.delete(this.#cache.keys().next().value as string)
|
||||
}
|
||||
|
||||
/* A short reason without anything that could carry the token. */
|
||||
#describe(e: unknown, secrets: string[], timeoutMs: number): string {
|
||||
if (e instanceof Error && e.name === 'AbortError') return `timed out after ${timeoutMs} ms`
|
||||
const cause = e instanceof Error ? (e.cause as { code?: unknown } | undefined) : undefined
|
||||
const text = typeof cause?.code === 'string' ? cause.code : e instanceof Error ? e.message : 'request failed'
|
||||
return secrets.reduce((out, secret) => (secret === '' ? out : out.split(secret).join('***')), text)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/*
|
||||
Manage the hub's users file.
|
||||
|
||||
node --experimental-strip-types hub/users-cli.ts add <name> <role>
|
||||
node --experimental-strip-types hub/users-cli.ts passwd <name>
|
||||
node --experimental-strip-types hub/users-cli.ts role <name> <role>
|
||||
node --experimental-strip-types hub/users-cli.ts disable <name>
|
||||
node --experimental-strip-types hub/users-cli.ts enable <name>
|
||||
node --experimental-strip-types hub/users-cli.ts list
|
||||
|
||||
The file is `${HUB_DATA}/users.json` (default /data). On a terminal the password is asked
|
||||
twice without echo; in a script, put it in HUB_NEW_PASSWORD. In the container:
|
||||
|
||||
docker exec -it technitium-hub node --experimental-strip-types /app/hub/users-cli.ts add alice apps-admin
|
||||
*/
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import {
|
||||
ROLES,
|
||||
addUser,
|
||||
describeUsers,
|
||||
readUsersFile,
|
||||
setDisabled,
|
||||
setPassword,
|
||||
setRole,
|
||||
writeUsersFile,
|
||||
type UserRecord,
|
||||
} from './users.ts'
|
||||
|
||||
const USAGE = `Usage: users-cli <command>
|
||||
add <name> <role> roles: ${ROLES.join(', ')}
|
||||
passwd <name>
|
||||
role <name> <role>
|
||||
disable <name>
|
||||
enable <name>
|
||||
list
|
||||
The password is asked twice, or taken from HUB_NEW_PASSWORD.`
|
||||
|
||||
type Env = Record<string, string | undefined>
|
||||
|
||||
/* Reads one line from the terminal without echoing it. */
|
||||
function askHidden(prompt: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const stdin = process.stdin
|
||||
process.stderr.write(prompt)
|
||||
let line = ''
|
||||
stdin.setRawMode(true)
|
||||
stdin.resume()
|
||||
stdin.setEncoding('utf8')
|
||||
const done = (): void => {
|
||||
stdin.setRawMode(false)
|
||||
stdin.pause()
|
||||
stdin.off('data', onData)
|
||||
process.stderr.write('\n')
|
||||
}
|
||||
const onData = (chunk: string): void => {
|
||||
for (const ch of chunk) {
|
||||
if (ch === '\r' || ch === '\n') {
|
||||
done()
|
||||
resolve(line)
|
||||
return
|
||||
}
|
||||
if (ch === '\u0003') {
|
||||
done()
|
||||
reject(new Error('Cancelled.'))
|
||||
return
|
||||
}
|
||||
if (ch === '\u007f' || ch === '\b') line = line.slice(0, -1)
|
||||
else line += ch
|
||||
}
|
||||
}
|
||||
stdin.on('data', onData)
|
||||
})
|
||||
}
|
||||
|
||||
async function newPassword(env: Env): Promise<string> {
|
||||
if (env.HUB_NEW_PASSWORD !== undefined && env.HUB_NEW_PASSWORD !== '') return env.HUB_NEW_PASSWORD
|
||||
if (!process.stdin.isTTY) throw new Error('No terminal: set HUB_NEW_PASSWORD.')
|
||||
const first = await askHidden('New password: ')
|
||||
const second = await askHidden('Repeat it: ')
|
||||
if (first !== second) throw new Error('The passwords differ.')
|
||||
return first
|
||||
}
|
||||
|
||||
/*
|
||||
Runs one command against the users in `dir` and returns the lines to print. `password`
|
||||
supplies the password for add and passwd, so the tests need no terminal.
|
||||
*/
|
||||
export async function run(
|
||||
args: string[],
|
||||
dir: string,
|
||||
password: () => Promise<string>,
|
||||
): Promise<string[]> {
|
||||
const [command, name, extra] = args
|
||||
const users: UserRecord[] = readUsersFile(dir) ?? []
|
||||
const need = (value: string | undefined, what: string): string => {
|
||||
if (value === undefined || value === '') throw new Error(`Missing ${what}.\n${USAGE}`)
|
||||
return value
|
||||
}
|
||||
switch (command) {
|
||||
case 'add':
|
||||
writeUsersFile(dir, addUser(users, need(name, 'name'), need(extra, 'role'), await password()))
|
||||
return [`Added ${name} (${extra}).`]
|
||||
case 'passwd':
|
||||
writeUsersFile(dir, setPassword(users, need(name, 'name'), await password()))
|
||||
return [`Password of ${name} changed. Their sessions end on their next request.`]
|
||||
case 'role':
|
||||
writeUsersFile(dir, setRole(users, need(name, 'name'), need(extra, 'role')))
|
||||
return [`${name} is now ${extra}.`]
|
||||
case 'disable':
|
||||
writeUsersFile(dir, setDisabled(users, need(name, 'name'), true))
|
||||
return [`Disabled ${name}.`]
|
||||
case 'enable':
|
||||
writeUsersFile(dir, setDisabled(users, need(name, 'name'), false))
|
||||
return [`Enabled ${name}.`]
|
||||
case 'list':
|
||||
return users.length === 0 ? ['No users.'] : describeUsers(users)
|
||||
default:
|
||||
throw new Error(USAGE)
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] !== undefined && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
const dir = process.env.HUB_DATA?.trim() || '/data'
|
||||
run(process.argv.slice(2), dir, () => newPassword(process.env)).then(
|
||||
(lines) => {
|
||||
for (const line of lines) console.log(line)
|
||||
},
|
||||
(e: unknown) => {
|
||||
console.error(`users: ${e instanceof Error ? e.message : String(e)}`)
|
||||
process.exit(1)
|
||||
},
|
||||
)
|
||||
}
|
||||
+299
@@ -0,0 +1,299 @@
|
||||
/*
|
||||
The hub's own accounts: roles, password hashes and the users file.
|
||||
|
||||
`${HUB_DATA}/users.json` holds `{ users: [{ name, role, hash, disabled? }] }`, where hash is
|
||||
`scrypt$N$r$p$saltB64$keyB64`. The pure functions below are what the CLI (users-cli.ts)
|
||||
and the tests call; `Users` is what the running hub asks.
|
||||
*/
|
||||
import { randomBytes, scrypt, scryptSync, timingSafeEqual } from 'node:crypto'
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync, chmodSync, rmSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import type { Bootstrap } from './config.ts'
|
||||
|
||||
export type Role = 'viewer' | 'operator' | 'apps-admin' | 'admin'
|
||||
export type Capability = 'read' | 'apps.write' | 'operate' | 'audit.read' | 'admin'
|
||||
|
||||
export const ROLES: readonly Role[] = ['viewer', 'operator', 'apps-admin', 'admin']
|
||||
|
||||
/* A simple rank. operator and apps-admin are siblings: use `can` to ask about a capability. */
|
||||
const RANK: Record<Role, number> = { viewer: 1, operator: 2, 'apps-admin': 2, admin: 3 }
|
||||
|
||||
const CAPABILITIES: Record<Role, readonly Capability[]> = {
|
||||
viewer: ['read'],
|
||||
operator: ['read', 'operate'],
|
||||
'apps-admin': ['read', 'apps.write'],
|
||||
admin: ['read', 'apps.write', 'operate', 'audit.read', 'admin'],
|
||||
}
|
||||
|
||||
export const isRole = (value: unknown): value is Role => typeof value === 'string' && (ROLES as readonly string[]).includes(value)
|
||||
|
||||
export const rank = (role: Role): number => RANK[role]
|
||||
|
||||
/** True when `role` ranks at least as high as `min`. */
|
||||
export const requires = (role: Role, min: Role): boolean => RANK[role] >= RANK[min]
|
||||
|
||||
export const can = (role: Role, capability: Capability): boolean => CAPABILITIES[role].includes(capability)
|
||||
|
||||
export interface UserRecord {
|
||||
name: string
|
||||
role: Role
|
||||
hash: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/* ---- Passwords ---- */
|
||||
|
||||
const N = 16384
|
||||
const R = 8
|
||||
const P = 1
|
||||
const KEYLEN = 64
|
||||
|
||||
export function hashPassword(password: string): string {
|
||||
const salt = randomBytes(16)
|
||||
const key = scryptSync(password, salt, KEYLEN, { N, r: R, p: P })
|
||||
return `scrypt$${N}$${R}$${P}$${salt.toString('base64')}$${key.toString('base64')}`
|
||||
}
|
||||
|
||||
interface Parsed {
|
||||
n: number
|
||||
r: number
|
||||
p: number
|
||||
salt: Buffer
|
||||
key: Buffer
|
||||
}
|
||||
|
||||
function parseHash(stored: string): Parsed | null {
|
||||
const parts = stored.split('$')
|
||||
if (parts.length !== 6 || parts[0] !== 'scrypt') return null
|
||||
const [n, r, p] = [Number(parts[1]), Number(parts[2]), Number(parts[3])]
|
||||
if (![n, r, p].every((v) => Number.isInteger(v) && v > 0) || n > 1 << 20 || r > 32 || p > 16) return null
|
||||
const salt = Buffer.from(parts[4], 'base64')
|
||||
const key = Buffer.from(parts[5], 'base64')
|
||||
return salt.length === 0 || key.length === 0 ? null : { n, r, p, salt, key }
|
||||
}
|
||||
|
||||
export const isHash = (stored: string): boolean => parseHash(stored) !== null
|
||||
|
||||
function derive(password: string, h: Parsed): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
scrypt(password, h.salt, h.key.length, { N: h.n, r: h.r, p: h.p, maxmem: 256 * h.n * h.r }, (err, key) =>
|
||||
err ? reject(err) : resolve(key),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/** Checks a password against a stored hash; false for a malformed hash. Does not block the loop. */
|
||||
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
|
||||
const h = parseHash(stored)
|
||||
if (h === null) return false
|
||||
try {
|
||||
return timingSafeEqual(await derive(password, h), h.key)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- The file: parse and pure edits ---- */
|
||||
|
||||
const NAME = /^[A-Za-z0-9._@-]{1,64}$/
|
||||
|
||||
export function validateName(name: string): void {
|
||||
if (!NAME.test(name)) throw new Error('A user name is 1 to 64 letters, digits, . _ @ -.')
|
||||
}
|
||||
|
||||
const same = (a: string, b: string): boolean => a.toLowerCase() === b.toLowerCase()
|
||||
|
||||
export function parseUsersFile(text: string): UserRecord[] {
|
||||
let data: unknown
|
||||
try {
|
||||
data = JSON.parse(text)
|
||||
} catch {
|
||||
throw new Error('users.json is not valid JSON.')
|
||||
}
|
||||
const list = (data as { users?: unknown } | null)?.users
|
||||
if (!Array.isArray(list)) throw new Error('users.json must be {"users": [...]}.')
|
||||
const users: UserRecord[] = []
|
||||
for (const item of list as Record<string, unknown>[]) {
|
||||
const name = item?.name
|
||||
if (typeof name !== 'string' || !NAME.test(name)) throw new Error(`users.json: invalid user name ${JSON.stringify(name)}.`)
|
||||
if (!isRole(item.role)) throw new Error(`users.json: user "${name}" has an unknown role.`)
|
||||
if (typeof item.hash !== 'string' || !isHash(item.hash)) throw new Error(`users.json: user "${name}" has no valid hash.`)
|
||||
if (users.some((u) => same(u.name, name))) throw new Error(`users.json lists "${name}" twice.`)
|
||||
const record: UserRecord = { name, role: item.role, hash: item.hash }
|
||||
if (item.disabled === true) record.disabled = true
|
||||
users.push(record)
|
||||
}
|
||||
return users
|
||||
}
|
||||
|
||||
export const serializeUsers = (users: UserRecord[]): string => JSON.stringify({ users }, null, 2) + '\n'
|
||||
|
||||
export const findUser = (users: UserRecord[], name: string): UserRecord | undefined => users.find((u) => same(u.name, name))
|
||||
|
||||
function must(users: UserRecord[], name: string): UserRecord {
|
||||
const found = findUser(users, name)
|
||||
if (found === undefined) throw new Error(`No user "${name}".`)
|
||||
return found
|
||||
}
|
||||
|
||||
export function checkPassword(password: string): void {
|
||||
if (password.length < 8) throw new Error('The password must be at least 8 characters.')
|
||||
}
|
||||
|
||||
function checkRole(role: string): Role {
|
||||
if (!isRole(role)) throw new Error(`The role must be one of: ${ROLES.join(', ')}.`)
|
||||
return role
|
||||
}
|
||||
|
||||
/* Each edit returns a new list; the caller writes it. */
|
||||
|
||||
export function addUser(users: UserRecord[], name: string, role: string, password: string): UserRecord[] {
|
||||
validateName(name)
|
||||
const checked = checkRole(role)
|
||||
checkPassword(password)
|
||||
if (findUser(users, name) !== undefined) throw new Error(`User "${name}" already exists.`)
|
||||
return [...users, { name, role: checked, hash: hashPassword(password) }]
|
||||
}
|
||||
|
||||
export function setPassword(users: UserRecord[], name: string, password: string): UserRecord[] {
|
||||
checkPassword(password)
|
||||
const target = must(users, name)
|
||||
return users.map((u) => (u === target ? { ...u, hash: hashPassword(password) } : u))
|
||||
}
|
||||
|
||||
export function setRole(users: UserRecord[], name: string, role: string): UserRecord[] {
|
||||
const checked = checkRole(role)
|
||||
const target = must(users, name)
|
||||
return users.map((u) => (u === target ? { ...u, role: checked } : u))
|
||||
}
|
||||
|
||||
export function setDisabled(users: UserRecord[], name: string, disabled: boolean): UserRecord[] {
|
||||
const target = must(users, name)
|
||||
return users.map((u) => {
|
||||
if (u !== target) return u
|
||||
const { disabled: _old, ...rest } = u
|
||||
return disabled ? { ...rest, disabled: true } : rest
|
||||
})
|
||||
}
|
||||
|
||||
/** One line per user, never the hash. */
|
||||
export const describeUsers = (users: UserRecord[]): string[] =>
|
||||
users.map((u) => `${u.name}\t${u.role}${u.disabled === true ? '\tdisabled' : ''}`)
|
||||
|
||||
/* ---- The file: disk ---- */
|
||||
|
||||
export const usersPath = (dir: string): string => path.join(dir, 'users.json')
|
||||
|
||||
/** The users, or null when the file does not exist. Throws when it exists but is invalid. */
|
||||
export function readUsersFile(dir: string): UserRecord[] | null {
|
||||
const file = usersPath(dir)
|
||||
if (!existsSync(file)) return null
|
||||
return parseUsersFile(readFileSync(file, 'utf8'))
|
||||
}
|
||||
|
||||
/** Writes a temp file and renames it over users.json; mode 0600 where the platform honours it. */
|
||||
export function writeUsersFile(dir: string, users: UserRecord[]): void {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
const file = usersPath(dir)
|
||||
const temp = `${file}.${process.pid}.tmp`
|
||||
try {
|
||||
writeFileSync(temp, serializeUsers(users), { mode: 0o600 })
|
||||
try {
|
||||
chmodSync(temp, 0o600)
|
||||
} catch {
|
||||
// Not every platform has modes.
|
||||
}
|
||||
renameSync(temp, file)
|
||||
} catch (e) {
|
||||
rmSync(temp, { force: true })
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- The running hub's view ---- */
|
||||
|
||||
/* Compared when the role, hash or disabled flag changes, so a session can tell its user was edited. */
|
||||
export const signature = (u: UserRecord): string => `${u.role}|${u.hash}|${u.disabled === true ? 'x' : ''}`
|
||||
|
||||
export class Users {
|
||||
#dir: string
|
||||
#users: UserRecord[]
|
||||
#fromFile: boolean
|
||||
#mtime = 0
|
||||
#warned = false
|
||||
/** A one-line notice for the start-up log, or null. */
|
||||
readonly notice: string | null
|
||||
/* Checked when a name is unknown, so an unknown name costs the same as a wrong password. */
|
||||
#dummy = hashPassword(randomBytes(8).toString('hex'))
|
||||
|
||||
private constructor(dir: string, users: UserRecord[], fromFile: boolean, notice: string | null) {
|
||||
this.#dir = dir
|
||||
this.#users = users
|
||||
this.#fromFile = fromFile
|
||||
this.notice = notice
|
||||
if (fromFile) this.#mtime = this.#stat()
|
||||
}
|
||||
|
||||
/*
|
||||
users.json when it exists (HUB_USER and HUB_PASSWORD are then ignored); else the one admin
|
||||
from the environment; else an error. The file is never written here.
|
||||
*/
|
||||
static load(dir: string, bootstrap: Bootstrap | null): Users {
|
||||
const fromFile = readUsersFile(dir)
|
||||
if (fromFile !== null) {
|
||||
const notice =
|
||||
bootstrap === null ? null : `${usersPath(dir)} exists: HUB_USER and HUB_PASSWORD are ignored.`
|
||||
return new Users(dir, fromFile, true, notice)
|
||||
}
|
||||
if (bootstrap === null) {
|
||||
throw new Error(
|
||||
`No users: ${usersPath(dir)} does not exist and HUB_USER / HUB_PASSWORD are not set. ` +
|
||||
'Create a user with: node --experimental-strip-types hub/users-cli.ts add <name> admin',
|
||||
)
|
||||
}
|
||||
validateName(bootstrap.user)
|
||||
const record: UserRecord = { name: bootstrap.user, role: 'admin', hash: hashPassword(bootstrap.password) }
|
||||
return new Users(dir, [record], false, `No ${usersPath(dir)}: the single admin from HUB_USER / HUB_PASSWORD is in use.`)
|
||||
}
|
||||
|
||||
#stat(): number {
|
||||
try {
|
||||
return statSync(usersPath(this.#dir)).mtimeMs
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/* Cheap enough to run on every request: one stat. A broken file keeps the last good list. */
|
||||
refresh(): void {
|
||||
if (!this.#fromFile) return
|
||||
const mtime = this.#stat()
|
||||
if (mtime === this.#mtime) return
|
||||
this.#mtime = mtime
|
||||
try {
|
||||
const next = readUsersFile(this.#dir)
|
||||
this.#users = next ?? []
|
||||
this.#warned = false
|
||||
} catch (e) {
|
||||
if (!this.#warned) console.error(`hub: ${e instanceof Error ? e.message : String(e)} Keeping the previous users.`)
|
||||
this.#warned = true
|
||||
}
|
||||
}
|
||||
|
||||
find(name: string): UserRecord | undefined {
|
||||
this.refresh()
|
||||
return findUser(this.#users, name)
|
||||
}
|
||||
|
||||
/** The enabled user when the password is right, else null. Costs the same either way. */
|
||||
async authenticate(name: string, password: string): Promise<UserRecord | null> {
|
||||
const user = this.find(name.trim())
|
||||
const good = await verifyPassword(password, user?.hash ?? this.#dummy)
|
||||
return user !== undefined && good && user.disabled !== true ? user : null
|
||||
}
|
||||
|
||||
/** True when the user was found and is disabled (for the audit log only). */
|
||||
isDisabled(name: string): boolean {
|
||||
return this.find(name.trim())?.disabled === true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
// @vitest-environment node
|
||||
/*
|
||||
Stage 8, notices to the outside: the signature, the timeout and the single retry, what is logged,
|
||||
and the two endpoints (status and test) on a hub that has a webhook.
|
||||
*/
|
||||
import { createHmac } from 'node:crypto'
|
||||
import http from 'node:http'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { parseConfig } from './config.ts'
|
||||
import { fake, startHub, type Fake, type TestHub } from './fake-servers.ts'
|
||||
import { Webhook, sign } from './webhook.ts'
|
||||
|
||||
const urlConfig = (webhookUrl: string | null, webhookSecret: string | null = null) => ({ webhookUrl, webhookSecret, domain: 'hub.example' })
|
||||
|
||||
describe('Webhook', () => {
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
it('posts {event, at, hub, items} and signs the exact body when there is a secret', async () => {
|
||||
const seen: { url: string; init: RequestInit }[] = []
|
||||
const hook = new Webhook(urlConfig('https://hooks.example/in?token=abc', 's3cret'), {
|
||||
now: () => Date.parse('2026-11-01T00:00:00Z'),
|
||||
fetch: (async (url: string, init: RequestInit) => {
|
||||
seen.push({ url, init })
|
||||
return new Response('ok')
|
||||
}) as typeof fetch,
|
||||
})
|
||||
hook.send('drift.found', [{ kind: 'settings', server: 'a', fields: ['blockingType'] }])
|
||||
await hook.settled()
|
||||
expect(seen).toHaveLength(1)
|
||||
const body = String(seen[0].init.body)
|
||||
expect(JSON.parse(body)).toEqual({ event: 'drift.found', at: '2026-11-01T00:00:00.000Z', hub: 'hub.example', items: [{ kind: 'settings', server: 'a', fields: ['blockingType'] }] })
|
||||
const headers = seen[0].init.headers as Record<string, string>
|
||||
expect(headers['x-hub-signature']).toBe(`sha256=${createHmac('sha256', 's3cret').update(body).digest('hex')}`)
|
||||
expect(headers['x-hub-signature']).toBe(sign('s3cret', body))
|
||||
})
|
||||
|
||||
it('sends no signature without a secret and nothing at all without a URL', async () => {
|
||||
const seen: RequestInit[] = []
|
||||
const fetcher = (async (_u: string, init: RequestInit) => {
|
||||
seen.push(init)
|
||||
return new Response('ok')
|
||||
}) as typeof fetch
|
||||
const hook = new Webhook(urlConfig('https://hooks.example/in'), { fetch: fetcher })
|
||||
hook.send('drift.cleared', [{ kind: 'app' }])
|
||||
await hook.settled()
|
||||
expect((seen[0].headers as Record<string, string>)['x-hub-signature']).toBeUndefined()
|
||||
const none = new Webhook(urlConfig(null), { fetch: fetcher })
|
||||
none.send('drift.found', [{ kind: 'app' }])
|
||||
await none.settled()
|
||||
expect(seen).toHaveLength(1)
|
||||
expect(none.status()).toEqual({ configured: false, host: null, signed: false, last: null })
|
||||
})
|
||||
|
||||
it('retries once and logs the host only, never the query string or the error text', async () => {
|
||||
const log = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
const out = vi.spyOn(console, 'log').mockImplementation(() => undefined)
|
||||
let tries = 0
|
||||
const hook = new Webhook(urlConfig('https://hooks.example:8443/in?token=TOPSECRET'), {
|
||||
retryDelayMs: 0,
|
||||
fetch: (async () => {
|
||||
tries++
|
||||
throw new TypeError('fetch failed https://hooks.example:8443/in?token=TOPSECRET', { cause: { code: 'ECONNREFUSED' } })
|
||||
}) as typeof fetch,
|
||||
})
|
||||
hook.send('drift.found', [{ kind: 'app' }])
|
||||
await hook.settled()
|
||||
expect(tries).toBe(2)
|
||||
const text = [...log.mock.calls, ...out.mock.calls].flat().join(' ')
|
||||
expect(text).toContain('hooks.example:8443')
|
||||
expect(text).toContain('ECONNREFUSED')
|
||||
expect(text).not.toContain('TOPSECRET')
|
||||
expect(text).not.toContain('token=')
|
||||
expect(hook.status()).toMatchObject({ configured: true, host: 'hooks.example:8443', last: { event: 'drift.found', ok: false, error: 'ECONNREFUSED' } })
|
||||
expect(JSON.stringify(hook.status())).not.toContain('TOPSECRET')
|
||||
})
|
||||
|
||||
it('treats a non-2xx answer as a failure and stops after the one retry; a second success is ok', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
const answers = [500, 204]
|
||||
let tries = 0
|
||||
const hook = new Webhook(urlConfig('https://hooks.example/in'), { retryDelayMs: 0, fetch: (async () => new Response(null, { status: answers[tries++] ?? 500 })) as typeof fetch })
|
||||
hook.send('drift.found', [{ kind: 'app' }])
|
||||
await hook.settled()
|
||||
expect(tries).toBe(2)
|
||||
expect(hook.status().last).toMatchObject({ ok: true })
|
||||
const bad = new Webhook(urlConfig('https://hooks.example/in'), { retryDelayMs: 0, fetch: (async () => new Response(null, { status: 503 })) as typeof fetch })
|
||||
expect(await bad.deliver('webhook.test', [{}])).toMatchObject({ ok: false, error: 'HTTP 503' })
|
||||
})
|
||||
|
||||
it('does not wait for a hanging receiver when sending, and gives up after the timeout', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
const hook = new Webhook(urlConfig('https://hooks.example/in'), {
|
||||
timeoutMs: 40,
|
||||
retryDelayMs: 0,
|
||||
fetch: ((_u: string, init: RequestInit) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
init.signal?.addEventListener('abort', () => reject((init.signal as AbortSignal).reason))
|
||||
})) as typeof fetch,
|
||||
})
|
||||
const started = Date.now()
|
||||
hook.send('drift.found', [{ kind: 'app' }])
|
||||
expect(Date.now() - started).toBeLessThan(30)
|
||||
await hook.settled()
|
||||
expect(hook.status().last).toMatchObject({ ok: false, error: 'TimeoutError' })
|
||||
})
|
||||
|
||||
it('collects failed writes of one moment into a single notice', async () => {
|
||||
const bodies: { event: string; items: unknown[] }[] = []
|
||||
const hook = new Webhook(urlConfig('https://hooks.example/in'), {
|
||||
batchMs: 5,
|
||||
fetch: (async (_u: string, init: RequestInit) => {
|
||||
bodies.push(JSON.parse(String(init.body)))
|
||||
return new Response('ok')
|
||||
}) as typeof fetch,
|
||||
})
|
||||
hook.queueFailed({ action: 'a', server: 'x' })
|
||||
hook.queueFailed({ action: 'a', server: 'y' })
|
||||
await hook.settled()
|
||||
expect(bodies).toHaveLength(1)
|
||||
expect(bodies[0]).toMatchObject({ event: 'write.failed', items: [{ server: 'x' }, { server: 'y' }] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('HUB_WEBHOOK_URL', () => {
|
||||
const base = { HUB_SERVERS: 'a=http://a.example', A_TOKEN: 't', HUB_USER: 'u', HUB_PASSWORD: 'p' }
|
||||
it('accepts http and https and refuses anything else', () => {
|
||||
expect(parseConfig({ ...base, HUB_WEBHOOK_URL: 'https://h.example/x?y=1', HUB_WEBHOOK_SECRET: 's' })).toMatchObject({ webhookUrl: 'https://h.example/x?y=1', webhookSecret: 's' })
|
||||
expect(parseConfig(base)).toMatchObject({ webhookUrl: null, webhookSecret: null })
|
||||
expect(() => parseConfig({ ...base, HUB_WEBHOOK_URL: 'ftp://h.example' })).toThrow(/HUB_WEBHOOK_URL/)
|
||||
expect(() => parseConfig({ ...base, HUB_WEBHOOK_URL: 'h.example' })).toThrow(/HUB_WEBHOOK_URL/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the webhook on a hub', () => {
|
||||
let a: Fake
|
||||
let b: Fake
|
||||
let hub: TestHub
|
||||
let receiver: http.Server
|
||||
let received: { headers: http.IncomingHttpHeaders; body: string }[]
|
||||
let status = 200
|
||||
|
||||
beforeAll(async () => {
|
||||
a = await fake()
|
||||
b = await fake()
|
||||
receiver = http.createServer((req, res) => {
|
||||
const chunks: Buffer[] = []
|
||||
req.on('data', (c: Buffer) => chunks.push(c))
|
||||
req.on('end', () => {
|
||||
received.push({ headers: req.headers, body: Buffer.concat(chunks).toString('utf8') })
|
||||
res.writeHead(status).end('ok')
|
||||
})
|
||||
})
|
||||
await new Promise<void>((r) => receiver.listen(0, '127.0.0.1', r))
|
||||
hub = await startHub(a, b, {
|
||||
HUB_WEBHOOK_URL: `http://127.0.0.1:${(receiver.address() as AddressInfo).port}/in?token=QUERYSECRET`,
|
||||
HUB_WEBHOOK_SECRET: 'shared-secret',
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
hub.close()
|
||||
a.server.close()
|
||||
b.server.close()
|
||||
receiver.close()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
received = []
|
||||
status = 200
|
||||
})
|
||||
|
||||
it('shows its status to an admin only, with the host and never the query', async () => {
|
||||
for (const role of ['viewer', 'operator', 'apps-admin']) expect((await hub.call(role, 'hub/webhook/status', {}, 'GET')).code).toBe(403)
|
||||
const got = await hub.call('admin', 'hub/webhook/status', {}, 'GET')
|
||||
expect(got.code).toBe(200)
|
||||
expect(got.body.response).toMatchObject({ configured: true, signed: true, last: null })
|
||||
expect(got.body.response.host).toMatch(/^127\.0\.0\.1:\d+$/)
|
||||
expect(JSON.stringify(got.body)).not.toContain('QUERYSECRET')
|
||||
})
|
||||
|
||||
it('refuses the test to every role but admin, and refuses GET', async () => {
|
||||
for (const role of ['viewer', 'operator', 'apps-admin']) expect((await hub.call(role, 'hub/webhook/test')).code).toBe(403)
|
||||
expect((await hub.call('admin', 'hub/webhook/test', {}, 'GET')).code).toBe(405)
|
||||
expect(received).toEqual([])
|
||||
})
|
||||
|
||||
it('sends a signed test notice, records the result and audits it', async () => {
|
||||
const done = await hub.call('admin', 'hub/webhook/test')
|
||||
expect(done.code).toBe(200)
|
||||
expect(done.body.response.last).toMatchObject({ event: 'webhook.test', ok: true })
|
||||
expect(received).toHaveLength(1)
|
||||
const { headers, body } = received[0]
|
||||
expect(headers['x-hub-signature']).toBe(`sha256=${createHmac('sha256', 'shared-secret').update(body).digest('hex')}`)
|
||||
expect(JSON.parse(body)).toMatchObject({ event: 'webhook.test', hub: 'hub' })
|
||||
const audit = await hub.audit()
|
||||
expect(audit.find((e) => e.action === 'webhook.test')).toMatchObject({ user: 'admin', outcome: 'ok' })
|
||||
expect(JSON.stringify(audit)).not.toContain('QUERYSECRET')
|
||||
expect(JSON.stringify(audit)).not.toContain('shared-secret')
|
||||
})
|
||||
|
||||
it('reports a failing receiver in the answer without failing the request', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
status = 500
|
||||
const done = await hub.call('admin', 'hub/webhook/test')
|
||||
expect(done.code).toBe(200)
|
||||
expect(done.body.status).toBe('ok')
|
||||
expect(done.body.response.last).toMatchObject({ ok: false, error: 'HTTP 500' })
|
||||
expect(received).toHaveLength(2)
|
||||
expect((await hub.audit()).find((e) => e.action === 'webhook.test' && e.outcome === 'failed')).toBeTruthy()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('sends write.failed for a failed audited write, without the values', async () => {
|
||||
a.on('zones/records/add', () => ({ error: 'The record already exists.' }))
|
||||
const done = await hub.call('admin', 'zones/records/add', { node: 'a', zone: 'example.com', domain: 'www.example.com', type: 'TXT', text: 'TOPVALUE' })
|
||||
expect(done.body.status).toBe('error')
|
||||
for (let i = 0; i < 40 && received.length === 0; i++) await new Promise((r) => setTimeout(r, 50))
|
||||
expect(received).toHaveLength(1)
|
||||
const sent = JSON.parse(received[0].body)
|
||||
expect(sent.event).toBe('write.failed')
|
||||
expect(sent.items[0]).toMatchObject({ action: 'zones.records.add', server: 'a', user: 'admin' })
|
||||
expect(received[0].body).not.toContain('TOPVALUE')
|
||||
// A failed login is not a write.
|
||||
received = []
|
||||
await fetch(`${hub.base}/api/user/login`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: 'user=admin&pass=wrong' })
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
expect(received).toEqual([])
|
||||
})
|
||||
})
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
Notices to the outside (stage 8): when HUB_WEBHOOK_URL is set, the hub POSTs a small JSON body
|
||||
`{event, at, hub, items}` on a change of drift (`drift.found`, `drift.cleared`) and when an audited
|
||||
write failed (`write.failed`). With HUB_WEBHOOK_SECRET the exact bytes of the body are signed:
|
||||
`X-Hub-Signature: sha256=<hex hmac>`.
|
||||
|
||||
A delivery never blocks or fails the request that caused it: it runs on its own with a timeout and
|
||||
at most one retry. Errors go to the server log with the HOST only (the URL may carry a token in its
|
||||
query string) and never the error text. Items carry names and field names, never values.
|
||||
*/
|
||||
import { createHmac } from 'node:crypto'
|
||||
import type { HubConfig } from './config.ts'
|
||||
|
||||
export type WebhookEvent = 'drift.found' | 'drift.cleared' | 'write.failed' | 'webhook.test'
|
||||
|
||||
export interface Delivery {
|
||||
event: WebhookEvent
|
||||
at: string
|
||||
ok: boolean
|
||||
/** `HTTP 500`, or an error code or name. Never a URL. */
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface WebhookStatus {
|
||||
configured: boolean
|
||||
/** Host and port only. */
|
||||
host: string | null
|
||||
signed: boolean
|
||||
last: Delivery | null
|
||||
}
|
||||
|
||||
export interface WebhookOptions {
|
||||
fetch?: typeof fetch
|
||||
timeoutMs?: number
|
||||
retryDelayMs?: number
|
||||
/** How long write.failed items are collected into one notice. */
|
||||
batchMs?: number
|
||||
now?: () => number
|
||||
}
|
||||
|
||||
export const sign = (secret: string, body: string): string => `sha256=${createHmac('sha256', secret).update(body).digest('hex')}`
|
||||
|
||||
export class Webhook {
|
||||
#url: string | null
|
||||
#secret: string | null
|
||||
#domain: string
|
||||
#host: string | null
|
||||
#fetch: typeof fetch
|
||||
#timeoutMs: number
|
||||
#retryDelayMs: number
|
||||
#batchMs: number
|
||||
#now: () => number
|
||||
#last: Delivery | null = null
|
||||
#pending = new Set<Promise<unknown>>()
|
||||
#queued: Record<string, unknown>[] = []
|
||||
#timer: NodeJS.Timeout | null = null
|
||||
|
||||
constructor(config: Pick<HubConfig, 'webhookUrl' | 'webhookSecret' | 'domain'>, options: WebhookOptions = {}) {
|
||||
this.#url = config.webhookUrl
|
||||
this.#secret = config.webhookSecret
|
||||
this.#domain = config.domain
|
||||
this.#host = config.webhookUrl === null ? null : new URL(config.webhookUrl).host
|
||||
this.#fetch = options.fetch ?? fetch
|
||||
this.#timeoutMs = options.timeoutMs ?? 5000
|
||||
this.#retryDelayMs = options.retryDelayMs ?? 1000
|
||||
this.#batchMs = options.batchMs ?? 250
|
||||
this.#now = options.now ?? Date.now
|
||||
}
|
||||
|
||||
get configured(): boolean {
|
||||
return this.#url !== null
|
||||
}
|
||||
|
||||
status(): WebhookStatus {
|
||||
return { configured: this.#url !== null, host: this.#host, signed: this.#secret !== null, last: this.#last }
|
||||
}
|
||||
|
||||
/** Resolves when every delivery started so far has ended (for the tests). */
|
||||
async settled(): Promise<void> {
|
||||
if (this.#timer !== null) this.#flush()
|
||||
while (this.#pending.size > 0) await Promise.allSettled([...this.#pending])
|
||||
}
|
||||
|
||||
#body(event: WebhookEvent, items: Record<string, unknown>[]): string {
|
||||
return JSON.stringify({ event, at: new Date(this.#now()).toISOString(), hub: this.#domain, items })
|
||||
}
|
||||
|
||||
async #attempt(body: string): Promise<string | null> {
|
||||
const headers: Record<string, string> = { 'content-type': 'application/json' }
|
||||
if (this.#secret !== null) headers['x-hub-signature'] = sign(this.#secret, body)
|
||||
try {
|
||||
const res = await this.#fetch(this.#url as string, { method: 'POST', headers, body, redirect: 'manual', signal: AbortSignal.timeout(this.#timeoutMs) })
|
||||
await res.body?.cancel().catch(() => undefined)
|
||||
return res.status >= 200 && res.status < 300 ? null : `HTTP ${res.status}`
|
||||
} catch (e) {
|
||||
const code = (e as { cause?: { code?: unknown } })?.cause?.code ?? (e as { code?: unknown })?.code
|
||||
return typeof code === 'string' ? code : e instanceof Error ? e.name : 'failed'
|
||||
}
|
||||
}
|
||||
|
||||
/* Delivers one notice and records the result. Returns it. */
|
||||
async deliver(event: WebhookEvent, items: Record<string, unknown>[]): Promise<Delivery> {
|
||||
if (this.#url === null) return { event, at: new Date(this.#now()).toISOString(), ok: false, error: 'not configured' }
|
||||
const body = this.#body(event, items)
|
||||
let error = await this.#attempt(body)
|
||||
if (error !== null) {
|
||||
if (this.#retryDelayMs > 0) await new Promise((r) => setTimeout(r, this.#retryDelayMs))
|
||||
error = await this.#attempt(body)
|
||||
}
|
||||
const result: Delivery = { event, at: new Date(this.#now()).toISOString(), ok: error === null, ...(error === null ? {} : { error }) }
|
||||
this.#last = result
|
||||
if (error !== null) console.error(`hub: webhook delivery to ${this.#host} failed (${error}).`)
|
||||
return result
|
||||
}
|
||||
|
||||
/* Fire and forget: never throws, never waits. */
|
||||
send(event: WebhookEvent, items: Record<string, unknown>[]): void {
|
||||
if (this.#url === null || items.length === 0) return
|
||||
const run = this.deliver(event, items).catch(() => undefined)
|
||||
this.#pending.add(run)
|
||||
void run.finally(() => this.#pending.delete(run))
|
||||
}
|
||||
|
||||
/* Collects `write.failed` items for a moment, so ten failed servers make one notice. */
|
||||
queueFailed(item: Record<string, unknown>): void {
|
||||
if (this.#url === null) return
|
||||
this.#queued.push(item)
|
||||
if (this.#timer === null) {
|
||||
this.#timer = setTimeout(() => this.#flush(), this.#batchMs)
|
||||
this.#timer.unref()
|
||||
}
|
||||
}
|
||||
|
||||
#flush(): void {
|
||||
if (this.#timer !== null) clearTimeout(this.#timer)
|
||||
this.#timer = null
|
||||
const items = this.#queued.splice(0, 50)
|
||||
this.#queued = []
|
||||
this.send('write.failed', items)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
// @vitest-environment node
|
||||
/*
|
||||
Stage 7, zones: the merged list, copying a zone and making a Secondary. The servers are fakes that
|
||||
keep a zone table and record every call.
|
||||
*/
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { fake, startHub, ROLES, type Call, type Fake, type TestHub } from './fake-servers.ts'
|
||||
import { classifyRow, countRecordLines, secondaryOptions, validNameServer, cleanZone, type ZoneCell } from './zones-sync.ts'
|
||||
import { routeFor } from './routes.ts'
|
||||
|
||||
interface Z {
|
||||
name: string
|
||||
type: string
|
||||
soaSerial?: number | null
|
||||
disabled?: boolean
|
||||
dnssecStatus?: string
|
||||
internal?: boolean
|
||||
catalog?: string | null
|
||||
}
|
||||
|
||||
interface State {
|
||||
zones: Z[]
|
||||
files: Record<string, string>
|
||||
options: Record<string, Record<string, unknown>>
|
||||
tsig: string[]
|
||||
}
|
||||
|
||||
const state = new Map<Fake, State>()
|
||||
|
||||
function install(f: Fake, init: Partial<State>): void {
|
||||
const s: State = { zones: [], files: {}, options: {}, tsig: [], ...init }
|
||||
state.set(f, s)
|
||||
f.on('zones/list', (c) => {
|
||||
const per = Number(c.query.get('zonesPerPage') ?? 10)
|
||||
const page = Number(c.query.get('pageNumber') ?? 1)
|
||||
return { response: { zones: s.zones.slice((page - 1) * per, page * per), pageNumber: page, totalPages: Math.max(1, Math.ceil(s.zones.length / per)), totalZones: s.zones.length } }
|
||||
})
|
||||
f.on('zones/export', (c) => {
|
||||
const text = s.files[c.query.get('zone') ?? '']
|
||||
return text === undefined ? { error: 'No such zone was found.' } : { text }
|
||||
})
|
||||
f.on('zones/create', (c) => {
|
||||
s.zones.push({ name: c.query.get('zone') ?? c.form.get('zone') ?? '', type: c.query.get('type') ?? c.form.get('type') ?? 'Primary', soaSerial: 1 })
|
||||
return { response: {} }
|
||||
})
|
||||
f.on('zones/import', (c) => {
|
||||
s.files[c.query.get('zone') ?? ''] = c.text
|
||||
return { response: {} }
|
||||
})
|
||||
f.on('zones/options/get', (c) => ({
|
||||
response: { zoneTransfer: 'Deny', zoneTransferNetworkACL: [], zoneTransferTsigKeyNames: [], notify: 'None', notifyNameServers: [], ...(s.options[c.query.get('zone') ?? ''] ?? {}) },
|
||||
}))
|
||||
f.on('zones/options/set', (c) => {
|
||||
const zone = c.form.get('zone') ?? ''
|
||||
const next: Record<string, unknown> = { ...(s.options[zone] ?? {}) }
|
||||
for (const [k, v] of c.form) {
|
||||
if (k === 'zone') continue
|
||||
next[k] = k.endsWith('ACL') || k.endsWith('Names') || k.endsWith('Servers') ? (v === 'false' ? [] : v.split(',')) : v
|
||||
}
|
||||
s.options[zone] = next
|
||||
return { response: {} }
|
||||
})
|
||||
f.on('settings/getTsigKeyNames', () => ({ response: { tsigKeyNames: s.tsig } }))
|
||||
}
|
||||
|
||||
const SOA = '$ORIGIN example.net.\n@ IN SOA ns1 hostmaster 5 900 300 604800 900\n@ IN NS ns1\nwww IN A 203.0.113.5\n; a comment\n'
|
||||
|
||||
let a: Fake
|
||||
let b: Fake
|
||||
let hub: TestHub
|
||||
|
||||
beforeAll(async () => {
|
||||
a = await fake()
|
||||
b = await fake()
|
||||
hub = await startHub(a, b)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
hub.close()
|
||||
a.server.close()
|
||||
b.server.close()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
a.calls = []
|
||||
b.calls = []
|
||||
a.down = false
|
||||
b.down = false
|
||||
install(a, {
|
||||
zones: [
|
||||
{ name: 'example.net', type: 'Primary', soaSerial: 5, dnssecStatus: 'Unsigned' },
|
||||
{ name: 'lan', type: 'Primary', soaSerial: 3 },
|
||||
{ name: 'only-a.test', type: 'Primary', soaSerial: 1 },
|
||||
{ name: 'fwd.test', type: 'Forwarder', disabled: false },
|
||||
{ name: 'signed.test', type: 'Primary', soaSerial: 2, dnssecStatus: 'SignedWithNSEC' },
|
||||
{ name: 'sec.test', type: 'Secondary', soaSerial: 9 },
|
||||
{ name: 'primary.test', type: 'Primary', soaSerial: 10 },
|
||||
{ name: 'localhost', type: 'Primary', soaSerial: 1, internal: true },
|
||||
],
|
||||
files: { 'example.net': SOA, 'fwd.test': 'fwd.test. 3600 IN FWD this-server\n', 'signed.test': SOA, 'lan': SOA, 'primary.test': SOA },
|
||||
tsig: ['transfer.key'],
|
||||
})
|
||||
install(b, {
|
||||
zones: [
|
||||
{ name: 'example.net', type: 'Primary', soaSerial: 5, dnssecStatus: 'Unsigned' },
|
||||
{ name: 'lan', type: 'Primary', soaSerial: 4 },
|
||||
{ name: 'only-b.test', type: 'Primary', soaSerial: 1 },
|
||||
{ name: 'primary.test', type: 'Secondary', soaSerial: 8 },
|
||||
{ name: 'localhost', type: 'Primary', soaSerial: 1, internal: true },
|
||||
],
|
||||
files: {},
|
||||
tsig: ['transfer.key'],
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
a.down = false
|
||||
b.down = false
|
||||
})
|
||||
|
||||
const writes = (f: Fake): Call[] => f.calls.filter((c) => c.method === 'POST')
|
||||
|
||||
const present = (type: string, serial: number | null = null): ZoneCell => ({ state: 'present', type, serial })
|
||||
const absent: ZoneCell = { state: 'absent' }
|
||||
const unknown: ZoneCell = { state: 'unknown' }
|
||||
|
||||
describe('the status of a row', () => {
|
||||
it('is the same when the zone is alike everywhere', () => {
|
||||
expect(classifyRow('z', { a: present('Primary', 5), b: present('Primary', 5) }).status).toBe('same')
|
||||
})
|
||||
|
||||
it('says serial-differs for two Primary zones with different serials', () => {
|
||||
expect(classifyRow('z', { a: present('Primary', 5), b: present('Primary', 6) }).status).toBe('serial-differs')
|
||||
})
|
||||
|
||||
it('says type-differs for types that do not follow one another', () => {
|
||||
expect(classifyRow('z', { a: present('Primary', 5), b: present('Forwarder') }).status).toBe('type-differs')
|
||||
expect(classifyRow('z', { a: present('Stub', 5), b: present('Secondary', 5) }).status).toBe('type-differs')
|
||||
})
|
||||
|
||||
it('treats a Primary and its Secondary as the intended setup, and a lagging Secondary as behind, not as a divergence', () => {
|
||||
const same = classifyRow('z', { a: present('Primary', 10), b: present('Secondary', 10) })
|
||||
expect(same).toMatchObject({ status: 'same', note: 'primary-secondary' })
|
||||
const behind = classifyRow('z', { a: present('Primary', 10), b: present('Secondary', 8) })
|
||||
expect(behind).toMatchObject({ status: 'same', note: 'secondary-behind' })
|
||||
expect(classifyRow('z', { a: present('Secondary', 8), b: present('Primary', 10) })).toMatchObject({ status: 'same', note: 'secondary-behind' })
|
||||
// A Secondary that is AHEAD of its primary is not normal.
|
||||
expect(classifyRow('z', { a: present('Primary', 8), b: present('Secondary', 10) }).status).toBe('serial-differs')
|
||||
})
|
||||
|
||||
it('says only-on for a zone one server lacks', () => {
|
||||
expect(classifyRow('z', { a: present('Primary', 1), b: absent })).toMatchObject({ status: 'only-on', onlyOn: 'a' })
|
||||
})
|
||||
|
||||
it('does not call a zone only-on while the other server did not answer', () => {
|
||||
expect(classifyRow('z', { a: present('Primary', 1), b: unknown }).status).toBe('unknown')
|
||||
expect(classifyRow('z', { a: present('Primary', 1), b: present('Primary', 2), c: unknown }).status).toBe('serial-differs')
|
||||
})
|
||||
|
||||
it('names the reachable servers that lack a zone the others have', () => {
|
||||
expect(classifyRow('z', { a: present('Primary', 1), b: present('Primary', 1), c: absent })).toMatchObject({ status: 'missing-on', missingOn: ['c'] })
|
||||
})
|
||||
|
||||
it('ignores serials of types that have none', () => {
|
||||
expect(classifyRow('z', { a: present('Forwarder'), b: present('Forwarder') }).status).toBe('same')
|
||||
})
|
||||
})
|
||||
|
||||
describe('small helpers', () => {
|
||||
it('counts the record lines of a zone file', () => {
|
||||
expect(countRecordLines(SOA)).toBe(3)
|
||||
expect(countRecordLines('')).toBe(0)
|
||||
})
|
||||
|
||||
it('checks the zone name', () => {
|
||||
expect(cleanZone('Example.NET.')).toBe('Example.NET')
|
||||
for (const bad of [undefined, '', '*.example.net', 'a b', 'a..b']) expect(cleanZone(bad)).toBeNull()
|
||||
})
|
||||
|
||||
it('checks a name server address', () => {
|
||||
for (const ok of ['10.0.0.1', '10.0.0.1:53', 'ns.example.net', 'ns.example.net:5353', '2001:db8::1', '[2001:db8::1]:53']) expect(validNameServer(ok), ok).toBe(true)
|
||||
for (const bad of ['', 'a b', '10.0.0.1,10.0.0.2', 'ns.example.net:abc', '*.example.net', 'x:1:2']) expect(validNameServer(bad), bad).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('secondaryOptions', () => {
|
||||
it('widens the modes that cannot hold a list and keeps what is listed', () => {
|
||||
const out = secondaryOptions('z', { zoneTransfer: 'Deny', zoneTransferNetworkACL: ['10.0.0.9'], notify: 'ZoneNameServers', notifyNameServers: ['10.0.0.8'] }, '10.0.0.2', '')
|
||||
expect(out).toEqual({
|
||||
zone: 'z',
|
||||
zoneTransfer: 'UseSpecifiedNetworkACL',
|
||||
zoneTransferNetworkACL: '10.0.0.9,10.0.0.2',
|
||||
zoneTransferTsigKeyNames: 'false',
|
||||
notify: 'BothZoneAndSpecifiedNameServers',
|
||||
notifyNameServers: '10.0.0.8,10.0.0.2',
|
||||
})
|
||||
})
|
||||
|
||||
it('maps the other modes and keeps those that already take a list', () => {
|
||||
expect(secondaryOptions('z', { zoneTransfer: 'AllowOnlyZoneNameServers', notify: 'None' }, '10.0.0.2', 'k')).toMatchObject({
|
||||
zoneTransfer: 'AllowZoneNameServersAndUseSpecifiedNetworkACL',
|
||||
notify: 'SpecifiedNameServers',
|
||||
zoneTransferTsigKeyNames: 'k',
|
||||
})
|
||||
expect(secondaryOptions('z', { zoneTransfer: 'UseSpecifiedNetworkACL', notify: 'SpecifiedNameServers' }, '10.0.0.2', '')).toMatchObject({
|
||||
zoneTransfer: 'UseSpecifiedNetworkACL',
|
||||
notify: 'SpecifiedNameServers',
|
||||
})
|
||||
})
|
||||
|
||||
it('is null when the address and key are already there', () => {
|
||||
expect(
|
||||
secondaryOptions('z', { zoneTransfer: 'UseSpecifiedNetworkACL', zoneTransferNetworkACL: ['10.0.0.2'], zoneTransferTsigKeyNames: ['k'], notify: 'SpecifiedNameServers', notifyNameServers: ['10.0.0.2'] }, '10.0.0.2', 'k'),
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('routes', () => {
|
||||
it('lists the zone endpoints with their kinds and rights', () => {
|
||||
expect(routeFor('hub/zones/matrix')).toEqual({ kind: 'read', capability: 'read' })
|
||||
expect(routeFor('hub/zones/copy/preview')).toEqual({ kind: 'read', capability: 'admin' })
|
||||
expect(routeFor('hub/zones/copy')).toEqual({ kind: 'write-all', capability: 'admin' })
|
||||
expect(routeFor('hub/zones/secondary/preview')).toEqual({ kind: 'read', capability: 'admin' })
|
||||
expect(routeFor('hub/zones/secondary')).toEqual({ kind: 'write-all', capability: 'admin' })
|
||||
})
|
||||
|
||||
it('refuses the admin endpoints to every other role, without touching a server', async () => {
|
||||
const params = { source: 'a', zone: 'example.net', targets: 'b', target: 'b', primaryAddress: '10.0.0.1', targetAddress: '10.0.0.2' }
|
||||
for (const role of ROLES.filter((r) => r !== 'admin')) {
|
||||
for (const p of ['hub/zones/copy/preview', 'hub/zones/copy', 'hub/zones/secondary/preview', 'hub/zones/secondary']) {
|
||||
const r = await hub.call(role, p, params)
|
||||
expect(r.code, `${role} ${p}`).toBe(403)
|
||||
}
|
||||
}
|
||||
expect(a.calls).toHaveLength(0)
|
||||
expect(b.calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('lets a viewer read the merged list', async () => {
|
||||
expect((await hub.call('viewer', 'hub/zones/matrix', {}, 'GET')).body.status).toBe('ok')
|
||||
})
|
||||
})
|
||||
|
||||
describe('hub/zones/matrix', () => {
|
||||
it('merges the zones of both servers, one row per name, skipping the internal ones', async () => {
|
||||
const r = await hub.call('admin', 'hub/zones/matrix', {}, 'GET')
|
||||
const rows = r.body.response.zones as { name: string; status: string; onlyOn?: string; note?: string; cells: Record<string, any> }[]
|
||||
const by = Object.fromEntries(rows.map((z) => [z.name, z]))
|
||||
expect(Object.keys(by).sort()).toEqual(['example.net', 'fwd.test', 'lan', 'only-a.test', 'only-b.test', 'primary.test', 'sec.test', 'signed.test'])
|
||||
expect(by['example.net'].status).toBe('same')
|
||||
expect(by.lan.status).toBe('serial-differs')
|
||||
expect(by['only-a.test']).toMatchObject({ status: 'only-on', onlyOn: 'a' })
|
||||
expect(by['only-b.test']).toMatchObject({ status: 'only-on', onlyOn: 'b' })
|
||||
expect(by['primary.test']).toMatchObject({ status: 'same', note: 'secondary-behind' })
|
||||
expect(by['example.net'].cells.a).toMatchObject({ state: 'present', type: 'Primary', serial: 5, dnssec: 'Unsigned' })
|
||||
expect(by.lan.cells.b.serial).toBe(4)
|
||||
expect(r.body.response.servers).toEqual([
|
||||
{ id: 'a', ok: true, writable: true, host: '127.0.0.1' },
|
||||
{ id: 'b', ok: true, writable: true, host: '127.0.0.1' },
|
||||
])
|
||||
expect(r.body.hubWarnings).toEqual([])
|
||||
})
|
||||
|
||||
it('asks for every page', async () => {
|
||||
const s = state.get(a)!
|
||||
s.zones = Array.from({ length: 1200 }, (_, i) => ({ name: `z${String(i).padStart(4, '0')}.test`, type: 'Primary', soaSerial: 1 }))
|
||||
const r = await hub.call('admin', 'hub/zones/matrix', {}, 'GET')
|
||||
expect(r.body.response.zones.filter((z: { name: string }) => z.name.startsWith('z'))).toHaveLength(1200)
|
||||
expect(a.calls.filter((c) => c.path === 'zones/list').length).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
|
||||
it('marks the cells of a server that did not answer as unknown, warns, and does not call the zone only-on', async () => {
|
||||
b.down = true
|
||||
const r = await hub.call('admin', 'hub/zones/matrix', {}, 'GET')
|
||||
expect(r.body.status).toBe('ok')
|
||||
expect(r.body.hubWarnings).toHaveLength(1)
|
||||
expect(r.body.hubWarnings[0]).toMatch(/^b:/)
|
||||
const rows = r.body.response.zones as { name: string; status: string; cells: Record<string, any> }[]
|
||||
const lan = rows.find((z) => z.name === 'lan')!
|
||||
expect(lan.cells.b).toEqual({ state: 'unknown' })
|
||||
expect(lan.status).toBe('unknown')
|
||||
expect(rows.some((z) => z.status === 'only-on')).toBe(false)
|
||||
expect(r.body.response.servers.find((s: { id: string }) => s.id === 'b').ok).toBe(false)
|
||||
})
|
||||
|
||||
it('fails when no server answers', async () => {
|
||||
a.down = true
|
||||
b.down = true
|
||||
const r = await hub.call('admin', 'hub/zones/matrix', {}, 'GET')
|
||||
expect(r.body.status).toBe('error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('hub/zones/copy/preview', () => {
|
||||
const preview = (params: Record<string, string>) => hub.call('admin', 'hub/zones/copy/preview', params)
|
||||
|
||||
it('says what the source has and what each target holds', async () => {
|
||||
const r = await preview({ source: 'a', zone: 'example.net', targets: 'b' })
|
||||
expect(r.body.response).toMatchObject({
|
||||
zone: 'example.net',
|
||||
source: { server: 'a', type: 'Primary', serial: 5, records: 3 },
|
||||
targets: [{ server: 'b', ok: true, exists: true, type: 'Primary', serial: 5, needsOverwrite: true }],
|
||||
})
|
||||
// only-a.test has no file on the fake, so the export fails and the preview says so.
|
||||
expect((await preview({ source: 'a', zone: 'only-a.test', targets: 'b' })).body.status).toBe('error')
|
||||
})
|
||||
|
||||
it('shows a target that lacks the zone as free to create', async () => {
|
||||
state.get(a)!.files['only-a.test'] = SOA
|
||||
const r = await preview({ source: 'a', zone: 'only-a.test', targets: 'b' })
|
||||
expect(r.body.response.targets[0]).toMatchObject({ server: 'b', ok: true, exists: false, needsOverwrite: false })
|
||||
})
|
||||
|
||||
it('refuses a Secondary, a signed zone, a missing zone and the source itself, each with a reason', async () => {
|
||||
expect((await preview({ source: 'a', zone: 'sec.test', targets: 'b' })).body.errorMessage).toMatch(/Only Primary and Forwarder/)
|
||||
expect((await preview({ source: 'a', zone: 'signed.test', targets: 'b' })).body.errorMessage).toMatch(/DNSSEC-signed/)
|
||||
expect((await preview({ source: 'a', zone: 'nope.test', targets: 'b' })).body.errorMessage).toMatch(/no zone nope\.test/)
|
||||
expect((await preview({ source: 'a', zone: 'example.net', targets: 'a' })).body.errorMessage).toMatch(/server it comes from/)
|
||||
expect((await preview({ source: 'x', zone: 'example.net', targets: 'b' })).body.errorMessage).toMatch(/Unknown source server/)
|
||||
expect((await preview({ source: 'a', zone: 'example.net', targets: '' })).body.errorMessage).toMatch(/at least one server/)
|
||||
expect((await preview({ source: 'a', zone: '*.net', targets: 'b' })).body.errorMessage).toMatch(/valid zone name/)
|
||||
})
|
||||
|
||||
it('refuses a target that holds the zone as another type', async () => {
|
||||
state.get(b)!.zones.push({ name: 'fwd.test', type: 'Primary', soaSerial: 1 })
|
||||
const r = await preview({ source: 'a', zone: 'fwd.test', targets: 'b' })
|
||||
expect(r.body.response.targets[0]).toMatchObject({ ok: false, exists: true, type: 'Primary' })
|
||||
expect(r.body.response.targets[0].error).toMatch(/cannot replace it/)
|
||||
})
|
||||
|
||||
it('does not write anything and reads the export with the read token', async () => {
|
||||
await preview({ source: 'a', zone: 'example.net', targets: 'b' })
|
||||
expect(writes(a)).toHaveLength(0)
|
||||
expect(writes(b)).toHaveLength(0)
|
||||
expect(a.calls.find((c) => c.path === 'zones/export')!.token).toBe('read-a')
|
||||
})
|
||||
})
|
||||
|
||||
describe('hub/zones/copy', () => {
|
||||
const copy = (params: Record<string, string>) => hub.call('admin', 'hub/zones/copy', params)
|
||||
|
||||
it('creates the zone on a target that lacks it and imports the text, with the admin token', async () => {
|
||||
state.get(a)!.files['only-a.test'] = SOA
|
||||
const r = await copy({ source: 'a', zone: 'only-a.test', targets: 'b' })
|
||||
expect(r.body.response.results).toEqual([{ server: 'b', ok: true, message: 'Zone created and filled.' }])
|
||||
const [create, imp] = writes(b)
|
||||
expect(create.path).toBe('zones/create')
|
||||
expect(create.token).toBe('admin-b')
|
||||
expect(Object.fromEntries(new URLSearchParams([...create.form, ...create.query]))).toMatchObject({ zone: 'only-a.test', type: 'Primary' })
|
||||
expect(imp.path).toBe('zones/import')
|
||||
expect(imp.token).toBe('admin-b')
|
||||
expect(Object.fromEntries(imp.query)).toMatchObject({ zone: 'only-a.test', overwrite: 'true', overwriteZone: 'true', overwriteSoaSerial: 'true' })
|
||||
expect(imp.text).toBe(SOA)
|
||||
expect(writes(a)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('creates a Forwarder empty and fills it by the import', async () => {
|
||||
const r = await copy({ source: 'a', zone: 'fwd.test', targets: 'b' })
|
||||
expect(r.body.response.results[0].ok).toBe(true)
|
||||
const create = writes(b)[0]
|
||||
expect(Object.fromEntries(new URLSearchParams([...create.form, ...create.query]))).toMatchObject({ zone: 'fwd.test', type: 'Forwarder', initializeForwarder: 'false' })
|
||||
})
|
||||
|
||||
it('refuses to replace a zone that exists unless Overwrite was ticked for that server', async () => {
|
||||
const refused = await copy({ source: 'a', zone: 'example.net', targets: 'b' })
|
||||
expect(refused.body.response.results[0]).toMatchObject({ server: 'b', ok: false })
|
||||
expect(refused.body.response.results[0].message).toMatch(/Tick Overwrite/)
|
||||
expect(writes(b)).toHaveLength(0)
|
||||
|
||||
const other = await copy({ source: 'a', zone: 'example.net', targets: 'b', overwrite: 'a' })
|
||||
expect(other.body.response.results[0].ok).toBe(false)
|
||||
expect(writes(b)).toHaveLength(0)
|
||||
|
||||
const done = await copy({ source: 'a', zone: 'example.net', targets: 'b', overwrite: 'b' })
|
||||
expect(done.body.response.results[0]).toEqual({ server: 'b', ok: true, message: 'Records replaced.' })
|
||||
expect(writes(b).map((c) => c.path)).toEqual(['zones/import'])
|
||||
})
|
||||
|
||||
it('refuses a Secondary source and a copy to the source, and writes nothing', async () => {
|
||||
expect((await copy({ source: 'a', zone: 'sec.test', targets: 'b' })).code).toBe(400)
|
||||
expect((await copy({ source: 'a', zone: 'example.net', targets: 'a' })).code).toBe(400)
|
||||
expect(writes(a)).toHaveLength(0)
|
||||
expect(writes(b)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('reports a target that fails and still finishes the others', async () => {
|
||||
state.get(a)!.files['only-a.test'] = SOA
|
||||
b.on('zones/create', () => ({ error: 'Access was denied.' }))
|
||||
const r = await copy({ source: 'a', zone: 'only-a.test', targets: 'b' })
|
||||
expect(r.body.response.results[0]).toMatchObject({ server: 'b', ok: false })
|
||||
expect(r.body.response.results[0].message).toMatch(/could not be created: Access was denied/)
|
||||
})
|
||||
|
||||
it('says an empty zone was left when the import fails after the create', async () => {
|
||||
state.get(a)!.files['only-a.test'] = SOA
|
||||
b.on('zones/import', () => ({ error: 'Bad record.' }))
|
||||
const r = await copy({ source: 'a', zone: 'only-a.test', targets: 'b' })
|
||||
expect(r.body.response.results[0].ok).toBe(false)
|
||||
expect(r.body.response.results[0].message).toMatch(/created but the import failed \(Bad record\.\); an empty zone is left on b/)
|
||||
})
|
||||
|
||||
it('reports an unreachable target without failing the request', async () => {
|
||||
state.get(a)!.files['only-a.test'] = SOA
|
||||
b.down = true
|
||||
const r = await copy({ source: 'a', zone: 'only-a.test', targets: 'b' })
|
||||
expect(r.body.status).toBe('ok')
|
||||
expect(r.body.response.results[0]).toMatchObject({ server: 'b', ok: false })
|
||||
})
|
||||
|
||||
it('writes one audit line per target with the zone, the source, the target and the outcome, and no zone content', async () => {
|
||||
state.get(a)!.files['only-a.test'] = SOA
|
||||
await copy({ source: 'a', zone: 'only-a.test', targets: 'b' })
|
||||
const line = (await hub.audit()).find((e) => e.action === 'zones.copy')!
|
||||
expect(line).toMatchObject({ target: 'only-a.test', server: 'b', outcome: 'ok' })
|
||||
expect(line.detail).toMatch(/from a/)
|
||||
expect(JSON.stringify(line)).not.toContain('203.0.113.5')
|
||||
})
|
||||
})
|
||||
|
||||
describe('hub/zones/secondary', () => {
|
||||
const input = { source: 'a', target: 'b', zone: 'example.net', primaryAddress: '10.0.0.1', targetAddress: '10.0.0.2' }
|
||||
const secondary = (params: Record<string, string>) => hub.call('admin', 'hub/zones/secondary', params)
|
||||
const preview = (params: Record<string, string>) => hub.call('admin', 'hub/zones/secondary/preview', params)
|
||||
|
||||
beforeEach(() => {
|
||||
// The target does not have the zone yet.
|
||||
state.get(b)!.zones = state.get(b)!.zones.filter((z) => z.name !== 'example.net')
|
||||
})
|
||||
|
||||
it('previews the two steps and writes nothing', async () => {
|
||||
const r = await preview(input)
|
||||
expect(r.body.response.steps).toHaveLength(2)
|
||||
expect(r.body.response.steps[0]).toMatchObject({ server: 'a', role: 'source', changes: true })
|
||||
expect(r.body.response.steps[1]).toMatchObject({ server: 'b', role: 'target', changes: true })
|
||||
expect(writes(a)).toHaveLength(0)
|
||||
expect(writes(b)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('adds the target to transfer and notify on the source, keeping what is there, then creates the Secondary', async () => {
|
||||
state.get(a)!.options['example.net'] = { zoneTransfer: 'UseSpecifiedNetworkACL', zoneTransferNetworkACL: ['10.9.9.9'], notify: 'SpecifiedNameServers', notifyNameServers: ['10.9.9.8'] }
|
||||
const r = await secondary({ ...input, tsigKeyName: 'transfer.key' })
|
||||
expect(r.body.response.results.map((x: { server: string; ok: boolean }) => [x.server, x.ok])).toEqual([['a', true], ['b', true]])
|
||||
const set = writes(a)[0]
|
||||
expect(set.path).toBe('zones/options/set')
|
||||
expect(set.token).toBe('admin-a')
|
||||
expect(Object.fromEntries(set.form)).toEqual({
|
||||
zone: 'example.net',
|
||||
zoneTransfer: 'UseSpecifiedNetworkACL',
|
||||
zoneTransferNetworkACL: '10.9.9.9,10.0.0.2',
|
||||
zoneTransferTsigKeyNames: 'transfer.key',
|
||||
notify: 'SpecifiedNameServers',
|
||||
notifyNameServers: '10.9.9.8,10.0.0.2',
|
||||
})
|
||||
const create = writes(b)[0]
|
||||
expect(create.path).toBe('zones/create')
|
||||
expect(create.token).toBe('admin-b')
|
||||
expect(Object.fromEntries(new URLSearchParams([...create.form, ...create.query]))).toMatchObject({
|
||||
zone: 'example.net',
|
||||
type: 'Secondary',
|
||||
primaryNameServerAddresses: '10.0.0.1',
|
||||
zoneTransferProtocol: 'Tcp',
|
||||
tsigKeyName: 'transfer.key',
|
||||
})
|
||||
// The source went first.
|
||||
expect(a.calls.findIndex((c) => c.path === 'zones/options/set')).toBeGreaterThan(-1)
|
||||
})
|
||||
|
||||
it('can run again: an address already allowed is not written twice and the source is left alone', async () => {
|
||||
await secondary(input)
|
||||
state.get(b)!.zones = state.get(b)!.zones.filter((z) => z.name !== 'example.net')
|
||||
a.calls = []
|
||||
const again = await secondary(input)
|
||||
expect(again.body.response.results[0]).toMatchObject({ server: 'a', ok: true })
|
||||
expect(again.body.response.results[0].message).toMatch(/already allow/)
|
||||
expect(writes(a)).toHaveLength(0)
|
||||
expect(writes(b).filter((c) => c.path === 'zones/create')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('reports a failing target and says the source change was left in place', async () => {
|
||||
b.on('zones/create', () => ({ error: 'The primary is not reachable.' }))
|
||||
const r = await secondary(input)
|
||||
const [source, target] = r.body.response.results
|
||||
expect(source).toMatchObject({ server: 'a', ok: true })
|
||||
expect(target).toMatchObject({ server: 'b', ok: false })
|
||||
expect(target.message).toMatch(/could not be created: The primary is not reachable\./)
|
||||
expect(target.message).toMatch(/left in place/)
|
||||
})
|
||||
|
||||
it('does not touch the target when the source step fails', async () => {
|
||||
a.on('zones/options/set', () => ({ error: 'Access was denied.' }))
|
||||
const r = await secondary(input)
|
||||
expect(r.body.response.results[0]).toMatchObject({ server: 'a', ok: false })
|
||||
expect(r.body.response.results[1]).toMatchObject({ server: 'b', ok: false })
|
||||
expect(writes(b)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('refuses what cannot work, with a reason and no write', async () => {
|
||||
const refused = async (patch: Record<string, string>, text: RegExp) => {
|
||||
const r = await secondary({ ...input, ...patch })
|
||||
expect(r.code).toBe(400)
|
||||
expect(r.body.errorMessage).toMatch(text)
|
||||
}
|
||||
await refused({ zone: 'sec.test' }, /Only a Primary zone/)
|
||||
await refused({ zone: 'nope.test' }, /no zone nope\.test/)
|
||||
await refused({ target: 'a' }, /two different servers/)
|
||||
await refused({ primaryAddress: '' }, /primary's address/)
|
||||
await refused({ targetAddress: 'ns.example.net' }, /IP address/)
|
||||
await refused({ tsigKeyName: 'missing.key' }, /does not exist on a/)
|
||||
state.get(b)!.zones.push({ name: 'example.net', type: 'Primary' })
|
||||
await refused({}, /already has example\.net/)
|
||||
expect(writes(a)).toHaveLength(0)
|
||||
expect(writes(b)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('needs the TSIG key on both servers', async () => {
|
||||
state.get(b)!.tsig = []
|
||||
const r = await secondary({ ...input, tsigKeyName: 'transfer.key' })
|
||||
expect(r.body.errorMessage).toMatch(/does not exist on b/)
|
||||
expect(writes(a)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('audits both steps', async () => {
|
||||
await secondary(input)
|
||||
// Newest first: the two lines of this call are the first two of their action.
|
||||
const lines = (await hub.audit()).filter((e) => e.action === 'zones.secondary').slice(0, 2)
|
||||
expect(lines.map((l) => [l.server, l.outcome]).sort()).toEqual([['a', 'ok'], ['b', 'ok']])
|
||||
expect(lines[0].target).toBe('example.net')
|
||||
expect(lines[0].detail).toMatch(/from a to b/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,656 @@
|
||||
/*
|
||||
Zones across the servers (stage 7): the merged list, copying a zone to another server, and
|
||||
making a zone a Secondary of another server's Primary (zone transfer, AXFR, done by Technitium
|
||||
itself rather than by copying records over and over).
|
||||
|
||||
* `buildZoneMatrix` reads `zones/list` of every server (all pages, read token) and puts one row
|
||||
per zone name. `classifyRow` is the pure part that decides the status of a row.
|
||||
* `ZonesSync.copyPreview` / `copy` move a zone as a zone file: `zones/export` from the source
|
||||
with the READ token, then `zones/create` (when the zone is missing) and `zones/import` on each
|
||||
target with that server's ADMIN token. The parameter names are the ones the console's own
|
||||
Import zone dialog sends (`importZone` and `saveZoneFile` in src/api/zones.ts) and its Add
|
||||
zone dialog sends (add-zone.ts).
|
||||
* `ZonesSync.secondaryPreview` / `makeSecondary` do the two steps of a transfer: on the source
|
||||
`zones/options/set` adds the target to the transfer ACL and to the notify list (what is there
|
||||
stays), then on the target `zones/create` type Secondary. Each step can run again with no harm.
|
||||
|
||||
Only Primary and Forwarder zones are copied; a Secondary, Stub or Catalog zone is refused with a
|
||||
reason. A DNSSEC-signed Primary is refused too: its keys do not travel in a zone file, so the copy
|
||||
would carry signatures nobody can renew. No zone content is written to the audit log.
|
||||
*/
|
||||
import { isIP } from 'node:net'
|
||||
import type { HubConfig, HubServer } from './config.ts'
|
||||
import { cleanDomain } from './servers-run.ts'
|
||||
import { Upstream, UpstreamError, type RawAnswer } from './upstream.ts'
|
||||
|
||||
export class ZoneUserError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'ZoneUserError'
|
||||
}
|
||||
}
|
||||
|
||||
const reason = (e: unknown): string => (e instanceof Error ? e.message : 'failed')
|
||||
|
||||
export const PAGE_SIZE = 500
|
||||
export const MAX_PAGES = 40
|
||||
/* The largest zone file the hub carries between servers. */
|
||||
export const MAX_ZONE_BYTES = 5 * 1024 * 1024
|
||||
const EXPORT_TIMEOUT_MS = 30_000
|
||||
|
||||
/* ---- The merged list ---- */
|
||||
|
||||
export interface ListedZone {
|
||||
name: string
|
||||
type: string
|
||||
lastModified?: string
|
||||
disabled?: boolean
|
||||
soaSerial?: number | null
|
||||
dnssecStatus?: string
|
||||
catalog?: string | null
|
||||
internal?: boolean
|
||||
}
|
||||
|
||||
export interface ZoneCell {
|
||||
state: 'present' | 'absent' | 'unknown'
|
||||
type?: string
|
||||
serial?: number | null
|
||||
disabled?: boolean
|
||||
dnssec?: string
|
||||
lastModified?: string
|
||||
}
|
||||
|
||||
export type RowStatus = 'same' | 'serial-differs' | 'type-differs' | 'only-on' | 'missing-on' | 'unknown'
|
||||
|
||||
export interface MatrixRow {
|
||||
name: string
|
||||
cells: Record<string, ZoneCell>
|
||||
status: RowStatus
|
||||
/* The server that has the zone, for `only-on`. */
|
||||
onlyOn?: string
|
||||
/* The reachable servers that lack it, for `missing-on`. */
|
||||
missingOn?: string[]
|
||||
/* A Primary with a Secondary of it is the intended setup: said here, not counted as a divergence. */
|
||||
note?: 'primary-secondary' | 'secondary-behind'
|
||||
}
|
||||
|
||||
export interface ZoneMatrix {
|
||||
/* `host` is where the server's address points, to prefill the primary's address of a Secondary. */
|
||||
servers: { id: string; ok: boolean; writable: boolean; host: string; error?: string }[]
|
||||
zones: MatrixRow[]
|
||||
}
|
||||
|
||||
/* The pairs where one side follows the other: not a type difference. */
|
||||
const FOLLOWS: Record<string, string> = { Secondary: 'Primary', SecondaryForwarder: 'Forwarder', SecondaryCatalog: 'Catalog' }
|
||||
|
||||
function pairedTypes(types: Set<string>): boolean {
|
||||
if (types.size !== 2) return false
|
||||
const [a, b] = [...types]
|
||||
return FOLLOWS[a] === b || FOLLOWS[b] === a
|
||||
}
|
||||
|
||||
/* The status of one row, from its cells. Pure. */
|
||||
export function classifyRow(name: string, cells: Record<string, ZoneCell>): MatrixRow {
|
||||
const entries = Object.entries(cells)
|
||||
const present = entries.filter(([, c]) => c.state === 'present')
|
||||
const absent = entries.filter(([, c]) => c.state === 'absent').map(([id]) => id)
|
||||
const unknown = entries.some(([, c]) => c.state === 'unknown')
|
||||
const row: MatrixRow = { name, cells, status: 'same' }
|
||||
|
||||
if (present.length < 2) {
|
||||
// Whether it is "only on" one server cannot be said while a server that might have it did not answer.
|
||||
if (unknown || present.length === 0) return { ...row, status: 'unknown' }
|
||||
return { ...row, status: 'only-on', onlyOn: present[0][0] }
|
||||
}
|
||||
|
||||
const types = new Set(present.map(([, c]) => c.type ?? ''))
|
||||
if (types.size > 1 && !pairedTypes(types)) return { ...row, status: 'type-differs', ...(absent.length > 0 ? { missingOn: absent } : {}) }
|
||||
|
||||
if (types.size > 1) {
|
||||
// Primary and its Secondary: lagging is expected, ahead is not.
|
||||
const master = present.find(([, c]) => FOLLOWS[c.type ?? ''] === undefined)![1]
|
||||
const followers = present.filter(([, c]) => FOLLOWS[c.type ?? ''] !== undefined)
|
||||
const ahead = followers.some(([, c]) => typeof c.serial === 'number' && typeof master.serial === 'number' && c.serial > master.serial)
|
||||
const behind = followers.some(([, c]) => typeof c.serial === 'number' && typeof master.serial === 'number' && c.serial < master.serial)
|
||||
if (ahead) return { ...row, status: 'serial-differs', ...(absent.length > 0 ? { missingOn: absent } : {}) }
|
||||
return finish({ ...row, note: behind ? 'secondary-behind' : 'primary-secondary' }, absent, unknown)
|
||||
}
|
||||
|
||||
const [type] = [...types]
|
||||
const serials = new Set(present.map(([, c]) => c.serial))
|
||||
if (type === 'Primary' && serials.size > 1) return { ...row, status: 'serial-differs', ...(absent.length > 0 ? { missingOn: absent } : {}) }
|
||||
return finish(row, absent, unknown)
|
||||
}
|
||||
|
||||
function finish(row: MatrixRow, absent: string[], unknown: boolean): MatrixRow {
|
||||
if (absent.length > 0) return { ...row, status: 'missing-on', missingOn: absent }
|
||||
return unknown ? { ...row, status: 'unknown' } : row
|
||||
}
|
||||
|
||||
/* Every page of one server's zones, without the server's own internal zones. */
|
||||
export async function listAllZones(upstream: Upstream, server: HubServer, fresh = false): Promise<{ zones: ListedZone[]; truncated: boolean }> {
|
||||
if (fresh) upstream.invalidate(server.id)
|
||||
const zones: ListedZone[] = []
|
||||
let truncated = false
|
||||
for (let page = 1; ; page++) {
|
||||
const r = (await upstream.call(server, 'zones/list', { pageNumber: String(page), zonesPerPage: String(PAGE_SIZE) })) as {
|
||||
zones?: ListedZone[]
|
||||
totalPages?: number
|
||||
} | null
|
||||
for (const z of r?.zones ?? []) if (z.internal !== true) zones.push(z)
|
||||
const total = typeof r?.totalPages === 'number' ? r.totalPages : 1
|
||||
if (page >= total) break
|
||||
if (page >= MAX_PAGES) {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
}
|
||||
return { zones, truncated }
|
||||
}
|
||||
|
||||
export async function buildZoneMatrix(config: HubConfig, upstream: Upstream): Promise<{ matrix: ZoneMatrix; warnings: string[] }> {
|
||||
const perServer = await Promise.all(
|
||||
config.servers.map(async (server) => {
|
||||
try {
|
||||
return { server, error: undefined, ...(await listAllZones(upstream, server)) }
|
||||
} catch (e) {
|
||||
return { server, error: reason(e), zones: [] as ListedZone[], truncated: false }
|
||||
}
|
||||
}),
|
||||
)
|
||||
const warnings = perServer.filter((p) => p.error !== undefined).map((p) => `${p.server.id}: ${p.error}`)
|
||||
if (warnings.length === perServer.length) throw new Error(`No server answered. ${warnings.join('; ')}`.trim())
|
||||
for (const p of perServer) if (p.truncated) warnings.push(`${p.server.id}: more than ${MAX_PAGES * PAGE_SIZE} zones, the list is cut short.`)
|
||||
|
||||
const names = [...new Set(perServer.flatMap((p) => p.zones.map((z) => z.name)))].sort((a, b) => a.localeCompare(b))
|
||||
const zones = names.map((name) => {
|
||||
const cells: Record<string, ZoneCell> = {}
|
||||
for (const p of perServer) {
|
||||
if (p.error !== undefined) {
|
||||
cells[p.server.id] = { state: 'unknown' }
|
||||
continue
|
||||
}
|
||||
const z = p.zones.find((x) => x.name === name)
|
||||
cells[p.server.id] =
|
||||
z === undefined
|
||||
? { state: 'absent' }
|
||||
: {
|
||||
state: 'present',
|
||||
type: z.type,
|
||||
serial: typeof z.soaSerial === 'number' ? z.soaSerial : null,
|
||||
disabled: z.disabled === true,
|
||||
...(z.dnssecStatus === undefined ? {} : { dnssec: z.dnssecStatus }),
|
||||
...(z.lastModified === undefined ? {} : { lastModified: z.lastModified }),
|
||||
}
|
||||
}
|
||||
return classifyRow(name, cells)
|
||||
})
|
||||
const servers = perServer.map((p) => ({
|
||||
id: p.server.id,
|
||||
ok: p.error === undefined,
|
||||
writable: p.server.adminToken !== undefined,
|
||||
host: new URL(p.server.url).hostname,
|
||||
...(p.error === undefined ? {} : { error: p.error }),
|
||||
}))
|
||||
return { matrix: { servers, zones }, warnings }
|
||||
}
|
||||
|
||||
/* ---- Copy ---- */
|
||||
|
||||
const COPYABLE = new Set(['Primary', 'Forwarder'])
|
||||
|
||||
/* A zone name as the hub accepts it: a plain domain, not a wildcard and not the root. */
|
||||
export function cleanZone(value: string | undefined): string | null {
|
||||
const zone = cleanDomain(value)
|
||||
return zone === null || zone.startsWith('*') ? null : zone
|
||||
}
|
||||
|
||||
/* Records in a zone file, roughly: the lines that are not blank, a comment or a `$` directive. */
|
||||
export function countRecordLines(text: string): number {
|
||||
let n = 0
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const t = line.trim()
|
||||
if (t !== '' && !t.startsWith(';') && !t.startsWith('$')) n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
/* The body of an answer as text, stopping at `cap` bytes and `ms` milliseconds. */
|
||||
export async function readCapped(file: RawAnswer, cap: number, ms: number, what = 'The zone file'): Promise<string> {
|
||||
if (file.body === null) return ''
|
||||
const timer = setTimeout(() => file.abort(), ms)
|
||||
const reader = file.body.getReader()
|
||||
const chunks: Uint8Array[] = []
|
||||
let size = 0
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
size += value.length
|
||||
if (size > cap) {
|
||||
file.abort()
|
||||
throw new ZoneUserError(`${what} is larger than ${Math.round(cap / 1024 / 1024)} MB.`)
|
||||
}
|
||||
chunks.push(value)
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof ZoneUserError) throw e
|
||||
throw new UpstreamError('unreachable', 'the zone file could not be read in time')
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
return Buffer.concat(chunks).toString('utf8')
|
||||
}
|
||||
|
||||
export interface TargetResult {
|
||||
server: string
|
||||
ok: boolean
|
||||
message?: string
|
||||
}
|
||||
|
||||
interface SourceZone {
|
||||
zone: ListedZone
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
export interface CopyTargetPreview {
|
||||
server: string
|
||||
ok: boolean
|
||||
error?: string
|
||||
writable: boolean
|
||||
exists: boolean
|
||||
type?: string
|
||||
serial?: number | null
|
||||
/* The user has to tick Overwrite for this one. */
|
||||
needsOverwrite: boolean
|
||||
}
|
||||
|
||||
export interface CopyPreview {
|
||||
zone: string
|
||||
source: { server: string; type: string; serial: number | null; records: number; bytes: number }
|
||||
warnings: string[]
|
||||
targets: CopyTargetPreview[]
|
||||
}
|
||||
|
||||
export class ZonesSync {
|
||||
#config: HubConfig
|
||||
#upstream: Upstream
|
||||
|
||||
constructor(config: HubConfig, upstream: Upstream) {
|
||||
this.#config = config
|
||||
this.#upstream = upstream
|
||||
}
|
||||
|
||||
#server(id: string, what = 'server'): HubServer {
|
||||
const found = this.#config.servers.find((s) => s.id === id)
|
||||
if (found === undefined) throw new ZoneUserError(`Unknown ${what}: ${id}.`)
|
||||
return found
|
||||
}
|
||||
|
||||
#targets(ids: string[], source: string): HubServer[] {
|
||||
const unique = [...new Set(ids)]
|
||||
if (unique.length === 0) throw new ZoneUserError('Choose at least one server.')
|
||||
if (unique.includes(source)) throw new ZoneUserError('A zone cannot be copied to the server it comes from.')
|
||||
return unique.map((id) => this.#server(id))
|
||||
}
|
||||
|
||||
/* The zone as the source lists it, and why it may not be copied. */
|
||||
async #source(server: HubServer, zone: string): Promise<SourceZone> {
|
||||
let listed: ListedZone[]
|
||||
try {
|
||||
listed = (await listAllZones(this.#upstream, server, true)).zones
|
||||
} catch (e) {
|
||||
throw new ZoneUserError(`${server.id}: ${reason(e)}`)
|
||||
}
|
||||
const found = listed.find((z) => z.name.toLowerCase() === zone.toLowerCase())
|
||||
if (found === undefined) throw new ZoneUserError(`${server.id} has no zone ${zone}.`)
|
||||
if (!COPYABLE.has(found.type)) {
|
||||
throw new ZoneUserError(`${zone} is a ${found.type} zone on ${server.id}. Only Primary and Forwarder zones are copied; a ${found.type} zone gets its content from elsewhere.`)
|
||||
}
|
||||
if (found.dnssecStatus !== undefined && found.dnssecStatus !== 'Unsigned') {
|
||||
throw new ZoneUserError(`${zone} is DNSSEC-signed. The signing keys do not travel in a zone file, so a copy would carry signatures that cannot be renewed. Sign the copy on the target instead.`)
|
||||
}
|
||||
const warnings: string[] = []
|
||||
if (found.disabled === true) warnings.push(`${zone} is disabled on ${server.id}; the copy is created enabled.`)
|
||||
if (typeof found.catalog === 'string' && found.catalog !== '') warnings.push(`${zone} is a member of the catalog ${found.catalog} on ${server.id}; the copy is not added to a catalog.`)
|
||||
return { zone: found, warnings }
|
||||
}
|
||||
|
||||
async #readZone(server: HubServer, zone: string): Promise<string> {
|
||||
let file: RawAnswer
|
||||
try {
|
||||
file = await this.#upstream.raw(server, 'zones/export', { zone })
|
||||
} catch (e) {
|
||||
throw new ZoneUserError(`${server.id}: ${reason(e)}`)
|
||||
}
|
||||
try {
|
||||
return await readCapped(file, MAX_ZONE_BYTES, EXPORT_TIMEOUT_MS)
|
||||
} catch (e) {
|
||||
throw new ZoneUserError(`${server.id}: ${reason(e)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/* What is on a target for this zone, or why the target cannot take it. */
|
||||
async #look(target: HubServer, zone: string, type: string): Promise<CopyTargetPreview> {
|
||||
const base = { server: target.id, writable: target.adminToken !== undefined }
|
||||
if (target.adminToken === undefined) return { ...base, ok: false, error: 'The hub has no admin token for this server, so it cannot write to it.', exists: false, needsOverwrite: false }
|
||||
let listed: ListedZone[]
|
||||
try {
|
||||
listed = (await listAllZones(this.#upstream, target, true)).zones
|
||||
} catch (e) {
|
||||
return { ...base, ok: false, error: reason(e), exists: false, needsOverwrite: false }
|
||||
}
|
||||
const found = listed.find((z) => z.name.toLowerCase() === zone.toLowerCase())
|
||||
if (found === undefined) return { ...base, ok: true, exists: false, needsOverwrite: false }
|
||||
const here = { ...base, exists: true, type: found.type, serial: typeof found.soaSerial === 'number' ? found.soaSerial : null }
|
||||
if (found.type !== type) {
|
||||
return { ...here, ok: false, needsOverwrite: false, error: `${target.id} has ${zone} as a ${found.type} zone; a ${type} zone cannot replace it. Delete or convert it there first.` }
|
||||
}
|
||||
return { ...here, ok: true, needsOverwrite: true }
|
||||
}
|
||||
|
||||
async copyPreview(sourceId: string, zoneRaw: string, ids: string[]): Promise<CopyPreview> {
|
||||
const zone = cleanZone(zoneRaw)
|
||||
if (zone === null) throw new ZoneUserError('A valid zone name is required.')
|
||||
const source = this.#server(sourceId, 'source server')
|
||||
const targets = this.#targets(ids, source.id)
|
||||
const { zone: listed, warnings } = await this.#source(source, zone)
|
||||
const text = await this.#readZone(source, zone)
|
||||
return {
|
||||
zone,
|
||||
source: {
|
||||
server: source.id,
|
||||
type: listed.type,
|
||||
serial: typeof listed.soaSerial === 'number' ? listed.soaSerial : null,
|
||||
records: countRecordLines(text),
|
||||
bytes: Buffer.byteLength(text),
|
||||
},
|
||||
warnings,
|
||||
targets: await Promise.all(targets.map((t) => this.#look(t, zone, listed.type))),
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Copies the zone to each target. A target that has the zone is touched only when its id is in
|
||||
`overwrite` (checked here, not only by the console). Create and import are two calls: when the
|
||||
import fails after the create, the answer says an empty zone was left behind.
|
||||
*/
|
||||
async copy(sourceId: string, zoneRaw: string, ids: string[], overwrite: string[]): Promise<{ results: TargetResult[] }> {
|
||||
const zone = cleanZone(zoneRaw)
|
||||
if (zone === null) throw new ZoneUserError('A valid zone name is required.')
|
||||
const source = this.#server(sourceId, 'source server')
|
||||
const targets = this.#targets(ids, source.id)
|
||||
const { zone: listed } = await this.#source(source, zone)
|
||||
const text = await this.#readZone(source, zone)
|
||||
if (text.trim() === '') throw new ZoneUserError(`${source.id} exported an empty zone file for ${zone}.`)
|
||||
|
||||
const results = await Promise.all(
|
||||
targets.map(async (target): Promise<TargetResult> => {
|
||||
const look = await this.#look(target, zone, listed.type)
|
||||
if (!look.ok) return { server: target.id, ok: false, message: look.error }
|
||||
if (look.exists && !overwrite.includes(target.id)) {
|
||||
return { server: target.id, ok: false, message: `${zone} already exists here (${look.type}, serial ${look.serial ?? 'none'}). Tick Overwrite to replace its records.` }
|
||||
}
|
||||
try {
|
||||
if (!look.exists) {
|
||||
const params: Record<string, string> = { zone, type: listed.type }
|
||||
// A Forwarder is created empty and filled by the import (add-zone.ts: `initializeForwarder=false`).
|
||||
if (listed.type === 'Forwarder') params.initializeForwarder = 'false'
|
||||
await this.#upstream.write(target, 'zones/create', params)
|
||||
}
|
||||
} catch (e) {
|
||||
return { server: target.id, ok: false, message: `the zone could not be created: ${reason(e)}` }
|
||||
}
|
||||
try {
|
||||
// The three switches and a text/plain body, as `importZone` sends them.
|
||||
await this.#upstream.write(target, 'zones/import', { zone, overwrite: 'true', overwriteZone: 'true', overwriteSoaSerial: 'true' }, text)
|
||||
} catch (e) {
|
||||
return {
|
||||
server: target.id,
|
||||
ok: false,
|
||||
message: look.exists
|
||||
? `the import failed: ${reason(e)}`
|
||||
: `the zone was created but the import failed (${reason(e)}); an empty zone is left on ${target.id}`,
|
||||
}
|
||||
}
|
||||
return { server: target.id, ok: true, message: look.exists ? 'Records replaced.' : 'Zone created and filled.' }
|
||||
}),
|
||||
)
|
||||
return { results }
|
||||
}
|
||||
|
||||
/* ---- Make secondary ---- */
|
||||
|
||||
async #tsigNames(server: HubServer): Promise<string[]> {
|
||||
this.#upstream.invalidate(server.id)
|
||||
const r = (await this.#upstream.call(server, 'settings/getTsigKeyNames', {})) as { tsigKeyNames?: string[] } | null
|
||||
return r?.tsigKeyNames ?? []
|
||||
}
|
||||
|
||||
async secondaryPreview(input: SecondaryInput): Promise<SecondaryPreview> {
|
||||
const plan = await this.#plan(input)
|
||||
return { zone: plan.zone, source: plan.source.id, target: plan.target.id, warnings: plan.warnings, steps: plan.steps }
|
||||
}
|
||||
|
||||
async makeSecondary(input: SecondaryInput): Promise<{ results: TargetResult[] }> {
|
||||
const plan = await this.#plan(input)
|
||||
const results: TargetResult[] = []
|
||||
const { source, target } = plan
|
||||
|
||||
if (plan.sourceParams === null) {
|
||||
results.push({ server: source.id, ok: true, message: 'Transfer and notify already allow the target; nothing was changed.' })
|
||||
} else {
|
||||
try {
|
||||
await this.#upstream.write(source, 'zones/options/set', plan.sourceParams)
|
||||
results.push({ server: source.id, ok: true, message: 'Zone transfer and notify now include the target.' })
|
||||
} catch (e) {
|
||||
results.push({ server: source.id, ok: false, message: `${reason(e)}; nothing was changed on either server` })
|
||||
results.push({ server: target.id, ok: false, message: 'Not attempted: the source step failed.' })
|
||||
return { results }
|
||||
}
|
||||
}
|
||||
try {
|
||||
await this.#upstream.write(target, 'zones/create', plan.targetParams)
|
||||
results.push({ server: target.id, ok: true, message: 'Secondary zone created; it will fetch the zone from the primary.' })
|
||||
} catch (e) {
|
||||
results.push({
|
||||
server: target.id,
|
||||
ok: false,
|
||||
message: `the Secondary zone could not be created: ${reason(e)}. The change on ${source.id} (zone transfer and notify) was left in place; run this again after fixing the cause.`,
|
||||
})
|
||||
}
|
||||
return { results }
|
||||
}
|
||||
|
||||
async #plan(input: SecondaryInput): Promise<SecondaryPlan> {
|
||||
const zone = cleanZone(input.zone)
|
||||
if (zone === null) throw new ZoneUserError('A valid zone name is required.')
|
||||
const source = this.#server(input.source, 'source server')
|
||||
const target = this.#server(input.target, 'target server')
|
||||
if (source.id === target.id) throw new ZoneUserError('The source and the target must be two different servers.')
|
||||
if (source.adminToken === undefined || target.adminToken === undefined) {
|
||||
throw new ZoneUserError(`The hub has no admin token for ${source.adminToken === undefined ? source.id : target.id}, so it cannot change it.`)
|
||||
}
|
||||
const primaryAddress = input.primaryAddress.trim()
|
||||
const targetAddress = input.targetAddress.trim()
|
||||
if (!validNameServer(primaryAddress)) throw new ZoneUserError("Give the primary's address as the target reaches it: an IP address or a host name.")
|
||||
if (isIP(targetAddress) === 0) throw new ZoneUserError("Give the target's IP address as the source sees it (the zone transfer list takes addresses).")
|
||||
const key = (input.tsigKeyName ?? '').trim()
|
||||
|
||||
const warnings: string[] = []
|
||||
const { zone: listed } = await this.#sourceForSecondary(source, zone)
|
||||
let existsOnTarget: ListedZone | undefined
|
||||
try {
|
||||
existsOnTarget = (await listAllZones(this.#upstream, target, true)).zones.find((z) => z.name.toLowerCase() === zone.toLowerCase())
|
||||
} catch (e) {
|
||||
throw new ZoneUserError(`${target.id}: ${reason(e)}`)
|
||||
}
|
||||
if (existsOnTarget !== undefined) {
|
||||
throw new ZoneUserError(`${target.id} already has ${zone} as a ${existsOnTarget.type} zone. Delete it there first if it should become a Secondary.`)
|
||||
}
|
||||
if (key !== '') {
|
||||
for (const s of [source, target]) {
|
||||
let names: string[]
|
||||
try {
|
||||
names = await this.#tsigNames(s)
|
||||
} catch (e) {
|
||||
throw new ZoneUserError(`${s.id}: ${reason(e)}`)
|
||||
}
|
||||
if (!names.includes(key)) throw new ZoneUserError(`The TSIG key "${key}" does not exist on ${s.id}. Add it in that server's Settings first.`)
|
||||
}
|
||||
}
|
||||
|
||||
let options: ZoneOptionsLike
|
||||
try {
|
||||
this.#upstream.invalidate(source.id)
|
||||
options = (await this.#upstream.call(source, 'zones/options/get', { zone })) as ZoneOptionsLike
|
||||
} catch (e) {
|
||||
throw new ZoneUserError(`${source.id}: ${reason(e)}`)
|
||||
}
|
||||
if (typeof options.catalog === 'string' && options.catalog !== '' && options.overrideCatalogZoneTransfer !== true) {
|
||||
warnings.push(`${zone} follows the zone transfer settings of the catalog ${options.catalog} on ${source.id}; the change may have no effect until the zone overrides them.`)
|
||||
}
|
||||
|
||||
const sourceParams = secondaryOptions(zone, options, targetAddress, key)
|
||||
const targetParams: Record<string, string> = {
|
||||
zone,
|
||||
type: 'Secondary',
|
||||
catalog: '',
|
||||
primaryNameServerAddresses: primaryAddress,
|
||||
zoneTransferProtocol: 'Tcp',
|
||||
tsigKeyName: key,
|
||||
validateZone: 'false',
|
||||
}
|
||||
const steps: SecondaryStep[] = [
|
||||
{
|
||||
server: source.id,
|
||||
role: 'source',
|
||||
summary:
|
||||
sourceParams === null
|
||||
? `${zone} on ${source.id} already allows zone transfer and notify to ${targetAddress}.`
|
||||
: `On ${source.id}: allow zone transfer of ${zone} to ${targetAddress}${key === '' ? '' : ` with TSIG key ${key}`} and notify ${targetAddress}. What is already listed stays.`,
|
||||
changes: sourceParams !== null,
|
||||
},
|
||||
{
|
||||
server: target.id,
|
||||
role: 'target',
|
||||
summary: `On ${target.id}: create ${zone} as a Secondary zone that fetches it from ${primaryAddress} over TCP${key === '' ? '' : ` signed with ${key}`}.`,
|
||||
changes: true,
|
||||
},
|
||||
]
|
||||
return { zone, source, target, warnings, steps, sourceParams, targetParams, type: listed.type }
|
||||
}
|
||||
|
||||
async #sourceForSecondary(source: HubServer, zone: string): Promise<SourceZone> {
|
||||
let listed: ListedZone[]
|
||||
try {
|
||||
listed = (await listAllZones(this.#upstream, source, true)).zones
|
||||
} catch (e) {
|
||||
throw new ZoneUserError(`${source.id}: ${reason(e)}`)
|
||||
}
|
||||
const found = listed.find((z) => z.name.toLowerCase() === zone.toLowerCase())
|
||||
if (found === undefined) throw new ZoneUserError(`${source.id} has no zone ${zone}.`)
|
||||
if (found.type !== 'Primary') throw new ZoneUserError(`${zone} is a ${found.type} zone on ${source.id}. Only a Primary zone can be transferred to a Secondary.`)
|
||||
return { zone: found, warnings: [] }
|
||||
}
|
||||
}
|
||||
|
||||
export interface SecondaryInput {
|
||||
source: string
|
||||
target: string
|
||||
zone: string
|
||||
primaryAddress: string
|
||||
targetAddress: string
|
||||
tsigKeyName?: string
|
||||
}
|
||||
|
||||
export interface SecondaryStep {
|
||||
server: string
|
||||
role: 'source' | 'target'
|
||||
summary: string
|
||||
/* False when nothing would be written there. */
|
||||
changes: boolean
|
||||
}
|
||||
|
||||
export interface SecondaryPreview {
|
||||
zone: string
|
||||
source: string
|
||||
target: string
|
||||
warnings: string[]
|
||||
steps: SecondaryStep[]
|
||||
}
|
||||
|
||||
interface SecondaryPlan {
|
||||
zone: string
|
||||
source: HubServer
|
||||
target: HubServer
|
||||
warnings: string[]
|
||||
steps: SecondaryStep[]
|
||||
/* Null when the source already allows it. */
|
||||
sourceParams: Record<string, string> | null
|
||||
targetParams: Record<string, string>
|
||||
type: string
|
||||
}
|
||||
|
||||
interface ZoneOptionsLike {
|
||||
catalog?: string | null
|
||||
overrideCatalogZoneTransfer?: boolean
|
||||
zoneTransfer?: string
|
||||
zoneTransferNetworkACL?: string[]
|
||||
zoneTransferTsigKeyNames?: string[]
|
||||
notify?: string
|
||||
notifyNameServers?: string[]
|
||||
}
|
||||
|
||||
/* An address for a name server: an IP, a host name, either with a port. */
|
||||
export function validNameServer(value: string): boolean {
|
||||
if (value === '' || value.length > 255 || /[\s,]/.test(value)) return false
|
||||
if (isIP(value) !== 0) return true
|
||||
const bracketed = /^\[([^\]]+)\](?::(\d{1,5}))?$/.exec(value)
|
||||
if (bracketed !== null) return isIP(bracketed[1]) === 6
|
||||
const [host, port, extra] = value.split(':')
|
||||
if (extra !== undefined || (port !== undefined && !/^\d{1,5}$/.test(port))) return false
|
||||
return isIP(host) === 4 || cleanZone(host) !== null
|
||||
}
|
||||
|
||||
/* A transfer mode that also lets the listed networks in. */
|
||||
const TRANSFER_WITH_ACL: Record<string, string> = { Deny: 'UseSpecifiedNetworkACL', AllowOnlyZoneNameServers: 'AllowZoneNameServersAndUseSpecifiedNetworkACL' }
|
||||
const NOTIFY_WITH_LIST: Record<string, string> = { None: 'SpecifiedNameServers', ZoneNameServers: 'BothZoneAndSpecifiedNameServers' }
|
||||
|
||||
const joined = (list: string[]): string => (list.length === 0 ? 'false' : list.join(','))
|
||||
|
||||
/*
|
||||
The `zones/options/set` that lets `address` transfer and be notified, keeping what is there: modes
|
||||
that cannot hold a list are widened to the matching one that can, an address already listed is not
|
||||
added twice. Null when nothing needs to change. Only the transfer and notify fields are sent, so
|
||||
the rest of the zone's options is not touched.
|
||||
*/
|
||||
export function secondaryOptions(zone: string, current: ZoneOptionsLike, address: string, tsigKey: string): Record<string, string> | null {
|
||||
const mode = current.zoneTransfer ?? 'Deny'
|
||||
const acl = [...(current.zoneTransferNetworkACL ?? [])]
|
||||
const keys = [...(current.zoneTransferTsigKeyNames ?? [])]
|
||||
const notify = current.notify ?? 'None'
|
||||
const notifyList = [...(current.notifyNameServers ?? [])]
|
||||
|
||||
const nextMode = TRANSFER_WITH_ACL[mode] ?? mode
|
||||
if (nextMode !== 'Allow' && !acl.includes(address)) acl.push(address)
|
||||
if (tsigKey !== '' && !keys.includes(tsigKey)) keys.push(tsigKey)
|
||||
const nextNotify = NOTIFY_WITH_LIST[notify] ?? notify
|
||||
if (!notifyList.includes(address)) notifyList.push(address)
|
||||
|
||||
const unchanged =
|
||||
nextMode === mode &&
|
||||
nextNotify === notify &&
|
||||
acl.length === (current.zoneTransferNetworkACL ?? []).length &&
|
||||
keys.length === (current.zoneTransferTsigKeyNames ?? []).length &&
|
||||
notifyList.length === (current.notifyNameServers ?? []).length
|
||||
if (unchanged) return null
|
||||
return {
|
||||
zone,
|
||||
zoneTransfer: nextMode,
|
||||
zoneTransferNetworkACL: joined(acl),
|
||||
zoneTransferTsigKeyNames: joined(keys),
|
||||
notify: nextNotify,
|
||||
notifyNameServers: joined(notifyList),
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"mock": "node dev/mock-api.mjs",
|
||||
"hub": "node --experimental-strip-types hub/server.ts",
|
||||
"hub:user": "node --experimental-strip-types hub/users-cli.ts",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "oxlint",
|
||||
"lint:language": "node dev/check-language.mjs",
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { apiRequest, type ApiOutcome } from './client'
|
||||
|
||||
/*
|
||||
`hub/blocking/app-lists/count`: how many domains the block lists of the Advanced Blocking app
|
||||
hold on one server. The hub downloads them (the app has no API for it) and caches the counts
|
||||
for hours. A list it could not read has `count: null` and the reason in `error`.
|
||||
*/
|
||||
export interface ListCount {
|
||||
url: string
|
||||
count: number | null
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface ListCounts {
|
||||
lists: ListCount[]
|
||||
/** The sum of the lists that were counted. */
|
||||
total: number
|
||||
}
|
||||
|
||||
export function getAppListCounts(
|
||||
token: string | null,
|
||||
name: string,
|
||||
node: string,
|
||||
): Promise<ApiOutcome<{ response: ListCounts }>> {
|
||||
return apiRequest('hub/blocking/app-lists/count', { token, body: { name, node } })
|
||||
}
|
||||
+287
-9
@@ -1,4 +1,5 @@
|
||||
import { apiRequest, type ApiOutcome } from './client'
|
||||
import { urlApi } from '../app/base'
|
||||
import { apiRequest, envelopeOutcome, type ApiOutcome } from './client'
|
||||
|
||||
/*
|
||||
The nine endpoints of the `apps` family (upstream/master:www/js/apps.js).
|
||||
@@ -61,6 +62,8 @@ export interface StoreApp {
|
||||
installedVersion?: string
|
||||
/** Only when `installed` is true. */
|
||||
updateAvailable?: boolean
|
||||
/** Only behind a hub, in the All servers listing: the servers that have it installed. */
|
||||
installedOn?: string[]
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -88,28 +91,32 @@ export function listApps(
|
||||
|
||||
export function listStoreApps(
|
||||
token: string | null,
|
||||
node = '',
|
||||
): Promise<ApiOutcome<{ response: { storeApps: StoreApp[] } }>> {
|
||||
return apiRequest('apps/listStoreApps', { token })
|
||||
return apiRequest('apps/listStoreApps', { token, node })
|
||||
}
|
||||
|
||||
/* Behind a hub, `node` is the one server the call is for; elsewhere it stays empty and is not sent. */
|
||||
export function downloadAndInstall(
|
||||
token: string | null,
|
||||
name: string,
|
||||
url: string,
|
||||
node = '',
|
||||
): Promise<ApiOutcome<{ response: { installedApp: InstalledApp } }>> {
|
||||
return apiRequest('apps/downloadAndInstall', { token, body: { name, url } })
|
||||
return apiRequest('apps/downloadAndInstall', { token, body: { name, url }, node })
|
||||
}
|
||||
|
||||
export function downloadAndUpdate(
|
||||
token: string | null,
|
||||
name: string,
|
||||
url: string,
|
||||
node = '',
|
||||
): Promise<ApiOutcome<{ response: { updatedApp: InstalledApp } }>> {
|
||||
return apiRequest('apps/downloadAndUpdate', { token, body: { name, url } })
|
||||
return apiRequest('apps/downloadAndUpdate', { token, body: { name, url }, node })
|
||||
}
|
||||
|
||||
export function uninstallApp(token: string | null, name: string): Promise<ApiOutcome> {
|
||||
return apiRequest('apps/uninstall', { token, body: { name } })
|
||||
export function uninstallApp(token: string | null, name: string, node = ''): Promise<ApiOutcome> {
|
||||
return apiRequest('apps/uninstall', { token, body: { name }, node })
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -136,8 +143,9 @@ export function setAppConfig(
|
||||
token: string | null,
|
||||
name: string,
|
||||
config: string,
|
||||
node = '',
|
||||
): Promise<ApiOutcome> {
|
||||
return apiRequest('apps/config/set', { token, method: 'POST', body: { name, config } })
|
||||
return apiRequest('apps/config/set', { token, method: 'POST', body: { name, config }, node })
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -174,16 +182,286 @@ export function installApp(
|
||||
token: string | null,
|
||||
name: string,
|
||||
file: File,
|
||||
node = '',
|
||||
): Promise<ApiOutcome<{ response: { installedApp: InstalledApp } }>> {
|
||||
const { path, form } = buildUpload('apps/install', name, file)
|
||||
return apiRequest(path, { token, form })
|
||||
return apiRequest(path, { token, form, node })
|
||||
}
|
||||
|
||||
export function updateApp(
|
||||
token: string | null,
|
||||
name: string,
|
||||
file: File,
|
||||
node = '',
|
||||
): Promise<ApiOutcome<{ response: { updatedApp: InstalledApp } }>> {
|
||||
const { path, form } = buildUpload('apps/update', name, file)
|
||||
return apiRequest(path, { token, form })
|
||||
return apiRequest(path, { token, form, node })
|
||||
}
|
||||
|
||||
/*
|
||||
The hub's matrix of apps over servers (`hub/apps/matrix`, hub/apps-matrix.ts). Only on a
|
||||
hub. A config is never in it: just a hash per server and the comparison.
|
||||
*/
|
||||
export interface MatrixCell {
|
||||
installed: boolean
|
||||
version?: string
|
||||
updateAvailable?: boolean
|
||||
updateVersion?: string
|
||||
configStatus: 'none' | 'read' | 'unreadable'
|
||||
configHash?: string
|
||||
configError?: string
|
||||
}
|
||||
|
||||
export interface MatrixApp {
|
||||
name: string
|
||||
description: string
|
||||
servers: Record<string, MatrixCell>
|
||||
configs: 'same' | 'different' | 'single' | 'unknown'
|
||||
diffCount?: number
|
||||
versions: 'same' | 'different' | 'single'
|
||||
}
|
||||
|
||||
export interface AppsMatrixData {
|
||||
servers: { id: string; ok: boolean; writable: boolean; error?: string }[]
|
||||
apps: MatrixApp[]
|
||||
}
|
||||
|
||||
export function getAppsMatrix(token: string | null): Promise<ApiOutcome<{ response: AppsMatrixData }>> {
|
||||
return apiRequest('hub/apps/matrix', { token })
|
||||
}
|
||||
|
||||
/*
|
||||
App writes across servers (hub/apps-write.ts). The hub resolves a store URL from each
|
||||
server's own store; the client never sends one.
|
||||
*/
|
||||
export type HubRunAction = 'install-store' | 'update-store' | 'uninstall'
|
||||
|
||||
export interface StepResult {
|
||||
step: 'apps' | 'configs' | 'settings' | 'lists'
|
||||
status: 'ok' | 'failed' | 'skipped' | 'not-run'
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface TargetResult {
|
||||
server: string
|
||||
ok: boolean
|
||||
message?: string
|
||||
/* Only a profile apply: what each step did on this server. */
|
||||
steps?: StepResult[]
|
||||
}
|
||||
|
||||
export type RunOutcome = ApiOutcome<{ response: { results: TargetResult[] } }>
|
||||
|
||||
export function runHubApps(token: string | null, action: HubRunAction, name: string, targets: string[]): Promise<RunOutcome> {
|
||||
return apiRequest('hub/apps/run', { token, method: 'POST', body: { action, name, targets: targets.join(',') } })
|
||||
}
|
||||
|
||||
/* The same zip to several servers: multipart, as the hub's `hub/apps/upload` expects. */
|
||||
export function uploadHubApp(
|
||||
token: string | null,
|
||||
action: 'install' | 'update',
|
||||
name: string,
|
||||
file: File,
|
||||
targets: string[],
|
||||
): Promise<RunOutcome> {
|
||||
const form = new FormData()
|
||||
form.append('action', action)
|
||||
form.append('name', name)
|
||||
form.append('targets', targets.join(','))
|
||||
form.append('fileAppZip', file)
|
||||
return apiRequest('hub/apps/upload', { token, form })
|
||||
}
|
||||
|
||||
/*
|
||||
Config sync behind a hub (hub/apps-config.ts): the comparison of one app's config across
|
||||
servers, the baseline kept in the hub, the fields that stay local to each server, and the
|
||||
preview and apply that bring servers to baseline plus their own local values.
|
||||
*/
|
||||
export interface FieldChange {
|
||||
path: string
|
||||
type: 'added' | 'removed' | 'changed'
|
||||
a?: unknown
|
||||
b?: unknown
|
||||
}
|
||||
|
||||
export interface LineOp {
|
||||
op: 'same' | 'add' | 'remove'
|
||||
text: string
|
||||
}
|
||||
|
||||
export type ConfigDiff =
|
||||
| { kind: 'json'; changes: FieldChange[] }
|
||||
| { kind: 'text'; lines: LineOp[]; truncated: boolean; changed: number }
|
||||
|
||||
export interface Baseline {
|
||||
app: string
|
||||
text: string
|
||||
importedFrom?: string
|
||||
updatedAt: string
|
||||
updatedBy: string
|
||||
}
|
||||
|
||||
export interface Overrides {
|
||||
paths: string[]
|
||||
values: Record<string, Record<string, unknown>>
|
||||
}
|
||||
|
||||
export interface ServerConfigView {
|
||||
id: string
|
||||
ok: boolean
|
||||
error?: string
|
||||
installed: boolean
|
||||
writable: boolean
|
||||
version?: string
|
||||
text: string | null
|
||||
hash: string
|
||||
effective?: string
|
||||
warnings: string[]
|
||||
/** Null without a baseline. */
|
||||
inSync: boolean | null
|
||||
vsBaseline?: ConfigDiff
|
||||
vsOthers: Record<string, ConfigDiff>
|
||||
}
|
||||
|
||||
export interface ConfigCompare {
|
||||
name: string
|
||||
baseline: Baseline | null
|
||||
overrides: Overrides
|
||||
servers: ServerConfigView[]
|
||||
}
|
||||
|
||||
export interface TargetPreview {
|
||||
server: string
|
||||
ok: boolean
|
||||
error?: string
|
||||
writable: boolean
|
||||
current: string | null
|
||||
currentHash: string
|
||||
effective: string
|
||||
changes: ConfigDiff
|
||||
unchanged: boolean
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
export const getConfigCompare = (token: string | null, name: string): Promise<ApiOutcome<{ response: ConfigCompare }>> =>
|
||||
apiRequest('hub/apps/config/compare', { token, body: { name } })
|
||||
|
||||
export const importBaseline = (token: string | null, name: string, server: string): Promise<ApiOutcome<{ response: { baseline: Baseline } }>> =>
|
||||
apiRequest('hub/apps/baseline/import', { token, method: 'POST', body: { name, server } })
|
||||
|
||||
export const saveBaseline = (token: string | null, name: string, text: string): Promise<ApiOutcome<{ response: { baseline: Baseline } }>> =>
|
||||
apiRequest('hub/apps/baseline/save', { token, method: 'POST', body: { name, text } })
|
||||
|
||||
export const saveOverrides = (token: string | null, name: string, overrides: Overrides): Promise<ApiOutcome<{ response: { overrides: Overrides } }>> =>
|
||||
apiRequest('hub/apps/overrides/save', {
|
||||
token,
|
||||
method: 'POST',
|
||||
body: { name, paths: overrides.paths.join(','), values: JSON.stringify(overrides.values) },
|
||||
})
|
||||
|
||||
export const getCurrentLocalValues = (token: string | null, name: string, paths: string[]): Promise<ApiOutcome<{ response: { values: Overrides['values'] } }>> =>
|
||||
apiRequest('hub/apps/overrides/current', { token, method: 'POST', body: { name, paths: paths.join(',') } })
|
||||
|
||||
export const previewConfig = (token: string | null, name: string, targets: string[]): Promise<ApiOutcome<{ response: { targets: TargetPreview[] } }>> =>
|
||||
apiRequest('hub/apps/config/preview', { token, method: 'POST', body: { name, targets: targets.join(',') } })
|
||||
|
||||
export const applyConfig = (token: string | null, name: string, targets: string[], expect: Record<string, string>): Promise<RunOutcome> =>
|
||||
apiRequest('hub/apps/config/apply', { token, method: 'POST', body: { name, targets: targets.join(','), expect: JSON.stringify(expect) } })
|
||||
|
||||
/*
|
||||
History, rollback, drift, and moving baselines between hubs (hub/apps-config.ts, hub/drift.ts).
|
||||
*/
|
||||
export interface HistoryEntry {
|
||||
id: string
|
||||
at: string
|
||||
/** A server id, or `baseline` for an earlier text of the baseline. */
|
||||
server: string
|
||||
kind: string
|
||||
size: number
|
||||
hash: string
|
||||
}
|
||||
|
||||
export interface HistoryDiff {
|
||||
entry: HistoryEntry
|
||||
server: string
|
||||
writable: boolean
|
||||
current: string
|
||||
currentHash: string
|
||||
version: string
|
||||
changes: ConfigDiff
|
||||
unchanged: boolean
|
||||
}
|
||||
|
||||
export type DriftStatus = 'differs' | 'unreadable' | 'not-installed'
|
||||
/* What drifted: an app's config, the settings, or the Blocked and Allowed lists. */
|
||||
export type DriftKind = 'app' | 'settings' | 'lists'
|
||||
|
||||
export interface DriftEntry {
|
||||
kind: DriftKind
|
||||
/* The app's name; absent for settings and lists. */
|
||||
name?: string
|
||||
app?: string
|
||||
server: string
|
||||
since: string
|
||||
status: DriftStatus
|
||||
/* Setting names, or blocked and allowed. Never values. */
|
||||
fields?: string[]
|
||||
}
|
||||
|
||||
export interface DriftState {
|
||||
checkedAt: string | null
|
||||
apps: DriftEntry[]
|
||||
checking?: boolean
|
||||
}
|
||||
|
||||
export interface ImportPlan {
|
||||
apps: { app: string; status: 'new' | 'changed' | 'same'; ignoredServers: string[] }[]
|
||||
applied: boolean
|
||||
}
|
||||
|
||||
export const getHistory = (token: string | null, name: string): Promise<ApiOutcome<{ response: { entries: HistoryEntry[] } }>> =>
|
||||
apiRequest('hub/apps/history/list', { token, body: { name } })
|
||||
|
||||
export const getHistoryEntry = (token: string | null, name: string, id: string): Promise<ApiOutcome<{ response: { entry: HistoryEntry; text: string } }>> =>
|
||||
apiRequest('hub/apps/history/get', { token, body: { name, id } })
|
||||
|
||||
export const getHistoryDiff = (token: string | null, name: string, id: string, server: string): Promise<ApiOutcome<{ response: HistoryDiff }>> =>
|
||||
apiRequest('hub/apps/history/diff', { token, body: { name, id, server } })
|
||||
|
||||
export const rollbackHistory = (token: string | null, name: string, id: string, targets: string[], expect: Record<string, string>): Promise<RunOutcome> =>
|
||||
apiRequest('hub/apps/history/rollback', { token, method: 'POST', body: { name, id, targets: targets.join(','), expect: JSON.stringify(expect) } })
|
||||
|
||||
export const restoreBaseline = (token: string | null, name: string, id: string): Promise<ApiOutcome<{ response: { baseline: Baseline } }>> =>
|
||||
apiRequest('hub/apps/baseline/restore', { token, method: 'POST', body: { name, id } })
|
||||
|
||||
export const getDrift = (token: string | null): Promise<ApiOutcome<{ response: DriftState }>> => apiRequest('hub/drift/get', { token })
|
||||
|
||||
export const checkDrift = (token: string | null): Promise<ApiOutcome<{ response: DriftState }>> => apiRequest('hub/drift/check', { token, method: 'POST', body: {} })
|
||||
|
||||
export const acceptDrift = (token: string | null, name: string, server: string): Promise<ApiOutcome<{ response: { baseline: Baseline } }>> =>
|
||||
apiRequest('hub/apps/drift/accept', { token, method: 'POST', body: { name, server } })
|
||||
|
||||
export const importBaselines = (token: string | null, data: string, apply: boolean): Promise<ApiOutcome<{ response: ImportPlan }>> =>
|
||||
apiRequest('hub/apps/import', { token, method: 'POST', body: { data, apply: apply ? 'true' : 'false' } })
|
||||
|
||||
/* The file of all baselines. A download is not the usual envelope, so it is read here (as the rule lists are). */
|
||||
export async function exportBaselines(token: string | null): Promise<ApiOutcome<{ filename: string; text: string }>> {
|
||||
const headers: Record<string, string> = {}
|
||||
if (token) headers.Authorization = `Bearer ${token}`
|
||||
let res: Response
|
||||
let text: string
|
||||
try {
|
||||
res = await fetch(urlApi('api/hub/apps/export'), { headers })
|
||||
text = await res.text()
|
||||
} catch {
|
||||
return { kind: 'error', message: 'Unable to connect to the server. Please try again.' }
|
||||
}
|
||||
try {
|
||||
const env = JSON.parse(text) as { status?: string; hub?: string }
|
||||
if (env.status !== undefined && env.status !== 'ok') return envelopeOutcome<{ filename: string; text: string }>(env, token)
|
||||
} catch {
|
||||
return { kind: 'error', message: 'The hub did not send a file.' }
|
||||
}
|
||||
const filename = /filename="([^"]+)"/.exec(res.headers.get('content-disposition') ?? '')?.[1] ?? 'hub-baselines.json'
|
||||
return { kind: 'ok', data: { filename, text } }
|
||||
}
|
||||
+7
-6
@@ -2,6 +2,7 @@ import { urlApi } from '../app/base'
|
||||
import { envelopeOutcome, type ApiOutcome } from './client'
|
||||
import { queryLogs, type QueryLogEntry } from './logs'
|
||||
import { addDomain, deleteDomain, type DomainList } from './zonelists'
|
||||
import { nodeArg } from '../lib/hub-write'
|
||||
|
||||
/*
|
||||
The Blocking section's own reads. Every endpoint here already exists in the server;
|
||||
@@ -144,15 +145,15 @@ export async function recentBlocked(
|
||||
}
|
||||
|
||||
/* `allowDomain` (other-zones.js:637): out of Blocked first, then into Allowed. */
|
||||
export async function allowDomain(token: string | null, domain: string): Promise<ApiOutcome> {
|
||||
const removed = await deleteDomain('blocked', token, domain)
|
||||
export async function allowDomain(token: string | null, domain: string, node = ''): Promise<ApiOutcome> {
|
||||
const removed = await deleteDomain('blocked', token, domain, ...nodeArg(node))
|
||||
if (removed.kind !== 'ok') return removed
|
||||
return addDomain('allowed', token, domain)
|
||||
return addDomain('allowed', token, domain, ...nodeArg(node))
|
||||
}
|
||||
|
||||
/* `blockDomain` (other-zones.js:686): out of Allowed first, then into Blocked. */
|
||||
export async function blockDomain(token: string | null, domain: string): Promise<ApiOutcome> {
|
||||
const removed = await deleteDomain('allowed', token, domain)
|
||||
export async function blockDomain(token: string | null, domain: string, node = ''): Promise<ApiOutcome> {
|
||||
const removed = await deleteDomain('allowed', token, domain, ...nodeArg(node))
|
||||
if (removed.kind !== 'ok') return removed
|
||||
return addDomain('blocked', token, domain)
|
||||
return addDomain('blocked', token, domain, ...nodeArg(node))
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { urlApi } from '../app/base'
|
||||
import { setHubWarnings } from './hub-warnings'
|
||||
/*
|
||||
The console's HTTP client. A replica of the old console's `HTTPRequest` helper
|
||||
(upstream/master:DnsServerCore/www/js/common.js:28).
|
||||
@@ -66,6 +67,8 @@ export interface ApiOptions {
|
||||
interface Envelope {
|
||||
status?: string
|
||||
errorMessage?: string
|
||||
/** Only from the hub: servers that did not answer an aggregate call. */
|
||||
hubWarnings?: unknown
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -101,6 +104,8 @@ cannot go through it because their good answer is not JSON (`allowed/export`,
|
||||
export function envelopeOutcome<T>(payload: Envelope, token?: string | null): ApiOutcome<T> {
|
||||
switch (payload.status) {
|
||||
case 'ok':
|
||||
// Only an aggregate answer carries the field; a per-node call must not clear it.
|
||||
if (payload.hubWarnings !== undefined) setHubWarnings(payload.hubWarnings)
|
||||
return { kind: 'ok', data: payload as T }
|
||||
case 'invalid-token':
|
||||
onExpired?.(token)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { getDrift, type DriftEntry } from './apps'
|
||||
|
||||
/*
|
||||
Which servers no longer have the config the hub says they should. The session carries only a count
|
||||
(`info.hubDrift`), cheap enough to read at sign-in; the list comes from `hub/drift/get`. The header
|
||||
subscribes to this store, and the screens that fix a drift call `refreshHubDrift` afterwards.
|
||||
*/
|
||||
interface State {
|
||||
count: number
|
||||
entries: readonly DriftEntry[]
|
||||
}
|
||||
|
||||
let current: State = { count: 0, entries: [] }
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
function set(next: State): void {
|
||||
if (next.count === current.count && next.entries.length === current.entries.length && next.entries.every((e, i) => e === current.entries[i])) return
|
||||
current = next
|
||||
for (const fn of listeners) fn()
|
||||
}
|
||||
|
||||
/** The count from the session, before the list is read. */
|
||||
export function setHubDriftCount(count: number): void {
|
||||
if (current.entries.length === 0) set({ count, entries: [] })
|
||||
}
|
||||
|
||||
export async function refreshHubDrift(token: string | null): Promise<void> {
|
||||
const outcome = await getDrift(token)
|
||||
if (outcome.kind !== 'ok') return
|
||||
const entries = Array.isArray(outcome.data.response?.apps) ? outcome.data.response.apps : []
|
||||
set({ count: entries.filter((e) => e.status === 'differs').length, entries })
|
||||
}
|
||||
|
||||
export function resetHubDrift(): void {
|
||||
set({ count: 0, entries: [] })
|
||||
}
|
||||
|
||||
const subscribe = (fn: () => void): (() => void) => {
|
||||
listeners.add(fn)
|
||||
return () => listeners.delete(fn)
|
||||
}
|
||||
const snapshot = (): State => current
|
||||
|
||||
export function useHubDrift(): State {
|
||||
return useSyncExternalStore(subscribe, snapshot, snapshot)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { urlApi } from '../app/base'
|
||||
import { apiRequest, envelopeOutcome, type ApiOutcome } from './client'
|
||||
import type { ConfigDiff, RunOutcome } from './apps'
|
||||
|
||||
/*
|
||||
Profiles and the webhook, behind a hub (hub/profiles.ts, hub/webhook.ts). Every endpoint needs the
|
||||
hub's admin role. A profile holds settings secrets, so no answer shows one: they appear as
|
||||
`<secret>`, and an app's config text is never sent, only whether there is one.
|
||||
*/
|
||||
|
||||
export interface ProfileSummary {
|
||||
name: string
|
||||
apps: number
|
||||
configs: number
|
||||
settings: number | null
|
||||
lists: { blocked: number; allowed: number } | null
|
||||
updatedAt: string
|
||||
updatedBy: string
|
||||
}
|
||||
|
||||
export interface ProfileView {
|
||||
name: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
updatedBy: string
|
||||
apps: { name: string; version: string; hasConfig: boolean; bytes: number }[]
|
||||
settings: Record<string, unknown> | null
|
||||
lists: { blocked: number; allowed: number } | null
|
||||
}
|
||||
|
||||
export const listProfiles = (token: string | null): Promise<ApiOutcome<{ response: { profiles: ProfileSummary[] } }>> => apiRequest('hub/profiles/list', { token })
|
||||
|
||||
export const getProfile = (token: string | null, name: string): Promise<ApiOutcome<{ response: ProfileView }>> => apiRequest('hub/profiles/get', { token, body: { name } })
|
||||
|
||||
export interface SnapshotParts {
|
||||
/* Undefined takes every app that has a baseline. */
|
||||
apps?: string[]
|
||||
settings: boolean
|
||||
lists: boolean
|
||||
}
|
||||
|
||||
export const snapshotProfile = (token: string | null, name: string, parts: SnapshotParts): Promise<ApiOutcome<{ response: { profile: ProfileView; replaced: boolean } }>> =>
|
||||
apiRequest('hub/profiles/snapshot', {
|
||||
token,
|
||||
method: 'POST',
|
||||
body: { name, settings: String(parts.settings), lists: String(parts.lists), ...(parts.apps === undefined ? {} : { apps: parts.apps.join(',') }) },
|
||||
})
|
||||
|
||||
export const deleteProfile = (token: string | null, name: string): Promise<ApiOutcome<{ response: { deleted: boolean } }>> =>
|
||||
apiRequest('hub/profiles/delete', { token, method: 'POST', body: { name } })
|
||||
|
||||
export interface ProfileImportPlan {
|
||||
profiles: { name: string; status: 'new' | 'changed' | 'same'; apps: number; settings: boolean; lists: boolean }[]
|
||||
applied: boolean
|
||||
}
|
||||
|
||||
export const importProfiles = (token: string | null, data: string, apply: boolean): Promise<ApiOutcome<{ response: ProfileImportPlan }>> =>
|
||||
apiRequest('hub/profiles/import', { token, method: 'POST', body: { data, apply: apply ? 'true' : 'false' } })
|
||||
|
||||
/* The file of profiles (it holds settings secrets). A download is not the usual envelope. */
|
||||
export async function exportProfiles(token: string | null, name?: string): Promise<ApiOutcome<{ filename: string; text: string }>> {
|
||||
const headers: Record<string, string> = {}
|
||||
if (token) headers.Authorization = `Bearer ${token}`
|
||||
let res: Response
|
||||
let text: string
|
||||
try {
|
||||
res = await fetch(urlApi(`api/hub/profiles/export${name === undefined ? '' : `?name=${encodeURIComponent(name)}`}`), { headers })
|
||||
text = await res.text()
|
||||
} catch {
|
||||
return { kind: 'error', message: 'Unable to connect to the server. Please try again.' }
|
||||
}
|
||||
try {
|
||||
const env = JSON.parse(text) as { status?: string }
|
||||
if (env.status !== undefined && env.status !== 'ok') return envelopeOutcome<{ filename: string; text: string }>(env, token)
|
||||
} catch {
|
||||
return { kind: 'error', message: 'The hub did not send a file.' }
|
||||
}
|
||||
const filename = /filename="([^"]+)"/.exec(res.headers.get('content-disposition') ?? '')?.[1] ?? 'hub-profiles.json'
|
||||
return { kind: 'ok', data: { filename, text } }
|
||||
}
|
||||
|
||||
export interface ProfileAppState {
|
||||
name: string
|
||||
state: 'install' | 'present' | 'version-differs' | 'not-in-store'
|
||||
profileVersion: string
|
||||
storeVersion?: string
|
||||
installedVersion?: string
|
||||
}
|
||||
|
||||
export interface ProfileConfigState {
|
||||
name: string
|
||||
state: 'same' | 'differs' | 'after-install' | 'unreadable'
|
||||
error?: string
|
||||
changes?: ConfigDiff
|
||||
}
|
||||
|
||||
export interface ProfileTargetPreview {
|
||||
server: string
|
||||
writable: boolean
|
||||
ok: boolean
|
||||
error?: string
|
||||
apps: ProfileAppState[]
|
||||
configs: ProfileConfigState[]
|
||||
settings: { fields: string[]; secretFields: string[]; unchanged: boolean; changes: ConfigDiff; warnings: string[]; error?: string } | null
|
||||
lists: { blocked: { missing: number; extra: number }; allowed: { missing: number; extra: number }; status: string; tooMany?: string; error?: string } | null
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
export const previewProfile = (token: string | null, name: string, targets: string[], removeExtras: boolean): Promise<ApiOutcome<{ response: { profile: string; targets: ProfileTargetPreview[] } }>> =>
|
||||
apiRequest('hub/profiles/preview', { token, method: 'POST', body: { name, targets: targets.join(','), removeExtras: String(removeExtras) } })
|
||||
|
||||
/* `from` says, per server, the step to start at: the retry of a failed run. */
|
||||
export const applyProfile = (token: string | null, name: string, targets: string[], removeExtras: boolean, from: Record<string, string>): Promise<RunOutcome> =>
|
||||
apiRequest('hub/profiles/apply', { token, method: 'POST', body: { name, targets: targets.join(','), removeExtras: String(removeExtras), from: JSON.stringify(from) } })
|
||||
|
||||
export interface WebhookStatus {
|
||||
configured: boolean
|
||||
/* Host and port only, never the address. */
|
||||
host: string | null
|
||||
signed: boolean
|
||||
last: { event: string; at: string; ok: boolean; error?: string } | null
|
||||
}
|
||||
|
||||
export const getWebhookStatus = (token: string | null): Promise<ApiOutcome<{ response: WebhookStatus }>> => apiRequest('hub/webhook/status', { token })
|
||||
|
||||
export const testWebhook = (token: string | null): Promise<ApiOutcome<{ response: WebhookStatus }>> => apiRequest('hub/webhook/test', { token, method: 'POST', body: {} })
|
||||
@@ -0,0 +1,26 @@
|
||||
import { apiRequest } from './client'
|
||||
import type { RunOutcome } from './apps'
|
||||
|
||||
/*
|
||||
The same change on several servers, outside Apps (hub/servers-run.ts). The hub checks the
|
||||
parameters and sends the one matching call to each target with that server's admin token; the
|
||||
answer is a result per server.
|
||||
*/
|
||||
export type ServerAction =
|
||||
| 'blocked.add'
|
||||
| 'blocked.delete'
|
||||
| 'allowed.add'
|
||||
| 'allowed.delete'
|
||||
| 'blocking.disable'
|
||||
| 'blocking.enable'
|
||||
| 'blocking.updateLists'
|
||||
| 'blocking.setListUrls'
|
||||
| 'cache.flush'
|
||||
| 'cache.delete'
|
||||
|
||||
/** What an action needs besides the servers: a `domain`, `minutes`, or `urls` (one per line). */
|
||||
export type ServerParams = Partial<Record<'domain' | 'minutes' | 'urls', string>>
|
||||
|
||||
export function runOnServers(token: string | null, action: ServerAction, params: ServerParams, targets: string[]): Promise<RunOutcome> {
|
||||
return apiRequest('hub/servers/run', { token, method: 'POST', body: { action, ...params, targets: targets.join(',') } })
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { apiRequest, type ApiOutcome } from './client'
|
||||
import type { ConfigDiff, HistoryEntry, RunOutcome } from './apps'
|
||||
|
||||
/*
|
||||
Settings and blocking across the servers, behind a hub (hub/settings-config.ts). Every endpoint
|
||||
needs the hub's admin role: settings carry secrets, so even the comparison is for admins. No answer
|
||||
ever carries a secret; a secret shows as `<secret>` and a row says only that it differs.
|
||||
*/
|
||||
export const SECRET_PLACEHOLDER = '<secret>'
|
||||
|
||||
export interface SettingsCell {
|
||||
value: unknown
|
||||
differs: boolean
|
||||
secretChanged?: boolean
|
||||
}
|
||||
|
||||
export interface SettingsRow {
|
||||
name: string
|
||||
group: 'blocking' | 'general'
|
||||
local: boolean
|
||||
secret: boolean
|
||||
expected?: unknown
|
||||
cells: Record<string, SettingsCell>
|
||||
}
|
||||
|
||||
export interface SettingsServer {
|
||||
id: string
|
||||
ok: boolean
|
||||
error?: string
|
||||
writable: boolean
|
||||
status: 'same' | 'differs' | 'local-only' | 'unknown'
|
||||
differing: number
|
||||
localDiffering: number
|
||||
paused: boolean
|
||||
}
|
||||
|
||||
export interface SettingsBaselineView {
|
||||
settings: Record<string, unknown>
|
||||
importedFrom?: string
|
||||
updatedAt: string
|
||||
updatedBy: string
|
||||
}
|
||||
|
||||
export interface SettingsCompare {
|
||||
baseline: SettingsBaselineView | null
|
||||
withBaseline: boolean
|
||||
localFields: { builtIn: string[]; builtInPatterns: string[]; extra: string[] }
|
||||
blockingFields: string[]
|
||||
servers: SettingsServer[]
|
||||
fields: SettingsRow[]
|
||||
}
|
||||
|
||||
export const getSettingsCompare = (token: string | null): Promise<ApiOutcome<{ response: SettingsCompare }>> => apiRequest('hub/settings/compare', { token })
|
||||
|
||||
export const importSettingsBaseline = (token: string | null, server: string, group?: 'blocking'): Promise<ApiOutcome<{ response: { baseline: SettingsBaselineView } }>> =>
|
||||
apiRequest('hub/settings/baseline/import', { token, method: 'POST', body: { server, ...(group === undefined ? {} : { group }) } })
|
||||
|
||||
export const saveSettingsBaseline = (token: string | null, text: string): Promise<ApiOutcome<{ response: { baseline: SettingsBaselineView } }>> =>
|
||||
apiRequest('hub/settings/baseline/save', { token, method: 'POST', body: { text } })
|
||||
|
||||
export const saveSettingsOverrides = (token: string | null, paths: string[]): Promise<ApiOutcome<{ response: { overrides: { paths: string[] } } }>> =>
|
||||
apiRequest('hub/settings/overrides/save', { token, method: 'POST', body: { paths: paths.join(',') } })
|
||||
|
||||
export interface SettingsPreview {
|
||||
server: string
|
||||
ok: boolean
|
||||
error?: string
|
||||
writable: boolean
|
||||
currentHash: string
|
||||
changes: ConfigDiff
|
||||
secretFields: string[]
|
||||
unchanged: boolean
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
export const previewSettings = (token: string | null, targets: string[], group?: 'blocking'): Promise<ApiOutcome<{ response: { targets: SettingsPreview[] } }>> =>
|
||||
apiRequest('hub/settings/preview', { token, method: 'POST', body: { targets: targets.join(','), ...(group === undefined ? {} : { group }) } })
|
||||
|
||||
export const applySettings = (token: string | null, targets: string[], expect: Record<string, string>, group?: 'blocking'): Promise<RunOutcome> =>
|
||||
apiRequest('hub/settings/apply', { token, method: 'POST', body: { targets: targets.join(','), expect: JSON.stringify(expect), ...(group === undefined ? {} : { group }) } })
|
||||
|
||||
export const getSettingsHistory = (token: string | null): Promise<ApiOutcome<{ response: { entries: HistoryEntry[] } }>> => apiRequest('hub/settings/history/list', { token })
|
||||
|
||||
export const getSettingsHistoryEntry = (
|
||||
token: string | null,
|
||||
id: string,
|
||||
): Promise<ApiOutcome<{ response: { entry: HistoryEntry; settings?: Record<string, unknown>; lists?: { blocked: number; allowed: number } } }>> =>
|
||||
apiRequest('hub/settings/history/get', { token, body: { id } })
|
||||
|
||||
export interface ListCounts {
|
||||
count: number
|
||||
missing: number
|
||||
extra: number
|
||||
missingSample: string[]
|
||||
extraSample: string[]
|
||||
}
|
||||
|
||||
export interface ListsServer {
|
||||
id: string
|
||||
ok: boolean
|
||||
error?: string
|
||||
writable: boolean
|
||||
blocked: ListCounts
|
||||
allowed: ListCounts
|
||||
status: 'same' | 'differs' | 'unknown'
|
||||
tooMany?: string
|
||||
}
|
||||
|
||||
export interface ListsCompare {
|
||||
baseline: { blocked: number; allowed: number; importedFrom?: string; updatedAt: string; updatedBy: string } | null
|
||||
withBaseline: boolean
|
||||
servers: ListsServer[]
|
||||
}
|
||||
|
||||
export const getListsCompare = (token: string | null): Promise<ApiOutcome<{ response: ListsCompare }>> => apiRequest('hub/blocking/lists/compare', { token })
|
||||
|
||||
export const importListsBaseline = (token: string | null, server: string): Promise<ApiOutcome<{ response: { imported: boolean } }>> =>
|
||||
apiRequest('hub/blocking/lists/baseline/import', { token, method: 'POST', body: { server } })
|
||||
|
||||
export const previewLists = (token: string | null, targets: string[], removeExtras: boolean): Promise<ApiOutcome<{ response: { targets: ListsServer[] } }>> =>
|
||||
apiRequest('hub/blocking/lists/preview', { token, method: 'POST', body: { targets: targets.join(','), removeExtras: String(removeExtras) } })
|
||||
|
||||
export const applyLists = (token: string | null, targets: string[], removeExtras: boolean): Promise<RunOutcome> =>
|
||||
apiRequest('hub/blocking/lists/apply', { token, method: 'POST', body: { targets: targets.join(','), removeExtras: String(removeExtras) } })
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useSyncExternalStore } from 'react'
|
||||
|
||||
/*
|
||||
What the hub says about the servers behind it. On an aggregate call it adds a
|
||||
top-level `hubWarnings: string[]` to the envelope when a server did not answer
|
||||
("ndns: timed out after 8000 ms"). A server never sends it, so the list stays empty
|
||||
outside the hub.
|
||||
|
||||
The envelope is read in one place (`client.ts`), far from the header that shows
|
||||
this, so the latest list lives here and the header subscribes. Every ok response
|
||||
sets it: an answer without warnings clears it.
|
||||
*/
|
||||
let current: readonly string[] = []
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
export function setHubWarnings(next: unknown): void {
|
||||
const list = Array.isArray(next) ? next.filter((w): w is string => typeof w === 'string') : []
|
||||
if (list.length === current.length && list.every((w, i) => w === current[i])) return
|
||||
current = list
|
||||
for (const fn of listeners) fn()
|
||||
}
|
||||
|
||||
export function getHubWarnings(): readonly string[] {
|
||||
return current
|
||||
}
|
||||
|
||||
export function subscribeHubWarnings(fn: () => void): () => void {
|
||||
listeners.add(fn)
|
||||
return () => listeners.delete(fn)
|
||||
}
|
||||
|
||||
export function useHubWarnings(): readonly string[] {
|
||||
return useSyncExternalStore(subscribeHubWarnings, getHubWarnings, getHubWarnings)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { apiRequest, type ApiOutcome } from './client'
|
||||
import type { RunOutcome } from './apps'
|
||||
|
||||
/*
|
||||
Zones across the servers, behind a hub (hub/zones-sync.ts): the merged list, copying a zone to
|
||||
other servers, and making a zone a Secondary of another server's Primary.
|
||||
*/
|
||||
export interface ZoneCell {
|
||||
state: 'present' | 'absent' | 'unknown'
|
||||
type?: string
|
||||
serial?: number | null
|
||||
disabled?: boolean
|
||||
dnssec?: string
|
||||
lastModified?: string
|
||||
}
|
||||
|
||||
export type RowStatus = 'same' | 'serial-differs' | 'type-differs' | 'only-on' | 'missing-on' | 'unknown'
|
||||
|
||||
export interface MatrixZone {
|
||||
name: string
|
||||
cells: Record<string, ZoneCell>
|
||||
status: RowStatus
|
||||
onlyOn?: string
|
||||
missingOn?: string[]
|
||||
note?: 'primary-secondary' | 'secondary-behind'
|
||||
}
|
||||
|
||||
export interface ZonesMatrixData {
|
||||
servers: { id: string; ok: boolean; writable: boolean; host: string; error?: string }[]
|
||||
zones: MatrixZone[]
|
||||
}
|
||||
|
||||
export const getZonesMatrix = (token: string | null): Promise<ApiOutcome<{ response: ZonesMatrixData }>> => apiRequest('hub/zones/matrix', { token })
|
||||
|
||||
export interface CopyTargetPreview {
|
||||
server: string
|
||||
ok: boolean
|
||||
error?: string
|
||||
writable: boolean
|
||||
exists: boolean
|
||||
type?: string
|
||||
serial?: number | null
|
||||
needsOverwrite: boolean
|
||||
}
|
||||
|
||||
export interface CopyPreview {
|
||||
zone: string
|
||||
source: { server: string; type: string; serial: number | null; records: number; bytes: number }
|
||||
warnings: string[]
|
||||
targets: CopyTargetPreview[]
|
||||
}
|
||||
|
||||
export const previewZoneCopy = (token: string | null, source: string, zone: string, targets: string[]): Promise<ApiOutcome<{ response: CopyPreview }>> =>
|
||||
apiRequest('hub/zones/copy/preview', { token, method: 'POST', body: { source, zone, targets: targets.join(',') } })
|
||||
|
||||
export const copyZone = (token: string | null, source: string, zone: string, targets: string[], overwrite: string[]): Promise<RunOutcome> =>
|
||||
apiRequest('hub/zones/copy', { token, method: 'POST', body: { source, zone, targets: targets.join(','), overwrite: overwrite.join(',') } })
|
||||
|
||||
export interface SecondaryInput {
|
||||
source: string
|
||||
target: string
|
||||
zone: string
|
||||
primaryAddress: string
|
||||
targetAddress: string
|
||||
tsigKeyName: string
|
||||
}
|
||||
|
||||
export interface SecondaryPreview {
|
||||
zone: string
|
||||
source: string
|
||||
target: string
|
||||
warnings: string[]
|
||||
steps: { server: string; role: 'source' | 'target'; summary: string; changes: boolean }[]
|
||||
}
|
||||
|
||||
export const previewSecondary = (token: string | null, input: SecondaryInput): Promise<ApiOutcome<{ response: SecondaryPreview }>> =>
|
||||
apiRequest('hub/zones/secondary/preview', { token, method: 'POST', body: { ...input } })
|
||||
|
||||
export const makeSecondary = (token: string | null, input: SecondaryInput): Promise<RunOutcome> =>
|
||||
apiRequest('hub/zones/secondary', { token, method: 'POST', body: { ...input } })
|
||||
@@ -60,6 +60,8 @@ export interface QueryLogEntry {
|
||||
qtype: string | null
|
||||
qclass: string | null
|
||||
answer: string | null
|
||||
/** Only from the hub: the server this merged row came from. */
|
||||
node?: string
|
||||
}
|
||||
|
||||
export interface QueryLogPage {
|
||||
|
||||
+4
-3
@@ -277,8 +277,8 @@ export async function setSettings(
|
||||
}
|
||||
|
||||
/** `settings/forceUpdateBlockLists`. No parameters: forces the download now. */
|
||||
export async function forceUpdateBlockLists(token: string | null): Promise<boolean> {
|
||||
const outcome = await apiRequest('settings/forceUpdateBlockLists', { token })
|
||||
export async function forceUpdateBlockLists(token: string | null, node = ''): Promise<boolean> {
|
||||
const outcome = await apiRequest('settings/forceUpdateBlockLists', { token, ...(node ? { node } : {}) })
|
||||
return outcome.kind === 'ok'
|
||||
}
|
||||
|
||||
@@ -286,10 +286,11 @@ export async function forceUpdateBlockLists(token: string | null): Promise<boole
|
||||
export async function temporaryDisableBlocking(
|
||||
token: string | null,
|
||||
minutes: string,
|
||||
node = '',
|
||||
): Promise<string | null> {
|
||||
const outcome = await apiRequest<{ response: { temporaryDisableBlockingTill: string } }>(
|
||||
'settings/temporaryDisableBlocking',
|
||||
{ token, body: { minutes } },
|
||||
{ token, body: { minutes }, ...(node ? { node } : {}) },
|
||||
)
|
||||
if (outcome.kind === 'ok') settingsChanged()
|
||||
return outcome.kind === 'ok' ? (outcome.data.response?.temporaryDisableBlockingTill ?? null) : null
|
||||
|
||||
+11
-7
@@ -172,8 +172,9 @@ export function addDomain(
|
||||
list: DomainList,
|
||||
token: string | null,
|
||||
domain: string,
|
||||
node = '',
|
||||
): Promise<ApiOutcome> {
|
||||
return apiRequest(`${list}/add`, { token, body: { domain } })
|
||||
return apiRequest(`${list}/add`, { token, body: { domain }, ...(node ? { node } : {}) })
|
||||
}
|
||||
|
||||
/** `deleteAllowedZone` / `deleteBlockedZone`. */
|
||||
@@ -181,13 +182,14 @@ export function deleteDomain(
|
||||
list: DomainList,
|
||||
token: string | null,
|
||||
domain: string,
|
||||
node = '',
|
||||
): Promise<ApiOutcome> {
|
||||
return apiRequest(`${list}/delete`, { token, body: { domain } })
|
||||
return apiRequest(`${list}/delete`, { token, body: { domain }, ...(node ? { node } : {}) })
|
||||
}
|
||||
|
||||
/** `flushAllowedZone` / `flushBlockedZone`. No `node`, unlike the rest. */
|
||||
export function flushList(list: DomainList, token: string | null): Promise<ApiOutcome> {
|
||||
return apiRequest(`${list}/flush`, { token })
|
||||
/** `flushAllowedZone` / `flushBlockedZone`. No `node`, unlike the rest, except behind a hub (a change is made on one server). */
|
||||
export function flushList(list: DomainList, token: string | null, node = ''): Promise<ApiOutcome> {
|
||||
return apiRequest(`${list}/flush`, { token, ...(node ? { node } : {}) })
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -200,9 +202,10 @@ export function importDomains(
|
||||
list: DomainList,
|
||||
token: string | null,
|
||||
zones: string,
|
||||
node = '',
|
||||
): Promise<ApiOutcome> {
|
||||
const field = list === 'allowed' ? 'allowedZones' : 'blockedZones'
|
||||
return apiRequest(`${list}/import`, { token, method: 'POST', body: { [field]: zones } })
|
||||
return apiRequest(`${list}/import`, { token, method: 'POST', body: { [field]: zones }, ...(node ? { node } : {}) })
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -214,6 +217,7 @@ token.
|
||||
export function exportDomains(
|
||||
list: DomainList,
|
||||
token: string | null,
|
||||
node = '',
|
||||
): Promise<{ ok: boolean; url?: string }> {
|
||||
return openDownload(token, `${list}/export`)
|
||||
return openDownload(token, `${list}/export`, node ? { node } : {})
|
||||
}
|
||||
@@ -6,6 +6,9 @@ only its outline, in the entry's own ink.
|
||||
*/
|
||||
.mark {
|
||||
margin-left: auto;
|
||||
/* Clear of the pin star, which sits over the entry's end (`.star` in Shell.module.css:
|
||||
24px wide at --s-3 from the edge, inside the entry's --s-5 padding), plus a --s-2 gap. */
|
||||
margin-right: calc(24px + var(--s-3) - var(--s-5) + var(--s-2));
|
||||
padding: 0 var(--s-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-full);
|
||||
|
||||
@@ -4,7 +4,8 @@ import styles from './BlockingMark.module.css'
|
||||
|
||||
/*
|
||||
The sidebar's mark on Blocking: whether blocking is disabled or paused, from
|
||||
anywhere in the console. OURS; what it says and when it reads are in
|
||||
anywhere in the console. Nothing while the Advanced Blocking app is blocking in the
|
||||
built-in blocking's place. OURS; what it says and when it reads are in
|
||||
`screens/blocking/blocking-state.ts`. The only count or state the sidebar carries.
|
||||
|
||||
The link's accessible name gains the sentence (`Blocking, paused until 14:30`)
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { DriftStatus } from './DriftStatus'
|
||||
import { refreshHubDrift, resetHubDrift } from '../api/hub-drift'
|
||||
import * as apps from '../api/apps'
|
||||
import * as settings from '../api/hub-settings'
|
||||
import * as profiles from '../api/hub-profiles'
|
||||
import type { DriftEntry } from '../api/apps'
|
||||
|
||||
/*
|
||||
The drift list with the kinds added in stage 8: settings and the Blocked and Allowed lists. Their
|
||||
actions are for an admin and reuse the compare views and the apply flows of Settings and Blocking;
|
||||
an entry names fields, never values. The webhook line is the admin's.
|
||||
*/
|
||||
|
||||
beforeEach(() => resetHubDrift())
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
const ENTRIES: DriftEntry[] = [
|
||||
{ kind: 'app', name: 'Blocker', app: 'Blocker', server: 'ndns', since: '2026-10-31T09:00:00Z', status: 'differs' },
|
||||
{ kind: 'settings', server: 'ndns', since: '2026-10-31T09:00:00Z', status: 'differs', fields: ['blockingType', 'defaultRecordTtl'] },
|
||||
{ kind: 'lists', server: 'ndns', since: '2026-10-31T09:00:00Z', status: 'differs', fields: ['blocked'] },
|
||||
{ kind: 'settings', server: 'mdns', since: '2026-10-31T09:00:00Z', status: 'unreadable' },
|
||||
]
|
||||
|
||||
async function load(entries: DriftEntry[] = ENTRIES) {
|
||||
vi.spyOn(apps, 'getDrift').mockResolvedValue({ kind: 'ok', data: { response: { checkedAt: 'x', apps: entries } } } as never)
|
||||
await act(async () => refreshHubDrift('t'))
|
||||
}
|
||||
|
||||
const NOT_SET = { configured: false, host: null, signed: false, last: null }
|
||||
|
||||
describe('DriftStatus kinds', () => {
|
||||
it('counts every kind that differs and lists settings and lists with their field names', async () => {
|
||||
vi.spyOn(profiles, 'getWebhookStatus').mockResolvedValue({ kind: 'ok', data: { response: NOT_SET } } as never)
|
||||
render(<DriftStatus token="t" canWrite writable={['ndns']} isAdmin />)
|
||||
await load()
|
||||
expect(await screen.findByRole('button', { name: 'Config drift' })).toHaveTextContent('3 config drifts')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Config drift' }))
|
||||
const settingsRow = await screen.findByRole('listitem', { name: 'Settings on ndns' })
|
||||
expect(within(settingsRow).getByText('blockingType, defaultRecordTtl')).toBeInTheDocument()
|
||||
expect(within(screen.getByRole('listitem', { name: 'Blocked and Allowed lists on ndns' })).getByText('blocked')).toBeInTheDocument()
|
||||
expect(within(screen.getByRole('listitem', { name: 'Settings on mdns' })).getByText('Cannot read')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('gives a non-admin no action on settings or lists, but keeps the app actions of a writer', async () => {
|
||||
render(<DriftStatus token="t" canWrite writable={['ndns']} />)
|
||||
await load()
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Config drift' }))
|
||||
const settingsRow = await screen.findByRole('listitem', { name: 'Settings on ndns' })
|
||||
expect(within(settingsRow).queryByRole('button')).not.toBeInTheDocument()
|
||||
expect(within(screen.getByRole('listitem', { name: 'Blocked and Allowed lists on ndns' })).queryByRole('button')).not.toBeInTheDocument()
|
||||
expect(within(screen.getByRole('listitem', { name: 'Blocker on ndns' })).getByRole('button', { name: 'Accept as baseline' })).toBeInTheDocument()
|
||||
expect(screen.queryByText('Webhook')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens the settings compare, and the blocking compare for lists', async () => {
|
||||
vi.spyOn(profiles, 'getWebhookStatus').mockResolvedValue({ kind: 'ok', data: { response: NOT_SET } } as never)
|
||||
const compare = vi.spyOn(settings, 'getSettingsCompare').mockResolvedValue({ kind: 'error', message: 'stop here' } as never)
|
||||
const lists = vi.spyOn(settings, 'getListsCompare').mockResolvedValue({ kind: 'error', message: 'stop here' } as never)
|
||||
vi.spyOn(apps, 'checkDrift').mockResolvedValue({ kind: 'ok', data: { response: { checkedAt: 'y', apps: ENTRIES } } } as never)
|
||||
render(<DriftStatus token="t" canWrite writable={['ndns']} isAdmin />)
|
||||
await load()
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Config drift' }))
|
||||
await userEvent.click(within(await screen.findByRole('listitem', { name: 'Settings on ndns' })).getByRole('button', { name: 'Open compare' }))
|
||||
await waitFor(() => expect(compare).toHaveBeenCalledTimes(1))
|
||||
expect(lists).not.toHaveBeenCalled()
|
||||
expect(await screen.findByRole('dialog', { name: 'Compare settings across servers' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('accepts the server settings as the baseline after a confirmation, then checks again', async () => {
|
||||
vi.spyOn(profiles, 'getWebhookStatus').mockResolvedValue({ kind: 'ok', data: { response: NOT_SET } } as never)
|
||||
const accept = vi.spyOn(settings, 'importSettingsBaseline').mockResolvedValue({ kind: 'ok', data: { response: { baseline: { settings: {}, updatedAt: '', updatedBy: '' } } } } as never)
|
||||
const check = vi.spyOn(apps, 'checkDrift').mockResolvedValue({ kind: 'ok', data: { response: { checkedAt: 'y', apps: [] } } } as never)
|
||||
render(<DriftStatus token="t" canWrite writable={['ndns']} isAdmin />)
|
||||
await load()
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Config drift' }))
|
||||
await userEvent.click(within(await screen.findByRole('listitem', { name: 'Settings on ndns' })).getByRole('button', { name: 'Accept as baseline' }))
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Accept' }))
|
||||
expect(accept).toHaveBeenCalledWith('t', 'ndns')
|
||||
await waitFor(() => expect(check).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('accepts the lists of a server as the baseline', async () => {
|
||||
vi.spyOn(profiles, 'getWebhookStatus').mockResolvedValue({ kind: 'ok', data: { response: NOT_SET } } as never)
|
||||
const accept = vi.spyOn(settings, 'importListsBaseline').mockResolvedValue({ kind: 'ok', data: { response: { imported: true } } } as never)
|
||||
vi.spyOn(apps, 'checkDrift').mockResolvedValue({ kind: 'ok', data: { response: { checkedAt: 'y', apps: [] } } } as never)
|
||||
render(<DriftStatus token="t" canWrite writable={['ndns']} isAdmin />)
|
||||
await load()
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Config drift' }))
|
||||
await userEvent.click(within(await screen.findByRole('listitem', { name: 'Blocked and Allowed lists on ndns' })).getByRole('button', { name: 'Accept as baseline' }))
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Accept' }))
|
||||
expect(accept).toHaveBeenCalledWith('t', 'ndns')
|
||||
})
|
||||
|
||||
it('reverts settings to the baseline through the preview', async () => {
|
||||
vi.spyOn(profiles, 'getWebhookStatus').mockResolvedValue({ kind: 'ok', data: { response: NOT_SET } } as never)
|
||||
const preview = vi.spyOn(settings, 'previewSettings').mockResolvedValue({
|
||||
kind: 'ok',
|
||||
data: { response: { targets: [{ server: 'ndns', ok: true, writable: true, currentHash: 'h1', changes: { kind: 'json', changes: [{ path: 'blockingType', type: 'changed', a: 'AnyAddress', b: 'NxDomain' }] }, secretFields: [], unchanged: false, warnings: [] }] } },
|
||||
} as never)
|
||||
const apply = vi.spyOn(settings, 'applySettings').mockResolvedValue({ kind: 'ok', data: { response: { results: [{ server: 'ndns', ok: true, message: 'Applied and verified (1 field).' }] } } } as never)
|
||||
vi.spyOn(apps, 'checkDrift').mockResolvedValue({ kind: 'ok', data: { response: { checkedAt: 'y', apps: [] } } } as never)
|
||||
render(<DriftStatus token="t" canWrite writable={['ndns']} isAdmin />)
|
||||
await load()
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Config drift' }))
|
||||
await userEvent.click(within(await screen.findByRole('listitem', { name: 'Settings on ndns' })).getByRole('button', { name: 'Revert to baseline' }))
|
||||
const picker = await screen.findByRole('dialog', { name: 'Apply the settings baseline' })
|
||||
await userEvent.click(within(picker).getByRole('button', { name: 'Preview' }))
|
||||
expect(preview).toHaveBeenCalledWith('t', ['ndns'], undefined)
|
||||
await userEvent.click(within(await screen.findByRole('dialog', { name: 'Apply the settings baseline: review' })).getByRole('button', { name: 'Apply' }))
|
||||
expect(apply).toHaveBeenCalledWith('t', ['ndns'], { ndns: 'h1' }, undefined)
|
||||
expect(await screen.findByText('Applied and verified (1 field).')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reverts the lists through the lists preview', async () => {
|
||||
vi.spyOn(profiles, 'getWebhookStatus').mockResolvedValue({ kind: 'ok', data: { response: NOT_SET } } as never)
|
||||
const none = { count: 0, missing: 0, extra: 0, missingSample: [], extraSample: [] }
|
||||
vi.spyOn(settings, 'previewLists').mockResolvedValue({ kind: 'ok', data: { response: { targets: [{ id: 'ndns', ok: true, writable: true, status: 'differs', blocked: { ...none, extra: 1 }, allowed: none }] } } } as never)
|
||||
const apply = vi.spyOn(settings, 'applyLists').mockResolvedValue({ kind: 'ok', data: { response: { results: [{ server: 'ndns', ok: true, message: 'Added 0, removed 0.' }] } } } as never)
|
||||
vi.spyOn(apps, 'checkDrift').mockResolvedValue({ kind: 'ok', data: { response: { checkedAt: 'y', apps: [] } } } as never)
|
||||
render(<DriftStatus token="t" canWrite writable={['ndns']} isAdmin />)
|
||||
await load()
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Config drift' }))
|
||||
await userEvent.click(within(await screen.findByRole('listitem', { name: 'Blocked and Allowed lists on ndns' })).getByRole('button', { name: 'Revert to baseline' }))
|
||||
await userEvent.click(within(await screen.findByRole('dialog', { name: 'Apply the Blocked and Allowed lists' })).getByRole('button', { name: 'Preview' }))
|
||||
await userEvent.click(within(await screen.findByRole('dialog', { name: 'Apply the Blocked and Allowed lists: review' })).getByRole('button', { name: 'Apply' }))
|
||||
expect(apply).toHaveBeenCalledWith('t', ['ndns'], false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('webhook line in the drift dialog', () => {
|
||||
it('shows the host and the last delivery, and sends a test', async () => {
|
||||
vi.spyOn(profiles, 'getWebhookStatus').mockResolvedValue({
|
||||
kind: 'ok',
|
||||
data: { response: { configured: true, host: 'hooks.example:8443', signed: true, last: { event: 'drift.found', at: '2026-11-01T00:00:00Z', ok: false, error: 'HTTP 500' } } },
|
||||
} as never)
|
||||
const test = vi.spyOn(profiles, 'testWebhook').mockResolvedValue({
|
||||
kind: 'ok',
|
||||
data: { response: { configured: true, host: 'hooks.example:8443', signed: true, last: { event: 'webhook.test', at: '2026-11-01T00:01:00Z', ok: true } } },
|
||||
} as never)
|
||||
render(<DriftStatus token="t" canWrite writable={['ndns']} isAdmin />)
|
||||
await load()
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Config drift' }))
|
||||
expect(await screen.findByText('hooks.example:8443')).toBeInTheDocument()
|
||||
expect(screen.getByText('Last delivery failed (HTTP 500)')).toBeInTheDocument()
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Send test' }))
|
||||
expect(test).toHaveBeenCalledWith('t')
|
||||
expect(await screen.findByText('Last delivery ok')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('says it is not configured, with no test button, and shows an error with Retry', async () => {
|
||||
const spy = vi
|
||||
.spyOn(profiles, 'getWebhookStatus')
|
||||
.mockResolvedValueOnce({ kind: 'error', message: 'boom' } as never)
|
||||
.mockResolvedValue({ kind: 'ok', data: { response: NOT_SET } } as never)
|
||||
render(<DriftStatus token="t" canWrite writable={['ndns']} isAdmin />)
|
||||
await load()
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Config drift' }))
|
||||
expect(await screen.findByText(/boom/)).toBeInTheDocument()
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
||||
expect(await screen.findByText('Not configured')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Send test' })).not.toBeInTheDocument()
|
||||
expect(spy).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,196 @@
|
||||
import { useState } from 'react'
|
||||
import { acceptDrift, checkDrift, type DriftEntry } from '../api/apps'
|
||||
import { refreshHubDrift, useHubDrift } from '../api/hub-drift'
|
||||
import { importListsBaseline, importSettingsBaseline } from '../api/hub-settings'
|
||||
import { AppConfigSync } from '../screens/apps/AppConfigSync'
|
||||
import { applyRequest } from '../screens/apps/applyRequest'
|
||||
import { RunFlow, pickerServers, type RunRequest } from '../screens/apps/RunFlow'
|
||||
import { SettingsCompare, listsApplyRequest, settingsApplyRequest, type Scope } from '../screens/settings/SettingsCompare'
|
||||
import { Alert } from '../ui/Alert'
|
||||
import { Button } from '../ui/Button'
|
||||
import { Confirm } from '../ui/Confirm'
|
||||
import { Dialog } from '../ui/Dialog'
|
||||
import { Empty } from '../ui/Empty'
|
||||
import { Tag } from '../ui/Tag'
|
||||
import styles from './Shell.module.css'
|
||||
import { WebhookStatus } from './WebhookStatus'
|
||||
|
||||
/*
|
||||
Behind a hub: a mark in the header when a server no longer has what the hub says it should (someone
|
||||
changed it in that server's own console), and the list of them. Three kinds: an app's config, the
|
||||
settings, and the Blocked and Allowed lists. Each can be looked at (the app's config view, or the
|
||||
compare view of Settings or Blocking); a writer can accept the server's state as the new baseline,
|
||||
or revert the server to the baseline after a preview. Settings and lists are for an admin, as their
|
||||
compare views are. An entry names fields, never values.
|
||||
*/
|
||||
|
||||
const STATUS = { differs: 'Differs', unreadable: 'Cannot read', 'not-installed': 'Not installed' } as const
|
||||
|
||||
const label = (e: DriftEntry): string => (e.kind === 'app' ? (e.name ?? e.app ?? '') : e.kind === 'settings' ? 'Settings' : 'Blocked and Allowed lists')
|
||||
const keyOf = (e: DriftEntry): string => `${e.kind}/${e.name ?? e.app ?? ''}/${e.server}`
|
||||
|
||||
export function DriftStatus({ token, canWrite, writable, isAdmin = false }: { token: string | null; canWrite: boolean; writable: string[]; isAdmin?: boolean }) {
|
||||
const { count, entries } = useHubDrift()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [configApp, setConfigApp] = useState<string | null>(null)
|
||||
const [compare, setCompare] = useState<Scope | null>(null)
|
||||
const [request, setRequest] = useState<RunRequest | null>(null)
|
||||
const [accepting, setAccepting] = useState<DriftEntry | null>(null)
|
||||
const [problem, setProblem] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
if (count === 0 && !open) return null
|
||||
|
||||
async function recheck() {
|
||||
await checkDrift(token)
|
||||
await refreshHubDrift(token)
|
||||
}
|
||||
|
||||
async function accept(entry: DriftEntry) {
|
||||
setBusy(true)
|
||||
setProblem(null)
|
||||
const outcome =
|
||||
entry.kind === 'app'
|
||||
? await acceptDrift(token, entry.name ?? entry.app ?? '', entry.server)
|
||||
: entry.kind === 'settings'
|
||||
? await importSettingsBaseline(token, entry.server)
|
||||
: await importListsBaseline(token, entry.server)
|
||||
if (outcome.kind !== 'ok') setProblem(outcome.kind === 'error' ? outcome.message : 'The session ended.')
|
||||
// The hub checks again after an app is accepted; for settings and lists it is asked to.
|
||||
if (entry.kind === 'app') await refreshHubDrift(token)
|
||||
else await recheck()
|
||||
setBusy(false)
|
||||
}
|
||||
|
||||
async function checkNow() {
|
||||
setBusy(true)
|
||||
setProblem(null)
|
||||
const outcome = await checkDrift(token)
|
||||
setBusy(false)
|
||||
if (outcome.kind !== 'ok') setProblem(outcome.kind === 'error' ? outcome.message : 'The session ended.')
|
||||
await refreshHubDrift(token)
|
||||
}
|
||||
|
||||
function revert(entry: DriftEntry) {
|
||||
const servers = pickerServers([{ id: entry.server, writable: writable.includes(entry.server) }])
|
||||
setRequest(
|
||||
entry.kind === 'app'
|
||||
? applyRequest(token, entry.name ?? entry.app ?? '', servers, [entry.server])
|
||||
: entry.kind === 'settings'
|
||||
? settingsApplyRequest(token, servers, [entry.server], 'settings')
|
||||
: listsApplyRequest(token, servers, [entry.server]),
|
||||
)
|
||||
}
|
||||
|
||||
/* Settings and lists are admin-only; an app's config follows the Apps right. */
|
||||
const mayAct = (e: DriftEntry): boolean => (e.kind === 'app' ? canWrite : isAdmin)
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" className={styles.status} data-attention="true" aria-label="Config drift" onClick={() => setOpen(true)}>
|
||||
<i className={styles.pdot} data-tone="warn" />
|
||||
<span className={styles.lbl}>{`${count} config ${count === 1 ? 'drift' : 'drifts'}`}</span>
|
||||
</button>
|
||||
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
size="medium"
|
||||
title="Config drift"
|
||||
actions={
|
||||
canWrite ? (
|
||||
<Button disabled={busy} onClick={() => void checkNow()}>
|
||||
Check now
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<p className="m-0 text-sm text-muted-foreground">
|
||||
These servers no longer have what the hub expects: an app's baseline plus the server's own local fields, or the shared settings and the Blocked and Allowed lists of their baselines.
|
||||
</p>
|
||||
{isAdmin && <WebhookStatus token={token} />}
|
||||
{problem !== null && <Alert type="danger" title="Error">{problem}</Alert>}
|
||||
{entries.length === 0 ? (
|
||||
<Empty title="No drift">Every server has what the hub expects.</Empty>
|
||||
) : (
|
||||
<ul className="m-0 flex list-none flex-col gap-3 p-0" aria-label="Drifts">
|
||||
{entries.map((e) => (
|
||||
<li key={keyOf(e)} aria-label={`${label(e)} on ${e.server}`} className="flex flex-wrap items-center gap-3 border-b border-border pb-3 text-sm last:border-b-0">
|
||||
<span className="font-medium">{label(e)}</span>
|
||||
<span>{e.server}</span>
|
||||
<Tag tone={e.status === 'differs' ? 'warn' : 'neutral'}>{STATUS[e.status]}</Tag>
|
||||
<span className="text-xs text-muted-foreground">since {new Date(e.since).toLocaleString()}</span>
|
||||
{e.fields !== undefined && e.fields.length > 0 && (
|
||||
<span className="basis-full font-mono text-xs text-muted-foreground">{e.fields.slice(0, 8).join(', ')}{e.fields.length > 8 ? ` and ${e.fields.length - 8} more` : ''}</span>
|
||||
)}
|
||||
<span className="ml-auto flex flex-wrap gap-2">
|
||||
{e.kind === 'app' ? (
|
||||
<Button size="sm" onClick={() => setConfigApp(e.name ?? e.app ?? '')}>
|
||||
Open config
|
||||
</Button>
|
||||
) : (
|
||||
isAdmin && (
|
||||
<Button size="sm" onClick={() => setCompare(e.kind === 'settings' ? 'settings' : 'blocking')}>
|
||||
Open compare
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
{mayAct(e) && e.status === 'differs' && (
|
||||
<>
|
||||
<Button size="sm" disabled={busy} onClick={() => setAccepting(e)}>
|
||||
Accept as baseline
|
||||
</Button>
|
||||
<Button size="sm" variant="primary" disabled={busy} onClick={() => revert(e)}>
|
||||
Revert to baseline
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Dialog>
|
||||
|
||||
<Confirm
|
||||
open={accepting !== null}
|
||||
title="Accept as baseline"
|
||||
text={
|
||||
accepting === null
|
||||
? ''
|
||||
: accepting.kind === 'app'
|
||||
? `Make the config of ${label(accepting)} on ${accepting.server} the baseline? The other servers will then differ from it. The old baseline is kept in the history.`
|
||||
: accepting.kind === 'settings'
|
||||
? `Make the settings of ${accepting.server} the baseline? The other servers will then differ from it. The old baseline is kept in the settings history.`
|
||||
: `Make the Blocked and Allowed lists of ${accepting.server} the baseline? The other servers will then differ from it.`
|
||||
}
|
||||
label="Accept"
|
||||
onClose={() => setAccepting(null)}
|
||||
onConfirm={() => accepting && accept(accepting)}
|
||||
/>
|
||||
|
||||
{configApp !== null && (
|
||||
<AppConfigSync token={token} name={configApp} canWrite={canWrite} onClose={() => setConfigApp(null)} onChanged={() => void refreshHubDrift(token)} />
|
||||
)}
|
||||
{compare !== null && (
|
||||
<SettingsCompare
|
||||
token={token}
|
||||
scope={compare}
|
||||
onClose={() => {
|
||||
setCompare(null)
|
||||
void recheck()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{request !== null && (
|
||||
<RunFlow
|
||||
request={request}
|
||||
onClose={(changed) => {
|
||||
setRequest(null)
|
||||
if (changed) void recheck()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { render, screen, within } from '@testing-library/react'
|
||||
import { Shell, type ShellSession } from './Shell'
|
||||
import { ThemeProvider } from '../theme/ThemeProvider'
|
||||
import * as client from '../api/client'
|
||||
import * as settings from '../api/settings'
|
||||
import type { DnsSettings } from '../api/settings'
|
||||
import { forgetRoot } from './route'
|
||||
import { AB_CONFIG, serveAdvancedBlocking } from '../screens/blocking/advanced-blocking.fixture'
|
||||
|
||||
/*
|
||||
The sidebar's mark and the header's status when the built-in blocking is off but the Advanced
|
||||
Blocking app blocks (`blocking-state.ts`): no OFF mark, no attention; and as before when the app
|
||||
is not blocking.
|
||||
*/
|
||||
|
||||
function session(): ShellSession {
|
||||
const all = ['Dashboard', 'Zones', 'Cache', 'Allowed', 'Blocked', 'Apps', 'DnsClient', 'Settings', 'DhcpServer', 'Administration', 'Logs']
|
||||
return {
|
||||
token: 'tok',
|
||||
displayName: 'Administrator',
|
||||
username: 'admin',
|
||||
info: {
|
||||
version: '15.6',
|
||||
uptimestamp: '2026-10-15T08:00:00Z',
|
||||
dnsServerDomain: 'dns.example.net',
|
||||
permissions: Object.fromEntries(all.map((s) => [s, { canView: true, canModify: true, canDelete: true }])),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const mount = () => render(<ThemeProvider><Shell session={session()} onLogout={() => {}} /></ThemeProvider>)
|
||||
const entry = () => within(screen.getByRole('navigation', { name: 'Sections' })).getByRole('link', { name: /^Blocking/ })
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
forgetRoot()
|
||||
window.history.replaceState(null, '', '/about/')
|
||||
vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'ok', data: { status: 'ok', response: {} } } as never)
|
||||
})
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
describe('Shell: the Blocking mark with the Advanced Blocking app', () => {
|
||||
it('built-in off, the app blocking: no mark', async () => {
|
||||
vi.spyOn(settings, 'getSettings').mockResolvedValue({ enableBlocking: false } as DnsSettings)
|
||||
const { get } = serveAdvancedBlocking()
|
||||
mount()
|
||||
await vi.waitFor(() => expect(get).toHaveBeenCalled())
|
||||
// Let the answer land: the mark must stay away, not just not have come yet.
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
await vi.waitFor(() => expect(entry()).toHaveAccessibleName('Blocking'))
|
||||
expect(entry()).not.toHaveTextContent('Off')
|
||||
expect(entry()).not.toHaveAttribute('title')
|
||||
})
|
||||
|
||||
it('built-in off, the app installed but not blocking: Off, as before', async () => {
|
||||
vi.spyOn(settings, 'getSettings').mockResolvedValue({ enableBlocking: false } as DnsSettings)
|
||||
serveAdvancedBlocking({ ...AB_CONFIG, enableBlocking: false })
|
||||
mount()
|
||||
await vi.waitFor(() => expect(entry()).toHaveAccessibleName('Blocking, disabled'))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,150 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { Shell, type ShellSession } from './Shell'
|
||||
import { ThemeProvider } from '../theme/ThemeProvider'
|
||||
import * as client from '../api/client'
|
||||
import { setHubWarnings } from '../api/hub-warnings'
|
||||
import { forgetRoot } from './route'
|
||||
|
||||
/*
|
||||
The console behind a hub (`info.hub`): no Change Password at login, no account entries
|
||||
the hub cannot serve, and a header that says when a server did not answer.
|
||||
*/
|
||||
|
||||
function session(hub: boolean): ShellSession {
|
||||
const all = ['Dashboard', 'Zones', 'Cache', 'Allowed', 'Blocked', 'Apps', 'DnsClient', 'Settings', 'DhcpServer', 'Administration', 'Logs']
|
||||
return {
|
||||
token: 'tok',
|
||||
displayName: 'Administrator',
|
||||
username: 'admin',
|
||||
info: {
|
||||
version: '1.0.0',
|
||||
uptimestamp: '2026-10-15T08:00:00Z',
|
||||
dnsServerDomain: 'hub',
|
||||
...(hub ? { hub: true } : {}),
|
||||
permissions: Object.fromEntries(all.map((s) => [s, { canView: s === 'Dashboard', canModify: false, canDelete: false }])),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function mount(hub: boolean, forcePasswordChange = false) {
|
||||
return render(
|
||||
<ThemeProvider>
|
||||
<Shell session={session(hub)} onLogout={() => {}} forcePasswordChange={forcePasswordChange} />
|
||||
</ThemeProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
forgetRoot()
|
||||
setHubWarnings([])
|
||||
window.history.replaceState(null, '', '/about/')
|
||||
vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'ok', data: { status: 'ok', response: {} } } as never)
|
||||
})
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
describe('Shell on a hub', () => {
|
||||
it('does not open Change Password after a factory-credentials login', () => {
|
||||
mount(true, true)
|
||||
expect(screen.queryByRole('dialog', { name: /change password/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('still opens it on a server', async () => {
|
||||
mount(false, true)
|
||||
expect(await screen.findByRole('dialog', { name: /change password/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('leaves out the account entries the hub cannot serve', async () => {
|
||||
mount(true)
|
||||
await userEvent.click(screen.getByRole('button', { name: /Administrator/ }))
|
||||
for (const name of ['My Profile', 'Change Password', 'Configure 2FA', 'Create API Token']) {
|
||||
expect(screen.queryByRole('menuitem', { name })).not.toBeInTheDocument()
|
||||
}
|
||||
expect(screen.getByRole('menuitem', { name: 'Change Theme' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('menuitem', { name: 'Logout' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps them on a server', async () => {
|
||||
mount(false)
|
||||
await userEvent.click(screen.getByRole('button', { name: /Administrator/ }))
|
||||
expect(screen.getByRole('menuitem', { name: 'My Profile' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('menuitem', { name: 'Create API Token' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('the header shows the servers that did not answer, read from an api response', async () => {
|
||||
mount(true)
|
||||
expect(screen.getByRole('button', { name: 'Server status' })).toHaveAttribute('data-attention', 'false')
|
||||
|
||||
// An aggregate response carries the warning; `apiRequest` reads it in one place.
|
||||
vi.restoreAllMocks()
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify({ status: 'ok', response: {}, hubWarnings: ['ndns: timed out after 8000 ms'] })),
|
||||
)
|
||||
await act(async () => {
|
||||
await client.apiRequest('dashboard/stats/get', { token: 'tok' })
|
||||
})
|
||||
|
||||
const status = screen.getByRole('button', { name: 'Server status' })
|
||||
expect(status).toHaveTextContent('1 server not answering')
|
||||
expect(status).toHaveAttribute('data-attention', 'true')
|
||||
await userEvent.click(status)
|
||||
expect(screen.getByText('ndns: timed out after 8000 ms')).toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
setHubWarnings(['a: x', 'b: y'])
|
||||
})
|
||||
expect(status).toHaveTextContent('2 servers not answering')
|
||||
// The next answer without warnings clears it.
|
||||
await act(async () => {
|
||||
setHubWarnings([])
|
||||
})
|
||||
expect(status).not.toHaveTextContent('not answering')
|
||||
})
|
||||
|
||||
it('marks the header with the config drift the session and the list report, on a hub only', async () => {
|
||||
vi.restoreAllMocks()
|
||||
vi.spyOn(client, 'apiRequest').mockImplementation((async (path: string) =>
|
||||
path === 'hub/drift/get'
|
||||
? { kind: 'ok', data: { status: 'ok', response: { checkedAt: 'x', apps: [{ kind: 'app', name: 'Blocker', app: 'Blocker', server: 'ndns', since: '2026-10-31T09:00:00Z', status: 'differs' }] } } }
|
||||
: { kind: 'ok', data: { status: 'ok', response: {} } }) as never)
|
||||
const hubSession = session(true)
|
||||
hubSession.info = { ...hubSession.info!, hubDrift: 1 }
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<Shell session={hubSession} onLogout={() => {}} />
|
||||
</ThemeProvider>,
|
||||
)
|
||||
expect(await screen.findByRole('button', { name: 'Config drift' })).toHaveTextContent('1 config drift')
|
||||
cleanup()
|
||||
mount(false)
|
||||
expect(screen.queryByRole('button', { name: 'Config drift' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
/* Per-node calls (live metrics, settings) arrive right after an aggregate and say nothing. */
|
||||
it('an ok response without the field leaves the warning, and an empty list clears it', async () => {
|
||||
mount(true)
|
||||
vi.restoreAllMocks()
|
||||
const answer = (body: object) =>
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ status: 'ok', response: {}, ...body })))
|
||||
answer({ hubWarnings: ['ndns: ECONNREFUSED'] })
|
||||
await act(async () => {
|
||||
await client.apiRequest('dashboard/stats/get', { token: 'tok' })
|
||||
})
|
||||
const status = screen.getByRole('button', { name: 'Server status' })
|
||||
expect(status).toHaveTextContent('1 server not answering')
|
||||
|
||||
answer({})
|
||||
await act(async () => {
|
||||
await client.apiRequest('settings/get', { token: 'tok', node: 'dns' })
|
||||
})
|
||||
expect(status).toHaveTextContent('1 server not answering')
|
||||
|
||||
answer({ hubWarnings: [] })
|
||||
await act(async () => {
|
||||
await client.apiRequest('dashboard/stats/get', { token: 'tok' })
|
||||
})
|
||||
expect(status).not.toHaveTextContent('not answering')
|
||||
})
|
||||
})
|
||||
@@ -173,7 +173,7 @@ it to hang a control off would be undoing that in order to follow a drawing.
|
||||
`:empty` is what keeps it from taking up room when nobody uses it: without it,
|
||||
seven of eight screens would gain a blank gap.
|
||||
*/
|
||||
.chromeSlot { display: flex; align-items: center; gap: var(--s-6); margin-bottom: var(--s-8); }
|
||||
.chromeSlot { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s-6); margin-bottom: var(--s-8); }
|
||||
.chromeSlot:empty { display: none; }
|
||||
|
||||
/*
|
||||
@@ -233,6 +233,8 @@ star, and the header is always there: toggle, breadcrumbs and the server's statu
|
||||
.pdot { width: 8px; height: 8px; border-radius: 99px; background: var(--ok, currentColor); }
|
||||
.pdot[data-tone='warn'], .pdot[data-tone='warning'] { background: var(--warn, currentColor); }
|
||||
.pdot[data-tone='bad'], .pdot[data-tone='danger'] { background: var(--bad, currentColor); }
|
||||
.warnings { margin: var(--s-6) 0 0; padding: 0; list-style: none; display: grid; gap: var(--s-3); font: 400 var(--t-3)/1.3 var(--font-mono); color: var(--ink); }
|
||||
.warnings li::before { content: ''; display: inline-block; width: 6px; height: 6px; margin-right: var(--s-4); border-radius: 99px; background: var(--warn); }
|
||||
.pop dl { display: grid; grid-template-columns: auto 1fr; gap: var(--s-4) var(--s-8); margin: 0; font-size: var(--t-3); }
|
||||
.pop dt { color: var(--mute); }
|
||||
.pop dd { margin: 0; text-align: right; font-family: var(--font-mono); }
|
||||
|
||||
+83
-20
@@ -31,11 +31,17 @@ import { publicUrl } from './base'
|
||||
import { Menu } from '../ui/Menu'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Command, CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
|
||||
import { ThisServerName } from '../ui/ClusterNodeSelect'
|
||||
import { HubMode, ThisServerName } from '../ui/ClusterNodeSelect'
|
||||
import { HubWritable } from '../lib/hub-write'
|
||||
import { HubServerIds } from '../lib/hub-all'
|
||||
import { FooterLinks } from '../ui/FooterLinks'
|
||||
import { Versions } from './Versions'
|
||||
import { BlockingMark } from './BlockingMark'
|
||||
import { useBlockingMark } from '../screens/blocking/blocking-state'
|
||||
import { useBlockingMark, useHubBlockingMark } from '../screens/blocking/blocking-state'
|
||||
import { useHubWarnings } from '../api/hub-warnings'
|
||||
import { refreshHubDrift, resetHubDrift, setHubDriftCount } from '../api/hub-drift'
|
||||
import { DriftStatus } from './DriftStatus'
|
||||
import { WebhookStatus } from './WebhookStatus'
|
||||
import { Confirm } from '../ui/Confirm'
|
||||
import { Notifier } from '../ui/Notifier'
|
||||
import type { Notice } from '../lib/notice'
|
||||
@@ -79,6 +85,9 @@ function keep(key: string, value: unknown) {
|
||||
}
|
||||
}
|
||||
|
||||
/* A stable empty list for a session without the field (outside a hub). */
|
||||
const NO_SERVERS: string[] = []
|
||||
|
||||
const ICONS: Record<string, IconName> = {
|
||||
dashboard: 'dashboard', zones: 'zones', cache: 'cache', blocking: 'blocked',
|
||||
apps: 'apps', dnsclient: 'dnsclient', settings: 'settings',
|
||||
@@ -102,6 +111,12 @@ export interface ShellSession {
|
||||
dnsServerDomain: string
|
||||
permissions?: Record<string, Permission>
|
||||
clusterInitialized?: boolean
|
||||
/** Only on the hub (`hub/`): a read-only console over several servers. */
|
||||
hub?: boolean
|
||||
/** Only on the hub: the servers it holds an admin token for. */
|
||||
hubWritable?: string[]
|
||||
/** Only on the hub: how many servers differ from their baseline when the session was read. */
|
||||
hubDrift?: number
|
||||
/** Only on an initialised cluster (WebServiceAuthApi.cs:104-106). */
|
||||
clusterDomain?: string
|
||||
clusterNodes?: { name: string; type: string }[]
|
||||
@@ -125,6 +140,24 @@ export function Shell({
|
||||
forcePasswordChange?: boolean
|
||||
}) {
|
||||
const permissions = session.info?.permissions
|
||||
const hub = session.info?.hub === true
|
||||
/* Behind a hub: the servers a change can be made on (the screens read it through `HubWritable`). */
|
||||
const hubWritable = session.info?.hubWritable ?? NO_SERVERS
|
||||
/* And every server it fronts, for the picker of a change made on several at once. */
|
||||
const hubServers = useMemo(() => (session.info?.clusterNodes ?? []).map((n) => n.name), [session.info?.clusterNodes])
|
||||
const hubWarnings = useHubWarnings()
|
||||
/* Behind a hub: the count comes with the session, the list is read now and then. */
|
||||
const hubDriftAtStart = session.info?.hubDrift
|
||||
useEffect(() => {
|
||||
if (!hub) return
|
||||
setHubDriftCount(hubDriftAtStart ?? 0)
|
||||
void refreshHubDrift(session.token)
|
||||
const timer = setInterval(() => void refreshHubDrift(session.token), 60_000)
|
||||
return () => {
|
||||
clearInterval(timer)
|
||||
resetHubDrift()
|
||||
}
|
||||
}, [hub, session.token, hubDriftAtStart])
|
||||
// Memoised: if it is recreated on every render, the `hashchange` resubscribes on each.
|
||||
const sections = useMemo(() => visibleSections(permissions), [permissions])
|
||||
/* The starting section comes from the address bar if it carries one, and only
|
||||
@@ -166,11 +199,11 @@ export function Shell({
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [])
|
||||
const [modal, setModal] = useState<ModalId | null>(forcePasswordChange ? 'password' : null)
|
||||
const [modal, setModal] = useState<ModalId | null>(forcePasswordChange && !hub ? 'password' : null)
|
||||
/* The factory password travels only with the opening the login caused; from the
|
||||
user menu `showChangePasswordModal()` is called with nothing (main.js). */
|
||||
const [forcedCurrentPassword, setForcedCurrentPassword] = useState<string | undefined>(
|
||||
forcePasswordChange ? 'admin' : undefined,
|
||||
forcePasswordChange && !hub ? 'admin' : undefined,
|
||||
)
|
||||
const [sub, setSub] = useState<string | null>(initialRoute?.sub ?? null)
|
||||
const [displayName, setDisplayName] = useState(session.displayName)
|
||||
@@ -247,7 +280,13 @@ export function Shell({
|
||||
|
||||
const current = sections.find((s) => s.id === active) ?? sections[0]
|
||||
/* Whether blocking is off or paused, marked on its entry (`BlockingMark.tsx`). */
|
||||
const blockingNow = useBlockingMark(session.token, permissions, sections.some((s) => s.id === 'blocking'))
|
||||
/* Behind a hub the Advanced Blocking app is asked on the first server (the one `settings/get` answers for). */
|
||||
const markNode = hub ? (session.info?.clusterNodes?.[0]?.name ?? '') : ''
|
||||
const showMark = sections.some((s) => s.id === 'blocking')
|
||||
const singleMark = useBlockingMark(session.token, permissions, showMark && !hub, hub, markNode)
|
||||
/* Behind a hub, every server counts: the mark names the ones that block by nothing. */
|
||||
const hubNow = useHubBlockingMark(session.token, permissions, showMark && hub, hubServers)
|
||||
const blockingNow = hub ? hubNow : singleMark
|
||||
|
||||
/*
|
||||
The jumps other screens' row menus make (`app/handoff.ts`). The section switch
|
||||
@@ -398,6 +437,9 @@ export function Shell({
|
||||
return (
|
||||
<HandoffContext.Provider value={handoff}>
|
||||
<ThisServerName.Provider value={info?.dnsServerDomain ?? session.info?.dnsServerDomain ?? ''}>
|
||||
<HubMode.Provider value={hub}>
|
||||
<HubWritable.Provider value={hubWritable}>
|
||||
<HubServerIds.Provider value={hubServers}>
|
||||
<SlotProvider>
|
||||
{(slot) => (
|
||||
<div className={styles.shell} data-rail={rail}>
|
||||
@@ -499,26 +541,31 @@ export function Shell({
|
||||
>
|
||||
{(close) => (
|
||||
<>
|
||||
<button type="button" onClick={() => { close(); open('profile') }}>
|
||||
My Profile
|
||||
</button>
|
||||
{/* The hub serves reads only: no profile, password, 2FA or tokens. */}
|
||||
{!hub && (
|
||||
<button type="button" onClick={() => { close(); open('profile') }}>
|
||||
My Profile
|
||||
</button>
|
||||
)}
|
||||
{/* main.js:71-87 (v15.5.1) switches on `sessionData.type`:
|
||||
`RemoteSSO` hides both, `RemoteLDAP` hides only the password
|
||||
(the directory owns it, the 2FA is still this server's) and
|
||||
`Local` or anything else shows both. */}
|
||||
{session.type !== 'RemoteSSO' && session.type !== 'RemoteLDAP' && (
|
||||
{!hub && session.type !== 'RemoteSSO' && session.type !== 'RemoteLDAP' && (
|
||||
<button type="button" onClick={() => { close(); open('password') }}>
|
||||
Change Password
|
||||
</button>
|
||||
)}
|
||||
{session.type !== 'RemoteSSO' && (
|
||||
{!hub && session.type !== 'RemoteSSO' && (
|
||||
<button type="button" onClick={() => { close(); open('twofa') }}>
|
||||
Configure 2FA
|
||||
</button>
|
||||
)}
|
||||
<button type="button" onClick={() => { close(); open('token') }}>
|
||||
Create API Token
|
||||
</button>
|
||||
{!hub && (
|
||||
<button type="button" onClick={() => { close(); open('token') }}>
|
||||
Create API Token
|
||||
</button>
|
||||
)}
|
||||
{/* index.html:71: after the account's own entries, before the
|
||||
update notification. */}
|
||||
<button type="button" onClick={() => { close(); open('theme') }}>
|
||||
@@ -573,10 +620,15 @@ export function Shell({
|
||||
<span key={i} data-last={i === crumbs.length - 1}>{c}</span>
|
||||
))}
|
||||
</div>
|
||||
{hub && <DriftStatus token={session.token} canWrite={permissions?.Apps?.canModify === true} writable={hubWritable} isAdmin={permissions?.Settings?.canModify === true} />}
|
||||
<Popover>
|
||||
<PopoverTrigger className={styles.status} data-attention={blockingNow != null} aria-label="Server status">
|
||||
<i className={styles.pdot} data-tone={blockingNow?.tone ?? 'ok'} />
|
||||
<span className={styles.lbl}>{blockingNow != null ? `Blocking ${blockingNow.text.toLowerCase()}` : (info?.dnsServerDomain ?? 'Server')}</span>
|
||||
<PopoverTrigger className={styles.status} data-attention={blockingNow != null || hubWarnings.length > 0} aria-label="Server status">
|
||||
<i className={styles.pdot} data-tone={hubWarnings.length > 0 ? 'warn' : (blockingNow?.tone ?? 'ok')} />
|
||||
<span className={styles.lbl}>
|
||||
{hubWarnings.length > 0
|
||||
? `${hubWarnings.length} ${hubWarnings.length === 1 ? 'server' : 'servers'} not answering`
|
||||
: blockingNow != null ? `Blocking ${blockingNow.text.toLowerCase()}` : (info?.dnsServerDomain ?? 'Server')}
|
||||
</span>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className={styles.pop}>
|
||||
<dl>
|
||||
@@ -585,6 +637,11 @@ export function Shell({
|
||||
<dt>Up since</dt><dd>{info?.uptimestamp ? new Date(info.uptimestamp).toLocaleString() : '-'}</dd>
|
||||
<dt>Blocking</dt><dd>{blockingNow ? blockingNow.label.replace(/^Blocking,?\s*/, '') : 'Enabled'}</dd>
|
||||
</dl>
|
||||
{hubWarnings.length > 0 && (
|
||||
<ul className={styles.warnings} aria-label="Servers not answering">
|
||||
{hubWarnings.map((w) => (<li key={w}>{w}</li>))}
|
||||
</ul>
|
||||
)}
|
||||
<Versions
|
||||
token={session.token}
|
||||
serverVersion={info?.version}
|
||||
@@ -618,20 +675,22 @@ export function Shell({
|
||||
token={session.token}
|
||||
nodes={session.info?.clusterNodes ?? []}
|
||||
clusterInitialised={session.info?.clusterInitialized === true}
|
||||
canBlock={hub ? permissions?.Blocked?.canModify !== false && permissions?.Allowed?.canModify !== false : true}
|
||||
/>
|
||||
) : current?.id === 'dnsclient' ? (
|
||||
<DnsClient
|
||||
token={session.token}
|
||||
nodes={session.info?.clusterNodes ?? []}
|
||||
clusterInitialised={session.info?.clusterInitialized === true}
|
||||
canImport={permissions?.DnsClient?.canModify !== false}
|
||||
request={pending != null && 'dnsclient' in pending ? pending.dnsclient : undefined}
|
||||
/>
|
||||
) : current?.id === 'about' ? (
|
||||
<About info={info} />
|
||||
<About info={info} hubPanel={hub && permissions?.Settings?.canModify === true ? <WebhookStatus token={session.token} /> : undefined} />
|
||||
) : current?.id === 'apps' ? (
|
||||
<Apps token={session.token} />
|
||||
<Apps token={session.token} nodes={session.info?.clusterNodes ?? []} clusterInitialised={session.info?.clusterInitialized === true} canWrite={permissions?.Apps?.canModify === true} writable={hubWritable} isAdmin={permissions?.Settings?.canModify === true} />
|
||||
) : current?.id === 'cache' ? (
|
||||
<Cache token={session.token} nodes={session.info?.clusterNodes ?? []} clusterInitialised={session.info?.clusterInitialized === true} />
|
||||
<Cache token={session.token} nodes={session.info?.clusterNodes ?? []} clusterInitialised={session.info?.clusterInitialized === true} canDelete={hub ? permissions?.Cache?.canDelete !== false : undefined} />
|
||||
) : current?.id === 'blocking' ? (
|
||||
<Blocking
|
||||
token={session.token}
|
||||
@@ -677,6 +736,7 @@ export function Shell({
|
||||
clusterInitialised={session.info?.clusterInitialized === true}
|
||||
sub={currentSub ?? 'View Logs'}
|
||||
onSubChange={setSub}
|
||||
canBlock={hub ? permissions?.Blocked?.canModify !== false && permissions?.Allowed?.canModify !== false : true}
|
||||
canDeleteLogs={permissions?.Logs?.canDelete !== false}
|
||||
canDeleteStats={permissions?.Dashboard?.canDelete !== false}
|
||||
request={pending != null && 'logs' in pending ? pending.logs : undefined}
|
||||
@@ -737,7 +797,7 @@ export function Shell({
|
||||
])}
|
||||
</CommandGroup>
|
||||
<CommandGroup heading="Account">
|
||||
<CommandItem value="My Profile" onSelect={() => { setPalette(false); open('profile') }}>My Profile</CommandItem>
|
||||
{!hub && <CommandItem value="My Profile" onSelect={() => { setPalette(false); open('profile') }}>My Profile</CommandItem>}
|
||||
<CommandItem value="Change Theme" onSelect={() => { setPalette(false); open('theme') }}>Change Theme</CommandItem>
|
||||
<CommandItem value="Logout" onSelect={() => { setPalette(false); onLogout() }}>Logout</CommandItem>
|
||||
</CommandGroup>
|
||||
@@ -798,6 +858,9 @@ export function Shell({
|
||||
</div>
|
||||
)}
|
||||
</SlotProvider>
|
||||
</HubServerIds.Provider>
|
||||
</HubWritable.Provider>
|
||||
</HubMode.Provider>
|
||||
</ThisServerName.Provider>
|
||||
</HandoffContext.Provider>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { getWebhookStatus, testWebhook, type WebhookStatus as Status } from '../api/hub-profiles'
|
||||
import { Button } from '../ui/Button'
|
||||
import { Tag } from '../ui/Tag'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
/*
|
||||
For the hub's admin: whether the hub sends notices to a webhook (HUB_WEBHOOK_URL), the host it
|
||||
sends them to (never the address, which may carry a token), how the last delivery went, and a
|
||||
button that sends a test notice. Read-only: the address and the secret are set on the hub.
|
||||
*/
|
||||
export function WebhookStatus({ token }: { token: string | null }) {
|
||||
const [status, setStatus] = useState<Status | null>(null)
|
||||
const [failure, setFailure] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const outcome = await getWebhookStatus(token)
|
||||
if (outcome.kind === 'ok') {
|
||||
setStatus(outcome.data.response)
|
||||
setFailure(null)
|
||||
} else setFailure(outcome.kind === 'error' ? outcome.message : 'The session ended.')
|
||||
}, [token])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
async function test() {
|
||||
setBusy(true)
|
||||
const outcome = await testWebhook(token)
|
||||
setBusy(false)
|
||||
if (outcome.kind === 'ok') {
|
||||
setStatus(outcome.data.response)
|
||||
setFailure(null)
|
||||
} else setFailure(outcome.kind === 'error' ? outcome.message : 'The session ended.')
|
||||
}
|
||||
|
||||
if (failure !== null && status === null) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm" role="alert">
|
||||
<span>Could not read the webhook status. {failure}</span>
|
||||
<Button size="sm" onClick={() => void load()}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (status === null) return <Skeleton className="h-6 w-2/3" role="status" aria-label="Loading the webhook status" />
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2" aria-label="Webhook">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="font-medium">Webhook</span>
|
||||
{status.configured ? <Tag tone="ok">Configured</Tag> : <Tag tone="neutral">Not configured</Tag>}
|
||||
{status.configured && <span className="text-muted-foreground">{status.host}</span>}
|
||||
{status.configured && status.last === null && <span className="text-xs text-muted-foreground">Nothing sent yet.</span>}
|
||||
{status.last !== null && (
|
||||
<>
|
||||
<Tag tone={status.last.ok ? 'ok' : 'dan'}>{status.last.ok ? 'Last delivery ok' : `Last delivery failed${status.last.error === undefined ? '' : ` (${status.last.error})`}`}</Tag>
|
||||
<span className="text-xs text-muted-foreground">{new Date(status.last.at).toLocaleString()}</span>
|
||||
</>
|
||||
)}
|
||||
{status.configured && (
|
||||
<Button size="sm" className="ml-auto" disabled={busy} onClick={() => void test()}>
|
||||
Send test
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{!status.configured && <p className="m-0 text-xs text-muted-foreground">Set HUB_WEBHOOK_URL on the hub to get a notice when a server drifts or a write fails.</p>}
|
||||
{failure !== null && (
|
||||
<p className="m-0 text-xs text-muted-foreground" role="alert">
|
||||
{failure}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { useContext, useRef, useState } from 'react'
|
||||
import { HubNode, nodeArg } from './hub-write'
|
||||
import { allowDomain, blockDomain, BLOCKED_TYPES } from '../api/blocking'
|
||||
import { noticeFromFailure, type Notice } from './notice'
|
||||
|
||||
@@ -32,7 +33,11 @@ export function verbFor(responseType: string): DomainVerb {
|
||||
return BLOCKED.includes(responseType.toLowerCase()) ? 'Allow Domain' : 'Block Domain'
|
||||
}
|
||||
|
||||
export function useDomainAction(token: string | null, onNotice: (n: Notice) => void) {
|
||||
/* `node` is the server the change is made on, for the hub; by default the one the section around it has chosen
|
||||
(`HubNode`, empty outside a hub: the server the console talks to). */
|
||||
export function useDomainAction(token: string | null, onNotice: (n: Notice) => void, node?: string) {
|
||||
const section = useContext(HubNode)
|
||||
const target = node ?? section
|
||||
const [busy, setBusy] = useState<ReadonlySet<string>>(new Set())
|
||||
/* Read by `run` so two clicks inside one render cannot both pass the guard. */
|
||||
const inFlight = useRef(new Set<string>())
|
||||
@@ -41,7 +46,7 @@ export function useDomainAction(token: string | null, onNotice: (n: Notice) => v
|
||||
if (inFlight.current.has(key)) return false
|
||||
inFlight.current.add(key)
|
||||
setBusy(new Set(inFlight.current))
|
||||
const outcome = await (verb === 'Allow Domain' ? allowDomain(token, domain) : blockDomain(token, domain))
|
||||
const outcome = await (verb === 'Allow Domain' ? allowDomain(token, domain, ...nodeArg(target)) : blockDomain(token, domain, ...nodeArg(target)))
|
||||
inFlight.current.delete(key)
|
||||
setBusy(new Set(inFlight.current))
|
||||
if (outcome.kind !== 'ok') {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { createContext, useContext, useState, type ReactNode } from 'react'
|
||||
import { runOnServers, type ServerAction, type ServerParams } from '../api/hub-run'
|
||||
import { RunFlow, pickerServers, type RunRequest } from '../screens/apps/RunFlow'
|
||||
import { HubWritable } from './hub-write'
|
||||
|
||||
/*
|
||||
Behind a hub, a change that belongs on several servers at once (Blocked and Allowed, the blocking
|
||||
switch, the block lists, the cache) goes through the same three steps as the apps: pick the
|
||||
servers, wait, read what happened on each, with "Retry failed". This is the glue:
|
||||
|
||||
· `HubServerIds`: every server the hub fronts, given by the shell. The picker lists all of them
|
||||
and disables the ones the hub holds no admin token for, with the reason.
|
||||
· `useAllServersFlow`: `start(spec)` opens the flow, `flow` is the element to render.
|
||||
· `HubAll`: what a section provides to the controls inside it when "All servers" is chosen and
|
||||
the role may make the change. A control that finds `null` there behaves as it always did.
|
||||
*/
|
||||
export const HubServerIds = createContext<readonly string[]>([])
|
||||
|
||||
export interface AllSpec {
|
||||
action: ServerAction
|
||||
params?: ServerParams
|
||||
title: string
|
||||
intro?: ReactNode
|
||||
confirmLabel?: string
|
||||
/** The servers ticked at the start; every writable one when absent. */
|
||||
preselected?: string[]
|
||||
/** After the flow closes, told whether any server changed. */
|
||||
onDone?: (changed: boolean) => void
|
||||
}
|
||||
|
||||
export type StartAll = (spec: AllSpec) => void
|
||||
|
||||
export const HubAll = createContext<StartAll | null>(null)
|
||||
|
||||
export function useAllServersFlow(token: string | null): { start: StartAll; flow: ReactNode } {
|
||||
const ids = useContext(HubServerIds)
|
||||
const writable = useContext(HubWritable)
|
||||
const [open, setOpen] = useState<{ spec: AllSpec; request: RunRequest } | null>(null)
|
||||
|
||||
const start: StartAll = (spec) => {
|
||||
const request: RunRequest = {
|
||||
title: spec.title,
|
||||
intro: spec.intro,
|
||||
confirmLabel: spec.confirmLabel ?? 'Run',
|
||||
servers: pickerServers(ids.map((id) => ({ id, writable: writable.includes(id) }))),
|
||||
preselected: spec.preselected ?? ids.filter((id) => writable.includes(id)),
|
||||
run: (targets) => runOnServers(token, spec.action, spec.params ?? {}, targets),
|
||||
}
|
||||
setOpen({ spec, request })
|
||||
}
|
||||
|
||||
const flow =
|
||||
open === null ? null : (
|
||||
<RunFlow
|
||||
request={open.request}
|
||||
onClose={(changed) => {
|
||||
const done = open.spec.onDone
|
||||
setOpen(null)
|
||||
done?.(changed)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
return { start, flow }
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { CHOOSE_A_SERVER, NO_ADMIN_TOKEN, hubDefaultNode, hubWriteState, nodeArg } from './hub-write'
|
||||
|
||||
const NODES = [
|
||||
{ name: 'mdns', type: 'Primary' },
|
||||
{ name: 'ndns', type: 'Secondary' },
|
||||
]
|
||||
|
||||
describe('hubWriteState', () => {
|
||||
const base = { hub: true, writable: ['mdns'], allowed: true }
|
||||
|
||||
it('allows a change on a chosen server the hub can write to', () => {
|
||||
expect(hubWriteState({ ...base, node: 'mdns' })).toEqual({ allowed: true, reason: null })
|
||||
})
|
||||
|
||||
it('says "Choose a server" for the aggregate and for no node', () => {
|
||||
for (const node of ['', 'cluster']) {
|
||||
expect(hubWriteState({ ...base, node })).toEqual({ allowed: false, reason: CHOOSE_A_SERVER })
|
||||
}
|
||||
})
|
||||
|
||||
it('says there is no admin token for a server the hub cannot write to', () => {
|
||||
expect(hubWriteState({ ...base, node: 'ndns' })).toEqual({ allowed: false, reason: NO_ADMIN_TOKEN })
|
||||
expect(NO_ADMIN_TOKEN).toBe('No admin token configured on the hub')
|
||||
})
|
||||
|
||||
it('gives no reason when the role is what says no (the control carries its own tooltip)', () => {
|
||||
expect(hubWriteState({ ...base, node: 'mdns', allowed: false })).toEqual({ allowed: false, reason: null })
|
||||
expect(hubWriteState({ ...base, node: '', allowed: false })).toEqual({ allowed: false, reason: null })
|
||||
})
|
||||
|
||||
it('is the permission and nothing more outside a hub', () => {
|
||||
expect(hubWriteState({ hub: false, node: '', writable: [], allowed: true })).toEqual({ allowed: true, reason: null })
|
||||
expect(hubWriteState({ hub: false, node: 'x', writable: [], allowed: false })).toEqual({ allowed: false, reason: null })
|
||||
})
|
||||
})
|
||||
|
||||
describe('hubDefaultNode', () => {
|
||||
it('names the first server behind a hub and nothing elsewhere', () => {
|
||||
expect(hubDefaultNode(true, NODES)).toBe('mdns')
|
||||
expect(hubDefaultNode(false, NODES)).toBe('')
|
||||
expect(hubDefaultNode(true, [])).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('nodeArg', () => {
|
||||
it('adds the argument only when a server is chosen', () => {
|
||||
expect(nodeArg('')).toEqual([])
|
||||
expect(nodeArg('mdns')).toEqual(['mdns'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import { createContext, useContext } from 'react'
|
||||
import { HubMode, type ClusterNode } from '../ui/ClusterNodeSelect'
|
||||
|
||||
/*
|
||||
Behind a hub a change is made on ONE server at a time (`node=<id>`), and only on a server the
|
||||
hub holds an admin token for. These are the pieces every screen that changes something shares,
|
||||
so the rule is written once.
|
||||
|
||||
· `HubWritable`: the servers the hub can write to (`hubWritable` of the session), given by the shell.
|
||||
· `hubWriteState`: whether a change is allowed on `node`, and if not, the sentence that says why.
|
||||
· `useHubWrite`: that, read from the contexts, for a screen's own node.
|
||||
· `useHubDefaultNode`: the server a screen without an "All servers" choice starts on.
|
||||
· `HubNode`: the server a section's controls act on, for the controls that are not handed it.
|
||||
|
||||
Outside a hub none of this applies: `allowed` is the permission and there is no reason.
|
||||
*/
|
||||
export const HubWritable = createContext<readonly string[]>([])
|
||||
|
||||
/* The server the controls of a section act on (`''` outside a hub, or when none is chosen). */
|
||||
export const HubNode = createContext('')
|
||||
|
||||
export const CHOOSE_A_SERVER = 'Choose a server'
|
||||
export const NO_ADMIN_TOKEN = 'No admin token configured on the hub'
|
||||
|
||||
export interface HubWrite {
|
||||
/** The role may change it, a server is chosen and the hub can write to it. */
|
||||
allowed: boolean
|
||||
/** Why not, when it is the node and not the role; `null` when allowed or when the role says no. */
|
||||
reason: string | null
|
||||
}
|
||||
|
||||
export function hubWriteState({
|
||||
hub,
|
||||
node,
|
||||
writable,
|
||||
allowed,
|
||||
}: {
|
||||
hub: boolean
|
||||
node: string
|
||||
writable: readonly string[]
|
||||
/** What the role allows (`canModify`). */
|
||||
allowed: boolean
|
||||
}): HubWrite {
|
||||
if (!hub) return { allowed, reason: null }
|
||||
if (!allowed) return { allowed: false, reason: null }
|
||||
if (node === '' || node === 'cluster') return { allowed: false, reason: CHOOSE_A_SERVER }
|
||||
if (!writable.includes(node)) return { allowed: false, reason: NO_ADMIN_TOKEN }
|
||||
return { allowed: true, reason: null }
|
||||
}
|
||||
|
||||
/* The write state of a screen on its own `node`. `allowed` is the role's permission, true when it has none. */
|
||||
export function useHubWrite(node: string, allowed = true): HubWrite {
|
||||
const hub = useContext(HubMode)
|
||||
const writable = useContext(HubWritable)
|
||||
return hubWriteState({ hub, node, writable, allowed })
|
||||
}
|
||||
|
||||
/*
|
||||
Where a screen without an "All servers" choice starts. Outside a hub that is the empty string (this
|
||||
server, which the real console takes as its own node). Behind a hub the empty string is the hub, not
|
||||
a server, so the first server is named: the selector shows it and the requests carry it.
|
||||
*/
|
||||
export function hubDefaultNode(hub: boolean, nodes: readonly ClusterNode[]): string {
|
||||
return hub && nodes.length > 0 ? nodes[0].name : ''
|
||||
}
|
||||
|
||||
export function useHubDefaultNode(nodes: readonly ClusterNode[]): string {
|
||||
return hubDefaultNode(useContext(HubMode), nodes)
|
||||
}
|
||||
|
||||
/*
|
||||
The optional `node` argument of an API function, spread into its call: nothing when no server is
|
||||
chosen, so a console used without a hub makes exactly the calls it always made.
|
||||
*/
|
||||
export const nodeArg = (node: string): [] | [string] => (node === '' ? [] : [node])
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { countDomains } from './list-count'
|
||||
|
||||
describe('countDomains', () => {
|
||||
it('counts hosts lines, skipping comments and the hosts file own names', () => {
|
||||
const text = [
|
||||
'# Title: a list',
|
||||
'127.0.0.1 localhost',
|
||||
'::1 ip6-localhost',
|
||||
'0.0.0.0 ads.example.com',
|
||||
'0.0.0.0 track.example.com # inline',
|
||||
'0.0.0.0 ads.example.com',
|
||||
'',
|
||||
].join('\r\n')
|
||||
expect(countDomains(text)).toBe(2)
|
||||
})
|
||||
|
||||
it('counts plain domains and wildcards once each', () => {
|
||||
expect(countDomains('a.example\nB.example\n*.c.example\na.example\n! note\n')).toBe(3)
|
||||
})
|
||||
|
||||
it('counts adblock domain rules, not exceptions, options-only lines or regexes', () => {
|
||||
const text = ['[Adblock Plus 2.0]', '! Title', '||ads.example.com^', '||t.example.net^$third-party', '@@||ok.example.com^', '/banner[0-9]+/', '##.ad'].join('\n')
|
||||
expect(countDomains(text)).toBe(2)
|
||||
})
|
||||
|
||||
it('counts several names on one hosts line', () => {
|
||||
expect(countDomains('0.0.0.0 a.example b.example')).toBe(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
How many distinct domains a block list file holds. OURS, for the Blocking Overview's
|
||||
"Block List Domains" figure when the Advanced Blocking app is in use: the app has no API
|
||||
that says it, so the lists are downloaded and counted. The hub has its own copy of this
|
||||
function (`hub/app-lists.ts`), kept in step by hand because the hub does not share code with
|
||||
the console build; both are tested against the same sample.
|
||||
|
||||
Counted: a hosts line (`0.0.0.0 example.com`, every name after the address), a plain
|
||||
domain (`example.com`, `*.example.com`), and an Adblock Plus domain rule (`||example.com^`,
|
||||
with or without options). Not counted: blank lines, comments (`#`, `!`, `[Adblock Plus]`),
|
||||
Adblock exceptions (`@@`), regular expressions, and the hosts file's own names (`localhost`,
|
||||
`broadcasthost`, `ip6-*`).
|
||||
*/
|
||||
const ADDRESS = /^(?:\d{1,3}(?:\.\d{1,3}){3}|[0-9a-f:]*:[0-9a-f:]*)$/i
|
||||
const NAME = /^[a-z0-9_]([a-z0-9_.-]*[a-z0-9_])?$/i
|
||||
const OWN = new Set(['localhost', 'localhost.localdomain', 'local', 'broadcasthost', '0.0.0.0'])
|
||||
|
||||
function accept(raw: string, into: Set<string>): void {
|
||||
const name = raw.replace(/^\*\./, '').replace(/\.$/, '').toLowerCase()
|
||||
if (name === '' || OWN.has(name) || name.startsWith('ip6-') || !NAME.test(name)) return
|
||||
into.add(name)
|
||||
}
|
||||
|
||||
export function countDomains(text: string): number {
|
||||
const domains = new Set<string>()
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const t = line.trim()
|
||||
if (t === '' || t.startsWith('#') || t.startsWith('!') || t.startsWith('[') || t.startsWith('@@') || t.startsWith('/')) continue
|
||||
if (t.startsWith('||')) {
|
||||
const end = t.search(/[\^$/]/)
|
||||
accept(end < 0 ? t.slice(2) : t.slice(2, end), domains)
|
||||
continue
|
||||
}
|
||||
const tokens = t.split('#')[0].trim().split(/\s+/)
|
||||
if (tokens.length > 1 && ADDRESS.test(tokens[0])) for (const name of tokens.slice(1)) accept(name, domains)
|
||||
else if (tokens.length === 1) accept(tokens[0], domains)
|
||||
}
|
||||
return domains.size
|
||||
}
|
||||
@@ -61,7 +61,8 @@ four literals the product does not say.
|
||||
What stays is the `Server` panel: version and uptime are facts upstream's own
|
||||
About shows, right under the title.
|
||||
*/
|
||||
export function About({ info }: { info?: Info }) {
|
||||
/* `hubPanel`: behind a hub, the hub's own status for its admin (the webhook), under Server. */
|
||||
export function About({ info, hubPanel }: { info?: Info; hubPanel?: ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<SectionHeader title="About" subtitle="Technitium DNS Server and this console" />
|
||||
@@ -166,6 +167,12 @@ export function About({ info }: { info?: Info }) {
|
||||
</Body>
|
||||
</Panel>
|
||||
|
||||
{hubPanel != null && (
|
||||
<Panel title="Hub notifications" description="Where the hub sends drift and failure notices">
|
||||
<Body>{hubPanel}</Body>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{/*
|
||||
This console's own panels (deviation 9 of CONVENTIONS.md, approved on
|
||||
2026-10-16), under Server. Technitium's card on the left stays literal.
|
||||
|
||||
@@ -21,6 +21,8 @@ export function AppCard({
|
||||
onUpdate,
|
||||
onStoreUpdate,
|
||||
onUninstall,
|
||||
readOnly = false,
|
||||
hideConfig = false,
|
||||
}: {
|
||||
app: InstalledApp
|
||||
busy: boolean
|
||||
@@ -28,6 +30,10 @@ export function AppCard({
|
||||
onUpdate: () => void
|
||||
onStoreUpdate: () => void
|
||||
onUninstall: () => void
|
||||
/** Behind a hub nothing can be changed yet: no action buttons. */
|
||||
readOnly?: boolean
|
||||
/** Behind a hub the config is not edited from here yet. */
|
||||
hideConfig?: boolean
|
||||
}) {
|
||||
const hasUpdate = app.updateAvailable === true && Boolean(app.updateUrl)
|
||||
|
||||
@@ -35,7 +41,7 @@ export function AppCard({
|
||||
<li className={styles.gridRow} aria-label={app.name}>
|
||||
<div className={styles.nameCell}>
|
||||
<h2>{app.name}</h2>
|
||||
{app.description && <p className={styles.desc}>{app.description}</p>}
|
||||
{app.description && <p className={`${styles.desc} line-clamp-2`} title={app.description}>{app.description}</p>}
|
||||
</div>
|
||||
|
||||
<div className={styles.mono}>
|
||||
@@ -54,10 +60,15 @@ export function AppCard({
|
||||
<Tag tone={hasUpdate ? 'warn' : 'ok'}>{hasUpdate ? 'Update available' : 'Up to date'}</Tag>
|
||||
</div>
|
||||
|
||||
{readOnly ? (
|
||||
<div />
|
||||
) : (
|
||||
<div className={styles.rowActs}>
|
||||
<Button disabled={busy} onClick={onConfig}>
|
||||
Config
|
||||
</Button>
|
||||
{!hideConfig && (
|
||||
<Button disabled={busy} onClick={onConfig}>
|
||||
Config
|
||||
</Button>
|
||||
)}
|
||||
<Button disabled={busy} onClick={onUpdate}>
|
||||
Update
|
||||
</Button>
|
||||
@@ -94,6 +105,7 @@ export function AppCard({
|
||||
)}
|
||||
</Menu>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{app.dnsApps.length > 0 && (
|
||||
<Details className={styles.rowDetails} summary="More Details">
|
||||
|
||||
@@ -25,6 +25,7 @@ export function AppConfig({
|
||||
name,
|
||||
initialConfig,
|
||||
onSaved,
|
||||
node = '',
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (o: boolean) => void
|
||||
@@ -32,6 +33,8 @@ export function AppConfig({
|
||||
name: string
|
||||
initialConfig: string
|
||||
onSaved: (name: string) => void
|
||||
/** Behind a hub: the one server it goes to. */
|
||||
node?: string
|
||||
}) {
|
||||
const [config, setConfig] = useState(initialConfig)
|
||||
const [alert, setAlert] = useState<AlertState | null>(null)
|
||||
@@ -47,7 +50,7 @@ export function AppConfig({
|
||||
|
||||
async function save() {
|
||||
setBusy(true)
|
||||
const outcome = await setAppConfig(token, name, config)
|
||||
const outcome = await setAppConfig(token, name, config, node)
|
||||
setBusy(false)
|
||||
|
||||
if (outcome.kind !== 'ok') {
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { AppConfigSync } from './AppConfigSync'
|
||||
import { AppsMatrix } from './AppsMatrix'
|
||||
import * as api from '../../api/apps'
|
||||
import type { ConfigCompare, ServerConfigView } from '../../api/apps'
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
const A = JSON.stringify({ ttl: 30, db: '/a.db' }, null, 2)
|
||||
const B = JSON.stringify({ ttl: 60, db: '/b.db' }, null, 2)
|
||||
|
||||
function view(id: string, over: Partial<ServerConfigView> = {}): ServerConfigView {
|
||||
return { id, ok: true, installed: true, writable: true, version: '1.0', text: A, hash: 'h-' + id, warnings: [], inSync: null, vsOthers: {}, ...over }
|
||||
}
|
||||
|
||||
/* mdns is the baseline; ndns differs in ttl and in the local db path. */
|
||||
const WITH_BASELINE: ConfigCompare = {
|
||||
name: 'Blocker',
|
||||
baseline: { app: 'Blocker', text: A, importedFrom: 'mdns', updatedAt: '2026-10-31T08:00:00Z', updatedBy: 'alice' },
|
||||
overrides: { paths: ['db'], values: { mdns: { db: '/a.db' }, ndns: { db: '/b.db' } } },
|
||||
servers: [
|
||||
view('mdns', { inSync: true, vsBaseline: { kind: 'json', changes: [] } }),
|
||||
view('ndns', {
|
||||
text: B,
|
||||
inSync: false,
|
||||
vsBaseline: { kind: 'json', changes: [{ path: 'ttl', type: 'changed', a: 60, b: 30 }, { path: 'db', type: 'changed', a: '/x.db', b: '/b.db' }] },
|
||||
}),
|
||||
],
|
||||
}
|
||||
|
||||
const NO_BASELINE: ConfigCompare = {
|
||||
name: 'Blocker',
|
||||
baseline: null,
|
||||
overrides: { paths: [], values: {} },
|
||||
servers: [
|
||||
view('mdns', { vsOthers: { ndns: { kind: 'json', changes: [{ path: 'ttl', type: 'changed', a: 60, b: 30 }] } } }),
|
||||
view('ndns', { text: B }),
|
||||
],
|
||||
}
|
||||
|
||||
function mockCompare(data: ConfigCompare) {
|
||||
return vi.spyOn(api, 'getConfigCompare').mockResolvedValue({ kind: 'ok', data: { response: data } } as never)
|
||||
}
|
||||
|
||||
const ok = (results: api.TargetResult[]) => ({ kind: 'ok', data: { response: { results } } }) as never
|
||||
|
||||
function mount(canWrite: boolean) {
|
||||
const onChanged = vi.fn()
|
||||
render(<AppConfigSync token="t" name="Blocker" canWrite={canWrite} onClose={() => {}} onChanged={onChanged} />)
|
||||
return { onChanged }
|
||||
}
|
||||
|
||||
describe('Compare', () => {
|
||||
it('shows a skeleton while loading, then the badges and the fields that differ', async () => {
|
||||
mockCompare(WITH_BASELINE)
|
||||
mount(false)
|
||||
expect(screen.getByRole('status', { name: 'Loading the configs' })).toBeInTheDocument()
|
||||
expect(await screen.findByText('In sync')).toBeInTheDocument()
|
||||
expect(screen.getByText('Differs')).toBeInTheDocument()
|
||||
const dialog = screen.getByRole('dialog', { name: 'App config: Blocker' })
|
||||
expect(within(dialog).getByRole('columnheader', { name: 'ndns' })).toBeInTheDocument()
|
||||
expect(within(dialog).getByText('ttl')).toBeInTheDocument()
|
||||
expect(within(dialog).getByText('60')).toBeInTheDocument()
|
||||
expect(within(dialog).getByText('expected 30')).toBeInTheDocument()
|
||||
// The local field is marked.
|
||||
const dbRow = within(dialog).getByText('db').closest('tr') as HTMLElement
|
||||
expect(within(dbRow).getByText('Local')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('compares the servers to each other when there is no baseline', async () => {
|
||||
mockCompare(NO_BASELINE)
|
||||
mount(false)
|
||||
expect(await screen.findByText('No baseline yet')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('No baseline').length).toBeGreaterThan(0)
|
||||
expect(screen.getByText('ttl')).toBeInTheDocument()
|
||||
expect(screen.getByText('30')).toBeInTheDocument()
|
||||
expect(screen.getByText('60')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows lines for a text config', async () => {
|
||||
mockCompare({
|
||||
...WITH_BASELINE,
|
||||
servers: [
|
||||
view('mdns', { inSync: true, vsBaseline: { kind: 'text', lines: [], truncated: false, changed: 0 } }),
|
||||
view('ndns', { inSync: false, vsBaseline: { kind: 'text', lines: [{ op: 'same', text: 'a = 1' }, { op: 'remove', text: 'b = 2' }, { op: 'add', text: 'b = 3' }], truncated: false, changed: 2 } }),
|
||||
],
|
||||
})
|
||||
mount(false)
|
||||
const lines = await screen.findByLabelText('Line differences')
|
||||
expect(within(lines).getByText(/b = 3/)).toHaveAttribute('data-op', 'add')
|
||||
expect(within(lines).getByText(/b = 2/)).toHaveAttribute('data-op', 'remove')
|
||||
})
|
||||
|
||||
it('shows an error with Retry', async () => {
|
||||
const spy = vi.spyOn(api, 'getConfigCompare').mockResolvedValueOnce({ kind: 'error', message: 'No server answered.' } as never)
|
||||
mount(false)
|
||||
expect(await screen.findByText(/Could not read the configs/)).toHaveTextContent('No server answered.')
|
||||
spy.mockResolvedValue({ kind: 'ok', data: { response: WITH_BASELINE } } as never)
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
||||
expect(await screen.findByText('In sync')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('role gating', () => {
|
||||
it('gives a reader no editing controls', async () => {
|
||||
mockCompare(WITH_BASELINE)
|
||||
mount(false)
|
||||
await screen.findByText('In sync')
|
||||
expect(screen.queryByRole('button', { name: 'Apply…' })).not.toBeInTheDocument()
|
||||
await userEvent.click(screen.getByRole('tab', { name: 'Baseline' }))
|
||||
expect(screen.getByLabelText('Baseline text')).toHaveAttribute('readonly')
|
||||
expect(screen.queryByRole('button', { name: 'Save baseline' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /^Import from/ })).not.toBeInTheDocument()
|
||||
await userEvent.click(screen.getByRole('tab', { name: 'Local fields' }))
|
||||
expect(screen.getByLabelText('db on mdns')).toHaveAttribute('readonly')
|
||||
expect(screen.queryByRole('button', { name: 'Save local fields' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('tells a reader who sees no baseline to ask, without import buttons', async () => {
|
||||
mockCompare(NO_BASELINE)
|
||||
mount(false)
|
||||
await screen.findByText('No baseline yet')
|
||||
expect(screen.queryByRole('button', { name: /^Import from/ })).not.toBeInTheDocument()
|
||||
expect(screen.getByText(/apps administrator/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers a writer the import from each server when there is no baseline', async () => {
|
||||
const spy = mockCompare(NO_BASELINE)
|
||||
const imp = vi.spyOn(api, 'importBaseline').mockResolvedValue({ kind: 'ok', data: { response: { baseline: WITH_BASELINE.baseline } } } as never)
|
||||
const { onChanged } = mount(true)
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Import from mdns' }))
|
||||
expect(imp).toHaveBeenCalledWith('t', 'Blocker', 'mdns')
|
||||
await waitFor(() => expect(spy.mock.calls.length).toBeGreaterThan(1))
|
||||
expect(onChanged).toHaveBeenCalled()
|
||||
expect(await screen.findByText('Baseline imported from mdns.')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Baseline and Local fields', () => {
|
||||
it('shows who updated the baseline and a validation error from the hub inline', async () => {
|
||||
mockCompare(WITH_BASELINE)
|
||||
const save = vi.spyOn(api, 'saveBaseline').mockResolvedValue({ kind: 'error', message: 'The baseline is not valid JSON: Unexpected token at position 4' } as never)
|
||||
mount(true)
|
||||
await userEvent.click(await screen.findByRole('tab', { name: 'Baseline' }))
|
||||
expect(screen.getByText(/Imported from mdns\. Updated by alice/)).toBeInTheDocument()
|
||||
const box = screen.getByLabelText('Baseline text')
|
||||
await userEvent.clear(box)
|
||||
await userEvent.type(box, 'x')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save baseline' }))
|
||||
expect(save).toHaveBeenCalledWith('t', 'Blocker', 'x')
|
||||
expect(await screen.findByText(/not valid JSON: Unexpected token at position 4/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('adds a local field, fills the values from the servers and saves them', async () => {
|
||||
mockCompare({ ...WITH_BASELINE, overrides: { paths: [], values: {} } })
|
||||
const current = vi.spyOn(api, 'getCurrentLocalValues').mockResolvedValue({ kind: 'ok', data: { response: { values: { mdns: { db: '/a.db' }, ndns: { db: '/b.db' } } } } } as never)
|
||||
const save = vi.spyOn(api, 'saveOverrides').mockResolvedValue({ kind: 'ok', data: { response: { overrides: WITH_BASELINE.overrides } } } as never)
|
||||
mount(true)
|
||||
await userEvent.click(await screen.findByRole('tab', { name: 'Local fields' }))
|
||||
expect(screen.getByText('No local fields yet.')).toBeInTheDocument()
|
||||
await userEvent.type(screen.getByLabelText('New local field'), 'db')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Add field' }))
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Fill from the current server configs' }))
|
||||
expect(current).toHaveBeenCalledWith('t', 'Blocker', ['db'])
|
||||
expect(await screen.findByDisplayValue('"/b.db"')).toBeInTheDocument()
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save local fields' }))
|
||||
expect(save).toHaveBeenCalledWith('t', 'Blocker', { paths: ['db'], values: { mdns: { db: '/a.db' }, ndns: { db: '/b.db' } } })
|
||||
})
|
||||
|
||||
it('refuses a value that is not JSON, without calling the hub', async () => {
|
||||
mockCompare(WITH_BASELINE)
|
||||
const save = vi.spyOn(api, 'saveOverrides')
|
||||
mount(true)
|
||||
await userEvent.click(await screen.findByRole('tab', { name: 'Local fields' }))
|
||||
const box = screen.getByLabelText('db on mdns')
|
||||
await userEvent.clear(box)
|
||||
await userEvent.type(box, 'no quotes')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save local fields' }))
|
||||
expect(await screen.findByText(/db on mdns is not valid JSON/)).toBeInTheDocument()
|
||||
expect(save).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Apply', () => {
|
||||
it('picks servers, previews, applies with the preview hashes, and retries only what failed', async () => {
|
||||
mockCompare({ ...WITH_BASELINE, servers: [WITH_BASELINE.servers[0], { ...WITH_BASELINE.servers[1], writable: true }] })
|
||||
const preview = vi.spyOn(api, 'previewConfig').mockResolvedValue({
|
||||
kind: 'ok',
|
||||
data: {
|
||||
response: {
|
||||
targets: [
|
||||
{ server: 'ndns', ok: true, writable: true, current: B, currentHash: 'hash-ndns', effective: A, unchanged: false, warnings: ['Version 0.9 here, 1.0 on mdns: versions differ, align versions first.'], changes: WITH_BASELINE.servers[1].vsBaseline },
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never)
|
||||
const apply = vi
|
||||
.spyOn(api, 'applyConfig')
|
||||
.mockResolvedValueOnce(ok([{ server: 'ndns', ok: false, message: 'The config is invalid.' }]))
|
||||
.mockResolvedValueOnce(ok([{ server: 'ndns', ok: true, message: 'Applied and verified.' }]))
|
||||
const { onChanged } = mount(true)
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Apply…' }))
|
||||
|
||||
const picker = await screen.findByRole('dialog', { name: 'Apply the baseline of Blocker' })
|
||||
// Only the server that differs is ticked.
|
||||
expect(within(picker).getByRole('checkbox', { name: 'ndns' })).toBeChecked()
|
||||
expect(within(picker).getByRole('checkbox', { name: 'mdns' })).not.toBeChecked()
|
||||
await userEvent.click(within(picker).getByRole('button', { name: 'Preview' }))
|
||||
expect(preview).toHaveBeenCalledWith('t', 'Blocker', ['ndns'])
|
||||
|
||||
const review = await screen.findByRole('dialog', { name: 'Apply the baseline of Blocker: review' })
|
||||
expect(within(review).getByText('Will change')).toBeInTheDocument()
|
||||
expect(within(review).getByText(/align versions first/)).toBeInTheDocument()
|
||||
expect(within(review).getByText('ttl')).toBeInTheDocument()
|
||||
expect(apply).not.toHaveBeenCalled()
|
||||
await userEvent.click(within(review).getByRole('button', { name: 'Apply' }))
|
||||
expect(apply).toHaveBeenLastCalledWith('t', 'Blocker', ['ndns'], { ndns: 'hash-ndns' })
|
||||
|
||||
const results = await screen.findByRole('dialog', { name: 'Apply the baseline of Blocker: results' })
|
||||
expect(within(results).getByText('The config is invalid.')).toBeInTheDocument()
|
||||
await userEvent.click(within(results).getByRole('button', { name: 'Retry failed' }))
|
||||
expect(apply).toHaveBeenCalledTimes(2)
|
||||
await waitFor(() => expect(within(screen.getByRole('dialog')).getByText('Applied and verified.')).toBeInTheDocument())
|
||||
await userEvent.click(within(screen.getByRole('dialog')).getAllByRole('button', { name: 'Close' }).at(-1) as HTMLElement)
|
||||
await waitFor(() => expect(onChanged).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('cannot apply without a baseline, and lists a server without a token as disabled', async () => {
|
||||
mockCompare(NO_BASELINE)
|
||||
mount(true)
|
||||
await screen.findByText('No baseline yet')
|
||||
expect(screen.getByRole('button', { name: 'Apply…' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('disables a server the hub cannot write to in the picker', async () => {
|
||||
mockCompare({ ...WITH_BASELINE, servers: [WITH_BASELINE.servers[0], { ...WITH_BASELINE.servers[1], writable: false }] })
|
||||
mount(true)
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Apply…' }))
|
||||
const picker = await screen.findByRole('dialog', { name: 'Apply the baseline of Blocker' })
|
||||
expect(within(picker).getByRole('checkbox', { name: 'ndns' })).toBeDisabled()
|
||||
expect(within(picker).getByText('No admin token configured on the hub')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a review failure from the hub', async () => {
|
||||
mockCompare(WITH_BASELINE)
|
||||
vi.spyOn(api, 'previewConfig').mockResolvedValue({ kind: 'error', message: 'This app has no baseline yet.' } as never)
|
||||
mount(true)
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Apply…' }))
|
||||
await userEvent.click(within(await screen.findByRole('dialog', { name: 'Apply the baseline of Blocker' })).getByRole('button', { name: 'Preview' }))
|
||||
expect(await screen.findByText('This app has no baseline yet.')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('from the matrix', () => {
|
||||
const DATA: api.AppsMatrixData = {
|
||||
servers: [{ id: 'mdns', ok: true, writable: true }, { id: 'ndns', ok: true, writable: true }],
|
||||
apps: [
|
||||
{
|
||||
name: 'Blocker', description: '', configs: 'different', diffCount: 2, versions: 'same',
|
||||
servers: { mdns: { installed: true, version: '1.0', configStatus: 'read' }, ndns: { installed: true, version: '1.0', configStatus: 'read' } },
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
it('opens the config view from the row menu for a reader and from the Different badge', async () => {
|
||||
vi.spyOn(api, 'getAppsMatrix').mockResolvedValue({ kind: 'ok', data: { response: DATA } } as never)
|
||||
const compare = mockCompare(WITH_BASELINE)
|
||||
render(<AppsMatrix token="t" />)
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Actions: Blocker' }))
|
||||
await userEvent.click(screen.getByRole('menuitem', { name: 'Config…' }))
|
||||
expect(await screen.findByRole('dialog', { name: 'App config: Blocker' })).toBeInTheDocument()
|
||||
expect(compare).toHaveBeenCalledWith('t', 'Blocker')
|
||||
await userEvent.click(within(screen.getByRole('dialog')).getAllByRole('button', { name: 'Close' }).at(-1) as HTMLElement)
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Compare the configs of Blocker' }))
|
||||
expect(await screen.findByRole('dialog', { name: 'App config: Blocker' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,545 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
getConfigCompare,
|
||||
getCurrentLocalValues,
|
||||
getHistory,
|
||||
getHistoryDiff,
|
||||
getHistoryEntry,
|
||||
importBaseline,
|
||||
restoreBaseline,
|
||||
saveBaseline,
|
||||
saveOverrides,
|
||||
type ConfigCompare,
|
||||
type HistoryDiff,
|
||||
type HistoryEntry,
|
||||
type ServerConfigView,
|
||||
} from '../../api/apps'
|
||||
import { Alert } from '../../ui/Alert'
|
||||
import { Button } from '../../ui/Button'
|
||||
import { Dialog } from '../../ui/Dialog'
|
||||
import { Empty, Failure } from '../../ui/Empty'
|
||||
import { Input, LabeledTextarea } from '../../ui/Field'
|
||||
import { Tag } from '../../ui/Tag'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { DiffView, FieldTable, fieldRows } from './ConfigDiffView'
|
||||
import { RunFlow, pickerServers, type RunRequest } from './RunFlow'
|
||||
import { applyRequest, rollbackRequest } from './applyRequest'
|
||||
import { Select } from '../../ui/Field'
|
||||
|
||||
/*
|
||||
One app's config across the servers, behind a hub: compare them, keep a baseline in the hub,
|
||||
mark the fields that stay local to each server, then preview and apply. Everyone who can read
|
||||
sees the comparison; the editors and Apply are for roles that may change apps.
|
||||
*/
|
||||
|
||||
type Tab = 'compare' | 'baseline' | 'local' | 'history'
|
||||
|
||||
const when = (iso: string): string => (iso === '' ? '' : new Date(iso).toLocaleString())
|
||||
|
||||
function ServerBadge({ s, hasBaseline }: { s: ServerConfigView; hasBaseline: boolean }) {
|
||||
if (!s.ok) return <Tag tone="dan">Did not answer</Tag>
|
||||
if (!s.installed) return <Tag>Not installed</Tag>
|
||||
if (!hasBaseline || s.inSync === null) return <Tag>No baseline</Tag>
|
||||
return s.inSync ? <Tag tone="ok">In sync</Tag> : <Tag tone="warn">Differs</Tag>
|
||||
}
|
||||
|
||||
export function AppConfigSync({
|
||||
token,
|
||||
name,
|
||||
canWrite,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
token: string | null
|
||||
name: string
|
||||
canWrite: boolean
|
||||
onClose: () => void
|
||||
onChanged: () => void
|
||||
}) {
|
||||
const [data, setData] = useState<ConfigCompare | null>(null)
|
||||
const [failure, setFailure] = useState<string | null>(null)
|
||||
const [tab, setTab] = useState<Tab>('compare')
|
||||
const [request, setRequest] = useState<RunRequest | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [problem, setProblem] = useState<string | null>(null)
|
||||
const [saved, setSaved] = useState<string | null>(null)
|
||||
// The editors.
|
||||
const [baselineText, setBaselineText] = useState('')
|
||||
const [paths, setPaths] = useState<string[]>([])
|
||||
const [values, setValues] = useState<Record<string, Record<string, string>>>({})
|
||||
const [newPath, setNewPath] = useState('')
|
||||
const [loads, setLoads] = useState(0)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const outcome = await getConfigCompare(token, name)
|
||||
if (outcome.kind !== 'ok') {
|
||||
setFailure(outcome.kind === 'error' ? outcome.message : 'The session ended.')
|
||||
return
|
||||
}
|
||||
const d = outcome.data.response
|
||||
setData(d)
|
||||
setLoads((n) => n + 1)
|
||||
setFailure(null)
|
||||
setBaselineText(d.baseline?.text ?? '')
|
||||
setPaths(d.overrides.paths)
|
||||
setValues(
|
||||
Object.fromEntries(d.servers.map((s) => [s.id, Object.fromEntries(d.overrides.paths.flatMap((p) => (Object.hasOwn(d.overrides.values[s.id] ?? {}, p) ? [[p, JSON.stringify(d.overrides.values[s.id][p])]] : [])))])),
|
||||
)
|
||||
}, [token, name])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
const hasBaseline = data?.baseline != null
|
||||
|
||||
async function run(call: () => Promise<{ kind: string; message?: string }>, done: string) {
|
||||
setBusy(true)
|
||||
setProblem(null)
|
||||
setSaved(null)
|
||||
const outcome = await call()
|
||||
setBusy(false)
|
||||
if (outcome.kind !== 'ok') {
|
||||
setProblem(outcome.kind === 'error' ? (outcome.message ?? 'Failed.') : 'The session ended.')
|
||||
return
|
||||
}
|
||||
setSaved(done)
|
||||
await load()
|
||||
onChanged()
|
||||
}
|
||||
|
||||
function saveLocal() {
|
||||
const parsed: Record<string, Record<string, unknown>> = {}
|
||||
for (const [server, mine] of Object.entries(values)) {
|
||||
parsed[server] = {}
|
||||
for (const [path, text] of Object.entries(mine)) {
|
||||
if (!paths.includes(path) || text.trim() === '') continue
|
||||
try {
|
||||
parsed[server][path] = JSON.parse(text)
|
||||
} catch {
|
||||
setProblem(`The value of ${path} on ${server} is not valid JSON (a string needs quotes).`)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
void run(() => saveOverrides(token, name, { paths, values: parsed }), 'Local fields saved.')
|
||||
}
|
||||
|
||||
async function fill() {
|
||||
setBusy(true)
|
||||
setProblem(null)
|
||||
const outcome = await getCurrentLocalValues(token, name, paths)
|
||||
setBusy(false)
|
||||
if (outcome.kind !== 'ok') {
|
||||
setProblem(outcome.kind === 'error' ? outcome.message : 'The session ended.')
|
||||
return
|
||||
}
|
||||
setValues(Object.fromEntries(Object.entries(outcome.data.response.values).map(([s, mine]) => [s, Object.fromEntries(Object.entries(mine).map(([p, v]) => [p, JSON.stringify(v)]))])))
|
||||
}
|
||||
|
||||
function pickable(extra: Record<string, string> = {}) {
|
||||
return pickerServers(
|
||||
(data?.servers ?? []).map((s) => ({ id: s.id, writable: s.writable, ok: s.ok })),
|
||||
{ ...Object.fromEntries((data?.servers ?? []).filter((s) => s.ok && !s.installed).map((s) => [s.id, 'Not installed'])), ...extra },
|
||||
)
|
||||
}
|
||||
|
||||
function startApply() {
|
||||
if (data === null) return
|
||||
setRequest(applyRequest(token, name, pickable(), data.servers.filter((s) => s.installed && s.inSync === false).map((s) => s.id)))
|
||||
}
|
||||
|
||||
function startRollback(entry: HistoryEntry) {
|
||||
// Preselect the server the version came from: putting it on another server would also carry its local fields.
|
||||
setRequest(rollbackRequest(token, name, entry, pickable(), entry.server === 'baseline' ? [] : [entry.server]))
|
||||
}
|
||||
|
||||
const importButtons = (data?.servers ?? []).filter((s) => s.installed && s.ok)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={(o) => {
|
||||
if (!o) onClose()
|
||||
}}
|
||||
size="wide"
|
||||
title={`App config: ${name}`}
|
||||
actions={
|
||||
canWrite ? (
|
||||
<Button variant="primary" disabled={!hasBaseline || busy} onClick={startApply}>
|
||||
Apply…
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{failure !== null && data === null ? (
|
||||
<Failure>
|
||||
<span className="flex flex-col items-center gap-3">
|
||||
<span>Could not read the configs. {failure}</span>
|
||||
<Button variant="primary" onClick={() => void load()}>
|
||||
Retry
|
||||
</Button>
|
||||
</span>
|
||||
</Failure>
|
||||
) : data === null ? (
|
||||
<div className="flex flex-col gap-3" role="status" aria-label="Loading the configs">
|
||||
<Skeleton className="h-6 w-1/3" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</div>
|
||||
) : (
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(v) => setTab(v as Tab)}
|
||||
// The project has no `data-horizontal` variant, so the component's own flex-col never applies.
|
||||
className="flex-col"
|
||||
>
|
||||
<TabsList variant="line">
|
||||
<TabsTrigger value="compare">Compare</TabsTrigger>
|
||||
<TabsTrigger value="baseline">Baseline</TabsTrigger>
|
||||
<TabsTrigger value="local">Local fields</TabsTrigger>
|
||||
<TabsTrigger value="history">History</TabsTrigger>
|
||||
</TabsList>
|
||||
{problem !== null && <Alert type="danger" title="Error">{problem}</Alert>}
|
||||
{saved !== null && <Alert type="success" title="Saved." onDismiss={() => setSaved(null)}>{saved}</Alert>}
|
||||
|
||||
<TabsContent value="compare" className="flex flex-col gap-4 pt-3">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
{data.servers.map((s) => (
|
||||
<span key={s.id} className="flex items-center gap-2 text-sm">
|
||||
<span className="font-medium">{s.id}</span>
|
||||
{s.version !== undefined && <span className="text-xs text-muted-foreground">v{s.version}</span>}
|
||||
<ServerBadge s={s} hasBaseline={hasBaseline} />
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{data.servers.filter((s) => s.error !== undefined).map((s) => (
|
||||
<Alert key={s.id} type="warning" title={s.id}>
|
||||
{s.error}
|
||||
</Alert>
|
||||
))}
|
||||
{!hasBaseline && (
|
||||
<Empty
|
||||
title="No baseline yet"
|
||||
actions={
|
||||
canWrite ? (
|
||||
<span className="flex flex-wrap justify-center gap-2">
|
||||
{importButtons.map((s) => (
|
||||
<Button key={s.id} variant="primary" disabled={busy} onClick={() => void run(() => importBaseline(token, name, s.id), `Baseline imported from ${s.id}.`)}>
|
||||
Import from {s.id}
|
||||
</Button>
|
||||
))}
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
The baseline is the config every server should have. Import it from a server that is right, then mark the fields that must differ per server as local.
|
||||
{!canWrite && ' An apps administrator can set it up.'}
|
||||
</Empty>
|
||||
)}
|
||||
<CompareBody data={data} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="baseline" className="flex flex-col gap-3 pt-3">
|
||||
<p className="m-0 text-xs text-muted-foreground">
|
||||
{data.baseline === null
|
||||
? 'No baseline saved.'
|
||||
: `${data.baseline.importedFrom !== undefined ? `Imported from ${data.baseline.importedFrom}. ` : ''}Updated by ${data.baseline.updatedBy} at ${when(data.baseline.updatedAt)}.`}
|
||||
</p>
|
||||
<LabeledTextarea
|
||||
label="Baseline text"
|
||||
mono
|
||||
rows={16}
|
||||
spellCheck={false}
|
||||
readOnly={!canWrite}
|
||||
value={baselineText}
|
||||
onChange={(e) => setBaselineText(e.target.value)}
|
||||
/>
|
||||
{canWrite && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="primary" disabled={busy} onClick={() => void run(() => saveBaseline(token, name, baselineText), 'Baseline saved.')}>
|
||||
Save baseline
|
||||
</Button>
|
||||
{importButtons.map((s) => (
|
||||
<Button key={s.id} disabled={busy} onClick={() => void run(() => importBaseline(token, name, s.id), `Baseline imported from ${s.id}.`)}>
|
||||
Import from {s.id}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="local" className="flex flex-col gap-4 pt-3">
|
||||
<p className="m-0 text-xs text-muted-foreground">
|
||||
Local fields are not synchronised: each server keeps its own value. Name a field by its path, for example <code>db.path</code> or <code>networks.0.cidr</code>. A value is JSON, so a string needs quotes.
|
||||
</p>
|
||||
{paths.length === 0 && <p className="m-0 text-sm text-muted-foreground">No local fields yet.</p>}
|
||||
{paths.map((p) => (
|
||||
<fieldset key={p} className="m-0 flex flex-col gap-2 border-0 p-0">
|
||||
<legend className="flex items-center gap-2 p-0 font-mono text-xs">
|
||||
{p}
|
||||
{canWrite && (
|
||||
<Button size="sm" onClick={() => setPaths((old) => old.filter((x) => x !== p))}>
|
||||
Remove {p}
|
||||
</Button>
|
||||
)}
|
||||
</legend>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{data.servers.map((s) => (
|
||||
<label key={s.id} className="flex min-w-48 flex-1 flex-col gap-1 text-xs">
|
||||
<span>
|
||||
{p} on {s.id}
|
||||
</span>
|
||||
<Input
|
||||
mono
|
||||
readOnly={!canWrite}
|
||||
aria-label={`${p} on ${s.id}`}
|
||||
placeholder="(no local value)"
|
||||
value={values[s.id]?.[p] ?? ''}
|
||||
onChange={(e) => setValues((old) => ({ ...old, [s.id]: { ...(old[s.id] ?? {}), [p]: e.target.value } }))}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
))}
|
||||
{canWrite && (
|
||||
<>
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<label className="flex flex-col gap-1 text-xs">
|
||||
<span>New local field</span>
|
||||
<Input mono aria-label="New local field" placeholder="db.path" value={newPath} onChange={(e) => setNewPath(e.target.value)} />
|
||||
</label>
|
||||
<Button
|
||||
disabled={newPath.trim() === '' || paths.includes(newPath.trim())}
|
||||
onClick={() => {
|
||||
setPaths((old) => [...old, newPath.trim()])
|
||||
setNewPath('')
|
||||
}}
|
||||
>
|
||||
Add field
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="primary" disabled={busy} onClick={saveLocal}>
|
||||
Save local fields
|
||||
</Button>
|
||||
<Button disabled={busy || paths.length === 0} onClick={() => void fill()}>
|
||||
Fill from the current server configs
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="history" className="pt-3">
|
||||
<HistoryTab
|
||||
token={token}
|
||||
name={name}
|
||||
canWrite={canWrite}
|
||||
servers={data.servers.filter((s) => s.installed && s.ok).map((s) => s.id)}
|
||||
active={tab === 'history'}
|
||||
onRollback={startRollback}
|
||||
onRestore={(entry) => run(() => restoreBaseline(token, name, entry.id), 'Baseline restored.')}
|
||||
version={String(loads)}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</Dialog>
|
||||
{request !== null && (
|
||||
<RunFlow
|
||||
request={request}
|
||||
onClose={(changed) => {
|
||||
setRequest(null)
|
||||
if (changed) {
|
||||
void load()
|
||||
onChanged()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const KIND: Record<string, string> = {
|
||||
'before-apply': 'Before an apply',
|
||||
'before-set': 'Before a save',
|
||||
'before-uninstall': 'Before an uninstall',
|
||||
'before-save': 'Old baseline',
|
||||
}
|
||||
|
||||
/*
|
||||
Every earlier config the hub kept: what a server had before the hub changed it, and what the
|
||||
baseline was before it was replaced. Pick one to read it and to see how it differs from a server now.
|
||||
*/
|
||||
function HistoryTab({
|
||||
token,
|
||||
name,
|
||||
canWrite,
|
||||
servers,
|
||||
active,
|
||||
onRollback,
|
||||
onRestore,
|
||||
version,
|
||||
}: {
|
||||
token: string | null
|
||||
name: string
|
||||
canWrite: boolean
|
||||
servers: string[]
|
||||
active: boolean
|
||||
onRollback: (entry: HistoryEntry) => void
|
||||
onRestore: (entry: HistoryEntry) => void
|
||||
/** Changes when the baseline does, so the list is read again. */
|
||||
version: string
|
||||
}) {
|
||||
const [entries, setEntries] = useState<HistoryEntry[] | null>(null)
|
||||
const [failure, setFailure] = useState<string | null>(null)
|
||||
const [picked, setPicked] = useState<string | null>(null)
|
||||
const [text, setText] = useState<string | null>(null)
|
||||
const [server, setServer] = useState(servers[0] ?? '')
|
||||
const [diff, setDiff] = useState<HistoryDiff | null>(null)
|
||||
const [diffFailure, setDiffFailure] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const outcome = await getHistory(token, name)
|
||||
if (outcome.kind === 'ok') {
|
||||
setEntries(outcome.data.response.entries)
|
||||
setFailure(null)
|
||||
} else setFailure(outcome.kind === 'error' ? outcome.message : 'The session ended.')
|
||||
}, [token, name])
|
||||
|
||||
useEffect(() => {
|
||||
if (active) void load()
|
||||
}, [active, load, version])
|
||||
|
||||
const entry = entries?.find((e) => e.id === picked) ?? null
|
||||
|
||||
useEffect(() => {
|
||||
if (picked === null) return
|
||||
void getHistoryEntry(token, name, picked).then((o) => setText(o.kind === 'ok' ? o.data.response.text : null))
|
||||
}, [token, name, picked])
|
||||
|
||||
useEffect(() => {
|
||||
if (entry === null || entry.kind === 'before-save' || server === '') return
|
||||
void getHistoryDiff(token, name, entry.id, server).then((o) => {
|
||||
setDiff(o.kind === 'ok' ? o.data.response : null)
|
||||
setDiffFailure(o.kind === 'ok' ? null : o.kind === 'error' ? o.message : 'The session ended.')
|
||||
})
|
||||
}, [token, name, entry, server])
|
||||
|
||||
if (failure !== null && entries === null) {
|
||||
return (
|
||||
<Failure>
|
||||
<span className="flex flex-col items-center gap-3">
|
||||
<span>Could not read the history. {failure}</span>
|
||||
<Button variant="primary" onClick={() => void load()}>
|
||||
Retry
|
||||
</Button>
|
||||
</span>
|
||||
</Failure>
|
||||
)
|
||||
}
|
||||
if (entries === null) return <Skeleton className="h-24 w-full" />
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<Empty title="No history yet">
|
||||
The hub keeps a copy of a server's config before it changes it, and of the baseline before it is replaced. They appear here.
|
||||
</Empty>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<ul className="m-0 flex max-h-56 list-none flex-col gap-1 overflow-auto p-0" aria-label="History">
|
||||
{entries.map((e) => (
|
||||
<li key={e.id}>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={e.id === picked}
|
||||
aria-label={`${new Date(e.at).toLocaleString()} ${e.server === 'baseline' ? 'baseline' : e.server} ${KIND[e.kind] ?? e.kind}`}
|
||||
className="flex w-full cursor-pointer flex-wrap items-center gap-3 rounded-md border border-border bg-transparent px-3 py-2 text-left text-sm transition-colors hover:bg-muted aria-pressed:bg-muted"
|
||||
onClick={() => {
|
||||
setPicked(e.id)
|
||||
// A server's own version is compared with that server by default.
|
||||
if (servers.includes(e.server)) setServer(e.server)
|
||||
setText(null)
|
||||
setDiff(null)
|
||||
}}
|
||||
>
|
||||
<span className="tabular-nums">{new Date(e.at).toLocaleString()}</span>
|
||||
<Tag tone={e.server === 'baseline' ? 'info' : 'neutral'}>{e.server === 'baseline' ? 'Baseline' : e.server}</Tag>
|
||||
<span className="text-muted-foreground">{KIND[e.kind] ?? e.kind}</span>
|
||||
<span className="text-xs text-muted-foreground">{e.size} bytes</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{entry !== null && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<LabeledTextarea label="Version text" mono rows={10} readOnly spellCheck={false} value={text ?? ''} onChange={() => {}} />
|
||||
{entry.kind !== 'before-save' && servers.length > 0 && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="flex max-w-60 flex-col gap-1 text-xs">
|
||||
<span>Compare with the config now on</span>
|
||||
<Select value={server} onChange={(e) => setServer(e.target.value)}>
|
||||
{servers.map((id) => (
|
||||
<option key={id} value={id}>
|
||||
{id}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</label>
|
||||
{diffFailure !== null ? (
|
||||
<Alert type="danger" title="Error">{diffFailure}</Alert>
|
||||
) : diff === null ? (
|
||||
<Skeleton className="h-16 w-full" />
|
||||
) : diff.unchanged ? (
|
||||
<p className="m-0 text-sm text-muted-foreground">{server} already has this version.</p>
|
||||
) : (
|
||||
<DiffView diff={diff.changes} from={`${server} now`} to="This version" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{canWrite && (
|
||||
<div className="flex gap-2">
|
||||
{entry.kind === 'before-save' ? (
|
||||
<Button variant="primary" onClick={() => onRestore(entry)}>
|
||||
Restore as baseline
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="primary" onClick={() => onRollback(entry)}>
|
||||
Roll back…
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* The differences, as a field table for JSON and as line diffs for text. */
|
||||
function CompareBody({ data }: { data: ConfigCompare }) {
|
||||
if (fieldRows(data) !== null) return <FieldTable data={data} />
|
||||
const live = data.servers.filter((s) => s.installed && s.ok)
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{live.map((s) => {
|
||||
const diff = data.baseline !== null ? s.vsBaseline : Object.values(s.vsOthers)[0]
|
||||
return diff === undefined ? null : (
|
||||
<section key={s.id} aria-label={`Differences ${s.id}`} className="flex flex-col gap-2">
|
||||
<h3 className="m-0 text-sm font-semibold">{s.id}</h3>
|
||||
<DiffView diff={diff} from={data.baseline !== null ? 'Now' : 'Other'} to={data.baseline !== null ? 'Expected' : s.id} />
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
{live.length === 0 && <p className="m-0 text-sm text-muted-foreground">Nothing to compare: the app is not installed on any server that answered.</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+179
-34
@@ -1,7 +1,8 @@
|
||||
import { KpiStrip } from '../../ui/KpiStrip'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useContext, useEffect, useState } from 'react'
|
||||
import {
|
||||
downloadAndUpdate,
|
||||
exportBaselines,
|
||||
getAppConfig,
|
||||
listApps,
|
||||
uninstallApp,
|
||||
@@ -17,7 +18,8 @@ import { StoreApps } from './StoreApps'
|
||||
import { Panel } from '../../ui/Panel'
|
||||
import { PageTabs } from '../../ui/PageTabs'
|
||||
import { UpdateApp } from './UpdateApp'
|
||||
import { Empty, Failure, Loading } from '../../ui/Empty'
|
||||
import { Empty, FailureRetry, LoadingRegion } from '../../ui/Empty'
|
||||
import { Menu } from '../../ui/Menu'
|
||||
import { Tag } from '../../ui/Tag'
|
||||
import { useSort } from '../../ui/Table'
|
||||
import { Icon } from '../../ui/Icon'
|
||||
@@ -25,6 +27,14 @@ import styles from './Apps.module.css'
|
||||
import { noticeFromFailure } from '../../lib/notice'
|
||||
import { Notifier } from '../../ui/Notifier'
|
||||
import { Confirm } from '../../ui/Confirm'
|
||||
import { InChrome } from '../../app/ChromeSlot'
|
||||
import { AGGREGATE, ClusterNodeSelect, HubMode, type ClusterNode } from '../../ui/ClusterNodeSelect'
|
||||
import { AppsMatrix } from './AppsMatrix'
|
||||
import { HubStore } from './HubStore'
|
||||
import { HubInstallFile } from './HubInstallFile'
|
||||
import { ImportBaselines } from './ImportBaselines'
|
||||
import { Profiles } from './Profiles'
|
||||
import { RunFlow, type RunRequest } from './RunFlow'
|
||||
|
||||
/*
|
||||
A replica of the Apps tab (apps.js + index.html:807-835).
|
||||
@@ -56,7 +66,59 @@ export function error(outcome: { kind: string; message?: string }): AlertState {
|
||||
return noticeFromFailure(outcome)
|
||||
}
|
||||
|
||||
export function Apps({ token }: { token: string | null }) {
|
||||
export function Apps({
|
||||
token,
|
||||
nodes = [],
|
||||
clusterInitialised = false,
|
||||
canWrite = false,
|
||||
writable = [],
|
||||
isAdmin = false,
|
||||
}: {
|
||||
token: string | null
|
||||
/** Only used behind a hub: its servers. */
|
||||
nodes?: ClusterNode[]
|
||||
clusterInitialised?: boolean
|
||||
/** Behind a hub: the role may change apps (`Apps.canModify`). */
|
||||
canWrite?: boolean
|
||||
/** Behind a hub: the servers the hub holds an admin token for. */
|
||||
writable?: string[]
|
||||
/** Behind a hub: the role is the hub's admin (profiles are for admins). */
|
||||
isAdmin?: boolean
|
||||
}) {
|
||||
/*
|
||||
Behind a hub (`info.hub`) this screen is read-only and has a server selector: "All
|
||||
servers" shows the matrix of apps over servers, one server shows that server's list.
|
||||
Anywhere else none of this exists.
|
||||
*/
|
||||
const hub = useContext(HubMode)
|
||||
const [node, setNode] = useState<string>(() => {
|
||||
try {
|
||||
return localStorage.getItem('hubAppsNode') || AGGREGATE
|
||||
} catch {
|
||||
return AGGREGATE
|
||||
}
|
||||
})
|
||||
const chosen = node === AGGREGATE || nodes.some((n) => n.name === node) ? node : AGGREGATE
|
||||
const matrixMode = hub && chosen === AGGREGATE
|
||||
const requestNode = hub && !matrixMode ? chosen : ''
|
||||
/* Behind a hub, changes are for writers and only on a server the hub can write to. Elsewhere always. */
|
||||
const mayChange = !hub || (!matrixMode && canWrite && writable.includes(chosen))
|
||||
const hubServers = nodes.map((n) => ({ id: n.name, writable: writable.includes(n.name) }))
|
||||
const [hubView, setHubView] = useState<'matrix' | 'store'>('matrix')
|
||||
const [fileDialog, setFileDialog] = useState(false)
|
||||
const [importDialog, setImportDialog] = useState(false)
|
||||
const [profilesDialog, setProfilesDialog] = useState(false)
|
||||
const [fileRequest, setFileRequest] = useState<RunRequest | null>(null)
|
||||
const [refresh, setRefresh] = useState(0)
|
||||
useEffect(() => {
|
||||
if (!hub) return
|
||||
try {
|
||||
localStorage.setItem('hubAppsNode', node)
|
||||
} catch {
|
||||
// Not remembered; the choice still works.
|
||||
}
|
||||
}, [hub, node])
|
||||
|
||||
const [apps, setApps] = useState<InstalledApp[] | null>(null)
|
||||
const [alert, setAlert] = useState<AlertState | null>(null)
|
||||
const [modal, setModal] = useState<Modal | null>(null)
|
||||
@@ -67,7 +129,7 @@ export function Apps({ token }: { token: string | null }) {
|
||||
const [pendingUninstall, setPendingUninstall] = useState<string | null>(null)
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
const outcome = await listApps(token)
|
||||
const outcome = await listApps(token, requestNode)
|
||||
if (outcome.kind === 'ok') {
|
||||
setApps(outcome.data.response.apps)
|
||||
setFailure(false)
|
||||
@@ -87,11 +149,11 @@ export function Apps({ token }: { token: string | null }) {
|
||||
setApps([])
|
||||
setFailure(true)
|
||||
setAlert(error(outcome))
|
||||
}, [token])
|
||||
}, [token, requestNode])
|
||||
|
||||
useEffect(() => {
|
||||
void reload()
|
||||
}, [reload])
|
||||
if (!matrixMode) void reload()
|
||||
}, [reload, matrixMode])
|
||||
|
||||
/*
|
||||
apps.js:425-449. The confirmation and the alert are upstream literals.
|
||||
@@ -103,7 +165,7 @@ export function Apps({ token }: { token: string | null }) {
|
||||
*/
|
||||
async function uninstall(name: string) {
|
||||
setBusy(name)
|
||||
const outcome = await uninstallApp(token, name)
|
||||
const outcome = await uninstallApp(token, name, requestNode)
|
||||
setBusy(null)
|
||||
|
||||
if (outcome.kind !== 'ok') {
|
||||
@@ -123,7 +185,7 @@ export function Apps({ token }: { token: string | null }) {
|
||||
if (!app.updateUrl) return
|
||||
|
||||
setBusy(app.name)
|
||||
const outcome = await downloadAndUpdate(token, app.name, app.updateUrl)
|
||||
const outcome = await downloadAndUpdate(token, app.name, app.updateUrl, requestNode)
|
||||
setBusy(null)
|
||||
|
||||
if (outcome.kind !== 'ok') {
|
||||
@@ -142,7 +204,7 @@ export function Apps({ token }: { token: string | null }) {
|
||||
// cluster's primary node. `config` can come null; the editor is left empty.
|
||||
async function openConfig(name: string) {
|
||||
setBusy(name)
|
||||
const outcome = await getAppConfig(token, name)
|
||||
const outcome = await getAppConfig(token, name, requestNode)
|
||||
setBusy(null)
|
||||
|
||||
if (outcome.kind !== 'ok') {
|
||||
@@ -169,13 +231,85 @@ export function Apps({ token }: { token: string | null }) {
|
||||
*/
|
||||
const { rows: sortedApps, toggle } = useSort({ name: (a: InstalledApp) => a.name }, apps ?? [])
|
||||
|
||||
/* The file of every baseline, saved by the browser. */
|
||||
async function exportFile() {
|
||||
const outcome = await exportBaselines(token)
|
||||
if (outcome.kind !== 'ok') {
|
||||
setAlert(error(outcome))
|
||||
return
|
||||
}
|
||||
const url = URL.createObjectURL(new Blob([outcome.data.text], { type: 'application/json' }))
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = outcome.data.filename
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const selector = hub ? (
|
||||
<InChrome>
|
||||
<ClusterNodeSelect nodes={nodes} initialised={clusterInitialised} aggregate value={chosen} onChange={setNode} />
|
||||
</InChrome>
|
||||
) : null
|
||||
|
||||
if (matrixMode) {
|
||||
return (
|
||||
<>
|
||||
{selector}
|
||||
<Notifier notice={alert} onClose={() => setAlert(null)} />
|
||||
<SectionHeader
|
||||
title="Apps"
|
||||
subtitle="DNS apps on every server"
|
||||
actions={
|
||||
canWrite ? (
|
||||
<>
|
||||
<Button variant="primary" onClick={() => setHubView((v) => (v === 'store' ? 'matrix' : 'store'))}>
|
||||
{hubView === 'store' ? 'Back to apps' : 'App Store'}
|
||||
</Button>
|
||||
<Button onClick={() => setFileDialog(true)}>Install from file</Button>
|
||||
{/* The three that move a whole set of apps share one menu: five buttons made a row of their own. */}
|
||||
<Menu label="Baselines" text="Baselines">
|
||||
{(close) => (
|
||||
<>
|
||||
<button type="button" onClick={() => { close(); void exportFile() }}>Export baselines</button>
|
||||
<button type="button" onClick={() => { close(); setImportDialog(true) }}>Import baselines</button>
|
||||
{isAdmin && <button type="button" onClick={() => { close(); setProfilesDialog(true) }}>Profiles</button>}
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
{hubView === 'store' && canWrite ? (
|
||||
<HubStore token={token} servers={hubServers} canWrite={canWrite} onChanged={() => setRefresh((n) => n + 1)} />
|
||||
) : (
|
||||
<AppsMatrix token={token} canWrite={canWrite} refresh={refresh} />
|
||||
)}
|
||||
{isAdmin && <Profiles open={profilesDialog} onOpenChange={setProfilesDialog} token={token} servers={hubServers} />}
|
||||
<ImportBaselines open={importDialog} onOpenChange={setImportDialog} token={token} onImported={() => setRefresh((n) => n + 1)} />
|
||||
<HubInstallFile open={fileDialog} onOpenChange={setFileDialog} token={token} servers={hubServers} onRequest={setFileRequest} />
|
||||
{fileRequest !== null && (
|
||||
<RunFlow
|
||||
request={fileRequest}
|
||||
onClose={(changed) => {
|
||||
setFileRequest(null)
|
||||
if (changed) setRefresh((n) => n + 1)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{selector}
|
||||
<Notifier notice={alert} onClose={() => setAlert(null)} />
|
||||
|
||||
<SectionHeader
|
||||
title="Apps"
|
||||
subtitle="DNS apps installed on this server"
|
||||
subtitle={hub ? `DNS apps installed on ${chosen}` : 'DNS apps installed on this server'}
|
||||
labels={
|
||||
withUpdate > 0 ? (
|
||||
<Tag tone="warn">
|
||||
@@ -184,12 +318,14 @@ export function Apps({ token }: { token: string | null }) {
|
||||
) : undefined
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="primary" onClick={() => setTab('store')}>
|
||||
App Store
|
||||
</Button>
|
||||
<Button onClick={() => setModal({ kind: 'install' })}>Install from file</Button>
|
||||
</>
|
||||
!mayChange ? undefined : (
|
||||
<>
|
||||
<Button variant="primary" onClick={() => setTab('store')}>
|
||||
App Store
|
||||
</Button>
|
||||
<Button onClick={() => setModal({ kind: 'install' })}>Install from file</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -204,36 +340,41 @@ export function Apps({ token }: { token: string | null }) {
|
||||
/>
|
||||
)}
|
||||
|
||||
<PageTabs
|
||||
label="Apps"
|
||||
tabs={[
|
||||
{ id: 'installed', label: 'Installed', count: apps?.length },
|
||||
{ id: 'store', label: 'App Store' },
|
||||
]}
|
||||
active={tab}
|
||||
onChoose={setTab}
|
||||
/>
|
||||
{mayChange && (
|
||||
<PageTabs
|
||||
label="Apps"
|
||||
tabs={[
|
||||
{ id: 'installed', label: 'Installed', count: apps?.length },
|
||||
{ id: 'store', label: 'App Store' },
|
||||
]}
|
||||
active={tab}
|
||||
onChoose={setTab}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tab === 'store' ? (
|
||||
<StoreApps token={token} onChanged={() => void reload()} />
|
||||
{tab === 'store' && mayChange ? (
|
||||
<StoreApps token={token} node={requestNode} onChanged={() => void reload()} />
|
||||
) : apps === null ? (
|
||||
<Loading />
|
||||
<LoadingRegion height="h-48">Loading…</LoadingRegion>
|
||||
) : failure ? (
|
||||
/* Solid and with no invitation: whether there are apps is not known, so
|
||||
the store is not offered. The server's message goes in the notice
|
||||
above. */
|
||||
<Failure>Could not load the installed apps.</Failure>
|
||||
<FailureRetry onRetry={() => void reload()}>Could not load the installed apps.</FailureRetry>
|
||||
) : apps.length === 0 ? (
|
||||
<Empty
|
||||
title="No apps installed"
|
||||
actions={
|
||||
<Button variant="primary" onClick={() => setTab('store')}>
|
||||
Open App Store
|
||||
</Button>
|
||||
!mayChange ? undefined : (
|
||||
<Button variant="primary" onClick={() => setTab('store')}>
|
||||
Open App Store
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
DNS Apps add behaviour to the server (query logging, advanced blocking, split horizon)
|
||||
without touching the base configuration. Open the store to see what is available.
|
||||
without touching the base configuration.
|
||||
{!mayChange && hub ? ' Install apps in the console of this server.' : ' Open the store to see what is available.'}
|
||||
</Empty>
|
||||
) : (
|
||||
<Panel title="Installed" description="Version, DNS apps and status">
|
||||
@@ -258,6 +399,7 @@ export function Apps({ token }: { token: string | null }) {
|
||||
key={app.name}
|
||||
app={app}
|
||||
busy={busy === app.name}
|
||||
readOnly={!mayChange}
|
||||
onConfig={() => void openConfig(app.name)}
|
||||
onUpdate={() => setModal({ kind: 'update', name: app.name })}
|
||||
onStoreUpdate={() => void updateFromStore(app)}
|
||||
@@ -281,6 +423,7 @@ export function Apps({ token }: { token: string | null }) {
|
||||
open={modal?.kind === 'install'}
|
||||
onOpenChange={(o) => setModal(o ? { kind: 'install' } : null)}
|
||||
token={token}
|
||||
node={requestNode}
|
||||
onInstalled={(name) => {
|
||||
setModal(null)
|
||||
setAlert({
|
||||
@@ -296,6 +439,7 @@ export function Apps({ token }: { token: string | null }) {
|
||||
onOpenChange={(o) => setModal(o && modal?.kind === 'update' ? modal : null)}
|
||||
token={token}
|
||||
name={modal?.kind === 'update' ? modal.name : ''}
|
||||
node={requestNode}
|
||||
onUpdated={(name) => {
|
||||
setModal(null)
|
||||
setAlert({
|
||||
@@ -311,6 +455,7 @@ export function Apps({ token }: { token: string | null }) {
|
||||
onOpenChange={(o) => setModal(o && modal?.kind === 'config' ? modal : null)}
|
||||
token={token}
|
||||
name={modal?.kind === 'config' ? modal.name : ''}
|
||||
node={requestNode}
|
||||
initialConfig={modal?.kind === 'config' ? modal.config : ''}
|
||||
onSaved={(name) => {
|
||||
setModal(null)
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { AppConfigSync } from './AppConfigSync'
|
||||
import { AppsMatrix } from './AppsMatrix'
|
||||
import { Apps } from './Apps'
|
||||
import { ImportBaselines } from './ImportBaselines'
|
||||
import { DriftStatus } from '../../app/DriftStatus'
|
||||
import { SlotProvider } from '../../app/ChromeSlot'
|
||||
import { HubMode } from '../../ui/ClusterNodeSelect'
|
||||
import { refreshHubDrift, resetHubDrift } from '../../api/hub-drift'
|
||||
import * as api from '../../api/apps'
|
||||
import type { ConfigCompare, DriftEntry, HistoryEntry, ServerConfigView } from '../../api/apps'
|
||||
|
||||
beforeEach(() => resetHubDrift())
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
const A = JSON.stringify({ ttl: 30 }, null, 2)
|
||||
const B = JSON.stringify({ ttl: 60 }, null, 2)
|
||||
|
||||
const view = (id: string, over: Partial<ServerConfigView> = {}): ServerConfigView => ({
|
||||
id, ok: true, installed: true, writable: true, version: '1.0', text: A, hash: 'h-' + id, warnings: [], inSync: true, vsOthers: {}, vsBaseline: { kind: 'json', changes: [] }, ...over,
|
||||
})
|
||||
|
||||
const COMPARE: ConfigCompare = {
|
||||
name: 'Blocker',
|
||||
baseline: { app: 'Blocker', text: A, updatedAt: '2026-10-31T08:00:00Z', updatedBy: 'alice' },
|
||||
overrides: { paths: [], values: {} },
|
||||
servers: [view('mdns'), view('ndns', { text: B, inSync: false })],
|
||||
}
|
||||
|
||||
const SERVER_ENTRY: HistoryEntry = { id: '2026-10-31T09-00-00.000Z__ndns__before-apply.txt', at: '2026-10-31T09:00:00.000Z', server: 'ndns', kind: 'before-apply', size: 20, hash: 'aaaa' }
|
||||
const BASELINE_ENTRY: HistoryEntry = { id: '2026-10-31T08-00-00.000Z__baseline__before-save.txt', at: '2026-10-31T08:00:00.000Z', server: 'baseline', kind: 'before-save', size: 18, hash: 'bbbb' }
|
||||
|
||||
const ok = (results: api.TargetResult[]) => ({ kind: 'ok', data: { response: { results } } }) as never
|
||||
|
||||
function mockHistory(entries: HistoryEntry[]) {
|
||||
vi.spyOn(api, 'getConfigCompare').mockResolvedValue({ kind: 'ok', data: { response: COMPARE } } as never)
|
||||
vi.spyOn(api, 'getHistory').mockResolvedValue({ kind: 'ok', data: { response: { entries } } } as never)
|
||||
vi.spyOn(api, 'getHistoryEntry').mockImplementation((async (_t: string | null, _n: string, id: string) => ({
|
||||
kind: 'ok',
|
||||
data: { response: { entry: entries.find((e) => e.id === id), text: id.includes('baseline') ? '{"old":true}' : '{"ttl":10}' } },
|
||||
})) as never)
|
||||
vi.spyOn(api, 'getHistoryDiff').mockImplementation((async (_t: string | null, _n: string, id: string, server: string) => ({
|
||||
kind: 'ok',
|
||||
data: {
|
||||
response: {
|
||||
entry: entries.find((e) => e.id === id), server, writable: true, current: B, currentHash: `hash-${server}`, version: '{"ttl":10}',
|
||||
changes: { kind: 'json', changes: [{ path: 'ttl', type: 'changed', a: 60, b: 10 }] }, unchanged: false,
|
||||
},
|
||||
},
|
||||
})) as never)
|
||||
}
|
||||
|
||||
async function openHistory(canWrite: boolean) {
|
||||
render(<AppConfigSync token="t" name="Blocker" canWrite={canWrite} onClose={() => {}} onChanged={() => {}} />)
|
||||
await userEvent.click(await screen.findByRole('tab', { name: 'History' }))
|
||||
}
|
||||
|
||||
describe('History tab', () => {
|
||||
it('has an empty state', async () => {
|
||||
mockHistory([])
|
||||
await openHistory(false)
|
||||
expect(await screen.findByText('No history yet')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('lists the saved versions and shows one with its difference from a server', async () => {
|
||||
mockHistory([SERVER_ENTRY, BASELINE_ENTRY])
|
||||
await openHistory(false)
|
||||
const list = await screen.findByRole('list', { name: 'History' })
|
||||
expect(within(list).getAllByRole('button')).toHaveLength(2)
|
||||
expect(within(list).getByText('Before an apply')).toBeInTheDocument()
|
||||
expect(within(list).getByText('Old baseline')).toBeInTheDocument()
|
||||
await userEvent.click(within(list).getByRole('button', { name: /ndns Before an apply/ }))
|
||||
expect(await screen.findByLabelText('Version text')).toHaveValue('{"ttl":10}')
|
||||
expect(await screen.findByText('ttl')).toBeInTheDocument()
|
||||
// Compared with the server the version came from.
|
||||
expect(api.getHistoryDiff).toHaveBeenCalledWith('t', 'Blocker', SERVER_ENTRY.id, 'ndns')
|
||||
// A reader may look, not act.
|
||||
expect(screen.queryByRole('button', { name: 'Roll back…' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Restore as baseline' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows an error with Retry', async () => {
|
||||
mockHistory([])
|
||||
const spy = vi.spyOn(api, 'getHistory').mockResolvedValueOnce({ kind: 'error', message: 'No such app.' } as never)
|
||||
await openHistory(false)
|
||||
expect(await screen.findByText(/Could not read the history/)).toHaveTextContent('No such app.')
|
||||
spy.mockResolvedValue({ kind: 'ok', data: { response: { entries: [] } } } as never)
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
||||
expect(await screen.findByText('No history yet')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('rolls back after a preview, with the preview hash, and shows the results', async () => {
|
||||
mockHistory([SERVER_ENTRY])
|
||||
const rollback = vi.spyOn(api, 'rollbackHistory').mockResolvedValue(ok([{ server: 'ndns', ok: true, message: 'Applied and verified.' }]))
|
||||
await openHistory(true)
|
||||
await userEvent.click(await screen.findByRole('button', { name: /ndns Before an apply/ }))
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Roll back…' }))
|
||||
const picker = await screen.findByRole('dialog', { name: 'Roll back Blocker' })
|
||||
// The version's own server is ticked already, and only it.
|
||||
expect(within(picker).getByRole('checkbox', { name: 'ndns' })).toBeChecked()
|
||||
expect(within(picker).getByRole('checkbox', { name: 'mdns' })).not.toBeChecked()
|
||||
await userEvent.click(within(picker).getByRole('button', { name: 'Preview' }))
|
||||
const review = await screen.findByRole('dialog', { name: 'Roll back Blocker: review' })
|
||||
expect(within(review).getByText('Will change')).toBeInTheDocument()
|
||||
expect(rollback).not.toHaveBeenCalled()
|
||||
await userEvent.click(within(review).getByRole('button', { name: 'Roll back' }))
|
||||
expect(rollback).toHaveBeenCalledWith('t', 'Blocker', SERVER_ENTRY.id, ['ndns'], { ndns: 'hash-ndns' })
|
||||
const results = await screen.findByRole('dialog', { name: 'Roll back Blocker: results' })
|
||||
expect(within(results).getByText('Applied and verified.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('restores an old baseline as the baseline, for writers only', async () => {
|
||||
mockHistory([BASELINE_ENTRY])
|
||||
const restore = vi.spyOn(api, 'restoreBaseline').mockResolvedValue({ kind: 'ok', data: { response: { baseline: COMPARE.baseline } } } as never)
|
||||
await openHistory(true)
|
||||
await userEvent.click(await screen.findByRole('button', { name: /baseline Old baseline/ }))
|
||||
expect(screen.queryByRole('button', { name: 'Roll back…' })).not.toBeInTheDocument()
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Restore as baseline' }))
|
||||
expect(restore).toHaveBeenCalledWith('t', 'Blocker', BASELINE_ENTRY.id)
|
||||
expect(await screen.findByText('Baseline restored.')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DriftStatus in the header', () => {
|
||||
const DRIFTS: DriftEntry[] = [
|
||||
{ kind: 'app', name: 'Blocker', app: 'Blocker', server: 'ndns', since: '2026-10-31T09:00:00Z', status: 'differs' },
|
||||
{ kind: 'app', name: 'Blocker', app: 'Blocker', server: 'mdns', since: '2026-10-31T09:00:00Z', status: 'unreadable' },
|
||||
]
|
||||
|
||||
async function load(entries: DriftEntry[]) {
|
||||
vi.spyOn(api, 'getDrift').mockResolvedValue({ kind: 'ok', data: { response: { checkedAt: 'x', apps: entries } } } as never)
|
||||
await act(async () => refreshHubDrift('t'))
|
||||
}
|
||||
|
||||
it('shows nothing without a drift and a count with one', async () => {
|
||||
const { container } = render(<DriftStatus token="t" canWrite writable={['ndns']} />)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
await load(DRIFTS)
|
||||
// Only a server that differs is counted; the unreadable one is listed in the dialog.
|
||||
expect(await screen.findByRole('button', { name: 'Config drift' })).toHaveTextContent('1 config drift')
|
||||
})
|
||||
|
||||
it('lists app, server, kind and since, and opens the config', async () => {
|
||||
vi.spyOn(api, 'getConfigCompare').mockResolvedValue({ kind: 'ok', data: { response: COMPARE } } as never)
|
||||
render(<DriftStatus token="t" canWrite={false} writable={[]} />)
|
||||
await load(DRIFTS)
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Config drift' }))
|
||||
const row = await screen.findByRole('listitem', { name: 'Blocker on ndns' })
|
||||
expect(within(row).getByText('Differs')).toBeInTheDocument()
|
||||
expect(within(screen.getByRole('listitem', { name: 'Blocker on mdns' })).getByText('Cannot read')).toBeInTheDocument()
|
||||
// A reader gets no actions.
|
||||
expect(screen.queryByRole('button', { name: 'Accept as baseline' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Revert to baseline' })).not.toBeInTheDocument()
|
||||
await userEvent.click(within(row).getByRole('button', { name: 'Open config' }))
|
||||
expect(await screen.findByRole('dialog', { name: 'App config: Blocker' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('accepts a server config as the baseline after a confirmation and refreshes', async () => {
|
||||
render(<DriftStatus token="t" canWrite writable={['ndns']} />)
|
||||
await load(DRIFTS)
|
||||
const accept = vi.spyOn(api, 'acceptDrift').mockResolvedValue({ kind: 'ok', data: { response: { baseline: COMPARE.baseline } } } as never)
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Config drift' }))
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Accept as baseline' }))
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Accept' }))
|
||||
expect(accept).toHaveBeenCalledWith('t', 'Blocker', 'ndns')
|
||||
await waitFor(() => expect(api.getDrift).toHaveBeenCalledTimes(2))
|
||||
})
|
||||
|
||||
it('reverts to the baseline through the preview', async () => {
|
||||
render(<DriftStatus token="t" canWrite writable={['ndns']} />)
|
||||
await load(DRIFTS)
|
||||
const preview = vi.spyOn(api, 'previewConfig').mockResolvedValue({
|
||||
kind: 'ok',
|
||||
data: { response: { targets: [{ server: 'ndns', ok: true, writable: true, current: B, currentHash: 'hash-ndns', effective: A, unchanged: false, warnings: [], changes: { kind: 'json', changes: [{ path: 'ttl', type: 'changed', a: 60, b: 30 }] } }] } },
|
||||
} as never)
|
||||
const apply = vi.spyOn(api, 'applyConfig').mockResolvedValue(ok([{ server: 'ndns', ok: true, message: 'Applied and verified.' }]))
|
||||
vi.spyOn(api, 'checkDrift').mockResolvedValue({ kind: 'ok', data: { response: { checkedAt: 'y', apps: [] } } } as never)
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Config drift' }))
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Revert to baseline' }))
|
||||
const picker = await screen.findByRole('dialog', { name: 'Apply the baseline of Blocker' })
|
||||
expect(within(picker).getByRole('checkbox', { name: 'ndns' })).toBeChecked()
|
||||
await userEvent.click(within(picker).getByRole('button', { name: 'Preview' }))
|
||||
expect(preview).toHaveBeenCalledWith('t', 'Blocker', ['ndns'])
|
||||
await userEvent.click(within(await screen.findByRole('dialog', { name: 'Apply the baseline of Blocker: review' })).getByRole('button', { name: 'Apply' }))
|
||||
expect(apply).toHaveBeenCalledWith('t', 'Blocker', ['ndns'], { ndns: 'hash-ndns' })
|
||||
expect(await screen.findByText('Applied and verified.')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Drift badge in the matrix', () => {
|
||||
it('marks the server cell whose config differs', async () => {
|
||||
vi.spyOn(api, 'getAppsMatrix').mockResolvedValue({
|
||||
kind: 'ok',
|
||||
data: {
|
||||
response: {
|
||||
servers: [{ id: 'mdns', ok: true, writable: true }, { id: 'ndns', ok: true, writable: true }],
|
||||
apps: [{ name: 'Blocker', description: '', configs: 'same', versions: 'same', servers: { mdns: { installed: true, version: '1.0', configStatus: 'read' }, ndns: { installed: true, version: '1.0', configStatus: 'read' } } }],
|
||||
},
|
||||
},
|
||||
} as never)
|
||||
vi.spyOn(api, 'getDrift').mockResolvedValue({ kind: 'ok', data: { response: { checkedAt: 'x', apps: [{ kind: 'app', name: 'Blocker', app: 'Blocker', server: 'ndns', since: 'z', status: 'differs' }, { kind: 'app', name: 'Blocker', app: 'Blocker', server: 'mdns', since: 'z', status: 'unreadable' }] } } } as never)
|
||||
render(<AppsMatrix token="t" />)
|
||||
await screen.findByText('Blocker')
|
||||
await waitFor(() => expect(screen.getAllByText('Drift')).toHaveLength(1))
|
||||
const row = screen.getByText('Blocker').closest('tr') as HTMLElement
|
||||
expect(within(row).getByText('Drift')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Export and import of baselines', () => {
|
||||
const NODES = [{ name: 'mdns', type: 'Primary' }]
|
||||
|
||||
function mountApps(canWrite: boolean) {
|
||||
vi.spyOn(api, 'getAppsMatrix').mockResolvedValue({ kind: 'ok', data: { response: { servers: [], apps: [] } } } as never)
|
||||
vi.spyOn(api, 'getDrift').mockResolvedValue({ kind: 'ok', data: { response: { checkedAt: null, apps: [] } } } as never)
|
||||
return render(
|
||||
<HubMode.Provider value>
|
||||
<SlotProvider>
|
||||
{(ref) => (
|
||||
<>
|
||||
<div ref={ref} />
|
||||
<Apps token="t" nodes={NODES} clusterInitialised canWrite={canWrite} writable={['mdns']} />
|
||||
</>
|
||||
)}
|
||||
</SlotProvider>
|
||||
</HubMode.Provider>,
|
||||
)
|
||||
}
|
||||
|
||||
it('offers both to writers only', async () => {
|
||||
const { unmount } = mountApps(false)
|
||||
await screen.findByText('No apps installed')
|
||||
expect(screen.queryByRole('button', { name: 'Baselines' })).not.toBeInTheDocument()
|
||||
unmount()
|
||||
mountApps(true)
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Baselines' }))
|
||||
expect(await screen.findByRole('menuitem', { name: 'Export baselines' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('menuitem', { name: 'Import baselines' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('downloads the file the hub sends', async () => {
|
||||
mountApps(true)
|
||||
const exported = vi.spyOn(api, 'exportBaselines').mockResolvedValue({ kind: 'ok', data: { filename: 'hub-baselines-2026-10-31.json', text: '{"hub":"technitium-hub"}' } } as never)
|
||||
const create = vi.fn(() => 'blob:x')
|
||||
Object.assign(URL, { createObjectURL: create, revokeObjectURL: vi.fn() })
|
||||
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Baselines' }))
|
||||
await userEvent.click(await screen.findByRole('menuitem', { name: 'Export baselines' }))
|
||||
await waitFor(() => expect(click).toHaveBeenCalled())
|
||||
expect(exported).toHaveBeenCalledWith('t')
|
||||
expect(create).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('checks the file first, summarises it, and imports only on confirmation', async () => {
|
||||
const spy = vi
|
||||
.spyOn(api, 'importBaselines')
|
||||
.mockResolvedValueOnce({ kind: 'ok', data: { response: { applied: false, apps: [{ app: 'Blocker', status: 'changed', ignoredServers: [] }, { app: 'Wild IP', status: 'new', ignoredServers: ['zzz'] }, { app: 'Split', status: 'same', ignoredServers: [] }] } } } as never)
|
||||
.mockResolvedValueOnce({ kind: 'ok', data: { response: { applied: true, apps: [{ app: 'Blocker', status: 'changed', ignoredServers: [] }] } } } as never)
|
||||
const onImported = vi.fn()
|
||||
render(<ImportBaselines open onOpenChange={() => {}} token="t" onImported={onImported} />)
|
||||
const file = new File(['{"hub":"technitium-hub"}'], 'b.json', { type: 'application/json' })
|
||||
await userEvent.upload(screen.getByLabelText('Baselines file'), file)
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Check file' }))
|
||||
const list = await screen.findByRole('list', { name: 'Apps in the file' })
|
||||
expect(within(list).getByText('Changed')).toBeInTheDocument()
|
||||
expect(within(list).getByText('New')).toBeInTheDocument()
|
||||
expect(within(list).getByText('Same')).toBeInTheDocument()
|
||||
expect(within(list).getByText(/zzz are ignored/)).toBeInTheDocument()
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
expect(spy.mock.calls[0][2]).toBe(false)
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Import' }))
|
||||
await waitFor(() => expect(spy).toHaveBeenCalledTimes(2))
|
||||
expect(spy.mock.calls[1][2]).toBe(true)
|
||||
expect(await screen.findByText('Imported.')).toBeInTheDocument()
|
||||
expect(onImported).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows why a file was refused and imports nothing', async () => {
|
||||
const spy = vi.spyOn(api, 'importBaselines').mockResolvedValue({ kind: 'error', message: 'This is not a baselines file of the hub.' } as never)
|
||||
render(<ImportBaselines open onOpenChange={() => {}} token="t" onImported={() => {}} />)
|
||||
await userEvent.upload(screen.getByLabelText('Baselines file'), new File(['{}'], 'x.json'))
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Check file' }))
|
||||
expect(await screen.findByText('This is not a baselines file of the hub.')).toBeInTheDocument()
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
expect(screen.queryByRole('button', { name: 'Import' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,243 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { render, screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { AppsMatrix } from './AppsMatrix'
|
||||
import { Apps } from './Apps'
|
||||
import { SlotProvider } from '../../app/ChromeSlot'
|
||||
import { HubMode } from '../../ui/ClusterNodeSelect'
|
||||
import * as api from '../../api/apps'
|
||||
import type { AppsMatrixData } from '../../api/apps'
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
const DATA: AppsMatrixData = {
|
||||
servers: [
|
||||
{ id: 'mdns', ok: true, writable: false },
|
||||
{ id: 'ndns', ok: true, writable: false },
|
||||
],
|
||||
apps: [
|
||||
{
|
||||
name: 'Advanced Blocking',
|
||||
description: 'Group based blocking',
|
||||
configs: 'different',
|
||||
diffCount: 2,
|
||||
versions: 'same',
|
||||
servers: {
|
||||
mdns: { installed: true, version: '10.1', configStatus: 'read', configHash: 'aaaa' },
|
||||
ndns: { installed: true, version: '10.1', configStatus: 'read', configHash: 'bbbb' },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Query Logs (Sqlite)',
|
||||
description: 'Logs queries',
|
||||
configs: 'same',
|
||||
versions: 'different',
|
||||
servers: {
|
||||
mdns: { installed: true, version: '7.0', configStatus: 'read', configHash: 'cccc' },
|
||||
ndns: { installed: true, version: '7.1', updateAvailable: true, updateVersion: '7.2', configStatus: 'read', configHash: 'cccc' },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Split Horizon',
|
||||
description: '',
|
||||
configs: 'single',
|
||||
versions: 'single',
|
||||
servers: {
|
||||
mdns: { installed: true, version: '9.0', configStatus: 'read', configHash: 'dddd' },
|
||||
ndns: { installed: false, configStatus: 'none' },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Wild IP',
|
||||
description: '',
|
||||
configs: 'unknown',
|
||||
versions: 'same',
|
||||
servers: {
|
||||
mdns: { installed: true, version: '5.1', configStatus: 'unreadable', configError: 'Access was denied' },
|
||||
ndns: { installed: true, version: '5.1', configStatus: 'read', configHash: 'eeee' },
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
function mockMatrix(data: AppsMatrixData = DATA) {
|
||||
return vi.spyOn(api, 'getAppsMatrix').mockResolvedValue({ kind: 'ok', data: { response: data } } as never)
|
||||
}
|
||||
|
||||
const row = (name: string) => screen.getByText(name).closest('tr') as HTMLElement
|
||||
|
||||
describe('AppsMatrix', () => {
|
||||
it('draws a row per app and a column per server', async () => {
|
||||
mockMatrix()
|
||||
render(<AppsMatrix token="t" />)
|
||||
expect(await screen.findByText('Advanced Blocking')).toBeInTheDocument()
|
||||
expect(screen.getByRole('columnheader', { name: 'mdns' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('columnheader', { name: 'ndns' })).toBeInTheDocument()
|
||||
expect(within(row('Split Horizon')).getByText('v9.0')).toBeInTheDocument()
|
||||
expect(within(row('Split Horizon')).getByText('Not installed')).toBeInTheDocument()
|
||||
expect(within(row('Query Logs (Sqlite)')).getByText('v7.1')).toBeInTheDocument()
|
||||
expect(within(row('Query Logs (Sqlite)')).getByText('Update v7.2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the config state of each app', async () => {
|
||||
mockMatrix()
|
||||
render(<AppsMatrix token="t" />)
|
||||
await screen.findByText('Advanced Blocking')
|
||||
expect(within(row('Advanced Blocking')).getByText('Different · 2')).toBeInTheDocument()
|
||||
expect(within(row('Query Logs (Sqlite)')).getByText('Same')).toBeInTheDocument()
|
||||
expect(within(row('Split Horizon')).getByText('Single server')).toBeInTheDocument()
|
||||
expect(within(row('Wild IP')).getByText('Unknown')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('explains an unknown config in a tooltip', async () => {
|
||||
mockMatrix()
|
||||
render(<AppsMatrix token="t" />)
|
||||
await screen.findByText('Advanced Blocking')
|
||||
await userEvent.hover(within(row('Wild IP')).getByText('Unknown'))
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent('mdns: Access was denied')
|
||||
})
|
||||
|
||||
it('filters with counts', async () => {
|
||||
mockMatrix()
|
||||
render(<AppsMatrix token="t" />)
|
||||
await screen.findByText('Advanced Blocking')
|
||||
const filters = screen.getByRole('group', { name: 'Filter apps' })
|
||||
expect(within(filters).getByRole('button', { name: 'All · 4' })).toBeInTheDocument()
|
||||
expect(within(filters).getByRole('button', { name: 'Version differs · 1' })).toBeInTheDocument()
|
||||
expect(within(filters).getByRole('button', { name: 'Config differs · 1' })).toBeInTheDocument()
|
||||
expect(within(filters).getByRole('button', { name: 'Update available · 1' })).toBeInTheDocument()
|
||||
|
||||
await userEvent.click(within(filters).getByRole('button', { name: 'Config differs · 1' }))
|
||||
expect(screen.getByText('Advanced Blocking')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Split Horizon')).not.toBeInTheDocument()
|
||||
|
||||
await userEvent.click(within(filters).getByRole('button', { name: 'Update available · 1' }))
|
||||
expect(screen.getByText('Query Logs (Sqlite)')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Advanced Blocking')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('marks a server that did not answer as unknown', async () => {
|
||||
mockMatrix({
|
||||
servers: [{ id: 'mdns', ok: true, writable: false }, { id: 'ndns', ok: false, writable: false, error: 'timed out' }],
|
||||
apps: [
|
||||
{
|
||||
name: 'Split Horizon',
|
||||
description: '',
|
||||
configs: 'unknown',
|
||||
versions: 'single',
|
||||
servers: {
|
||||
mdns: { installed: true, version: '9.0', configStatus: 'read' },
|
||||
ndns: { installed: false, configStatus: 'unreadable', configError: 'ndns did not answer' },
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
render(<AppsMatrix token="t" />)
|
||||
await screen.findByText('Split Horizon')
|
||||
expect(within(row('Split Horizon')).getByText('v9.0')).toBeInTheDocument()
|
||||
expect(within(row('Split Horizon')).getAllByText('Unknown')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('says so when no filter matches, with a way back', async () => {
|
||||
mockMatrix({ ...DATA, apps: [DATA.apps[2]] })
|
||||
render(<AppsMatrix token="t" />)
|
||||
await screen.findByText('Split Horizon')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Config differs · 0' }))
|
||||
expect(screen.getByText('No apps match')).toBeInTheDocument()
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Show all' }))
|
||||
expect(screen.getByText('Split Horizon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('has an empty state with an action', async () => {
|
||||
const spy = mockMatrix({ servers: DATA.servers, apps: [] })
|
||||
render(<AppsMatrix token="t" />)
|
||||
expect(await screen.findByText('No apps installed')).toBeInTheDocument()
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Refresh' }))
|
||||
expect(spy).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('shows loading and then an error with Retry', async () => {
|
||||
const spy = vi.spyOn(api, 'getAppsMatrix').mockResolvedValueOnce({ kind: 'error', message: 'No server answered.' } as never)
|
||||
render(<AppsMatrix token="t" />)
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Loading')
|
||||
expect(await screen.findByText(/Could not load the apps/)).toHaveTextContent('No server answered.')
|
||||
spy.mockResolvedValue({ kind: 'ok', data: { response: DATA } } as never)
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
||||
expect(await screen.findByText('Advanced Blocking')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Apps behind a hub', () => {
|
||||
const INSTALLED = {
|
||||
name: 'Split Horizon',
|
||||
description: 'Different answers',
|
||||
version: '9.0',
|
||||
dnsApps: [],
|
||||
}
|
||||
const NODES = [{ name: 'mdns', type: 'Primary' }, { name: 'ndns', type: 'Secondary' }]
|
||||
|
||||
function mount(hub: boolean) {
|
||||
return render(
|
||||
<HubMode.Provider value={hub}>
|
||||
<SlotProvider>
|
||||
{(ref) => (
|
||||
<>
|
||||
<div ref={ref} />
|
||||
<Apps token="t" nodes={NODES} clusterInitialised />
|
||||
</>
|
||||
)}
|
||||
</SlotProvider>
|
||||
</HubMode.Provider>,
|
||||
)
|
||||
}
|
||||
|
||||
it('shows the selector and the matrix for All servers', async () => {
|
||||
const matrix = mockMatrix()
|
||||
const list = vi.spyOn(api, 'listApps')
|
||||
mount(true)
|
||||
expect(await screen.findByText('Advanced Blocking')).toBeInTheDocument()
|
||||
expect(await screen.findByRole('combobox', { name: 'Server' })).toHaveTextContent('All servers')
|
||||
expect(matrix).toHaveBeenCalled()
|
||||
expect(list).not.toHaveBeenCalled()
|
||||
expect(screen.queryByRole('button', { name: 'App Store' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows one server read-only and remembers the choice', async () => {
|
||||
mockMatrix()
|
||||
const list = vi.spyOn(api, 'listApps').mockResolvedValue({ kind: 'ok', data: { response: { apps: [INSTALLED] } } } as never)
|
||||
mount(true)
|
||||
await userEvent.click(await screen.findByRole('combobox', { name: 'Server' }))
|
||||
await userEvent.click(screen.getByRole('option', { name: 'ndns' }))
|
||||
expect(await screen.findByText('Split Horizon')).toBeInTheDocument()
|
||||
expect(list).toHaveBeenCalledWith('t', 'ndns')
|
||||
expect(localStorage.getItem('hubAppsNode')).toBe('ndns')
|
||||
for (const name of ['Config', 'Update', 'Store Update', 'App Store', 'Install from file']) {
|
||||
expect(screen.queryByRole('button', { name })).not.toBeInTheDocument()
|
||||
}
|
||||
expect(screen.queryByRole('button', { name: /Actions: Split Horizon/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('tab', { name: /App Store/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('starts on the remembered server', async () => {
|
||||
localStorage.setItem('hubAppsNode', 'mdns')
|
||||
const list = vi.spyOn(api, 'listApps').mockResolvedValue({ kind: 'ok', data: { response: { apps: [INSTALLED] } } } as never)
|
||||
mount(true)
|
||||
expect(await screen.findByText('Split Horizon')).toBeInTheDocument()
|
||||
expect(list).toHaveBeenCalledWith('t', 'mdns')
|
||||
})
|
||||
|
||||
it('is unchanged outside a hub', async () => {
|
||||
const matrix = mockMatrix()
|
||||
const list = vi.spyOn(api, 'listApps').mockResolvedValue({ kind: 'ok', data: { response: { apps: [INSTALLED] } } } as never)
|
||||
mount(false)
|
||||
expect(await screen.findByText('Split Horizon')).toBeInTheDocument()
|
||||
expect(list).toHaveBeenCalledWith('t', '')
|
||||
expect(matrix).not.toHaveBeenCalled()
|
||||
expect(screen.queryByRole('combobox', { name: 'Server' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'App Store' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Config' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,312 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { getAppsMatrix, getDrift, listStoreApps, runHubApps, type AppsMatrixData, type HubRunAction, type MatrixApp } from '../../api/apps'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { Button } from '../../ui/Button'
|
||||
import { DataTable, type Col } from '../../ui/DataTable'
|
||||
import { Empty, FailureRetry, LoadingRegion } from '../../ui/Empty'
|
||||
import { Panel } from '../../ui/Panel'
|
||||
import { Segmented } from '../../ui/Segmented'
|
||||
import { Menu } from '../../ui/Menu'
|
||||
import { Tag } from '../../ui/Tag'
|
||||
import { RunFlow, pickerServers, type RunRequest } from './RunFlow'
|
||||
import { AppConfigSync } from './AppConfigSync'
|
||||
import { Tooltip } from '../../ui/Tooltip'
|
||||
import { head } from '../../ui/Table'
|
||||
|
||||
/*
|
||||
Behind a hub: one row per app, one column per server, and whether the configs agree.
|
||||
Read-only. The numbers come from `hub/apps/matrix`, which never sends a config, only its
|
||||
hash and the comparison, so nothing here can show a secret.
|
||||
*/
|
||||
|
||||
type Filter = 'all' | 'version' | 'config' | 'update'
|
||||
|
||||
/* 9.10 is newer than 9.9: compared part by part, numerically. */
|
||||
export function compareVersions(a: string, b: string): number {
|
||||
const [x, y] = [a, b].map((v) => v.split('.').map((p) => Number.parseInt(p, 10) || 0))
|
||||
for (let i = 0; i < Math.max(x.length, y.length); i++) {
|
||||
const d = (x[i] ?? 0) - (y[i] ?? 0)
|
||||
if (d !== 0) return d
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
const hasUpdate = (app: MatrixApp): boolean => Object.values(app.servers).some((c) => c.installed && c.updateAvailable === true)
|
||||
|
||||
const FILTERS: Record<Filter, (app: MatrixApp) => boolean> = {
|
||||
all: () => true,
|
||||
version: (app) => app.versions === 'different',
|
||||
config: (app) => app.configs === 'different',
|
||||
update: hasUpdate,
|
||||
}
|
||||
|
||||
/* Why a config's state is not known, from the cells and the servers that did not answer. */
|
||||
function unknownReason(app: MatrixApp, data: AppsMatrixData): string {
|
||||
const reasons = new Set<string>()
|
||||
for (const [id, cell] of Object.entries(app.servers)) {
|
||||
if (cell.installed && cell.configStatus === 'unreadable') reasons.add(`${id}: ${cell.configError ?? 'config not readable'}`)
|
||||
}
|
||||
for (const s of data.servers) if (!s.ok) reasons.add(`${s.id}: ${s.error ?? 'did not answer'}`)
|
||||
return [...reasons].join('; ') || 'The configs could not be compared.'
|
||||
}
|
||||
|
||||
function ConfigState({ app, data, onOpen }: { app: MatrixApp; data: AppsMatrixData; onOpen: () => void }) {
|
||||
switch (app.configs) {
|
||||
case 'same':
|
||||
return <Tag tone="ok">Same</Tag>
|
||||
case 'different':
|
||||
return (
|
||||
<button type="button" className="cursor-pointer border-0 bg-transparent p-0" aria-label={`Compare the configs of ${app.name}`} onClick={onOpen}>
|
||||
<Tag tone="warn">{app.diffCount === undefined ? 'Different' : `Different · ${app.diffCount}`}</Tag>
|
||||
</button>
|
||||
)
|
||||
case 'single':
|
||||
return <Badge variant="outline">Single server</Badge>
|
||||
default:
|
||||
return (
|
||||
<Tooltip text={unknownReason(app, data)} placement="top">
|
||||
<Badge variant="outline" tabIndex={0}>
|
||||
Unknown
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function ServerCell({ app, id, data, drifted }: { app: MatrixApp; id: string; data: AppsMatrixData; drifted: boolean }) {
|
||||
const cell = app.servers[id]
|
||||
if (data.servers.find((s) => s.id === id)?.ok === false) {
|
||||
return (
|
||||
<Tooltip text={`${id} did not answer`} placement="top">
|
||||
<span className="text-sm text-muted-foreground" tabIndex={0}>
|
||||
Unknown
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
if (cell === undefined || !cell.installed) return <span className="text-sm text-muted-foreground">Not installed</span>
|
||||
return (
|
||||
<span className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span>{cell.version === undefined ? 'Installed' : `v${cell.version}`}</span>
|
||||
{drifted && <Tag tone="warn">Drift</Tag>}
|
||||
{cell.updateAvailable === true && (
|
||||
<Tag tone="warn">{cell.updateVersion === undefined ? 'Update' : `Update v${cell.updateVersion}`}</Tag>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function AppsMatrix({ token, canWrite = false, refresh = 0 }: { token: string | null; canWrite?: boolean; refresh?: number }) {
|
||||
const [data, setData] = useState<AppsMatrixData | null>(null)
|
||||
const [request, setRequest] = useState<RunRequest | null>(null)
|
||||
const [configApp, setConfigApp] = useState<string | null>(null)
|
||||
/** `app/server` pairs whose config differs from what the hub expects. */
|
||||
const [drifted, setDrifted] = useState<Set<string>>(new Set())
|
||||
/** What the store offers, by app: needed to know whether "Align versions" can be done. */
|
||||
const [store, setStore] = useState<Record<string, string> | null>(null)
|
||||
const [failure, setFailure] = useState<string | null>(null)
|
||||
const [filter, setFilter] = useState<Filter>('all')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const outcome = await getAppsMatrix(token)
|
||||
if (outcome.kind === 'ok') {
|
||||
setData(outcome.data.response)
|
||||
setFailure(null)
|
||||
return
|
||||
}
|
||||
setFailure(outcome.kind === 'error' ? outcome.message : 'The session ended.')
|
||||
}, [token])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
void getDrift(token)
|
||||
.then((o) => {
|
||||
if (o.kind === 'ok' && Array.isArray(o.data.response?.apps)) {
|
||||
setDrifted(new Set(o.data.response.apps.filter((e) => e.kind === 'app' && e.status === 'differs').map((e) => `${e.name ?? e.app}\n${e.server}`)))
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [load, refresh, token])
|
||||
|
||||
useEffect(() => {
|
||||
if (!canWrite) return
|
||||
void listStoreApps(token).then((outcome) => {
|
||||
if (outcome.kind === 'ok') setStore(Object.fromEntries(outcome.data.response.storeApps.map((s) => [s.name, s.version])))
|
||||
})
|
||||
}, [canWrite, token])
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const apps = data?.apps ?? []
|
||||
return (Object.keys(FILTERS) as Filter[]).reduce(
|
||||
(acc, f) => ({ ...acc, [f]: apps.filter(FILTERS[f]).length }),
|
||||
{} as Record<Filter, number>,
|
||||
)
|
||||
}, [data])
|
||||
|
||||
if (failure !== null && data === null) {
|
||||
return (
|
||||
<FailureRetry
|
||||
onRetry={() => {
|
||||
setFailure(null)
|
||||
setData(null)
|
||||
void load()
|
||||
}}
|
||||
>
|
||||
Could not load the apps of the servers. {failure}
|
||||
</FailureRetry>
|
||||
)
|
||||
}
|
||||
if (data === null) return <LoadingRegion height="h-64">Loading the apps of every server…</LoadingRegion>
|
||||
|
||||
if (data.apps.length === 0) {
|
||||
return (
|
||||
<Empty
|
||||
title="No apps installed"
|
||||
actions={
|
||||
<Button variant="primary" onClick={() => void load()}>
|
||||
Refresh
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
None of the servers that answered has a DNS app installed. Install apps in a server's own console.
|
||||
</Empty>
|
||||
)
|
||||
}
|
||||
|
||||
const rows = data.apps.filter(FILTERS[filter])
|
||||
|
||||
/* The change menu of one app. Each item opens the server picker with the servers it makes sense on. */
|
||||
function actions(app: MatrixApp, d: AppsMatrixData) {
|
||||
const has = (id: string) => app.servers[id]?.installed === true
|
||||
const installed = d.servers.filter((s) => has(s.id)).map((s) => s.id)
|
||||
const outdated = installed.filter((id) => app.servers[id].updateAvailable === true)
|
||||
const missing = d.servers.filter((s) => s.ok && !has(s.id)).map((s) => s.id)
|
||||
const versions = installed.map((id) => app.servers[id].version ?? '0')
|
||||
const newest = versions.reduce((best, v) => (compareVersions(v, best) > 0 ? v : best), versions[0] ?? '0')
|
||||
const behind = installed.filter((id) => compareVersions(app.servers[id].version ?? '0', newest) < 0)
|
||||
const canAlign = behind.length > 0 && store?.[app.name] === newest
|
||||
|
||||
const open = (
|
||||
action: HubRunAction,
|
||||
title: string,
|
||||
confirmLabel: string,
|
||||
preselected: string[],
|
||||
allowed: string[],
|
||||
why: string,
|
||||
intro?: string,
|
||||
) =>
|
||||
setRequest({
|
||||
title,
|
||||
intro,
|
||||
confirmLabel,
|
||||
servers: pickerServers(d.servers, Object.fromEntries(d.servers.filter((s) => !allowed.includes(s.id)).map((s) => [s.id, why]))),
|
||||
preselected,
|
||||
run: (targets) => runHubApps(token, action, app.name, targets),
|
||||
})
|
||||
|
||||
return (
|
||||
<Menu label={`Actions: ${app.name}`}>
|
||||
{(close) => (
|
||||
<>
|
||||
<Button size="sm" onClick={() => { close(); setConfigApp(app.name) }}>
|
||||
Config…
|
||||
</Button>
|
||||
{canWrite && (
|
||||
<>
|
||||
<Button size="sm" disabled={outdated.length === 0} onClick={() => { close(); open('update-store', `Update ${app.name}`, 'Update', outdated, installed, 'Not installed') }}>
|
||||
Update on all
|
||||
</Button>
|
||||
<Button size="sm" disabled={missing.length === 0} onClick={() => { close(); open('install-store', `Install ${app.name}`, 'Install', missing, missing, 'Already installed') }}>
|
||||
Install on missing servers
|
||||
</Button>
|
||||
<Button size="sm" disabled={!canAlign} onClick={() => { close(); open('update-store', `Align ${app.name} to v${newest}`, 'Update', behind, behind, 'Already at the newest version') }}>
|
||||
Align versions
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
disabled={installed.length === 0}
|
||||
onClick={() => {
|
||||
close()
|
||||
open('uninstall', `Uninstall ${app.name}`, 'Uninstall', [], installed, 'Not installed', "Each server's config of this app is saved to the hub's history before it is uninstalled.")
|
||||
}}
|
||||
>
|
||||
Uninstall from…
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
const columns: Col<MatrixApp>[] = [
|
||||
{
|
||||
id: 'name',
|
||||
...head({ children: 'App' }),
|
||||
cell: (a) => (
|
||||
<span className="flex flex-col gap-1">
|
||||
<span className="text-sm font-medium">{a.name}</span>
|
||||
{a.description !== '' && (
|
||||
<span className="line-clamp-2 text-xs text-muted-foreground" title={a.description}>
|
||||
{a.description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
...data.servers.map(
|
||||
(s): Col<MatrixApp> => ({
|
||||
id: `server-${s.id}`,
|
||||
...head({ children: s.id }),
|
||||
cell: (a) => <ServerCell app={a} id={s.id} data={data} drifted={drifted.has(`${a.name}\n${s.id}`)} />,
|
||||
}),
|
||||
),
|
||||
{ id: 'config', ...head({ children: 'Config' }), cell: (a) => <ConfigState app={a} data={data} onOpen={() => setConfigApp(a.name)} /> },
|
||||
{ id: 'actions', ...head({ children: <span className="sr-only">Actions</span> }), cell: (a) => actions(a, data) },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Segmented
|
||||
label="Filter apps"
|
||||
active={filter}
|
||||
onChoose={setFilter}
|
||||
options={[
|
||||
{ id: 'all', label: `All · ${counts.all}` },
|
||||
{ id: 'version', label: `Version differs · ${counts.version}` },
|
||||
{ id: 'config', label: `Config differs · ${counts.config}` },
|
||||
{ id: 'update', label: `Update available · ${counts.update}` },
|
||||
]}
|
||||
/>
|
||||
<Panel title="Apps by server" description="Installed version and whether the configs match">
|
||||
{rows.length === 0 ? (
|
||||
<Empty
|
||||
title="No apps match"
|
||||
actions={
|
||||
<Button variant="primary" onClick={() => setFilter('all')}>
|
||||
Show all
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
No app fits this filter.
|
||||
</Empty>
|
||||
) : (
|
||||
<DataTable data={rows} columns={columns} getRowId={(a) => a.name} />
|
||||
)}
|
||||
</Panel>
|
||||
{configApp !== null && (
|
||||
<AppConfigSync token={token} name={configApp} canWrite={canWrite} onClose={() => setConfigApp(null)} onChanged={() => void load()} />
|
||||
)}
|
||||
{request !== null && (
|
||||
<RunFlow
|
||||
request={request}
|
||||
onClose={(changed) => {
|
||||
setRequest(null)
|
||||
if (changed) void load()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { AppsMatrix } from './AppsMatrix'
|
||||
import { Apps } from './Apps'
|
||||
import { SlotProvider } from '../../app/ChromeSlot'
|
||||
import { HubMode } from '../../ui/ClusterNodeSelect'
|
||||
import * as api from '../../api/apps'
|
||||
import type { AppsMatrixData, InstalledApp } from '../../api/apps'
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
/* mdns has Blocker v1.0 with an update; ndns has v2.0; Wild IP is only on mdns. ndns has no admin token. */
|
||||
const DATA: AppsMatrixData = {
|
||||
servers: [
|
||||
{ id: 'mdns', ok: true, writable: true },
|
||||
{ id: 'ndns', ok: true, writable: false },
|
||||
],
|
||||
apps: [
|
||||
{
|
||||
name: 'Blocker',
|
||||
description: '',
|
||||
configs: 'same',
|
||||
versions: 'different',
|
||||
servers: {
|
||||
mdns: { installed: true, version: '1.0', updateAvailable: true, updateVersion: '2.0', configStatus: 'read' },
|
||||
ndns: { installed: true, version: '2.0', configStatus: 'read' },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Wild IP',
|
||||
description: '',
|
||||
configs: 'single',
|
||||
versions: 'single',
|
||||
servers: {
|
||||
mdns: { installed: true, version: '5.1', configStatus: 'read' },
|
||||
ndns: { installed: false, configStatus: 'none' },
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const ok = (results: api.TargetResult[]) => ({ kind: 'ok', data: { response: { results } } }) as never
|
||||
|
||||
function mockReads(data: AppsMatrixData = DATA, store = [{ name: 'Blocker', version: '2.0' }]) {
|
||||
const matrix = vi.spyOn(api, 'getAppsMatrix').mockResolvedValue({ kind: 'ok', data: { response: data } } as never)
|
||||
vi.spyOn(api, 'listStoreApps').mockResolvedValue({ kind: 'ok', data: { response: { storeApps: store } } } as never)
|
||||
return matrix
|
||||
}
|
||||
|
||||
async function openMenu(app: string) {
|
||||
await userEvent.click(await screen.findByRole('button', { name: `Actions: ${app}` }))
|
||||
}
|
||||
|
||||
describe('matrix actions', () => {
|
||||
it('gives a reader a menu with Config only and a writer all the actions', async () => {
|
||||
mockReads()
|
||||
const { unmount } = render(<AppsMatrix token="t" />)
|
||||
await openMenu('Blocker')
|
||||
expect(screen.getByRole('menuitem', { name: 'Config…' })).toBeInTheDocument()
|
||||
for (const name of ['Update on all', 'Install on missing servers', 'Align versions', 'Uninstall from…']) {
|
||||
expect(screen.queryByRole('menuitem', { name })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name })).not.toBeInTheDocument()
|
||||
}
|
||||
unmount()
|
||||
|
||||
render(<AppsMatrix token="t" canWrite />)
|
||||
expect(await screen.findByRole('button', { name: 'Actions: Blocker' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Actions: Wild IP' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('enables only the actions that make sense for the row', async () => {
|
||||
mockReads()
|
||||
render(<AppsMatrix token="t" canWrite />)
|
||||
await openMenu('Blocker')
|
||||
expect(screen.getByRole('menuitem', { name: 'Update on all' })).toBeEnabled()
|
||||
expect(screen.getByRole('button', { name: 'Install on missing servers' })).toBeDisabled()
|
||||
expect(screen.getByRole('menuitem', { name: 'Align versions' })).toBeEnabled()
|
||||
expect(screen.getByRole('menuitem', { name: 'Uninstall from…' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('offers Align versions only when the store has the newest installed version', async () => {
|
||||
mockReads(DATA, [{ name: 'Blocker', version: '1.5' }])
|
||||
render(<AppsMatrix token="t" canWrite />)
|
||||
await openMenu('Blocker')
|
||||
await waitFor(() => expect(api.listStoreApps).toHaveBeenCalled())
|
||||
expect(screen.getByRole('button', { name: 'Align versions' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('offers Install on missing servers for an app that is not everywhere', async () => {
|
||||
mockReads()
|
||||
render(<AppsMatrix token="t" canWrite />)
|
||||
await openMenu('Wild IP')
|
||||
expect(screen.getByRole('menuitem', { name: 'Install on missing servers' })).toBeEnabled()
|
||||
expect(screen.getByRole('button', { name: 'Update on all' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('shows the picker with a server the hub cannot write to disabled, with the reason', async () => {
|
||||
mockReads()
|
||||
render(<AppsMatrix token="t" canWrite />)
|
||||
await openMenu('Wild IP')
|
||||
await userEvent.click(screen.getByRole('menuitem', { name: 'Install on missing servers' }))
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Install Wild IP' })
|
||||
// mdns already has it, ndns has no admin token: neither can be chosen.
|
||||
expect(within(dialog).getByRole('checkbox', { name: 'mdns' })).toBeDisabled()
|
||||
expect(within(dialog).getByRole('checkbox', { name: 'ndns' })).toBeDisabled()
|
||||
expect(within(dialog).getByText('Already installed')).toBeInTheDocument()
|
||||
expect(within(dialog).getByText('No admin token configured on the hub')).toBeInTheDocument()
|
||||
expect(within(dialog).getByRole('button', { name: 'Install' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('runs the change on the ticked servers, shows each result and refreshes', async () => {
|
||||
const matrix = mockReads()
|
||||
const run = vi.spyOn(api, 'runHubApps').mockResolvedValue(ok([{ server: 'mdns', ok: true, message: 'Updated from the store.' }]))
|
||||
render(<AppsMatrix token="t" canWrite />)
|
||||
await openMenu('Blocker')
|
||||
await userEvent.click(screen.getByRole('menuitem', { name: 'Update on all' }))
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Update Blocker' })
|
||||
expect(within(dialog).getByRole('checkbox', { name: 'mdns' })).toBeChecked()
|
||||
await userEvent.click(within(dialog).getByRole('button', { name: 'Update' }))
|
||||
expect(run).toHaveBeenCalledWith('t', 'update-store', 'Blocker', ['mdns'])
|
||||
|
||||
const results = await screen.findByRole('dialog', { name: 'Update Blocker: results' })
|
||||
expect(within(results).getByText('Updated from the store.')).toBeInTheDocument()
|
||||
expect(within(results).getByText('Done')).toBeInTheDocument()
|
||||
expect(within(results).queryByRole('button', { name: 'Retry failed' })).not.toBeInTheDocument()
|
||||
const before = matrix.mock.calls.length
|
||||
await userEvent.click(within(results).getAllByRole('button', { name: 'Close' }).at(-1) as HTMLElement)
|
||||
await waitFor(() => expect(matrix.mock.calls.length).toBeGreaterThan(before))
|
||||
})
|
||||
|
||||
it('retries only the servers that failed', async () => {
|
||||
const wide: AppsMatrixData = {
|
||||
servers: [
|
||||
{ id: 'mdns', ok: true, writable: true },
|
||||
{ id: 'ndns', ok: true, writable: true },
|
||||
],
|
||||
apps: [{ ...DATA.apps[0], servers: { ...DATA.apps[0].servers, ndns: { installed: true, version: '1.0', updateAvailable: true, configStatus: 'read' } } }],
|
||||
}
|
||||
mockReads(wide)
|
||||
const run = vi
|
||||
.spyOn(api, 'runHubApps')
|
||||
.mockResolvedValueOnce(ok([{ server: 'mdns', ok: true, message: 'Updated from the store.' }, { server: 'ndns', ok: false, message: 'Disk full.' }]))
|
||||
.mockResolvedValueOnce(ok([{ server: 'ndns', ok: true, message: 'Updated from the store.' }]))
|
||||
render(<AppsMatrix token="t" canWrite />)
|
||||
await openMenu('Blocker')
|
||||
await userEvent.click(screen.getByRole('menuitem', { name: 'Update on all' }))
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Update Blocker' })
|
||||
await userEvent.click(within(dialog).getByRole('button', { name: 'Update' }))
|
||||
expect(run).toHaveBeenLastCalledWith('t', 'update-store', 'Blocker', ['mdns', 'ndns'])
|
||||
|
||||
const results = await screen.findByRole('dialog', { name: 'Update Blocker: results' })
|
||||
expect(within(results).getByText('Disk full.')).toBeInTheDocument()
|
||||
await userEvent.click(within(results).getByRole('button', { name: 'Retry failed' }))
|
||||
expect(run).toHaveBeenLastCalledWith('t', 'update-store', 'Blocker', ['ndns'])
|
||||
await waitFor(() => expect(within(screen.getByRole('dialog')).queryByText('Disk full.')).not.toBeInTheDocument())
|
||||
expect(within(screen.getByRole('dialog')).getAllByText('Done')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('says the config is saved before an uninstall and starts with nothing ticked', async () => {
|
||||
mockReads()
|
||||
const run = vi.spyOn(api, 'runHubApps').mockResolvedValue(ok([{ server: 'mdns', ok: true }]))
|
||||
render(<AppsMatrix token="t" canWrite />)
|
||||
await openMenu('Wild IP')
|
||||
await userEvent.click(screen.getByRole('menuitem', { name: 'Uninstall from…' }))
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Uninstall Wild IP' })
|
||||
expect(within(dialog).getByText(/saved to the hub's history/)).toBeInTheDocument()
|
||||
expect(within(dialog).getByRole('button', { name: 'Uninstall' })).toBeDisabled()
|
||||
await userEvent.click(within(dialog).getByRole('checkbox', { name: 'mdns' }))
|
||||
await userEvent.click(within(dialog).getByRole('button', { name: 'Uninstall' }))
|
||||
expect(run).toHaveBeenCalledWith('t', 'uninstall', 'Wild IP', ['mdns'])
|
||||
})
|
||||
|
||||
it('shows a failed call as a failure with Retry', async () => {
|
||||
mockReads()
|
||||
const run = vi
|
||||
.spyOn(api, 'runHubApps')
|
||||
.mockResolvedValueOnce({ kind: 'error', message: 'Your role does not allow this.' } as never)
|
||||
.mockResolvedValueOnce(ok([{ server: 'mdns', ok: true }]))
|
||||
render(<AppsMatrix token="t" canWrite />)
|
||||
await openMenu('Blocker')
|
||||
await userEvent.click(screen.getByRole('menuitem', { name: 'Update on all' }))
|
||||
await userEvent.click(within(await screen.findByRole('dialog', { name: 'Update Blocker' })).getByRole('button', { name: 'Update' }))
|
||||
const results = await screen.findByRole('dialog', { name: 'Update Blocker: results' })
|
||||
expect(within(results).getByText('Your role does not allow this.')).toBeInTheDocument()
|
||||
await userEvent.click(within(results).getByRole('button', { name: 'Retry' }))
|
||||
await waitFor(() => expect(run).toHaveBeenCalledTimes(2))
|
||||
expect(await within(screen.getByRole('dialog')).findByText('Done')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Apps behind a hub, for writers', () => {
|
||||
const INSTALLED: InstalledApp = { name: 'Split Horizon', description: 'd', version: '9.0', updateAvailable: true, updateVersion: '9.1', updateUrl: 'https://x/s.zip', dnsApps: [] }
|
||||
const NODES = [{ name: 'mdns', type: 'Primary' }, { name: 'ndns', type: 'Secondary' }]
|
||||
|
||||
function mount(props: { canWrite: boolean; writable: string[] }) {
|
||||
return render(
|
||||
<HubMode.Provider value>
|
||||
<SlotProvider>
|
||||
{(ref) => (
|
||||
<>
|
||||
<div ref={ref} />
|
||||
<Apps token="t" nodes={NODES} clusterInitialised {...props} />
|
||||
</>
|
||||
)}
|
||||
</SlotProvider>
|
||||
</HubMode.Provider>,
|
||||
)
|
||||
}
|
||||
|
||||
it('shows App Store and Install from file on All servers to writers only', async () => {
|
||||
mockReads()
|
||||
const { unmount } = mount({ canWrite: false, writable: ['mdns'] })
|
||||
await screen.findByText('Blocker')
|
||||
expect(screen.queryByRole('button', { name: 'App Store' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Install from file' })).not.toBeInTheDocument()
|
||||
unmount()
|
||||
mount({ canWrite: true, writable: ['mdns'] })
|
||||
expect(await screen.findByRole('button', { name: 'App Store' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Install from file' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens the hub store and sends an install through the picker', async () => {
|
||||
mockReads()
|
||||
vi.spyOn(api, 'listStoreApps').mockResolvedValue({
|
||||
kind: 'ok',
|
||||
data: { response: { storeApps: [{ name: 'Failover', description: 'Backup', version: '8.0', url: 'u', size: '1', installed: false, installedOn: [] }] } },
|
||||
} as never)
|
||||
const run = vi.spyOn(api, 'runHubApps').mockResolvedValue(ok([{ server: 'mdns', ok: true }]))
|
||||
mount({ canWrite: true, writable: ['mdns'] })
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'App Store' }))
|
||||
expect(await screen.findByText('Failover')).toBeInTheDocument()
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Install…' }))
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Install Failover' })
|
||||
expect(within(dialog).getByRole('checkbox', { name: 'ndns' })).toBeDisabled()
|
||||
await userEvent.click(within(dialog).getByRole('button', { name: 'Install' }))
|
||||
expect(run).toHaveBeenCalledWith('t', 'install-store', 'Failover', ['mdns'])
|
||||
})
|
||||
|
||||
it('uploads a zip to the chosen servers', async () => {
|
||||
mockReads()
|
||||
const upload = vi.spyOn(api, 'uploadHubApp').mockResolvedValue(ok([{ server: 'mdns', ok: true }]))
|
||||
mount({ canWrite: true, writable: ['mdns'] })
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Install from file' }))
|
||||
const form = await screen.findByRole('dialog', { name: 'Install App' })
|
||||
await userEvent.type(within(form).getByLabelText('App Name'), 'Mine')
|
||||
const file = new File([new Uint8Array([1, 2, 3])], 'Mine.zip', { type: 'application/zip' })
|
||||
await userEvent.upload(within(form).getByLabelText('App Zip File'), file)
|
||||
await userEvent.click(within(form).getByRole('button', { name: 'Choose servers' }))
|
||||
const picker = await screen.findByRole('dialog', { name: 'Install Mine from file' })
|
||||
await userEvent.click(within(picker).getByRole('button', { name: 'Install' }))
|
||||
expect(upload).toHaveBeenCalledWith('t', 'install', 'Mine', file, ['mdns'])
|
||||
})
|
||||
|
||||
it('brings the single-server actions back for a writer, Config included, and sends them to that node', async () => {
|
||||
mockReads()
|
||||
localStorage.setItem('hubAppsNode', 'mdns')
|
||||
vi.spyOn(api, 'listApps').mockResolvedValue({ kind: 'ok', data: { response: { apps: [INSTALLED] } } } as never)
|
||||
const store = vi.spyOn(api, 'downloadAndUpdate').mockResolvedValue({ kind: 'ok', data: { response: {} } } as never)
|
||||
mount({ canWrite: true, writable: ['mdns'] })
|
||||
expect(await screen.findByText('Split Horizon')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Update' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'App Store' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Install from file' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Actions: Split Horizon/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Config' })).toBeInTheDocument()
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Store Update' }))
|
||||
expect(store).toHaveBeenCalledWith('t', 'Split Horizon', 'https://x/s.zip', 'mdns')
|
||||
})
|
||||
|
||||
it('keeps a viewer, and a server without an admin token, read-only', async () => {
|
||||
mockReads()
|
||||
vi.spyOn(api, 'listApps').mockResolvedValue({ kind: 'ok', data: { response: { apps: [INSTALLED] } } } as never)
|
||||
localStorage.setItem('hubAppsNode', 'ndns')
|
||||
const { unmount } = mount({ canWrite: true, writable: ['mdns'] })
|
||||
expect(await screen.findByText('Split Horizon')).toBeInTheDocument()
|
||||
for (const name of ['Update', 'Store Update', 'App Store', 'Install from file']) {
|
||||
expect(screen.queryByRole('button', { name })).not.toBeInTheDocument()
|
||||
}
|
||||
unmount()
|
||||
|
||||
localStorage.setItem('hubAppsNode', 'mdns')
|
||||
mount({ canWrite: false, writable: ['mdns'] })
|
||||
expect(await screen.findByText('Split Horizon')).toBeInTheDocument()
|
||||
for (const name of ['Update', 'Store Update', 'App Store', 'Install from file']) {
|
||||
expect(screen.queryByRole('button', { name })).not.toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { ConfigDiff, ConfigCompare, FieldChange } from '../../api/apps'
|
||||
import { DataTable, type Col } from '../../ui/DataTable'
|
||||
import { head } from '../../ui/Table'
|
||||
import { Tag } from '../../ui/Tag'
|
||||
|
||||
/* A value as one short line: JSON for anything that is not a string. */
|
||||
export function show(value: unknown): string {
|
||||
if (value === undefined) return '(absent)'
|
||||
const text = typeof value === 'string' ? value : JSON.stringify(value)
|
||||
return text.length > 90 ? `${text.slice(0, 87)}...` : text
|
||||
}
|
||||
|
||||
/* Is this field (or a parent of it) one of the local ones? */
|
||||
export const isLocal = (path: string, localPaths: string[]): boolean => localPaths.some((p) => path === p || path.startsWith(`${p}.`))
|
||||
|
||||
const LINE_CLASS = {
|
||||
same: 'text-muted-foreground',
|
||||
add: 'bg-success/10 text-foreground',
|
||||
remove: 'bg-destructive/10 text-foreground',
|
||||
} as const
|
||||
const MARK = { same: ' ', add: '+', remove: '-' } as const
|
||||
|
||||
/* The changes of one config to another: fields for JSON, lines for text. */
|
||||
export function DiffView({ diff, localPaths = [], from = 'Now', to = 'Expected' }: { diff: ConfigDiff; localPaths?: string[]; from?: string; to?: string }) {
|
||||
if (diff.kind === 'text') {
|
||||
if (diff.truncated) return <p className="m-0 text-sm text-muted-foreground">Too long to show line by line: about {diff.changed} lines differ.</p>
|
||||
if (diff.changed === 0) return <p className="m-0 text-sm text-muted-foreground">No differences.</p>
|
||||
return (
|
||||
<pre className="m-0 max-h-80 overflow-auto rounded-md border border-border font-mono text-xs leading-5" aria-label="Line differences">
|
||||
{diff.lines.map((l, i) => (
|
||||
<div key={i} className={`px-3 whitespace-pre-wrap ${LINE_CLASS[l.op]}`} data-op={l.op}>
|
||||
{MARK[l.op]} {l.text}
|
||||
</div>
|
||||
))}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
if (diff.changes.length === 0) return <p className="m-0 text-sm text-muted-foreground">No differences.</p>
|
||||
const columns: Col<FieldChange>[] = [
|
||||
{
|
||||
id: 'path',
|
||||
...head({ children: 'Field' }),
|
||||
cell: (c) => (
|
||||
<span className="flex flex-wrap items-center gap-2 font-mono text-xs">
|
||||
{c.path}
|
||||
{isLocal(c.path, localPaths) && <Tag tone="info">Local</Tag>}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ id: 'a', ...head({ children: from }), cell: (c) => <span className="font-mono text-xs">{show(c.a)}</span> },
|
||||
{ id: 'b', ...head({ children: to }), cell: (c) => <span className="font-mono text-xs">{show(c.b)}</span> },
|
||||
]
|
||||
return <DataTable data={diff.changes} columns={columns} getRowId={(c) => c.path} />
|
||||
}
|
||||
|
||||
interface Row {
|
||||
path: string
|
||||
cells: Record<string, { now?: unknown; expected?: unknown; differs: boolean }>
|
||||
}
|
||||
|
||||
/*
|
||||
The fields that differ, one row each and one column per server. With a baseline each server is
|
||||
compared to its own effective config (its local values in place); without one, every server is
|
||||
compared to the first.
|
||||
*/
|
||||
export function fieldRows(data: ConfigCompare): Row[] | null {
|
||||
const live = data.servers.filter((s) => s.installed && s.ok)
|
||||
const rows = new Map<string, Row>()
|
||||
const cell = (path: string, server: string) => {
|
||||
const row = rows.get(path) ?? { path, cells: {} }
|
||||
rows.set(path, row)
|
||||
return (row.cells[server] ??= { differs: false })
|
||||
}
|
||||
if (data.baseline !== null) {
|
||||
if (live.some((s) => s.vsBaseline?.kind === 'text')) return null
|
||||
for (const s of live) {
|
||||
if (s.vsBaseline?.kind !== 'json') continue
|
||||
for (const c of s.vsBaseline.changes) Object.assign(cell(c.path, s.id), { now: c.a, expected: c.b, differs: true })
|
||||
}
|
||||
return [...rows.values()]
|
||||
}
|
||||
const reference = live[0]
|
||||
if (reference === undefined) return []
|
||||
for (const o of live.slice(1)) {
|
||||
const diff = reference.vsOthers[o.id]
|
||||
if (diff === undefined) continue
|
||||
if (diff.kind === 'text') return null
|
||||
for (const c of diff.changes) {
|
||||
Object.assign(cell(c.path, reference.id), { now: c.b, differs: true })
|
||||
Object.assign(cell(c.path, o.id), { now: c.a, differs: true })
|
||||
}
|
||||
}
|
||||
return [...rows.values()]
|
||||
}
|
||||
|
||||
export function FieldTable({ data }: { data: ConfigCompare }) {
|
||||
const rows = fieldRows(data)
|
||||
if (rows === null) return null
|
||||
if (rows.length === 0) return <p className="m-0 text-sm text-muted-foreground">No differences between the servers.</p>
|
||||
const servers = data.servers.filter((s) => s.installed && s.ok)
|
||||
const withBaseline = data.baseline !== null
|
||||
const columns: Col<Row>[] = [
|
||||
{
|
||||
id: 'path',
|
||||
...head({ children: 'Field' }),
|
||||
cell: (r) => (
|
||||
<span className="flex flex-wrap items-center gap-2 font-mono text-xs">
|
||||
{r.path}
|
||||
{isLocal(r.path, data.overrides.paths) && <Tag tone="info">Local</Tag>}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
...servers.map(
|
||||
(s): Col<Row> => ({
|
||||
id: `s-${s.id}`,
|
||||
...head({ children: s.id }),
|
||||
cell: (r) => {
|
||||
const c = r.cells[s.id]
|
||||
if (c === undefined || !c.differs) return <span className="text-xs text-muted-foreground">{withBaseline ? 'Matches' : 'Same'}</span>
|
||||
return (
|
||||
<span className="flex flex-col gap-0.5 font-mono text-xs">
|
||||
<span>{show(c.now)}</span>
|
||||
{withBaseline && <span className="text-muted-foreground">expected {show(c.expected)}</span>}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
}),
|
||||
),
|
||||
]
|
||||
return <DataTable data={rows} columns={columns} getRowId={(r) => r.path} />
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { uploadHubApp } from '../../api/apps'
|
||||
import { Button } from '../../ui/Button'
|
||||
import { Dialog } from '../../ui/Dialog'
|
||||
import { LabeledInput } from '../../ui/Field'
|
||||
import { Notifier } from '../../ui/Notifier'
|
||||
import { pickerServers, type RunRequest } from './RunFlow'
|
||||
import type { AlertState } from './Apps'
|
||||
|
||||
/*
|
||||
"Install from file" in the All servers view: the same two fields as the single-server dialog
|
||||
(the validation order and wording are the same), then the server picker. The zip goes to the
|
||||
hub once and the hub sends it to every chosen server.
|
||||
*/
|
||||
export function HubInstallFile({
|
||||
open,
|
||||
onOpenChange,
|
||||
token,
|
||||
servers,
|
||||
onRequest,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (o: boolean) => void
|
||||
token: string | null
|
||||
servers: { id: string; writable: boolean }[]
|
||||
onRequest: (request: RunRequest) => void
|
||||
}) {
|
||||
const [name, setName] = useState('')
|
||||
const [alert, setAlert] = useState<AlertState | null>(null)
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName('')
|
||||
setAlert(null)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
function next() {
|
||||
if (name === '') {
|
||||
setAlert({ type: 'warning', title: 'Missing!', text: 'Please enter an application name.' })
|
||||
return
|
||||
}
|
||||
const file = fileRef.current?.files?.[0]
|
||||
if (!file) {
|
||||
setAlert({ type: 'warning', title: 'Missing!', text: 'Please select an application zip file to install.' })
|
||||
return
|
||||
}
|
||||
onRequest({
|
||||
title: `Install ${name} from file`,
|
||||
intro: 'The zip is sent to every server you choose.',
|
||||
confirmLabel: 'Install',
|
||||
servers: pickerServers(servers),
|
||||
preselected: servers.filter((s) => s.writable).map((s) => s.id),
|
||||
run: (targets) => uploadHubApp(token, 'install', name, file, targets),
|
||||
})
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Install App"
|
||||
actions={
|
||||
<Button variant="primary" onClick={next}>
|
||||
Choose servers
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Notifier notice={alert} onClose={() => setAlert(null)} />
|
||||
<LabeledInput label="App Name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<LabeledInput label="App Zip File" type="file" ref={fileRef} />
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { listStoreApps, runHubApps, type HubRunAction, type StoreApp } from '../../api/apps'
|
||||
import { Button } from '../../ui/Button'
|
||||
import { Empty, Failure, Loading } from '../../ui/Empty'
|
||||
import { Panel } from '../../ui/Panel'
|
||||
import { Tag } from '../../ui/Tag'
|
||||
import { RunFlow, pickerServers, type RunRequest } from './RunFlow'
|
||||
|
||||
/*
|
||||
The App Store behind a hub, in the All servers view. It lists what the first server's store
|
||||
offers and where each app is installed. Install and Update go to the servers the user picks;
|
||||
the hub takes each download URL from that server's own store, so none is sent from here.
|
||||
*/
|
||||
export function HubStore({
|
||||
token,
|
||||
servers,
|
||||
canWrite,
|
||||
onChanged,
|
||||
}: {
|
||||
token: string | null
|
||||
servers: { id: string; writable: boolean }[]
|
||||
canWrite: boolean
|
||||
onChanged: () => void
|
||||
}) {
|
||||
const [apps, setApps] = useState<StoreApp[] | null>(null)
|
||||
const [failure, setFailure] = useState<string | null>(null)
|
||||
const [request, setRequest] = useState<RunRequest | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const outcome = await listStoreApps(token)
|
||||
if (outcome.kind === 'ok') {
|
||||
setApps(outcome.data.response.storeApps)
|
||||
setFailure(null)
|
||||
} else setFailure(outcome.kind === 'error' ? outcome.message : 'The session ended.')
|
||||
}, [token])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
function open(app: StoreApp, action: HubRunAction, verb: string) {
|
||||
const on = app.installedOn ?? []
|
||||
const allowed = action === 'install-store' ? servers.map((s) => s.id).filter((id) => !on.includes(id)) : on
|
||||
setRequest({
|
||||
title: `${verb} ${app.name}`,
|
||||
confirmLabel: verb,
|
||||
servers: pickerServers(
|
||||
servers,
|
||||
Object.fromEntries(servers.filter((s) => !allowed.includes(s.id)).map((s) => [s.id, action === 'install-store' ? 'Already installed' : 'Not installed'])),
|
||||
),
|
||||
preselected: allowed,
|
||||
run: (targets) => runHubApps(token, action, app.name, targets),
|
||||
})
|
||||
}
|
||||
|
||||
if (failure !== null && apps === null) {
|
||||
return (
|
||||
<Failure>
|
||||
<span className="flex flex-col items-center gap-3">
|
||||
<span>Could not load the App Store. {failure}</span>
|
||||
<Button variant="primary" onClick={() => void load()}>
|
||||
Retry
|
||||
</Button>
|
||||
</span>
|
||||
</Failure>
|
||||
)
|
||||
}
|
||||
if (apps === null) return <Loading>Loading the App Store…</Loading>
|
||||
if (apps.length === 0) {
|
||||
return (
|
||||
<Empty
|
||||
title="No apps in the store"
|
||||
actions={
|
||||
<Button variant="primary" onClick={() => void load()}>
|
||||
Refresh
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
The store did not list any app.
|
||||
</Empty>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel title="DNS App Store" description="What the store offers and where it is installed" groups2>
|
||||
<ul className="m-0 flex list-none flex-col p-0" aria-label="Store apps">
|
||||
{apps.map((app) => {
|
||||
const on = app.installedOn ?? []
|
||||
return (
|
||||
<li key={app.name} aria-label={app.name} className="flex flex-wrap items-start justify-between gap-4 border-b border-border px-4 py-3 last:border-b-0">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
||||
<span className="flex flex-wrap items-center gap-2 text-sm font-medium">
|
||||
{app.name}
|
||||
<Tag>Version {app.version}</Tag>
|
||||
{on.map((id) => (
|
||||
<Tag key={id} tone="info">
|
||||
{id}
|
||||
</Tag>
|
||||
))}
|
||||
</span>
|
||||
<span className="line-clamp-2 text-xs text-muted-foreground" title={app.description}>{app.description}</span>
|
||||
</div>
|
||||
{canWrite && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="primary" onClick={() => open(app, 'install-store', 'Install')}>
|
||||
Install…
|
||||
</Button>
|
||||
{on.length > 0 && (
|
||||
<Button size="sm" onClick={() => open(app, 'update-store', 'Update')}>
|
||||
Update…
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
{request !== null && (
|
||||
<RunFlow
|
||||
request={request}
|
||||
onClose={(changed) => {
|
||||
setRequest(null)
|
||||
if (changed) {
|
||||
void load()
|
||||
onChanged()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { importBaselines, type ImportPlan } from '../../api/apps'
|
||||
import { Alert } from '../../ui/Alert'
|
||||
import { Button } from '../../ui/Button'
|
||||
import { Dialog } from '../../ui/Dialog'
|
||||
import { LabeledInput } from '../../ui/Field'
|
||||
import { Loading } from '../../ui/Empty'
|
||||
import { Tag } from '../../ui/Tag'
|
||||
|
||||
/*
|
||||
Loads a file of baselines (made by "Export baselines") into this hub. The file is checked first and
|
||||
the summary says, app by app, what is new, what changes and what is the same; nothing is written
|
||||
until Import is pressed. It only touches the hub's own baselines: no server gets a config from it.
|
||||
*/
|
||||
const STATUS = { new: 'New', changed: 'Changed', same: 'Same' } as const
|
||||
const TONE = { new: 'info', changed: 'warn', same: 'ok' } as const
|
||||
|
||||
export function ImportBaselines({
|
||||
open,
|
||||
onOpenChange,
|
||||
token,
|
||||
onImported,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (o: boolean) => void
|
||||
token: string | null
|
||||
onImported: () => void
|
||||
}) {
|
||||
const [text, setText] = useState<string | null>(null)
|
||||
const [plan, setPlan] = useState<ImportPlan | null>(null)
|
||||
const [problem, setProblem] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [done, setDone] = useState(false)
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setText(null)
|
||||
setPlan(null)
|
||||
setProblem(null)
|
||||
setBusy(false)
|
||||
setDone(false)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
async function check() {
|
||||
const file = fileRef.current?.files?.[0]
|
||||
if (!file) {
|
||||
setProblem('Please select a baselines file.')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setProblem(null)
|
||||
const content = await file.text()
|
||||
const outcome = await importBaselines(token, content, false)
|
||||
setBusy(false)
|
||||
if (outcome.kind !== 'ok') {
|
||||
setProblem(outcome.kind === 'error' ? outcome.message : 'The session ended.')
|
||||
return
|
||||
}
|
||||
setText(content)
|
||||
setPlan(outcome.data.response)
|
||||
}
|
||||
|
||||
async function commit() {
|
||||
if (text === null) return
|
||||
setBusy(true)
|
||||
const outcome = await importBaselines(token, text, true)
|
||||
setBusy(false)
|
||||
if (outcome.kind !== 'ok') {
|
||||
setProblem(outcome.kind === 'error' ? outcome.message : 'The session ended.')
|
||||
return
|
||||
}
|
||||
setPlan(outcome.data.response)
|
||||
setDone(true)
|
||||
onImported()
|
||||
}
|
||||
|
||||
const changes = plan?.apps.filter((a) => a.status !== 'same').length ?? 0
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Import baselines"
|
||||
actions={
|
||||
plan === null ? (
|
||||
<Button variant="primary" disabled={busy} onClick={() => void check()}>
|
||||
Check file
|
||||
</Button>
|
||||
) : !done ? (
|
||||
<Button variant="primary" disabled={busy || changes === 0} onClick={() => void commit()}>
|
||||
Import
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{problem !== null && <Alert type="danger" title="Error">{problem}</Alert>}
|
||||
{busy && <Loading>Working…</Loading>}
|
||||
{plan === null ? (
|
||||
<>
|
||||
<p className="m-0 text-sm text-muted-foreground">
|
||||
Choose a file made by Export baselines. It is checked first; nothing is changed until you press Import, and no server is touched.
|
||||
</p>
|
||||
<LabeledInput label="Baselines file" type="file" accept="application/json,.json" ref={fileRef} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{done && <Alert type="success" title="Imported.">The baselines were saved. The replaced baselines are in each app's history.</Alert>}
|
||||
<ul className="m-0 flex list-none flex-col gap-2 p-0" aria-label="Apps in the file">
|
||||
{plan.apps.map((a) => (
|
||||
<li key={a.app} className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="font-medium">{a.app}</span>
|
||||
<Tag tone={TONE[a.status]}>{STATUS[a.status]}</Tag>
|
||||
{a.ignoredServers.length > 0 && (
|
||||
<span className="text-xs text-muted-foreground">Local values of {a.ignoredServers.join(', ')} are ignored: this hub has no such server.</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{!done && changes === 0 && <p className="m-0 text-sm text-muted-foreground">Everything in the file is already here.</p>}
|
||||
</>
|
||||
)}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -17,11 +17,14 @@ export function InstallApp({
|
||||
onOpenChange,
|
||||
token,
|
||||
onInstalled,
|
||||
node = '',
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (o: boolean) => void
|
||||
token: string | null
|
||||
onInstalled: (name: string) => void
|
||||
/** Behind a hub: the one server it goes to. */
|
||||
node?: string
|
||||
}) {
|
||||
const [name, setName] = useState('')
|
||||
const [alert, setAlert] = useState<AlertState | null>(null)
|
||||
@@ -53,7 +56,7 @@ export function InstallApp({
|
||||
}
|
||||
|
||||
setBusy(true)
|
||||
const outcome = await installApp(token, name, file)
|
||||
const outcome = await installApp(token, name, file, node)
|
||||
setBusy(false)
|
||||
|
||||
if (outcome.kind !== 'ok') {
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { Profiles } from './Profiles'
|
||||
import * as api from '../../api/hub-profiles'
|
||||
import type { ProfileSummary, ProfileTargetPreview } from '../../api/hub-profiles'
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
const SERVERS = [
|
||||
{ id: 'mdns', writable: true },
|
||||
{ id: 'ndns', writable: true },
|
||||
]
|
||||
|
||||
const FRESH: ProfileSummary = { name: 'Fresh', apps: 2, configs: 1, settings: 12, lists: { blocked: 3, allowed: 1 }, updatedAt: '2026-11-01T00:00:00Z', updatedBy: 'admin' }
|
||||
|
||||
const listOf = (profiles: ProfileSummary[]) => vi.spyOn(api, 'listProfiles').mockResolvedValue({ kind: 'ok', data: { response: { profiles } } } as never)
|
||||
|
||||
const PREVIEW: ProfileTargetPreview = {
|
||||
server: 'ndns',
|
||||
writable: true,
|
||||
ok: true,
|
||||
apps: [
|
||||
{ name: 'Blocker', state: 'install', profileVersion: '2.0', storeVersion: '2.0' },
|
||||
{ name: 'Logs', state: 'version-differs', profileVersion: '2.0', installedVersion: '1.0' },
|
||||
],
|
||||
configs: [{ name: 'Blocker', state: 'after-install' }],
|
||||
settings: { fields: ['blockingType'], secretFields: ['tsigKeys'], unchanged: false, warnings: [], changes: { kind: 'json', changes: [{ path: 'blockingType', type: 'changed', a: 'AnyAddress', b: 'NxDomain' }] } },
|
||||
lists: { blocked: { missing: 2, extra: 1 }, allowed: { missing: 0, extra: 0 }, status: 'differs' },
|
||||
warnings: [],
|
||||
}
|
||||
|
||||
const result = (steps: { step: 'apps' | 'configs' | 'settings' | 'lists'; status: 'ok' | 'failed' | 'skipped' | 'not-run'; message: string }[], ok: boolean) =>
|
||||
({ kind: 'ok', data: { response: { results: [{ server: 'ndns', ok, message: ok ? 'Profile applied.' : 'Stopped at settings: refused', steps }] } } }) as never
|
||||
|
||||
describe('Profiles list', () => {
|
||||
it('shows a skeleton while it loads, then each profile with its parts', async () => {
|
||||
listOf([FRESH])
|
||||
render(<Profiles open onOpenChange={() => {}} token="t" servers={SERVERS} />)
|
||||
expect(screen.getByRole('status', { name: 'Loading the profiles' })).toBeInTheDocument()
|
||||
const row = await screen.findByRole('listitem', { name: 'Fresh' })
|
||||
expect(within(row).getByText('2 apps')).toBeInTheDocument()
|
||||
expect(within(row).getByText('1 config')).toBeInTheDocument()
|
||||
expect(within(row).getByText('12 settings')).toBeInTheDocument()
|
||||
expect(within(row).getByText('4 list domains')).toBeInTheDocument()
|
||||
for (const name of ['View', 'Apply to servers…', 'Export', 'Delete']) expect(within(row).getByRole('button', { name })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('has an empty state whose action creates a profile from the current baselines', async () => {
|
||||
listOf([])
|
||||
const snapshot = vi.spyOn(api, 'snapshotProfile').mockResolvedValue({ kind: 'ok', data: { response: { profile: {} as never, replaced: false } } } as never)
|
||||
render(<Profiles open onOpenChange={() => {}} token="t" servers={SERVERS} />)
|
||||
expect(await screen.findByText('No profiles yet')).toBeInTheDocument()
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Create from current baselines' }))
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Create a profile' })
|
||||
await userEvent.type(within(dialog).getByLabelText('Profile name'), 'Fresh')
|
||||
await userEvent.click(within(dialog).getByRole('button', { name: 'Create' }))
|
||||
expect(snapshot).toHaveBeenCalledWith('t', 'Fresh', { apps: undefined, settings: true, lists: true })
|
||||
})
|
||||
|
||||
it('lets the admin leave a part out', async () => {
|
||||
listOf([])
|
||||
const snapshot = vi.spyOn(api, 'snapshotProfile').mockResolvedValue({ kind: 'ok', data: { response: { profile: {} as never, replaced: false } } } as never)
|
||||
render(<Profiles open onOpenChange={() => {}} token="t" servers={SERVERS} />)
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Create from current baselines' }))
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Create a profile' })
|
||||
await userEvent.type(within(dialog).getByLabelText('Profile name'), 'Light')
|
||||
await userEvent.click(within(dialog).getByRole('checkbox', { name: 'Apps' }))
|
||||
await userEvent.click(within(dialog).getByRole('checkbox', { name: 'Blocked and Allowed lists' }))
|
||||
await userEvent.click(within(dialog).getByRole('button', { name: 'Create' }))
|
||||
expect(snapshot).toHaveBeenCalledWith('t', 'Light', { apps: [], settings: true, lists: false })
|
||||
})
|
||||
|
||||
it('shows an error with Retry', async () => {
|
||||
const spy = vi.spyOn(api, 'listProfiles').mockResolvedValueOnce({ kind: 'error', message: 'boom' } as never).mockResolvedValue({ kind: 'ok', data: { response: { profiles: [FRESH] } } } as never)
|
||||
render(<Profiles open onOpenChange={() => {}} token="t" servers={SERVERS} />)
|
||||
expect(await screen.findByText(/boom/)).toBeInTheDocument()
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
||||
expect(await screen.findByRole('listitem', { name: 'Fresh' })).toBeInTheDocument()
|
||||
expect(spy).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('views a profile with the secrets masked and no config text', async () => {
|
||||
listOf([FRESH])
|
||||
vi.spyOn(api, 'getProfile').mockResolvedValue({
|
||||
kind: 'ok',
|
||||
data: {
|
||||
response: {
|
||||
name: 'Fresh', createdAt: '', updatedAt: '2026-11-01T00:00:00Z', updatedBy: 'admin',
|
||||
apps: [{ name: 'Blocker', version: '2.0', hasConfig: true, bytes: 12 }],
|
||||
settings: { tsigKeys: [{ keyName: 'k', sharedSecret: '<secret>' }] },
|
||||
lists: { blocked: 3, allowed: 1 },
|
||||
},
|
||||
},
|
||||
} as never)
|
||||
render(<Profiles open onOpenChange={() => {}} token="t" servers={SERVERS} />)
|
||||
await userEvent.click(within(await screen.findByRole('listitem', { name: 'Fresh' })).getByRole('button', { name: 'View' }))
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Profile: Fresh' })
|
||||
expect(await within(dialog).findByText('With config')).toBeInTheDocument()
|
||||
expect((within(dialog).getByLabelText('Shared settings (JSON)') as HTMLTextAreaElement).value).toContain('<secret>')
|
||||
expect(within(dialog).getByText('3 blocked and 1 allowed domains.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('deletes only after a confirmation', async () => {
|
||||
const list = listOf([FRESH])
|
||||
const removed = vi.spyOn(api, 'deleteProfile').mockResolvedValue({ kind: 'ok', data: { response: { deleted: true } } } as never)
|
||||
render(<Profiles open onOpenChange={() => {}} token="t" servers={SERVERS} />)
|
||||
await userEvent.click(within(await screen.findByRole('listitem', { name: 'Fresh' })).getByRole('button', { name: 'Delete' }))
|
||||
expect(removed).not.toHaveBeenCalled()
|
||||
list.mockResolvedValue({ kind: 'ok', data: { response: { profiles: [] } } } as never)
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Delete' }))
|
||||
expect(removed).toHaveBeenCalledWith('t', 'Fresh')
|
||||
expect(await screen.findByText('No profiles yet')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('applying a profile', () => {
|
||||
it('previews the steps per server, applies, and shows the steps of each result', async () => {
|
||||
listOf([FRESH])
|
||||
const preview = vi.spyOn(api, 'previewProfile').mockResolvedValue({ kind: 'ok', data: { response: { profile: 'Fresh', targets: [PREVIEW] } } } as never)
|
||||
const apply = vi.spyOn(api, 'applyProfile').mockResolvedValue(
|
||||
result(
|
||||
[
|
||||
{ step: 'apps', status: 'ok', message: 'Installed Blocker.' },
|
||||
{ step: 'configs', status: 'ok', message: '1 config(s) applied, 0 already in sync.' },
|
||||
{ step: 'settings', status: 'ok', message: 'Applied and verified (1 field).' },
|
||||
{ step: 'lists', status: 'ok', message: 'Added 2, removed 0.' },
|
||||
],
|
||||
true,
|
||||
),
|
||||
)
|
||||
render(<Profiles open onOpenChange={() => {}} token="t" servers={SERVERS} />)
|
||||
await userEvent.click(within(await screen.findByRole('listitem', { name: 'Fresh' })).getByRole('button', { name: 'Apply to servers…' }))
|
||||
const picker = await screen.findByRole('dialog', { name: 'Apply the profile Fresh' })
|
||||
await userEvent.click(within(picker).getByRole('checkbox', { name: 'ndns' }))
|
||||
await userEvent.click(within(picker).getByRole('button', { name: 'Preview' }))
|
||||
expect(preview).toHaveBeenCalledWith('t', 'Fresh', ['ndns'], false)
|
||||
|
||||
const review = await screen.findByRole('dialog', { name: 'Apply the profile Fresh: review' })
|
||||
const steps = within(review).getByRole('list', { name: 'Steps on ndns' })
|
||||
expect(within(steps).getByText('1. Apps')).toBeInTheDocument()
|
||||
expect(within(steps).getByText('Will be installed')).toBeInTheDocument()
|
||||
expect(within(steps).getByText('Other version installed')).toBeInTheDocument()
|
||||
expect(within(steps).getByText('2. App configs')).toBeInTheDocument()
|
||||
expect(within(steps).getByText(/tsigKeys: the secret values are sent but never shown/)).toBeInTheDocument()
|
||||
expect(within(steps).getByText(/Blocked: 2 to add, 1 extra/)).toBeInTheDocument()
|
||||
await userEvent.click(within(review).getByRole('button', { name: 'Apply' }))
|
||||
|
||||
expect(apply).toHaveBeenCalledWith('t', 'Fresh', ['ndns'], false, {})
|
||||
const list = await screen.findByRole('list', { name: 'Steps on ndns' })
|
||||
expect(within(list).getByText('Installed Blocker.')).toBeInTheDocument()
|
||||
expect(within(list).getAllByText('Done')).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('removes list extras only when ticked in the review', async () => {
|
||||
listOf([FRESH])
|
||||
vi.spyOn(api, 'previewProfile').mockResolvedValue({ kind: 'ok', data: { response: { profile: 'Fresh', targets: [PREVIEW] } } } as never)
|
||||
const apply = vi.spyOn(api, 'applyProfile').mockResolvedValue(result([{ step: 'lists', status: 'ok', message: 'done' }], true))
|
||||
render(<Profiles open onOpenChange={() => {}} token="t" servers={SERVERS} />)
|
||||
await userEvent.click(within(await screen.findByRole('listitem', { name: 'Fresh' })).getByRole('button', { name: 'Apply to servers…' }))
|
||||
await userEvent.click(within(await screen.findByRole('dialog', { name: 'Apply the profile Fresh' })).getByRole('checkbox', { name: 'ndns' }))
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Preview' }))
|
||||
await userEvent.click(await screen.findByRole('checkbox', { name: 'Also remove extras' }))
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Apply' }))
|
||||
await waitFor(() => expect(apply).toHaveBeenCalledWith('t', 'Fresh', ['ndns'], true, {}))
|
||||
})
|
||||
|
||||
it('says which step failed and retries from that step', async () => {
|
||||
listOf([FRESH])
|
||||
vi.spyOn(api, 'previewProfile').mockResolvedValue({ kind: 'ok', data: { response: { profile: 'Fresh', targets: [PREVIEW] } } } as never)
|
||||
const apply = vi
|
||||
.spyOn(api, 'applyProfile')
|
||||
.mockResolvedValueOnce(
|
||||
result(
|
||||
[
|
||||
{ step: 'apps', status: 'ok', message: 'Installed Blocker.' },
|
||||
{ step: 'configs', status: 'ok', message: '1 config(s) applied, 0 already in sync.' },
|
||||
{ step: 'settings', status: 'failed', message: 'refused' },
|
||||
{ step: 'lists', status: 'not-run', message: 'An earlier step failed.' },
|
||||
],
|
||||
false,
|
||||
),
|
||||
)
|
||||
.mockResolvedValue(
|
||||
result(
|
||||
[
|
||||
{ step: 'apps', status: 'skipped', message: 'Done by an earlier run.' },
|
||||
{ step: 'configs', status: 'skipped', message: 'Done by an earlier run.' },
|
||||
{ step: 'settings', status: 'ok', message: 'Applied and verified (1 field).' },
|
||||
{ step: 'lists', status: 'ok', message: 'Added 2, removed 0.' },
|
||||
],
|
||||
true,
|
||||
),
|
||||
)
|
||||
render(<Profiles open onOpenChange={() => {}} token="t" servers={SERVERS} />)
|
||||
await userEvent.click(within(await screen.findByRole('listitem', { name: 'Fresh' })).getByRole('button', { name: 'Apply to servers…' }))
|
||||
await userEvent.click(within(await screen.findByRole('dialog', { name: 'Apply the profile Fresh' })).getByRole('checkbox', { name: 'ndns' }))
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Preview' }))
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Apply' }))
|
||||
|
||||
const list = await screen.findByRole('list', { name: 'Steps on ndns' })
|
||||
expect(within(list).getByText('Failed')).toBeInTheDocument()
|
||||
expect(within(list).getByText('Not run')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Stopped at settings/)).toBeInTheDocument()
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Retry failed' }))
|
||||
await waitFor(() => expect(apply).toHaveBeenCalledTimes(2))
|
||||
expect(apply).toHaveBeenLastCalledWith('t', 'Fresh', ['ndns'], false, { ndns: 'settings' })
|
||||
await waitFor(() => expect(within(screen.getByRole('list', { name: 'Steps on ndns' })).getAllByText('Done earlier')).toHaveLength(2))
|
||||
})
|
||||
|
||||
it('lists a server with no admin token disabled, and shows a preview error', async () => {
|
||||
listOf([FRESH])
|
||||
vi.spyOn(api, 'previewProfile').mockResolvedValue({ kind: 'error', message: 'unreachable' } as never)
|
||||
render(<Profiles open onOpenChange={() => {}} token="t" servers={[{ id: 'mdns', writable: false }, { id: 'ndns', writable: true }]} />)
|
||||
await userEvent.click(within(await screen.findByRole('listitem', { name: 'Fresh' })).getByRole('button', { name: 'Apply to servers…' }))
|
||||
const picker = await screen.findByRole('dialog', { name: 'Apply the profile Fresh' })
|
||||
expect(within(picker).getByRole('checkbox', { name: 'mdns' })).toBeDisabled()
|
||||
await userEvent.click(within(picker).getByRole('checkbox', { name: 'ndns' }))
|
||||
await userEvent.click(within(picker).getByRole('button', { name: 'Preview' }))
|
||||
expect(await screen.findByText('unreachable')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,546 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
applyProfile,
|
||||
deleteProfile,
|
||||
exportProfiles,
|
||||
getProfile,
|
||||
importProfiles,
|
||||
listProfiles,
|
||||
previewProfile,
|
||||
snapshotProfile,
|
||||
type ProfileImportPlan,
|
||||
type ProfileSummary,
|
||||
type ProfileTargetPreview,
|
||||
type ProfileView,
|
||||
} from '../../api/hub-profiles'
|
||||
import type { RunOutcome } from '../../api/apps'
|
||||
import { Alert } from '../../ui/Alert'
|
||||
import { Button } from '../../ui/Button'
|
||||
import { Check } from '../../ui/Check'
|
||||
import { Confirm } from '../../ui/Confirm'
|
||||
import { Dialog } from '../../ui/Dialog'
|
||||
import { Empty, Failure } from '../../ui/Empty'
|
||||
import { LabeledInput, LabeledTextarea } from '../../ui/Field'
|
||||
import { Tag } from '../../ui/Tag'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { DiffView } from './ConfigDiffView'
|
||||
import { RunFlow, pickerServers, type RunRequest } from './RunFlow'
|
||||
|
||||
/*
|
||||
Behind a hub, for an admin: profiles, named bundles of store apps (with the hub's baseline config of
|
||||
each), the shared settings and the Blocked and Allowed lists. One is made from the hub's current
|
||||
baselines, can be exported to another hub, and is applied to one or several servers through the
|
||||
same picker, review and results as every other change here. A server's steps run in order (apps,
|
||||
app configs, settings, lists) and a failed one stops the rest for that server; "Retry failed" starts
|
||||
again at the failed step. Nothing here shows a secret or an app's config text.
|
||||
*/
|
||||
|
||||
const sessionEnded = (kind: string, message?: string): string => (kind === 'error' ? (message ?? 'Failed.') : 'The session ended.')
|
||||
const when = (iso: string): string => (iso === '' ? '' : new Date(iso).toLocaleString())
|
||||
|
||||
const APP_STATE = {
|
||||
install: ['warn', 'Will be installed'],
|
||||
present: ['ok', 'Already installed'],
|
||||
'version-differs': ['info', 'Other version installed'],
|
||||
'not-in-store': ['dan', 'Not in this store'],
|
||||
} as const
|
||||
|
||||
const CONFIG_STATE = {
|
||||
same: ['ok', 'Same'],
|
||||
differs: ['warn', 'Will change'],
|
||||
'after-install': ['info', 'Set after install'],
|
||||
unreadable: ['dan', 'Cannot read'],
|
||||
} as const
|
||||
|
||||
/* ---- Apply: the review of what each server would get ---- */
|
||||
|
||||
function PreviewBody({ targets, opts }: { targets: ProfileTargetPreview[]; opts: { remove: boolean } }) {
|
||||
const [remove, setRemove] = useState(opts.remove)
|
||||
const extras = targets.reduce((n, t) => n + (t.lists?.blocked.extra ?? 0) + (t.lists?.allowed.extra ?? 0), 0)
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{targets.map((t) => (
|
||||
<section key={t.server} aria-label={`Preview ${t.server}`} className="flex flex-col gap-3">
|
||||
<h3 className="m-0 flex items-center gap-2 text-sm font-semibold">
|
||||
{t.server}
|
||||
{!t.ok && <Tag tone="dan">Cannot apply</Tag>}
|
||||
</h3>
|
||||
{t.error !== undefined && <Alert type="danger" title="Error">{t.error}</Alert>}
|
||||
{t.warnings.map((w) => (
|
||||
<Alert key={w} type="warning" title="Warning">
|
||||
{w}
|
||||
</Alert>
|
||||
))}
|
||||
{t.ok && (
|
||||
<ol className="m-0 flex list-none flex-col gap-3 p-0" aria-label={`Steps on ${t.server}`}>
|
||||
{t.apps.length > 0 && (
|
||||
<li className="flex flex-col gap-1 text-sm">
|
||||
<span className="font-medium">1. Apps</span>
|
||||
<ul className="m-0 flex list-none flex-col gap-1 p-0 pl-4">
|
||||
{t.apps.map((a) => (
|
||||
<li key={a.name} className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<span className="font-medium">{a.name}</span>
|
||||
<Tag tone={APP_STATE[a.state][0]}>{APP_STATE[a.state][1]}</Tag>
|
||||
<span className="text-muted-foreground">
|
||||
{a.state === 'version-differs' ? `${a.installedVersion} here, ${a.profileVersion} in the profile; it is not updated` : a.state === 'install' ? `Store offers ${a.storeVersion}` : `Version ${a.profileVersion}`}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
)}
|
||||
{t.configs.length > 0 && (
|
||||
<li className="flex flex-col gap-1 text-sm">
|
||||
<span className="font-medium">2. App configs</span>
|
||||
<ul className="m-0 flex list-none flex-col gap-2 p-0 pl-4">
|
||||
{t.configs.map((c) => (
|
||||
<li key={c.name} className="flex flex-col gap-1 text-xs">
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{c.name}</span>
|
||||
<Tag tone={CONFIG_STATE[c.state][0]}>{CONFIG_STATE[c.state][1]}</Tag>
|
||||
{c.error !== undefined && <span className="text-muted-foreground">{c.error}</span>}
|
||||
</span>
|
||||
{c.changes !== undefined && <DiffView diff={c.changes} from="Now" to="After apply" />}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
)}
|
||||
{t.settings !== null && (
|
||||
<li className="flex flex-col gap-1 text-sm">
|
||||
<span className="flex items-center gap-2 font-medium">
|
||||
3. Settings
|
||||
{t.settings.error !== undefined ? <Tag tone="dan">Cannot read</Tag> : t.settings.unchanged ? <Tag tone="ok">Already in sync</Tag> : <Tag tone="warn">{`${t.settings.fields.length} field(s) will change`}</Tag>}
|
||||
</span>
|
||||
{t.settings.error !== undefined && <span className="pl-4 text-xs text-muted-foreground">{t.settings.error}</span>}
|
||||
{t.settings.secretFields.length > 0 && <span className="pl-4 text-xs text-muted-foreground">{t.settings.secretFields.join(', ')}: the secret values are sent but never shown.</span>}
|
||||
{t.settings.error === undefined && !t.settings.unchanged && (
|
||||
<div className="pl-4">
|
||||
<DiffView diff={t.settings.changes} from="Now" to="After apply" />
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
)}
|
||||
{t.lists !== null && (
|
||||
<li className="flex flex-col gap-1 text-sm">
|
||||
<span className="flex items-center gap-2 font-medium">
|
||||
4. Blocked and Allowed lists
|
||||
{t.lists.error !== undefined ? <Tag tone="dan">Cannot read</Tag> : t.lists.status === 'same' ? <Tag tone="ok">Already in sync</Tag> : <Tag tone="warn">Will change</Tag>}
|
||||
</span>
|
||||
{t.lists.error !== undefined ? (
|
||||
<span className="pl-4 text-xs text-muted-foreground">{t.lists.error}</span>
|
||||
) : (
|
||||
<span className="pl-4 text-xs text-muted-foreground">
|
||||
Blocked: {t.lists.blocked.missing} to add, {t.lists.blocked.extra} extra. Allowed: {t.lists.allowed.missing} to add, {t.lists.allowed.extra} extra.
|
||||
</span>
|
||||
)}
|
||||
{t.lists.tooMany !== undefined && <span className="pl-4 text-xs text-muted-foreground">{t.lists.tooMany}</span>}
|
||||
</li>
|
||||
)}
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
{/* Only when a server has list domains the profile does not: otherwise there is nothing to remove. */}
|
||||
{extras > 0 && (
|
||||
<Check
|
||||
label="Also remove extras"
|
||||
help={`Deletes the ${extras} list domain(s) a server has that the profile does not. The server's lists are saved to the hub's history first.`}
|
||||
checked={remove}
|
||||
onChange={(on) => {
|
||||
opts.remove = on
|
||||
setRemove(on)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* The picker, the review and the results of applying one profile. */
|
||||
function applyRequestFor(token: string | null, name: string, servers: { id: string; writable: boolean }[]): RunRequest {
|
||||
const opts = { remove: false }
|
||||
// Where each server should start next time: the step that failed on it.
|
||||
const from: Record<string, string> = {}
|
||||
return {
|
||||
title: `Apply the profile ${name}`,
|
||||
intro: 'Each server goes through the steps in order: install missing apps from its own store, apply the app configs, the settings, then the lists. A step that fails stops the later ones for that server.',
|
||||
confirmLabel: 'Preview',
|
||||
servers: pickerServers(servers),
|
||||
preselected: [],
|
||||
review: async (targets) => {
|
||||
opts.remove = false
|
||||
const outcome = await previewProfile(token, name, targets, false)
|
||||
if (outcome.kind !== 'ok') return { kind: 'error', message: sessionEnded(outcome.kind, outcome.kind === 'error' ? outcome.message : undefined) }
|
||||
return { kind: 'ok', confirmLabel: 'Apply', context: undefined, body: <PreviewBody targets={outcome.data.response.targets} opts={opts} /> }
|
||||
},
|
||||
run: async (targets): Promise<RunOutcome> => {
|
||||
const outcome = await applyProfile(token, name, targets, opts.remove, { ...from })
|
||||
if (outcome.kind === 'ok') {
|
||||
for (const r of outcome.data.response.results) {
|
||||
const failed = r.steps?.find((s) => s.status === 'failed')
|
||||
if (r.ok || failed === undefined) delete from[r.server]
|
||||
else from[r.server] = failed.step
|
||||
}
|
||||
}
|
||||
return outcome
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Make one ---- */
|
||||
|
||||
function CreateProfile({ token, onClose, onDone }: { token: string | null; onClose: () => void; onDone: () => void }) {
|
||||
const [name, setName] = useState('')
|
||||
const [apps, setApps] = useState(true)
|
||||
const [settings, setSettings] = useState(true)
|
||||
const [lists, setLists] = useState(true)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [problem, setProblem] = useState<string | null>(null)
|
||||
|
||||
async function create() {
|
||||
setBusy(true)
|
||||
setProblem(null)
|
||||
const outcome = await snapshotProfile(token, name.trim(), { apps: apps ? undefined : [], settings, lists })
|
||||
setBusy(false)
|
||||
if (outcome.kind !== 'ok') return setProblem(sessionEnded(outcome.kind, outcome.kind === 'error' ? outcome.message : undefined))
|
||||
onDone()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={(o) => {
|
||||
if (!o) onClose()
|
||||
}}
|
||||
title="Create a profile"
|
||||
close="Cancel"
|
||||
actions={
|
||||
<Button variant="primary" disabled={busy || name.trim() === '' || (!apps && !settings && !lists)} onClick={() => void create()}>
|
||||
Create
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<p className="m-0 text-sm text-muted-foreground">
|
||||
A profile is a copy of the hub's baselines as they are now. An existing profile with the same name is replaced.
|
||||
</p>
|
||||
{problem !== null && <Alert type="danger" title="Error">{problem}</Alert>}
|
||||
<LabeledInput label="Profile name" value={name} maxLength={60} onChange={(e) => setName(e.target.value)} />
|
||||
<div className="flex flex-col gap-3" role="group" aria-label="Parts">
|
||||
<Check label="Apps" help="Every app that has a baseline, with its store version and its baseline config. Store apps only." checked={apps} onChange={setApps} />
|
||||
<Check label="Settings" help="The shared fields of the settings baseline. Host fields stay out." checked={settings} onChange={setSettings} />
|
||||
<Check label="Blocked and Allowed lists" help="The lists baseline." checked={lists} onChange={setLists} />
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
/* ---- View one ---- */
|
||||
|
||||
function ViewProfile({ token, name, onClose }: { token: string | null; name: string; onClose: () => void }) {
|
||||
const [view, setView] = useState<ProfileView | null>(null)
|
||||
const [failure, setFailure] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const outcome = await getProfile(token, name)
|
||||
if (outcome.kind === 'ok') {
|
||||
setView(outcome.data.response)
|
||||
setFailure(null)
|
||||
} else setFailure(sessionEnded(outcome.kind, outcome.kind === 'error' ? outcome.message : undefined))
|
||||
}, [token, name])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={(o) => {
|
||||
if (!o) onClose()
|
||||
}}
|
||||
size="wide"
|
||||
title={`Profile: ${name}`}
|
||||
>
|
||||
{failure !== null ? (
|
||||
<Failure>
|
||||
<span className="flex flex-col items-center gap-3">
|
||||
<span>Could not read the profile. {failure}</span>
|
||||
<Button variant="primary" onClick={() => void load()}>
|
||||
Retry
|
||||
</Button>
|
||||
</span>
|
||||
</Failure>
|
||||
) : view === null ? (
|
||||
<Skeleton className="h-24 w-full" role="status" aria-label="Loading the profile" />
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="m-0 text-xs text-muted-foreground">
|
||||
Made by {view.updatedBy} at {when(view.updatedAt)}. Secrets show as {'<secret>'}; an app's config text is not shown.
|
||||
</p>
|
||||
<section aria-label="Apps" className="flex flex-col gap-2">
|
||||
<h3 className="m-0 text-sm font-semibold">Apps</h3>
|
||||
{view.apps.length === 0 ? (
|
||||
<Empty compact>No apps.</Empty>
|
||||
) : (
|
||||
<ul className="m-0 flex list-none flex-col gap-1 p-0">
|
||||
{view.apps.map((a) => (
|
||||
<li key={a.name} className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="font-medium">{a.name}</span>
|
||||
<span className="text-muted-foreground">{a.version}</span>
|
||||
<Tag tone={a.hasConfig ? 'info' : 'neutral'}>{a.hasConfig ? 'With config' : 'No config'}</Tag>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
<section aria-label="Settings" className="flex flex-col gap-2">
|
||||
<h3 className="m-0 text-sm font-semibold">Settings</h3>
|
||||
{view.settings === null ? <Empty compact>Not part of this profile.</Empty> : <LabeledTextarea label="Shared settings (JSON)" mono rows={10} readOnly spellCheck={false} value={JSON.stringify(view.settings, null, 2)} onChange={() => {}} />}
|
||||
</section>
|
||||
<section aria-label="Lists" className="flex flex-col gap-2">
|
||||
<h3 className="m-0 text-sm font-semibold">Blocked and Allowed lists</h3>
|
||||
{view.lists === null ? <Empty compact>Not part of this profile.</Empty> : <p className="m-0 text-sm">{view.lists.blocked} blocked and {view.lists.allowed} allowed domains.</p>}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
/* ---- Import ---- */
|
||||
|
||||
function ImportProfiles({ token, onClose, onDone }: { token: string | null; onClose: () => void; onDone: () => void }) {
|
||||
const [text, setText] = useState<string | null>(null)
|
||||
const [plan, setPlan] = useState<ProfileImportPlan | null>(null)
|
||||
const [problem, setProblem] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [done, setDone] = useState(false)
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
async function check() {
|
||||
const file = fileRef.current?.files?.[0]
|
||||
if (!file) return setProblem('Please select a profiles file.')
|
||||
setBusy(true)
|
||||
setProblem(null)
|
||||
const content = await file.text()
|
||||
const outcome = await importProfiles(token, content, false)
|
||||
setBusy(false)
|
||||
if (outcome.kind !== 'ok') return setProblem(sessionEnded(outcome.kind, outcome.kind === 'error' ? outcome.message : undefined))
|
||||
setText(content)
|
||||
setPlan(outcome.data.response)
|
||||
}
|
||||
|
||||
async function commit() {
|
||||
if (text === null) return
|
||||
setBusy(true)
|
||||
const outcome = await importProfiles(token, text, true)
|
||||
setBusy(false)
|
||||
if (outcome.kind !== 'ok') return setProblem(sessionEnded(outcome.kind, outcome.kind === 'error' ? outcome.message : undefined))
|
||||
setPlan(outcome.data.response)
|
||||
setDone(true)
|
||||
onDone()
|
||||
}
|
||||
|
||||
const changes = plan?.profiles.filter((p) => p.status !== 'same').length ?? 0
|
||||
const TONE = { new: 'info', changed: 'warn', same: 'ok' } as const
|
||||
const LABEL = { new: 'New', changed: 'Changed', same: 'Same' } as const
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={(o) => {
|
||||
if (!o) onClose()
|
||||
}}
|
||||
title="Import profiles"
|
||||
actions={
|
||||
plan === null ? (
|
||||
<Button variant="primary" disabled={busy} onClick={() => void check()}>
|
||||
Check file
|
||||
</Button>
|
||||
) : !done ? (
|
||||
<Button variant="primary" disabled={busy || changes === 0} onClick={() => void commit()}>
|
||||
Import
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{problem !== null && <Alert type="danger" title="Error">{problem}</Alert>}
|
||||
{plan === null ? (
|
||||
<>
|
||||
<p className="m-0 text-sm text-muted-foreground">Choose a file made by Export. It is checked first; nothing is changed until you press Import, and no server is touched.</p>
|
||||
<LabeledInput label="Profiles file" type="file" accept="application/json,.json" ref={fileRef} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{done && <Alert type="success" title="Imported.">The profiles were saved.</Alert>}
|
||||
<ul className="m-0 flex list-none flex-col gap-2 p-0" aria-label="Profiles in the file">
|
||||
{plan.profiles.map((p) => (
|
||||
<li key={p.name} className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="font-medium">{p.name}</span>
|
||||
<Tag tone={TONE[p.status]}>{LABEL[p.status]}</Tag>
|
||||
<span className="text-xs text-muted-foreground">{p.apps} app(s){p.settings ? ', settings' : ''}{p.lists ? ', lists' : ''}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
/* ---- The list ---- */
|
||||
|
||||
export function Profiles({
|
||||
open,
|
||||
onOpenChange,
|
||||
token,
|
||||
servers,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (o: boolean) => void
|
||||
token: string | null
|
||||
/** The servers of the hub, and whether it holds an admin token for each. */
|
||||
servers: { id: string; writable: boolean }[]
|
||||
}) {
|
||||
const [profiles, setProfiles] = useState<ProfileSummary[] | null>(null)
|
||||
const [failure, setFailure] = useState<string | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [importing, setImporting] = useState(false)
|
||||
const [viewing, setViewing] = useState<string | null>(null)
|
||||
const [deleting, setDeleting] = useState<string | null>(null)
|
||||
const [request, setRequest] = useState<RunRequest | null>(null)
|
||||
const [problem, setProblem] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const outcome = await listProfiles(token)
|
||||
if (outcome.kind === 'ok') {
|
||||
setProfiles(outcome.data.response.profiles)
|
||||
setFailure(null)
|
||||
} else setFailure(sessionEnded(outcome.kind, outcome.kind === 'error' ? outcome.message : undefined))
|
||||
}, [token])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) void load()
|
||||
}, [open, load])
|
||||
|
||||
async function exportFile(name: string) {
|
||||
setProblem(null)
|
||||
const outcome = await exportProfiles(token, name)
|
||||
if (outcome.kind !== 'ok') return setProblem(sessionEnded(outcome.kind, outcome.kind === 'error' ? outcome.message : undefined))
|
||||
const url = URL.createObjectURL(new Blob([outcome.data.text], { type: 'application/json' }))
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = outcome.data.filename
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
async function remove(name: string) {
|
||||
const outcome = await deleteProfile(token, name)
|
||||
if (outcome.kind !== 'ok') setProblem(sessionEnded(outcome.kind, outcome.kind === 'error' ? outcome.message : undefined))
|
||||
await load()
|
||||
}
|
||||
|
||||
const create = (
|
||||
<Button variant="primary" onClick={() => setCreating(true)}>
|
||||
Create from current baselines
|
||||
</Button>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
size="wide"
|
||||
title="Profiles"
|
||||
actions={
|
||||
<>
|
||||
<Button onClick={() => setImporting(true)}>Import profiles</Button>
|
||||
{profiles !== null && profiles.length > 0 && create}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="m-0 text-sm text-muted-foreground">
|
||||
A profile brings a new or reset server in line: it installs the apps from that server's own store and applies their configs, the shared settings and the Blocked and Allowed lists.
|
||||
</p>
|
||||
{problem !== null && <Alert type="danger" title="Error">{problem}</Alert>}
|
||||
{failure !== null && profiles === null ? (
|
||||
<Failure>
|
||||
<span className="flex flex-col items-center gap-3">
|
||||
<span>Could not read the profiles. {failure}</span>
|
||||
<Button variant="primary" onClick={() => void load()}>
|
||||
Retry
|
||||
</Button>
|
||||
</span>
|
||||
</Failure>
|
||||
) : profiles === null ? (
|
||||
<div className="flex flex-col gap-3" role="status" aria-label="Loading the profiles">
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
</div>
|
||||
) : profiles.length === 0 ? (
|
||||
<Empty title="No profiles yet" actions={create}>
|
||||
Make one from the baselines the hub holds now, or import a file from another hub.
|
||||
</Empty>
|
||||
) : (
|
||||
<ul className="m-0 flex list-none flex-col gap-3 p-0" aria-label="Profiles">
|
||||
{profiles.map((p) => (
|
||||
<li key={p.name} aria-label={p.name} className="flex flex-wrap items-center gap-3 border-b border-border pb-3 text-sm last:border-b-0">
|
||||
<span className="font-medium">{p.name}</span>
|
||||
<Tag tone="neutral">{`${p.apps} app${p.apps === 1 ? '' : 's'}`}</Tag>
|
||||
{p.configs > 0 && <Tag tone="neutral">{`${p.configs} config${p.configs === 1 ? '' : 's'}`}</Tag>}
|
||||
{p.settings !== null && <Tag tone="neutral">{`${p.settings} settings`}</Tag>}
|
||||
{p.lists !== null && <Tag tone="neutral">{`${p.lists.blocked + p.lists.allowed} list domains`}</Tag>}
|
||||
<span className="text-xs text-muted-foreground">{p.updatedBy} · {when(p.updatedAt)}</span>
|
||||
<span className="ml-auto flex flex-wrap gap-2">
|
||||
<Button size="sm" onClick={() => setViewing(p.name)}>
|
||||
View
|
||||
</Button>
|
||||
<Button size="sm" variant="primary" onClick={() => setRequest(applyRequestFor(token, p.name, servers))}>
|
||||
Apply to servers…
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => void exportFile(p.name)}>
|
||||
Export
|
||||
</Button>
|
||||
<Button size="sm" variant="danger" onClick={() => setDeleting(p.name)}>
|
||||
Delete
|
||||
</Button>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Dialog>
|
||||
|
||||
{creating && (
|
||||
<CreateProfile
|
||||
token={token}
|
||||
onClose={() => setCreating(false)}
|
||||
onDone={() => {
|
||||
setCreating(false)
|
||||
void load()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{importing && (
|
||||
<ImportProfiles
|
||||
token={token}
|
||||
onClose={() => setImporting(false)}
|
||||
onDone={() => void load()}
|
||||
/>
|
||||
)}
|
||||
{viewing !== null && <ViewProfile token={token} name={viewing} onClose={() => setViewing(null)} />}
|
||||
<Confirm
|
||||
open={deleting !== null}
|
||||
title="Delete profile"
|
||||
text={`Delete the profile ${deleting ?? ''}? Servers that already had it applied keep what they got.`}
|
||||
label="Delete"
|
||||
onClose={() => setDeleting(null)}
|
||||
onConfirm={() => deleting !== null && remove(deleting)}
|
||||
/>
|
||||
{request !== null && <RunFlow request={request} onClose={() => setRequest(null)} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import type { RunOutcome, TargetResult } from '../../api/apps'
|
||||
import { Button } from '../../ui/Button'
|
||||
import { Check } from '../../ui/Check'
|
||||
import { Dialog } from '../../ui/Dialog'
|
||||
import { Failure, Loading } from '../../ui/Empty'
|
||||
import { Tag } from '../../ui/Tag'
|
||||
|
||||
/*
|
||||
Behind a hub, a change to the apps of several servers goes through three steps in one place:
|
||||
pick the servers, wait, read what happened on each. A server that cannot be chosen is still
|
||||
listed, disabled, with the reason, so nobody wonders where it went. "Retry failed" runs the
|
||||
same change again on the servers that failed and nowhere else.
|
||||
*/
|
||||
|
||||
export interface PickerServer {
|
||||
id: string
|
||||
/** Why this server cannot be chosen; absent when it can. */
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export interface RunRequest {
|
||||
title: string
|
||||
intro?: ReactNode
|
||||
confirmLabel: string
|
||||
servers: PickerServer[]
|
||||
/** The servers ticked at the start. Only those without a reason count. */
|
||||
preselected: string[]
|
||||
/*
|
||||
A review step between the picker and the change (the config sync uses it): it reads what
|
||||
would happen on the picked servers and draws it. `context` is handed back to `run`.
|
||||
*/
|
||||
review?: (targets: string[], setReady: (ready: boolean) => void) => Promise<{ kind: 'ok'; body: ReactNode; context: unknown; confirmLabel?: string } | { kind: 'error'; message: string }>
|
||||
run: (targets: string[], context?: unknown) => Promise<RunOutcome>
|
||||
}
|
||||
|
||||
/* What stops a server from being a target: it did not answer, or the hub has no admin token for it. */
|
||||
export function pickerServers(
|
||||
servers: { id: string; writable: boolean; ok?: boolean }[],
|
||||
extra: Record<string, string> = {},
|
||||
): PickerServer[] {
|
||||
return servers.map((s) => {
|
||||
const reason = s.ok === false ? 'Did not answer' : !s.writable ? 'No admin token configured on the hub' : extra[s.id]
|
||||
return reason === undefined ? { id: s.id } : { id: s.id, reason }
|
||||
})
|
||||
}
|
||||
|
||||
/* The steps of a profile apply, drawn under the server they ran on. */
|
||||
const STEP_LABEL = { apps: 'Apps', configs: 'App configs', settings: 'Settings', lists: 'Lists' } as const
|
||||
const STEP_STATUS = { ok: 'Done', failed: 'Failed', skipped: 'Done earlier', 'not-run': 'Not run' } as const
|
||||
|
||||
type Stage ='pick' | 'reviewing' | 'review' | 'running' | 'results'
|
||||
|
||||
export function RunFlow({ request, onClose }: { request: RunRequest; onClose: (changed: boolean) => void }) {
|
||||
const [stage, setStage] = useState<Stage>('pick')
|
||||
const [picked, setPicked] = useState<string[]>(() =>
|
||||
request.preselected.filter((id) => request.servers.some((s) => s.id === id && s.reason === undefined)),
|
||||
)
|
||||
const [results, setResults] = useState<TargetResult[]>([])
|
||||
const [failure, setFailure] = useState<string | null>(null)
|
||||
const [lastTargets, setLastTargets] = useState<string[]>([])
|
||||
const [reviewed, setReviewed] = useState<{ body: ReactNode; context: unknown; confirmLabel?: string } | null>(null)
|
||||
const [reviewFailure, setReviewFailure] = useState<string | null>(null)
|
||||
/* A review can hold the confirm button back until the user has answered it (an Overwrite tick). */
|
||||
const [ready, setReady] = useState(true)
|
||||
|
||||
async function next(targets: string[]) {
|
||||
if (request.review === undefined) return go(targets)
|
||||
setStage('reviewing')
|
||||
setReviewFailure(null)
|
||||
setReady(true)
|
||||
const outcome = await request.review(targets, setReady)
|
||||
if (outcome.kind === 'ok') setReviewed(outcome)
|
||||
else {
|
||||
setReviewed(null)
|
||||
setReviewFailure(outcome.message)
|
||||
}
|
||||
setLastTargets(targets)
|
||||
setStage('review')
|
||||
}
|
||||
|
||||
async function go(targets: string[], context: unknown = reviewed?.context) {
|
||||
setStage('running')
|
||||
setFailure(null)
|
||||
setLastTargets(targets)
|
||||
const outcome = await request.run(targets, context)
|
||||
if (outcome.kind === 'ok') {
|
||||
const fresh = outcome.data.response.results
|
||||
// A retry replaces only the rows it re-ran.
|
||||
setResults((old) => [...old.filter((r) => !fresh.some((f) => f.server === r.server)), ...fresh])
|
||||
} else {
|
||||
setFailure(outcome.kind === 'error' ? outcome.message : 'The session ended.')
|
||||
}
|
||||
setStage('results')
|
||||
}
|
||||
|
||||
const failed = results.filter((r) => !r.ok).map((r) => r.server)
|
||||
const changed = results.some((r) => r.ok)
|
||||
|
||||
if (stage === 'pick') {
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={() => onClose(false)}
|
||||
title={request.title}
|
||||
close="Cancel"
|
||||
actions={
|
||||
<Button variant="primary" disabled={picked.length === 0} onClick={() => void next(picked)}>
|
||||
{request.confirmLabel}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{request.intro != null && <div className="m-0 text-sm text-muted-foreground">{request.intro}</div>}
|
||||
<div className="flex flex-col gap-3" role="group" aria-label="Servers">
|
||||
{request.servers.map((s) => (
|
||||
<Check
|
||||
key={s.id}
|
||||
label={s.id}
|
||||
checked={picked.includes(s.id)}
|
||||
disabled={s.reason !== undefined}
|
||||
help={s.reason}
|
||||
onChange={(on) => setPicked((old) => (on ? [...old, s.id] : old.filter((id) => id !== s.id)))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
if (stage === 'reviewing') {
|
||||
return (
|
||||
<Dialog open onOpenChange={() => {}} title={request.title} close="Close">
|
||||
<Loading>Reading {lastTargets.join(', ')}…</Loading>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
if (stage === 'review') {
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={() => onClose(false)}
|
||||
title={`${request.title}: review`}
|
||||
size="wide"
|
||||
close="Cancel"
|
||||
actions={
|
||||
<>
|
||||
<Button onClick={() => setStage('pick')}>Back</Button>
|
||||
{reviewed !== null && (
|
||||
<Button variant="primary" disabled={!ready} onClick={() => void go(lastTargets)}>
|
||||
{reviewed.confirmLabel ?? request.confirmLabel}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{reviewFailure !== null ? <Failure>{reviewFailure}</Failure> : reviewed?.body}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
if (stage === 'running') {
|
||||
return (
|
||||
<Dialog open onOpenChange={() => {}} title={request.title} close="Close">
|
||||
<Loading>Working on {lastTargets.join(', ')}…</Loading>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={() => onClose(changed)}
|
||||
title={`${request.title}: results`}
|
||||
actions={
|
||||
failure !== null ? (
|
||||
<Button variant="primary" onClick={() => void go(lastTargets)}>
|
||||
Retry
|
||||
</Button>
|
||||
) : failed.length > 0 ? (
|
||||
<Button variant="primary" onClick={() => void go(failed)}>
|
||||
Retry failed
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{failure !== null ? (
|
||||
<Failure>{failure}</Failure>
|
||||
) : (
|
||||
<ul className="m-0 flex list-none flex-col gap-3 p-0" aria-label="Results">
|
||||
{results.map((r) => (
|
||||
<li key={r.server} className="flex flex-col gap-2 text-sm">
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{r.server}</span>
|
||||
<Tag tone={r.ok ? 'ok' : 'dan'}>{r.ok ? 'Done' : 'Failed'}</Tag>
|
||||
{r.message !== undefined && <span className="text-muted-foreground">{r.message}</span>}
|
||||
</span>
|
||||
{r.steps !== undefined && r.steps.length > 0 && (
|
||||
<ul className="m-0 flex list-none flex-col gap-1 p-0 pl-4" aria-label={`Steps on ${r.server}`}>
|
||||
{r.steps.map((s) => (
|
||||
<li key={s.step} className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<span className="font-medium">{STEP_LABEL[s.step]}</span>
|
||||
<Tag tone={s.status === 'ok' ? 'ok' : s.status === 'failed' ? 'dan' : 'neutral'}>{STEP_STATUS[s.status]}</Tag>
|
||||
<span className="text-muted-foreground">{s.message}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -30,8 +30,11 @@ and the freshly placed row is left without its "Store Update" button.
|
||||
export function StoreApps({
|
||||
token,
|
||||
onChanged,
|
||||
node = '',
|
||||
}: {
|
||||
token: string | null
|
||||
/** Behind a hub: the one server it acts on. */
|
||||
node?: string
|
||||
onChanged: () => void
|
||||
}) {
|
||||
const [storeApps, setStoreApps] = useState<StoreApp[] | null>(null)
|
||||
@@ -50,7 +53,7 @@ export function StoreApps({
|
||||
*/
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const outcome = await listStoreApps(token)
|
||||
const outcome = await listStoreApps(token, node)
|
||||
if (outcome.kind === 'ok') {
|
||||
setStoreApps(outcome.data.response.storeApps)
|
||||
return
|
||||
@@ -85,7 +88,7 @@ export function StoreApps({
|
||||
title: 'Store App Installed!',
|
||||
text: `DNS application '${app.name}' was installed successfully from DNS App Store.`,
|
||||
},
|
||||
downloadAndInstall(token, app.name, app.url),
|
||||
downloadAndInstall(token, app.name, app.url, node),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -97,7 +100,7 @@ export function StoreApps({
|
||||
title: 'Store App Updated!',
|
||||
text: `DNS application '${app.name}' was updated successfully from DNS App Store.`,
|
||||
},
|
||||
downloadAndUpdate(token, app.name, app.url),
|
||||
downloadAndUpdate(token, app.name, app.url, node),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -116,7 +119,7 @@ export function StoreApps({
|
||||
title: 'Store App Uninstalled!',
|
||||
text: `DNS application '${app.name}' was uninstalled successfully.`,
|
||||
},
|
||||
uninstallApp(token, app.name),
|
||||
uninstallApp(token, app.name, node),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Loaded 100 of 181 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user