Hub stage 5: manage each server from one console
Zones, Cache, Blocking, DNS Client, Settings, DHCP and Logs can now be changed on one chosen server through the hub, with that server's admin token. Operators manage cache and blocking; admins also zones, settings and DHCP. Under "All servers", or on a server without an admin token, the controls are disabled with a hint. The hub refuses settings/set changes to a server's web service fields and anything about users, SSO or LDAP; backup, restore, log and stats deletion and admin/* stay refused. Non-admins get settings with secrets blanked. Write params travel in the POST body, downloads pass through with a single-use token, and the audit log names no values. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
57e492cceb
commit
8bb1e01936
45 files changed
+1851
-190
No files matched your search
+36
-2
@@ -244,6 +244,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) => ({
|
||||
'allowed/export': [...allowed].join('\n') + '\n',
|
||||
'blocked/export': [...blocked].join('\n') + '\n',
|
||||
'zones/export': '$ORIGIN example.net.\n@ IN SOA ns1 hostmaster 1 900 300 604800 900\n',
|
||||
'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') ?? ''
|
||||
@@ -262,7 +270,16 @@ function route(path, q) {
|
||||
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 })
|
||||
@@ -278,7 +295,17 @@ 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/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
|
||||
const old = settings[k]
|
||||
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': case 'settings/temporaryDisableBlocking': return ok(path === 'settings/set' ? settings : { temporaryDisableBlockingTill: iso(now + 10 * 60_000) })
|
||||
case 'user/createSingleUseToken': return ok({ token: 'mock-once' })
|
||||
case 'settings/getTsigKeyNames': return ok({ tsigKeyNames: ['transfer.example.net'] })
|
||||
case 'dhcp/leases/list': return ok({ leases })
|
||||
case 'dhcp/scopes/list': return ok({ scopes })
|
||||
@@ -348,6 +375,13 @@ 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)
|
||||
const file = fileFor(path)
|
||||
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)))
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user