Hub stage 2: install, update and uninstall apps on several servers

Writes use only <ID>_ADMIN_TOKEN and need the apps.write capability.
Store URLs are resolved from each server's own listing. The config is saved
to history before an uninstall. Zip uploads are fanned out to the servers.
Every target's result is audited. The console gets a row menu on the
matrix, a target picker, a results dialog with Retry failed, the store and
install from file in All servers mode, and single-server actions for writers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-10-31 16:11:40 +07:00
1 parent 3d8df08fa0
commit 6acd2d656d
27 files changed
+1991 -71

No files matched your search

+30 -3
View File
@@ -202,11 +202,18 @@ const appConfigs = {
'Query Logs (Sqlite)': { enableLogging: true, maxLogDays: 365, sqliteDbPath: VARIANT === 'b' ? '/var/lib/dns/ndns/querylogs.db' : '/var/lib/dns/mdns/querylogs.db' },
'Split Horizon': { domainGroupMap: { 'lan.example.net': 'internal' }, networkGroupMap: { '10.0.0.0/8': 'internal' } },
}
const store = [
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']
@@ -279,7 +286,27 @@ function route(path, q) {
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/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/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' }] })