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 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-10-31 22:06:37 +07:00
1 parent 57e492cceb
commit 8bb1e01936
45 files changed
+1851 -190

No files matched your search

+6 -3
View File
@@ -1,4 +1,5 @@
import { useId, useRef, useState } from 'react'
import { useContext, useId, useRef, useState } from 'react'
import { HubNode, nodeArg } from '../../lib/hub-write'
import { addDomain, type DomainList } from '../../api/zonelists'
import { Panel, Body } from '../../ui/Panel'
import { Field, Input } from '../../ui/Field'
@@ -51,6 +52,8 @@ export function AddDomainBar({
onNotice: (n: Notice) => void
onChanged: (list: DomainList, domain: string) => void
}) {
/* Behind a hub: the server the section has chosen (empty elsewhere, which sends no `node`). */
const node = useContext(HubNode)
const [field, setField] = useState('')
const [busy, setBusy] = useState(false)
const entry = useRef<HTMLInputElement>(null)
@@ -82,7 +85,7 @@ export function AddDomainBar({
checkDomain(token, domain, {
settings: missing(permissions, 'Settings.canView'),
allowed: missing(permissions, 'Allowed.canView'),
}),
}, ...nodeArg(node)),
/* The names the Lists tab gives its rows: Technitium's catalogue, then ours. */
loadQuickList('quick-block-lists').then((c) => [...c, ...moreLists(c)]),
])
@@ -120,7 +123,7 @@ export function AddDomainBar({
}
setBusy(true)
const outcome = await addDomain(list, token, domain)
const outcome = await addDomain(list, token, domain, ...nodeArg(node))
setBusy(false)
if (outcome.kind !== 'ok') {
onNotice(noticeFromFailure(outcome))
+6 -3
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useId, useRef, useState, type FormEvent, type ReactNode } from 'react'
import { useCallback, useContext, useEffect, useId, useRef, useState, type FormEvent, type ReactNode } from 'react'
import { HubNode, nodeArg } from '../../lib/hub-write'
import { readSettings, setSettings, forceUpdateBlockLists } from '../../api/settings'
import { getDashboardStats } from '../../api/dashboard'
import { loadQuickList, type QuickEntry } from '../../lib/quick-lists'
@@ -209,7 +210,9 @@ export function BlockLists({
/** Told whether there are unsaved changes, for the tab's mark; false on leaving. */
onDirty?: (dirty: boolean) => void
}) {
const node = clusterInitialised ? 'cluster' : ''
/* Behind a hub the lists are the chosen server's own (no cluster); elsewhere the cluster's. */
const hubNode = useContext(HubNode)
const node = hubNode !== '' ? hubNode : clusterInitialised ? 'cluster' : ''
const viewNeed = missing(permissions, 'Settings.canView')
const modifyNeed = missing(permissions, 'Settings.canModify')
const statsNeed = missing(permissions, 'Dashboard.canView')
@@ -461,7 +464,7 @@ export function BlockLists({
// known if the read fails.
const before = await readSettings(token, node)
const from = before.kind !== 'ok' ? serverNext.current : (before.data.blockListNextUpdatedOn ?? null)
const ok = await forceUpdateBlockLists(token)
const ok = await forceUpdateBlockLists(token, ...nodeArg(hubNode))
setBusy(false)
if (!ok) return
// main.js:2356. The label becomes "Updating Now" without reloading the settings.
+41 -13
View File
@@ -1,9 +1,12 @@
import { useEffect, useState } from 'react'
import { useContext, useEffect, useState } from 'react'
import { ClusterNodeSelect, HubMode } from '../../ui/ClusterNodeSelect'
import { HubNode, useHubDefaultNode, useHubWrite } from '../../lib/hub-write'
import { HubWriteHint } from '../../ui/HubWriteHint'
import { getSettings } from '../../api/settings'
import { getDashboardStats } from '../../api/dashboard'
import { BlockingMenu } from '../dashboard/BlockingMenu'
import { Tag } from '../../ui/Tag'
import { missing } from './permissions'
import { missing, withoutWrites } from './permissions'
import { untilText, useBlockingState } from './blocking-state'
import { SubTabs } from '../../ui/SubTabs'
import { ruleSearch } from './rules-model'
@@ -36,12 +39,12 @@ function dropRule(): void {
/* What the tabs count: the rules (blocked and allowed zones) and the lists. Each is read
once and only when the session may read it; a failed read leaves the tab without one. */
function useCounts(token: string | null, permissions: Permissions) {
function useCounts(token: string | null, permissions: Permissions, node = '') {
const [counts, setCounts] = useState<{ Rules?: number; Lists?: number }>({})
useEffect(() => {
let alive = true
if (missing(permissions, 'Dashboard.canView') == null) {
void getDashboardStats(token).then((o) => {
void getDashboardStats(token, undefined, undefined, node).then((o) => {
if (alive && o.kind === 'ok') {
const s = o.data.stats
setCounts((c) => ({ ...c, Rules: s.blockedZones + s.allowedZones }))
@@ -49,7 +52,7 @@ function useCounts(token: string | null, permissions: Permissions) {
})
}
if (missing(permissions, 'Settings.canView') == null) {
void getSettings(token).then((s) => {
void getSettings(token, node).then((s) => {
if (alive && s != null) setCounts((c) => ({ ...c, Lists: s.blockListUrls?.length ?? 0 }))
})
}
@@ -58,14 +61,14 @@ function useCounts(token: string | null, permissions: Permissions) {
}
// The permission object is read once: the counts are a hint, not a live figure.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token])
}, [token, node])
return counts
}
/* The state strip of the canvas, above the tabs. Overview carries the full status block
itself, so the strip is for the other two. */
function Banner({ token, permissions }: { token: string | null; permissions: Permissions }) {
const { state } = useBlockingState(token, permissions)
function Banner({ token, permissions, node }: { token: string | null; permissions: Permissions; node: string }) {
const { state } = useBlockingState(token, permissions, true, node)
if (state == null || state === 'failed') return null
const canModify = missing(permissions, 'Settings.canModify') == null
return (
@@ -82,7 +85,7 @@ function Banner({ token, permissions }: { token: string | null; permissions: Per
</span>
{canModify && (
<span className={shared.bannerAct}>
<BlockingMenu token={token} text={state.kind === 'on' ? 'Temporarily Disable Blocking' : 'Enable Blocking'} onNotice={() => {}} />
<BlockingMenu token={token} text={state.kind === 'on' ? 'Temporarily Disable Blocking' : 'Enable Blocking'} onNotice={() => {}} node={node} />
</span>
)}
</div>
@@ -131,11 +134,30 @@ export function Blocking({
const [listsDirty, setListsDirty] = useState(false)
const counts = useCounts(token, permissions)
/*
Behind a hub the Rules and Lists tabs act on ONE server, chosen here (the first at the start;
Overview keeps its own selector, which also offers "All servers" for the figures). Where the
hub cannot write (no admin token) the writes are switched off and the line under the selector
says why. Outside a hub none of this exists: `hubNode` is empty and `permissions` are the role's.
*/
const hub = useContext(HubMode)
const defaultNode = useHubDefaultNode(nodes)
const [chosen, setChosen] = useState(defaultNode)
const hubNode = hub ? chosen : ''
const write = useHubWrite(hubNode)
const effective = write.allowed ? permissions : withoutWrites(permissions)
const counts = useCounts(token, permissions, hubNode)
const tabs = (
<>
{active !== 'Overview' && <Banner token={token} permissions={permissions} />}
{hub && active !== 'Overview' && (
<>
<ClusterNodeSelect nodes={nodes} initialised={clusterInitialised} value={chosen} onChange={setChosen} />
<HubWriteHint write={write} />
</>
)}
{active !== 'Overview' && <Banner token={token} permissions={effective} node={hubNode} />}
<SubTabs
label="Blocking sections"
section="blocking"
@@ -149,17 +171,23 @@ export function Blocking({
)
if (active === 'Rules') {
return <Rules tabs={tabs} token={token} permissions={permissions} nodes={nodes} clusterInitialised={clusterInitialised} />
return (
<HubNode.Provider value={hubNode}>
<Rules tabs={tabs} token={token} permissions={effective} nodes={nodes} clusterInitialised={clusterInitialised} />
</HubNode.Provider>
)
}
if (active === 'Lists') {
return (
<HubNode.Provider value={hubNode}>
<BlockLists
tabs={tabs}
token={token}
permissions={permissions}
permissions={effective}
clusterInitialised={clusterInitialised}
onDirty={setListsDirty}
/>
</HubNode.Provider>
)
}
return (
+15 -7
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import {
getDashboardStats, RANGES, RANGE_LABEL,
type ChartData, type DashboardStats, type Range,
@@ -6,7 +6,7 @@ import {
import { percentage, hasData, Legend, useHidden } from '../dashboard/Dashboard'
import { Chart } from '../dashboard/Chart'
import { InChrome } from '../../app/ChromeSlot'
import { ClusterNodeSelect, AGGREGATE, type ClusterNode } from '../../ui/ClusterNodeSelect'
import { ClusterNodeSelect, AGGREGATE, HubMode, type ClusterNode } from '../../ui/ClusterNodeSelect'
import { SectionHeader } from '../../ui/SectionHeader'
import { Segmented } from '../../ui/Segmented'
import { Panel, Body } from '../../ui/Panel'
@@ -20,7 +20,9 @@ import { TopTable } from './TopTable'
import { RecentBlocked } from './RecentBlocked'
import { LAST_HOUR_REFRESH_MS, useLiveView } from '../dashboard/useLive'
import { Locked } from './Locked'
import { missing, type Permissions } from './permissions'
import { missing, withoutWrites, type Permissions } from './permissions'
import { HubNode, useHubWrite } from '../../lib/hub-write'
import { HubWriteHint } from '../../ui/HubWriteHint'
import shared from './Blocking.module.css'
import styles from './Overview.module.css'
@@ -181,7 +183,7 @@ function Gap({ failure, loading, announce = true }: { failure: boolean; loading:
export function Overview({
tabs,
token,
permissions,
permissions: rolePermissions,
nodes = [],
clusterInitialised = false,
serverDomain,
@@ -193,9 +195,14 @@ export function Overview({
clusterInitialised?: boolean
serverDomain?: string
}) {
const statsNeed = missing(permissions, 'Dashboard.canView')
const statsNeed = missing(rolePermissions, 'Dashboard.canView')
const [range, setRange] = useState<Period>('LastHour')
const [node, setNode] = useState<string>(() => localStorage.getItem('dashboardClusterNode') || AGGREGATE)
/* Behind a hub, a change is allowed only on a chosen server the hub can write to: with "All servers"
the controls are switched off like a missing permission, and the line under the selector says why. */
const hub = useContext(HubMode)
const write = useHubWrite(node)
const permissions = write.allowed ? rolePermissions : withoutWrites(rolePermissions)
const [data, setData] = useState<DashboardStats | null>(null)
/** The first read of this period and node failed: there is nothing to show. */
const [failure, setFailure] = useState(false)
@@ -318,7 +325,7 @@ export function Overview({
const loading = data == null && !failure
return (
<>
<HubNode.Provider value={hub ? node : ''}>
<InChrome>
<ClusterNodeSelect
nodes={nodes}
@@ -328,6 +335,7 @@ export function Overview({
onChange={setNode}
/>
</InChrome>
<HubWriteHint write={write} />
<SectionHeader
title="Blocking"
@@ -465,6 +473,6 @@ export function Overview({
refreshOn={s?.totalBlocked}
/>
</div>
</>
</HubNode.Provider>
)
}
+14 -9
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { HubNode, nodeArg } from '../../lib/hub-write'
import { readRuleExport } from '../../api/blocking'
import { addDomain, deleteDomain, exportDomains, flushList, type DomainList } from '../../api/zonelists'
import { SectionHeader } from '../../ui/SectionHeader'
@@ -89,6 +90,8 @@ export function Rules({
nodes?: { name: string; type: string }[]
clusterInitialised?: boolean
}) {
/* Behind a hub: the server the section has chosen (empty elsewhere). */
const hubNode = useContext(HubNode)
const viewBlocked = missing(permissions, 'Blocked.canView') == null
const viewAllowed = missing(permissions, 'Allowed.canView') == null
/* Neither list can be read: there is nothing to count, so nothing is counted;
@@ -162,7 +165,8 @@ export function Rules({
change, the PRIMARY, where the change was made (other-zones.js:269, 434): a
secondary still answers the old list until the cluster syncs. */
const load = useCallback(async (fromPrimary = false) => {
const node = fromPrimary ? primary.current : ''
/* Behind a hub every read and change is on the server the section has chosen. */
const node = hubNode !== '' ? hubNode : fromPrimary ? primary.current : ''
const mine = ++asked.current
const read = (list: DomainList, may: boolean) => (may ? readRuleExport(list, token, node) : null)
const [b, a] = await Promise.all([read('blocked', viewBlocked), read('allowed', viewAllowed)])
@@ -181,7 +185,7 @@ export function Rules({
setFailed(false)
setStale(false)
setLastGood(new Date().toISOString())
}, [token, viewBlocked, viewAllowed])
}, [token, viewBlocked, viewAllowed, hubNode])
useEffect(() => {
void load()
@@ -252,7 +256,7 @@ export function Rules({
label: 'Delete',
action: () =>
mutate(
() => deleteDomain(rule.list, token, rule.domain),
() => deleteDomain(rule.list, token, rule.domain, ...nodeArg(hubNode)),
isAllowed
? { type: 'success', title: 'Deleted!', text: `Domain '${rule.domain}' was deleted from Allowed Zone successfully.` }
: { type: 'success', title: 'Deleted!', text: `Blocked zone '${rule.domain}' was deleted successfully.` },
@@ -268,7 +272,7 @@ export function Rules({
return
}
setBusy(true)
const outcome = await deleteDomain(rule.list, token, rule.domain)
const outcome = await deleteDomain(rule.list, token, rule.domain, ...nodeArg(hubNode))
if (!mounted.current) return
setBusy(false)
if (outcome.kind !== 'ok') {
@@ -285,7 +289,7 @@ export function Rules({
const deleted = undo.rules
setUndo({ rules: deleted, phase: 'restoring' })
setBusy(true)
const result = await restoreRules(deleted, (r) => addDomain(r.list, token, r.domain))
const result = await restoreRules(deleted, (r) => addDomain(r.list, token, r.domain, ...nodeArg(hubNode)))
if (!mounted.current) return
setBusy(false)
changed()
@@ -309,7 +313,7 @@ export function Rules({
label: 'Flush',
action: () =>
mutate(
() => flushList(list, token),
() => flushList(list, token, ...nodeArg(hubNode)),
isAllowed
? { type: 'success', title: 'Flushed!', text: 'Allowed zone was flushed successfully.' }
: { type: 'success', title: 'Flushed!', text: 'Blocked zone was flushed successfully.' },
@@ -319,7 +323,7 @@ export function Rules({
async function runExport(list: DomainList) {
setBusy(true)
const r = await exportDomains(list, token)
const r = await exportDomains(list, token, ...nodeArg(hubNode))
setBusy(false)
if (!r.ok) return
setNotice({
@@ -454,7 +458,7 @@ export function Rules({
are framed boxes of their own, and flush they doubled the panel's edge. */
<div className={styles.tree}>
<Lists
key={`${treeList}:${generation}`}
key={`${treeList}:${generation}:${hubNode}`}
list={treeList}
token={token}
nodes={nodes}
@@ -617,6 +621,7 @@ export function Rules({
list={importing}
open
token={token}
node={hubNode}
onClose={() => setImporting(null)}
onDone={(n) => {
setNotice(n)
+6 -2
View File
@@ -1,3 +1,5 @@
import { useContext } from 'react'
import { HubNode } from '../../lib/hub-write'
import { BlockingMenu } from '../dashboard/BlockingMenu'
import { Panel, Body } from '../../ui/Panel'
import { PermissionButton } from '../../ui/PermissionButton'
@@ -44,7 +46,9 @@ export function StatusPanel({
/* The reads (when, from where, and the one when a pause ends) are
`useBlockingState`'s, shared with the sidebar's mark. A change made from the
menu below reaches it as every settings write does (`lib/settings-signal.ts`). */
const { state, retry } = useBlockingState(token, permissions)
/* Behind a hub: the server the section has chosen. */
const node = useContext(HubNode)
const { state, retry } = useBlockingState(token, permissions, true, node)
if (viewNeed != null) return <Locked title="Blocking" need={viewNeed} />
@@ -109,7 +113,7 @@ export function StatusPanel({
{modifyNeed != null ? (
<PermissionButton permission={modifyNeed}>{verb}</PermissionButton>
) : (
<BlockingMenu token={token} text={verb} onNotice={onNotice} />
<BlockingMenu token={token} text={verb} onNotice={onNotice} node={node} />
)}
</div>
)}
+4 -2
View File
@@ -62,6 +62,8 @@ export function useBlockingState(
/** False reads nothing, as a missing `Settings.canView` does: the sidebar without
a Blocking entry to mark. */
enabled = true,
/** Behind a hub: the server read (a hub has no settings of its own). Empty elsewhere. */
node = '',
): { state: BlockingRead; reload: () => void; retry: () => void } {
const viewNeed = enabled ? missing(permissions, 'Settings.canView') : 'Settings.canView'
const [state, setState] = useState<BlockingRead>(null)
@@ -70,11 +72,11 @@ export function useBlockingState(
const reload = useCallback(() => {
if (viewNeed != null) return
const mine = ++asked.current
void getSettings(token).then((s) => {
void getSettings(token, node).then((s) => {
if (mine !== asked.current) return
setState(s == null ? 'failed' : stateOf(s.enableBlocking, s.temporaryDisableBlockingTill))
})
}, [token, viewNeed])
}, [token, viewNeed, node])
useEffect(() => {
// A read already travelling must not land after the permission went.
+6 -3
View File
@@ -2,6 +2,7 @@ import type { ApiOutcome } from '../../api/client'
import { readRuleExport } from '../../api/blocking'
import { resolve } from '../../api/dnsclient'
import { readSettings } from '../../api/settings'
import { nodeArg } from '../../lib/hub-write'
import type { QuickEntry } from '../../lib/quick-lists'
import { listName } from './list-lines'
@@ -321,11 +322,13 @@ export async function checkDomain(
token: string | null,
domain: string,
needs: { settings?: string; allowed?: string },
/** Behind a hub: the server asked (a hub has no server of its own to ask). */
node = '',
): Promise<ApiOutcome<CheckInputs>> {
const [answer, settings, allowed] = await Promise.all([
resolve(token, { server: 'this-server', domain, type: 'A', protocol: 'UDP', dnssec: false, eDnsClientSubnet: '' }),
needs.settings != null ? { need: needs.settings } : readSettings(token),
needs.allowed != null ? { need: needs.allowed } : readRuleExport('allowed', token),
resolve(token, { server: 'this-server', domain, type: 'A', protocol: 'UDP', dnssec: false, eDnsClientSubnet: '', ...(node === '' ? {} : { node }) }),
needs.settings != null ? { need: needs.settings } : readSettings(token, ...nodeArg(node)),
needs.allowed != null ? { need: needs.allowed } : readRuleExport('allowed', token, ...nodeArg(node)),
])
if (answer.kind !== 'ok') return answer
return {
+14
View File
@@ -19,6 +19,20 @@ export function missing(perms: Permissions, ...needs: Need[]): Need | undefined
return undefined
}
/*
Behind a hub, when the chosen node is not one a change can be made on ("All servers", or a server
the hub holds no admin token for): the same permissions with every write switched off. The
controls then render as they do for a role without the permission.
*/
export function withoutWrites(perms: Permissions): Permissions {
if (perms == null) return perms
const out: Record<string, Permission> = { ...perms }
for (const section of ['Allowed', 'Blocked', 'Settings']) {
out[section] = { canView: perms[section]?.canView ?? true, canModify: false, canDelete: false }
}
return out
}
/** The same sentence `PermissionButton` draws: `Requires Logs: View`. */
export function requiresText(need: string): string {
const [section, action] = need.split('.')