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:
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

+36 -2
View File
@@ -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)))
})
+1 -1
View File
@@ -11,7 +11,7 @@ version: "3.8"
# Токены кладутся в .env рядом с этим файлом (не в git):
# MDNS_TOKEN=...
# NDNS_TOKEN=...
# MDNS_ADMIN_TOKEN=... # токены hub-admin (Modify и Delete только на Apps): нужны, чтобы хаб менял приложения
# 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)
+34 -8
View File
@@ -14,7 +14,9 @@
└─► https://ndns.shnt.top/api/... (NDNS_TOKEN)
```
Что хаб показывает: Dashboard (сумма или один сервер), Logs (View Logs и Query Logs, объединённые с колонкой Server) и About. Остальные разделы скрыты, ими управляют в консоли каждого сервера.
Что хаб показывает: Dashboard (сумма или один сервер), Logs (View Logs и Query Logs, объединённые с колонкой Server), Apps, About и разделы управления одним сервером: Zones, Cache, Blocking (Blocked и Allowed), DNS Client, Settings и DHCP. В этих разделах хаб начинает с первого сервера, а менять что-либо можно только на выбранном сервере и только ролью, у которой есть нужное право (таблица в шаге 1а).
Остаётся в консоли каждого сервера: Administration (пользователи, группы, сессии, токены, SSO, LDAP), удаление логов и статистики, резервная копия и восстановление настроек (в копии лежат пользователи и токены), права на зоны и настройки веб-службы сервера (адреса, порты, HTTPS), через которую хаб сам ходит на сервер.
---
@@ -46,15 +48,22 @@ git tag hub-v1.0.0 && git push origin hub-v1.0.0
Сделайте это **на обоих серверах**: `https://mdns.shnt.top` и `https://ndns.shnt.top`. Войдите в консоль сервера как администратор.
1. **Группа.** Administration › Groups › **Add Group**: имя `hub-readers`, описание любое.
2. **Права группы.** Administration › Permissions: у группы `hub-readers` отметьте только **View** в четырёх разделах:
2. **Права группы.** Administration › Permissions: у группы `hub-readers` отметьте только **View** в каждом разделе, который хаб показывает:
| Раздел | Зачем хабу |
|---|---|
| Dashboard | статистика, топы, график по часам, живые метрики |
| Logs | список файлов логов и Query Logs |
| Apps | список приложений логирования, из которых Query Logs читает записи |
| Settings | состояние блокировки на Dashboard |
| 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`, а не для администратора:
@@ -76,14 +85,31 @@ git tag hub-v1.0.0 && git push origin hub-v1.0.0
Без этого шага хаб только читает. Токены записи отдельные, у отдельного пользователя. Сделайте это **на обоих серверах**:
1. **Группа** `hub-admins`. Права группы (Administration › Permissions):
- **Apps**: View, Modify и Delete;
- **Dashboard**, **Logs**, **Settings**: только View, и то лишь если хотите, чтобы этот токен мог читать то же, что `hub-reader`. Для записи приложений достаточно Apps.
- остальные разделы без прав.
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 | сохранение настроек, временное отключение блокировки, обновление списков. 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».
Какие роли хаба что могут: `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.»
---
+1 -1
View File
@@ -4,7 +4,7 @@
- **общее управление**: каждый сервер из одной консоли, действия «на все серверы», сравнение и выравнивание;
- **упор на Apps**: приложения на всех серверах и синхронизация их конфигов.
Статус: план, ничего из этого ещё не сделано. Сейчас хаб только читает (`docs/hub-deploy.md`).
Статус: этапы 0 и 1 (безопасность, Apps по серверам), 2 (установка и обновление), 3 (сравнение и синхронизация конфигов), 4 (история, откат, расхождения) и 5 (управление каждым сервером) сделаны. Этапы 6 и далее пока планы (`docs/hub-deploy.md`).
---
+31 -4
View File
@@ -8,11 +8,11 @@ read-only, Technitium-compatible `/api/*`:
- 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. The only writes are the app changes below, which use a separate admin token and a role with `apps.write`. 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.
- 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 only needs a read-only API token. Create it
for a user whose group has View, and nothing else, on Dashboard, Logs, Apps (the logging
apps Query Logs reads from) and Settings (the blocking state).
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
@@ -86,6 +86,33 @@ Public: `status`, `user/login`. After signing in: `user/session/get`, `user/logo
Everything else answers `403 The hub is read-only.` and is audited.
## 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.
+7 -3
View File
@@ -40,8 +40,12 @@ function fake(): Promise<Fake> {
const url = new URL(req.url ?? '/', 'http://fake')
const apiPath = url.pathname.replace(/^\/api\//, '')
const token = url.searchParams.get('token') ?? ''
const name = url.searchParams.get('name') ?? ''
f.calls.push({ path: apiPath, token, method: req.method ?? '', query: url.searchParams, body: Buffer.concat(chunks), contentType: String(req.headers['content-type'] ?? '') })
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 }))
@@ -58,7 +62,7 @@ function fake(): Promise<Fake> {
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 === url.searchParams.get('url'))
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()
+96
View File
@@ -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
}
+128 -3
View File
@@ -3,8 +3,19 @@ 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`.
Not in the table: `status`, `user/login`, `user/session/get` and `user/logout`, which the
server answers itself before the table is consulted.
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'
@@ -13,11 +24,22 @@ 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(),
@@ -25,10 +47,81 @@ export const ROUTES: Record<string, Route> = {
'dashboard/metrics/json': read(),
'apps/list': read(),
'logs/query': read(),
'logs/list': 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'),
'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(),
@@ -69,4 +162,36 @@ 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')
}
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,
'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/')
+4 -4
View File
@@ -286,7 +286,7 @@ describe('audit', () => {
expect(adminLogin.body.hubRole).toBe('admin')
const viewer = (await login(base, 'vic', 'password2')).body.token as string
expect((await call(base, 'zones/delete', { zone: 'x', node: 'a' }, admin)).code).toBe(403)
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)
@@ -296,7 +296,7 @@ describe('audit', () => {
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: 'zones/delete', server: 'a' })
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)
@@ -319,11 +319,11 @@ describe('audit', () => {
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', 'logs/list',
'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('zones/delete')).toBeUndefined()
expect(routeFor('logs/delete')).toBeUndefined()
expect(routeFor('constructor')).toBeUndefined()
expect(routeFor('hub/nothing')).toBeUndefined()
})
+596
View File
@@ -0,0 +1,596 @@
// @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', {}],
] 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', { enableBlocking: 'true' }],
] 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()
})
})
+3 -2
View File
@@ -170,7 +170,8 @@ describe('session', () => {
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: false, canModify: false, canDelete: false })
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 () => {
@@ -196,7 +197,7 @@ describe('session', () => {
describe('read-only', () => {
it('refuses everything that is not a listed read', async () => {
const token = await login()
const { code, body } = await call('zones/delete', { zone: 'x' }, token, 'POST')
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)
+156 -22
View File
@@ -4,15 +4,19 @@ answers from several real ones.
node --experimental-strip-types hub/server.ts
It serves the console's static build and a read-only, Technitium-compatible `/api/*`.
A call aimed at one server (`node=<id>`) is forwarded to it with that server's token; a
call with no node (or `node=cluster`) asks every server in parallel and merges the
answers (see merge.ts). Anything that would change data is refused.
It serves the console's static build and a Technitium-compatible `/api/*`. A read aimed at
one server (`node=<id>`) is forwarded to it with that server's token; a read with no node (or
`node=cluster`) asks every server in parallel and merges the answers (see merge.ts), and the
reads that have no merger (`perServer` in routes.ts) are refused without a node. A change
is made on ONE chosen server with its admin token, or by the app and config endpoints; whatever
is not in the table of routes.ts is refused.
*/
import http from 'node:http'
import { createReadStream } from 'node:fs'
import { stat } from 'node:fs/promises'
import path from 'node:path'
import { Readable } from 'node:stream'
import type { ReadableStream as WebStream } from 'node:stream/web'
import { fileURLToPath } from 'node:url'
import { loadConfig, HUB_VERSION, type HubConfig, type HubServer } from './config.ts'
import { Upstream, UpstreamError, type Params } from './upstream.ts'
@@ -20,7 +24,8 @@ import { Sessions } from './session.ts'
import { Users, can } from './users.ts'
import { LoginLimiter, lockedMessage } from './ratelimit.ts'
import { Audit } from './audit.ts'
import { routeFor } from './routes.ts'
import { effectiveRoute, largeBodyLimit, routeFor, type Route } from './routes.ts'
import { WEB_SERVICE_MESSAGE, isForbiddenSettingsField, redactSettings, safeTarget, webServiceChanges } from './guards.ts'
import { buildMatrix } from './apps-matrix.ts'
import { AppsWriter, actionForPath, type FileAction, type TargetResult } from './apps-write.ts'
import { TooLarge, parseMultipart, readLimited, type UploadedFile } from './multipart.ts'
@@ -363,8 +368,8 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
}
/* The write endpoints. The role has already been checked against the route's capability. */
async function write(req: http.IncomingMessage, res: http.ServerResponse, apiPath: string, kind: string, params: Params, who: Who, ip: string): Promise<void> {
if (kind === 'write-all') {
async function write(req: http.IncomingMessage, res: http.ServerResponse, apiPath: string, route: Route, params: Params, who: Who, ip: string): Promise<void> {
if (route.kind === 'write-all') {
if (req.method !== 'POST') {
send(res, 405, fail('Use POST.'))
return
@@ -421,12 +426,12 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
// write-one: an upstream endpoint aimed at ONE server (`node=<id>`).
const action = actionForPath(apiPath)
const node = params.node ?? ''
const server = byId.get(node)
if (action === null) {
send(res, 403, fail('The hub is read-only.'))
await serverWrite(req, res, apiPath, route, params, who, ip)
return
}
const node = params.node ?? ''
const server = byId.get(node)
if (server === undefined) {
send(res, 400, fail(node === '' || node === 'cluster' ? 'Choose one server: this change cannot be made on every server at once here.' : `Unknown node: ${node}`))
return
@@ -462,12 +467,129 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
send(res, 200, result.ok ? ok(response) : fail(`${server.id}: ${result.message}`))
}
const textBody = (req: http.IncomingMessage): boolean => /^text\/plain/i.test(String(req.headers['content-type'] ?? ''))
/*
A change to ONE server from the sections it is managed from (zones, cache, blocking, DNS client,
settings, DHCP). The role has been checked against the route. The server is the one named by
`node`, and its admin token does the work. What is forwarded is exactly the parameters that were
looked at here, in a form body (see `Upstream.write`), minus `node` and `token`. The audit line
holds the path, the server, one identifier and the outcome, never a value.
*/
async function serverWrite(req: http.IncomingMessage, res: http.ServerResponse, apiPath: string, route: Route, params: Params, who: Who, ip: string): Promise<void> {
const node = params.node ?? ''
const server = byId.get(node)
const line = { user: who.user, role: who.role, ip, action: apiPath.replace(/\//g, '.') }
if (server === undefined) {
send(res, 400, fail(node === '' || node === 'cluster' ? 'Choose a server: this change is made on one server at a time.' : `Unknown node: ${node}`))
return
}
if (server.adminToken === undefined) {
audit.log({ ...line, server: server.id, target: safeTarget(params), outcome: 'refused', detail: 'no admin token' })
send(res, 403, fail(`${server.id}: the hub has no admin token for this server, so it cannot change it.`))
return
}
const forward: Params = {}
for (const [k, v] of Object.entries(params)) if (k !== 'node' && k !== 'token') forward[k] = v
// A file or a text body (a zone import). The parameters may also arrive as multipart fields.
let body: FormData | string | undefined
if (route.upload === true && (isMultipart(req) || textBody(req))) {
let data: Buffer
try {
data = await readLimited(req)
} catch (e) {
if (e instanceof TooLarge) {
res.setHeader('connection', 'close')
send(res, 413, fail(e.message))
} else send(res, 400, fail('The upload could not be read.'))
return
}
if (isMultipart(req)) {
let parsed
try {
parsed = parseMultipart(data, String(req.headers['content-type'] ?? ''))
} catch {
send(res, 400, fail('The upload could not be read.'))
return
}
for (const [k, v] of Object.entries(parsed.fields)) if (k !== 'node' && k !== 'token') forward[k] = v
const file = parsed.files[0]
if (file !== undefined) {
const form = new FormData()
form.append(file.field, new Blob([file.data], { type: file.type }), file.filename)
body = form
}
} else body = data.toString('utf8')
}
// settings/set: no change to the web service the hub itself uses, and nothing about users (guards.ts).
if (apiPath === 'settings/set') {
if (Object.keys(forward).some(isForbiddenSettingsField)) {
audit.log({ ...line, server: server.id, outcome: 'refused', detail: 'user, SSO or LDAP field' })
send(res, 400, fail("User, SSO and LDAP settings are changed in that server's own console."))
return
}
try {
upstream.invalidate(server.id)
const changed = webServiceChanges(forward, await upstream.call(server, 'settings/get', {}, server.adminToken))
if (changed.length > 0) {
audit.log({ ...line, server: server.id, outcome: 'refused', detail: `web service: ${changed.join(', ').slice(0, 200)}` })
send(res, 400, fail(WEB_SERVICE_MESSAGE))
return
}
} catch (e) {
if (!(e instanceof UpstreamError)) throw e
audit.log({ ...line, server: server.id, outcome: 'failed', detail: 'could not read the current settings' })
send(res, 200, fail(`${server.id}: the web service settings could not be checked first (${e.message}); nothing was changed.`))
return
}
}
try {
const response = await upstream.write(server, apiPath, forward, body)
audit.log({ ...line, server: server.id, target: safeTarget(forward), outcome: 'ok' })
send(res, 200, apiPath === 'settings/set' ? { ...ok(response), server: server.id } : ok(response))
} catch (e) {
if (!(e instanceof UpstreamError)) throw e
audit.log({ ...line, server: server.id, target: safeTarget(forward), outcome: 'failed', detail: e.kind === 'unreachable' ? 'no answer' : 'refused by the server' })
send(res, 200, fail(e.kind === 'unreachable' ? `${server.id} did not answer: ${e.message}` : `${server.id}: ${e.message}`))
}
}
/* A file from one server (a zone, a log, a block list), passed on as it comes. */
async function download(res: http.ServerResponse, server: HubServer, apiPath: string, params: Params): Promise<void> {
try {
const file = await upstream.raw(server, apiPath, params)
res.writeHead(200, {
'content-type': file.contentType,
...(file.disposition === undefined ? {} : { 'content-disposition': file.disposition }),
'cache-control': 'no-store',
'x-content-type-options': 'nosniff',
'content-security-policy': "default-src 'none'; sandbox",
})
if (file.body === null) {
res.end()
return
}
res.on('close', () => file.abort())
Readable.fromWeb(file.body as WebStream).on('error', () => res.destroy()).pipe(res)
} catch (e) {
if (!(e instanceof UpstreamError)) throw e
send(res, 200, fail(e.kind === 'unreachable' ? `${server.id} did not answer: ${e.message}` : `${server.id}: ${e.message}`))
}
}
async function api(req: http.IncomingMessage, res: http.ServerResponse, apiPath: string, url: URL): Promise<void> {
// A multipart body (an app zip) is read later, once the caller is known to be allowed to send it.
// A multipart body (an app zip, a zone file) and a text body (a pasted zone file) are read later,
// once the caller is known to be allowed to send them.
// A config body may be large, but only for a signed-in caller (token in the header or the query).
const large = (apiPath.startsWith('hub/apps/') || apiPath === 'apps/config/set')
&& sessions.touch(bearer(req) ?? url.searchParams.get('token')) !== null
const form = isMultipart(req) ? new URLSearchParams() : await readForm(req, !large ? MAX_BODY : apiPath === 'hub/apps/import' ? MAX_IMPORT_BODY : MAX_CONFIG_BODY)
const own = apiPath.startsWith('hub/apps/') || apiPath === 'apps/config/set'
const wanted = own ? (apiPath === 'hub/apps/import' ? MAX_IMPORT_BODY : MAX_CONFIG_BODY) : largeBodyLimit(apiPath)
const large = wanted !== undefined && sessions.touch(bearer(req) ?? url.searchParams.get('token')) !== null
const deferred = isMultipart(req) || (routeFor(apiPath)?.upload === true && textBody(req))
const form = deferred ? new URLSearchParams() : await readForm(req, large ? (wanted as number) : MAX_BODY)
const params: Params = {}
for (const [k, v] of url.searchParams) params[k] = v
for (const [k, v] of form) params[k] = v
@@ -501,7 +623,8 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
}
const token = bearer(req) ?? params.token ?? null
const who = sessions.touch(token)
// A download opened in a new tab carries a single-use token instead of the session's.
const who = sessions.touch(token) ?? (req.method === 'GET' && routeFor(apiPath)?.raw === true ? sessions.takeSingleUse(token) : null)
if (who === null) {
send(res, 200, { status: 'invalid-token', errorMessage: 'Invalid token or session expired.' })
return
@@ -516,7 +639,11 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
send(res, 200, { status: 'ok' })
return
}
const route = routeFor(apiPath)
if (apiPath === 'user/createSingleUseToken') {
send(res, 200, ok({ token: sessions.openSingleUse(token as string) }))
return
}
const route = effectiveRoute(apiPath, params)
if (route === undefined) {
audit.log({
user: who.user, role: who.role, ip, action: 'refused-write', target: apiPath,
@@ -538,7 +665,7 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
return
}
if (route.kind !== 'read') {
await write(req, res, apiPath, route.kind, params, who, ip)
await write(req, res, apiPath, route, params, who, ip)
return
}
if (apiPath.startsWith('hub/apps/config/') || apiPath.startsWith('hub/apps/history/') || apiPath === 'hub/apps/overrides/current' || apiPath === 'hub/apps/export') {
@@ -591,15 +718,22 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
// What the real servers get: the caller's params minus the hub's own.
const { node = '', token: _token, ...forward } = params
const server = byId.get(node)
if (server !== undefined) {
send(res, 200, await single(server, apiPath, forward))
// A read with no merger is never answered by "the first server" for the aggregate: it needs a server.
if (route.perServer === true && server === undefined) {
send(res, 400, fail(node === '' || node === 'cluster' ? 'Choose a server: this is read from one server at a time.' : `Unknown node: ${node}`))
return
}
if (node !== '' && node !== 'cluster') {
send(res, 200, fail(`Unknown node: ${node}`))
if (route.raw === true && server !== undefined) {
await download(res, server, apiPath, forward)
return
}
send(res, 200, await aggregate(apiPath, forward))
let answer: Json
if (server !== undefined) answer = await single(server, apiPath, forward)
else if (node !== '' && node !== 'cluster') answer = fail(`Unknown node: ${node}`)
else answer = await aggregate(apiPath, forward)
// The settings hold secrets (TSIG keys, passwords): only an admin sees them (guards.ts).
if (apiPath === 'settings/get' && !can(who.role, 'admin') && answer.status === 'ok') answer = { ...answer, response: redactSettings(answer.response) }
send(res, 200, answer)
}
async function serveStatic(req: http.IncomingMessage, res: http.ServerResponse, url: URL): Promise<void> {
+56 -9
View File
@@ -6,9 +6,11 @@ the console reads from `user/login` and `user/session/get` (flat, as the real se
*/
import { randomBytes } from 'node:crypto'
import { HUB_VERSION, type HubConfig } from './config.ts'
import { can, signature, type Role, type UserRecord, type Users } from './users.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
@@ -20,16 +22,34 @@ interface Session extends Identity {
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. The hub shows the
Dashboard, the Logs and the Apps; About has no permission and is always there. Only the Apps can be
modified or deleted, and only by a role with `apps.write`.
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 Settings key also gates the
temporary blocking switch in the console, which the hub itself would let an operator use:
the console is the stricter of the two there.
*/
const PERMISSION_KEYS = [
'Dashboard', 'Zones', 'Cache', 'Allowed', 'Blocked', 'Apps', 'DnsClient', 'Settings', 'DhcpServer', 'Administration', 'Logs',
]
const VIEWABLE = new Set(['Dashboard', 'Logs', 'Apps'])
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
@@ -37,6 +57,7 @@ export class Sessions {
#now: () => number
#started: string
#seen = new Map<string, Session>()
#single = new Map<string, SingleUse>()
#drift: () => number
@@ -75,9 +96,31 @@ export class Sessions {
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) {
@@ -87,10 +130,14 @@ export class Sessions {
uptimestamp: this.#started,
dnsServerDomain: domain,
permissions: Object.fromEntries(
PERMISSION_KEYS.map((key) => {
const writes = key === 'Apps' && can(role, 'apps.write')
return [key, { canView: VIEWABLE.has(key), canModify: writes, canDelete: writes }]
}),
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). */
+79 -12
View File
@@ -21,6 +21,15 @@ export class UpstreamError extends Error {
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
@@ -61,18 +70,73 @@ export class Upstream {
/*
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. `body` is a multipart upload (the app zip).
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): Promise<unknown> {
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 {
const init: RequestInit = body === undefined ? { method: 'POST' } : { method: 'POST', body }
return await this.#exec(server, path, new URLSearchParams(params), server.adminToken, init, Math.max(this.#config.timeoutMs, WRITE_TIMEOUT_MS))
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`
@@ -89,7 +153,7 @@ export class Upstream {
const res = await this.#fetch(`${server.url}/api/${path}?${query.toString()}`, {
...init,
signal: controller.signal,
headers: { accept: 'application/json' },
headers: { accept: 'application/json', ...(init.headers as Record<string, string> | undefined) },
})
envelope = (await res.json()) as typeof envelope
} catch (e) {
@@ -98,16 +162,19 @@ export class Upstream {
clearTimeout(timer)
}
if (envelope?.status !== 'ok') {
const why =
envelope?.status === 'invalid-token'
? 'its API token was refused'
: (envelope?.errorMessage ?? `status ${String(envelope?.status)}`)
throw new UpstreamError('rejected', why)
}
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)
+7 -6
View File
@@ -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))
}
+4 -3
View File
@@ -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
View File
@@ -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 } : {})
}
+14 -3
View File
@@ -32,6 +32,7 @@ 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 { HubMode, ThisServerName } from '../ui/ClusterNodeSelect'
import { HubWritable } from '../lib/hub-write'
import { FooterLinks } from '../ui/FooterLinks'
import { Versions } from './Versions'
import { BlockingMark } from './BlockingMark'
@@ -82,6 +83,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',
@@ -135,6 +139,8 @@ export function Shell({
}) {
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
const hubWarnings = useHubWarnings()
/* Behind a hub: the count comes with the session, the list is read now and then. */
const hubDriftAtStart = session.info?.hubDrift
@@ -422,6 +428,7 @@ export function Shell({
<HandoffContext.Provider value={handoff}>
<ThisServerName.Provider value={info?.dnsServerDomain ?? session.info?.dnsServerDomain ?? ''}>
<HubMode.Provider value={hub}>
<HubWritable.Provider value={hubWritable}>
<SlotProvider>
{(slot) => (
<div className={styles.shell} data-rail={rail}>
@@ -602,7 +609,7 @@ 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={session.info?.hubWritable ?? []} />}
{hub && <DriftStatus token={session.token} canWrite={permissions?.Apps?.canModify === true} writable={hubWritable} />}
<Popover>
<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')} />
@@ -657,20 +664,22 @@ export function Shell({
token={session.token}
nodes={session.info?.clusterNodes ?? []}
clusterInitialised={session.info?.clusterInitialized === true}
canBlock={hub ? permissions?.Settings?.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} />
) : current?.id === 'apps' ? (
<Apps token={session.token} nodes={session.info?.clusterNodes ?? []} clusterInitialised={session.info?.clusterInitialized === true} canWrite={permissions?.Apps?.canModify === true} writable={session.info?.hubWritable ?? []} />
<Apps token={session.token} nodes={session.info?.clusterNodes ?? []} clusterInitialised={session.info?.clusterInitialized === true} canWrite={permissions?.Apps?.canModify === true} writable={hubWritable} />
) : 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}
@@ -716,6 +725,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}
@@ -837,6 +847,7 @@ export function Shell({
</div>
)}
</SlotProvider>
</HubWritable.Provider>
</HubMode.Provider>
</ThisServerName.Provider>
</HandoffContext.Provider>
+8 -3
View File
@@ -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') {
+51
View File
@@ -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'])
})
})
+75
View File
@@ -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])
+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('.')
+22 -3
View File
@@ -2,6 +2,9 @@ import { useState } from 'react'
import { getSettings, setSettings, temporaryDisableBlocking } from '../../api/settings'
import { Confirm } from '../../ui/Confirm'
import { Menu } from '../../ui/Menu'
import { Button } from '../../ui/Button'
import { Tooltip } from '../../ui/Tooltip'
import { nodeArg, useHubWrite } from '../../lib/hub-write'
import type { AlertType } from '../../ui/Alert'
import { noticeFromFailure } from '../../lib/notice'
@@ -53,8 +56,14 @@ export function BlockingMenu({
onNotice,
text = 'Blocking',
onChanged,
node = '',
allowed = true,
}: {
token: string | null
/** Behind a hub: the server the change is made on (empty or "cluster": none chosen, so it is switched off). */
node?: string
/** Behind a hub: what the role may do. */
allowed?: boolean
onNotice: (a: { type: AlertType; title: string; text: string }) => void
/** The trigger text. The Dashboard keeps upstream's "Blocking". */
text?: string
@@ -67,7 +76,7 @@ export function BlockingMenu({
async function loadState() {
setActive(null)
const s = await getSettings(token)
const s = await getSettings(token, ...nodeArg(node))
if (s != null) setActive(s.enableBlocking)
}
@@ -78,7 +87,7 @@ export function BlockingMenu({
label: turnOn ? 'Enable' : 'Disable',
variant: turnOn ? 'primary' : 'danger',
perform: async () => {
const r = await setSettings(token, { enableBlocking: String(turnOn) })
const r = await setSettings(token, { enableBlocking: String(turnOn), ...(node ? { node } : {}) })
// The same text as the other thirty-six: it was the only one that said
// a bare "Session expired." for this very condition.
if (r.kind !== 'ok') throw new Error(noticeFromFailure(r).text)
@@ -99,7 +108,7 @@ export function BlockingMenu({
label: 'Disable',
variant: 'danger',
perform: async () => {
const till = await temporaryDisableBlocking(token, String(minutes))
const till = await temporaryDisableBlocking(token, String(minutes), ...nodeArg(node))
if (till == null) throw new Error('The request failed.')
setActive(false)
onNotice({
@@ -126,6 +135,16 @@ export function BlockingMenu({
}
}
/* Behind a hub, only on a chosen server the hub can write to; the tooltip says why not. */
const write = useHubWrite(node, allowed)
if (!write.allowed) {
return (
<Tooltip text={write.reason ?? 'Requires Settings: Modify'}>
<Button disabled>{text}</Button>
</Tooltip>
)
}
return (
<>
<Menu label="Blocking options" text={text} onOpen={() => void loadState()}>
+12 -5
View File
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useContext, useEffect, useMemo, useRef, useState } from 'react'
import { HubNode } from '../../lib/hub-write'
import {
RANGE_LABEL, RANGES, getDashboardStats,
type DashboardStats, type Range, type Stats, type TopKind,
@@ -13,7 +14,7 @@ import { Heatmap } from './Heatmap.tsx'
import {
bucketWindow, periodFilters, protocolFilter, queryTypeFilter, responseTypeFilter,
} from './jump'
import { ClusterNodeSelect, AGGREGATE, type ClusterNode } from '../../ui/ClusterNodeSelect'
import { ClusterNodeSelect, AGGREGATE, HubMode, type ClusterNode } from '../../ui/ClusterNodeSelect'
import { InChrome } from '../../app/ChromeSlot'
import { TopStats } from './TopStats'
import { TopRowMenu } from './TopRowMenu'
@@ -52,11 +53,15 @@ export function Dashboard({
token,
nodes = [],
clusterInitialised = false,
canBlock = true,
}: {
token: string | null
nodes?: ClusterNode[]
clusterInitialised?: boolean
/** Behind a hub: whether the role may switch blocking or allow and block a domain. */
canBlock?: boolean
}) {
const hub = useContext(HubMode)
/*
Which node this screen is reading. Upstream offers the aggregate here and
remembers the choice per screen (`cluster.js`, key `dashboardClusterNode`).
@@ -96,7 +101,7 @@ export function Dashboard({
const [notice, setNotice] = useState<{ type: AlertType; title: string; text: string } | null>(null)
/* Allowing or blocking from a top list reports on the page (main.js:2825 and
2853 pass no alert placeholder). */
const action = useDomainAction(token, setNotice)
const action = useDomainAction(token, setNotice, hub ? node : '')
/*
The custom range. `start`/`end` are what is typed into the two fields;
`requested` is the last thing "Show" was pressed with, which is what triggers the
@@ -490,7 +495,7 @@ export function Dashboard({
rows={view?.topBlockedDomains ?? []}
onMore={() => setTop('TopBlockedDomains')}
failure={failure}
beforeMore={<BlockingMenu token={token} onNotice={setNotice} />}
beforeMore={<BlockingMenu token={token} onNotice={setNotice} node={hub ? node : ''} allowed={canBlock} />}
menu={(f) => <TopRowMenu kind="TopBlockedDomains" name={f.name} node={node} action={action} />}
/>
<Breakdown
@@ -505,7 +510,9 @@ export function Dashboard({
/>
</div>
<TopStats type={top} range={range} token={token} node={node} onClose={() => setTop(null)} />
<HubNode.Provider value={hub ? node : ''}>
<TopStats type={top} range={range} token={token} node={node} onClose={() => setTop(null)} />
</HubNode.Provider>
</>
)
}
+68
View File
@@ -0,0 +1,68 @@
import { describe, expect, it, vi, afterEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Dhcp } from './Dhcp'
import * as api from '../../api/dhcp'
import { HubMode } from '../../ui/ClusterNodeSelect'
import { HubWritable } from '../../lib/hub-write'
afterEach(() => vi.restoreAllMocks())
/* Behind a hub the screen starts on the first server, and changes only where the hub can write. */
const NODES = [
{ name: 'mdns', type: 'Primary' },
{ name: 'ndns', type: 'Secondary' },
]
function hubDhcp(writable: string[], canModify = true) {
vi.spyOn(api, 'listScopes').mockResolvedValue({ kind: 'ok', data: [] })
return render(
<HubMode.Provider value>
<HubWritable.Provider value={writable}>
<Dhcp token="t" sub="Scopes" nodes={NODES} clusterInitialised canModify={canModify} canDelete={canModify} />
</HubWritable.Provider>
</HubMode.Provider>,
)
}
describe('DHCP behind a hub', () => {
it('starts on the first server, named in the request and shown in the selector', async () => {
hubDhcp(['mdns', 'ndns'])
await screen.findByText('No Scope Found')
expect(api.listScopes).toHaveBeenCalledWith('t', 'mdns')
expect(screen.getByRole('combobox', { name: 'Server' })).toHaveTextContent('mdns')
expect(screen.queryByRole('option', { name: 'All servers' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Add Scope' })).toBeInTheDocument()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
it('switches changes off on a server the hub holds no admin token for, and says why', async () => {
const user = userEvent.setup()
hubDhcp(['mdns'])
await screen.findByText('No Scope Found')
await user.click(screen.getByRole('combobox', { name: 'Server' }))
await user.click(await screen.findByRole('option', { name: 'ndns' }))
expect(await screen.findByText(/No admin token configured on the hub/)).toBeInTheDocument()
expect(api.listScopes).toHaveBeenLastCalledWith('t', 'ndns')
expect(screen.queryByRole('button', { name: 'Add Scope' })).not.toBeInTheDocument()
})
it('says nothing about the server when the role is what forbids the change', async () => {
hubDhcp(['mdns'], false)
await screen.findByText('No Scope Found')
expect(screen.queryByRole('button', { name: 'Add Scope' })).not.toBeInTheDocument()
expect(screen.queryByText(/admin token|Choose a server/)).not.toBeInTheDocument()
})
})
describe('DHCP outside a hub', () => {
it('keeps the empty node and no hint', async () => {
vi.spyOn(api, 'listScopes').mockResolvedValue({ kind: 'ok', data: [] })
render(<Dhcp token="t" sub="Scopes" />)
await screen.findByText('No Scope Found')
expect(api.listScopes).toHaveBeenCalledWith('t', '')
expect(screen.getByRole('button', { name: 'Add Scope' })).toBeInTheDocument()
})
})
+11 -3
View File
@@ -3,6 +3,8 @@ import { SubTabs } from '../../ui/SubTabs'
import { ClusterNodeSelect } from '../../ui/ClusterNodeSelect'
import { Leases } from './Leases'
import { Scopes } from './Scopes'
import { useHubDefaultNode, useHubWrite } from '../../lib/hub-write'
import { HubWriteHint } from '../../ui/HubWriteHint'
/*
DHCP. Two sub-tabs, with upstream's literal labels (index.html:2496-2497):
@@ -48,12 +50,17 @@ export function Dhcp({
clusterInitialised = false,
token,
sub,
canModify = true,
canDelete = true,
canModify: mayModify = true,
canDelete: mayDelete = true,
}: DhcpProps) {
/* Which node this screen reads. Upstream mounts a selector here
and the parameter was already travelling empty. */
const [node, setNode] = useState<string>(() => '')
const defaultNode = useHubDefaultNode(nodes)
const [node, setNode] = useState<string>(defaultNode)
/* Behind a hub, a change is allowed only on a chosen server the hub can write to. */
const write = useHubWrite(node, mayModify)
const canModify = write.allowed
const canDelete = useHubWrite(node, mayDelete).allowed
const [nodeLocked, setNodeLocked] = useState(false)
const requested = (sub ?? 'Leases') as SubTab
const active: SubTab = SUB_TABS.includes(requested) ? requested : 'Leases'
@@ -85,6 +92,7 @@ export function Dhcp({
disabled={nodeLocked}
label="Cluster Node"
/>
<HubWriteHint write={write} />
{active === 'Scopes' ? (
<Scopes
tabs={tabs}
+13 -3
View File
@@ -4,6 +4,9 @@ import type { DnsClientRequest } from '../../app/handoff'
import { PROTOCOLS, TYPES, prepareServer, resolve } from '../../api/dnsclient'
import { type AlertType } from '../../ui/Alert'
import { Button } from '../../ui/Button'
import { PermissionButton } from '../../ui/PermissionButton'
import { useHubDefaultNode, useHubWrite } from '../../lib/hub-write'
import { HubWriteHint } from '../../ui/HubWriteHint'
import { Check } from '../../ui/Check'
import { Field, Input, LabeledInput, LabeledSelect } from '../../ui/Field'
import { Menu } from '../../ui/Menu'
@@ -32,18 +35,24 @@ export function DnsClient({
token,
nodes = [],
clusterInitialised = false,
canImport = true,
request,
}: {
token: string | null
/** The cluster nodes, for the node selector. */
nodes?: { name: string; type: string }[]
clusterInitialised?: boolean
/** `DnsClient.canModify`: Import writes the answer into a zone of the server. */
canImport?: boolean
/** A query another screen asked for: fill the form with it and run it. */
request?: DnsClientRequest
}) {
/* Upstream mounts one here (`optDnsClientClusterNode`), no aggregate, no
persistence. The jump picks a node only when it was given one. */
const [clusterNode, setClusterNode] = useState<string>(() => request?.node ?? '')
const defaultNode = useHubDefaultNode(nodes)
const [clusterNode, setClusterNode] = useState<string>(() => (request?.node != null && request.node !== 'cluster' ? request.node : defaultNode))
/* Behind a hub, Import is allowed only on a chosen server the hub can write to. */
const write = useHubWrite(clusterNode, canImport)
const [server, setServer] = useState('This Server {this-server}')
@@ -155,6 +164,7 @@ export function DnsClient({
onChange={setClusterNode}
label="Cluster Node"
/>
<HubWriteHint write={write} />
<Notifier notice={alert} onClose={() => setAlert(null)} />
@@ -223,9 +233,9 @@ export function DnsClient({
<Button variant="primary" disabled={busy} onClick={() => void fire(false)}>
Resolve
</Button>
<Button disabled={busy} onClick={() => void fire(true)}>
<PermissionButton permission={canImport ? undefined : 'DnsClient.canModify'} disabled={busy || !write.allowed} onClick={() => void fire(true)}>
Import
</Button>
</PermissionButton>
</div>
</Panel>
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it, vi, afterEach } from 'vitest'
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Cache } from './Lists'
import * as api from '../../api/zonelists'
import { HubMode } from '../../ui/ClusterNodeSelect'
import { HubWritable } from '../../lib/hub-write'
afterEach(() => vi.restoreAllMocks())
const NODES = [
{ name: 'mdns', type: 'Primary' },
{ name: 'ndns', type: 'Secondary' },
]
function hubCache(writable: string[], canDelete = true) {
vi.spyOn(api, 'listNode').mockResolvedValue({ kind: 'ok', data: { domain: '', zones: ['com'], records: [] } })
vi.spyOn(api, 'flushCache').mockResolvedValue({ kind: 'ok', data: {} })
return render(
<HubMode.Provider value>
<HubWritable.Provider value={writable}>
<Cache token="t" nodes={NODES} clusterInitialised canDelete={canDelete} />
</HubWritable.Provider>
</HubMode.Provider>,
)
}
describe('Cache behind a hub', () => {
it('reads the first server and flushes it', async () => {
const user = userEvent.setup()
hubCache(['mdns', 'ndns'])
const flush = await screen.findByRole('button', { name: 'Flush Cache' })
expect(api.listNode).toHaveBeenCalledWith('cache', 't', '', undefined, 'mdns')
expect(flush).toBeEnabled()
await user.click(flush)
await user.click(within(await screen.findByRole('alertdialog')).getByRole('button', { name: 'Flush Cache' }))
expect(api.flushCache).toHaveBeenCalledWith('t', 'mdns')
})
it('switches Flush Cache off on a server without an admin token, with the reason', async () => {
const user = userEvent.setup()
hubCache(['mdns'])
await screen.findByRole('button', { name: 'Flush Cache' })
await user.click(screen.getByRole('combobox', { name: 'Server' }))
await user.click(await screen.findByRole('option', { name: 'ndns' }))
expect(await screen.findByText(/No admin token configured on the hub/)).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Flush Cache' })).toBeDisabled()
})
})
+29 -11
View File
@@ -1,6 +1,8 @@
import { KpiStrip } from '../../ui/KpiStrip'
import { ClusterNodeSelect, primaryNodeName } from '../../ui/ClusterNodeSelect'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useContext, useEffect, useRef, useState } from 'react'
import { HubNode, useHubDefaultNode, useHubWrite } from '../../lib/hub-write'
import { HubWriteHint } from '../../ui/HubWriteHint'
import {
deleteDomain,
deleteCacheNode,
@@ -87,12 +89,15 @@ export function ImportDomains({
list,
open,
token,
node = '',
onClose,
onDone,
}: {
list: DomainList
open: boolean
token: string | null
/** Behind a hub: the server the lists are imported into. */
node?: string
onClose: () => void
onDone: (n: Notice) => void
}) {
@@ -133,7 +138,7 @@ export function ImportDomains({
}
setBusy(true)
const outcome = await importDomains(list, token, zones)
const outcome = await importDomains(list, token, zones, node)
setBusy(false)
if (outcome.kind !== 'ok') {
@@ -231,10 +236,19 @@ export function Lists({
* read its own view of the list again. */
onChanged?: () => void
}) {
const [clusterNode, setClusterNode] = useState<string>('')
/* Behind a hub the empty node is the hub, not a server: the cache starts on the first one. */
const defaultNode = useHubDefaultNode(nodes)
const [clusterNode, setClusterNode] = useState<string>(defaultNode)
/* Read by `load` through a ref, so choosing a node does not rebuild it (and
re-run the mount effect that reads the root). */
const cacheNode = useRef('')
const cacheNode = useRef(defaultNode)
/* Inside Blocking behind a hub: the server its Rules tab has chosen. The lists are that server's. */
const sectionNode = useContext(HubNode)
const sectionRef = useRef(sectionNode)
const listServer = embedded ? sectionNode : ''
/* A change is allowed only on a chosen server the hub can write to (outside a hub: the permission). */
const cacheWrite = useHubWrite(clusterNode, canDelete)
const listWrite = useHubWrite(listServer, canDelete)
const [node, setNode] = useState<ListNode | null>(null)
const [field, setField] = useState('')
@@ -280,7 +294,8 @@ export function Lists({
const primary = useRef('')
useEffect(() => {
primary.current = primaryNodeName(nodes, clusterInitialised)
}, [nodes, clusterInitialised])
sectionRef.current = embedded ? sectionNode : ''
}, [nodes, clusterInitialised, embedded, sectionNode])
/* Bumped whenever what the cache tree has read is out of date. */
const [treeEpoch, setTreeEpoch] = useState(0)
@@ -289,7 +304,7 @@ export function Lists({
async (domain: string, up?: boolean, fromPrimary = false) => {
/* other-zones.js:269 and 434: after a change, Allowed and Blocked are read
back from the PRIMARY node, where the change was made. Cache never is. */
const node = list === 'cache' ? cacheNode.current : fromPrimary ? primary.current : ''
const node = list === 'cache' ? cacheNode.current : sectionRef.current !== '' ? sectionRef.current : fromPrimary ? primary.current : ''
const outcome = await listNode(list, token, domain, up ? 'up' : undefined, node)
if (outcome.kind === 'ok') {
setNode(outcome.data)
@@ -424,7 +439,7 @@ export function Lists({
label: 'Delete',
action: () =>
mutate(
() => deleteDomain(domainList, token, nodeTitle),
() => deleteDomain(domainList, token, nodeTitle, listServer),
isAllowed
? {
type: 'success',
@@ -464,6 +479,7 @@ export function Lists({
label="Cluster Node"
/>
)}
{isCache && !embedded && <HubWriteHint write={cacheWrite} />}
{/* On Cache the identity band's second line moves up under the title: the
screen is one panel now, and a band naming the panel under a header
@@ -474,7 +490,7 @@ export function Lists({
subtitle={isCache ? 'Records cached by the recursive resolver' : undefined}
actions={
isCache ? (
<Button variant="danger" disabled={busy} onClick={askFlushCache}>
<Button variant="danger" disabled={busy || !cacheWrite.allowed} onClick={askFlushCache}>
Flush Cache
</Button>
) : undefined
@@ -630,7 +646,7 @@ export function Lists({
<PermissionButton
size="sm"
variant="danger"
disabled={busy}
disabled={busy || !(isCache ? cacheWrite : listWrite).allowed}
permission={canDelete ? undefined : `${TITLE[list]}.canDelete`}
onClick={isCache ? askDeleteCacheNode : askDeleteDomain}
>
@@ -676,10 +692,12 @@ export function Lists({
)
}
export function Cache({ token, nodes, clusterInitialised }: {
export function Cache({ token, nodes, clusterInitialised, canDelete }: {
token: string | null
nodes?: { name: string; type: string }[]
clusterInitialised?: boolean
/** `Cache.canDelete`: Flush and Delete. */
canDelete?: boolean
}) {
return <Lists list="cache" token={token} nodes={nodes} clusterInitialised={clusterInitialised} />
return <Lists list="cache" token={token} nodes={nodes} clusterInitialised={clusterInitialised} canDelete={canDelete} />
}
+22 -4
View File
@@ -1,6 +1,8 @@
import { useState } from 'react'
import { useContext, useState } from 'react'
import { HubNode, useHubDefaultNode, useHubWrite } from '../../lib/hub-write'
import { HubWriteHint } from '../../ui/HubWriteHint'
import { SubTabs } from '../../ui/SubTabs'
import { ClusterNodeSelect } from '../../ui/ClusterNodeSelect'
import { AGGREGATE, ClusterNodeSelect, HubMode } from '../../ui/ClusterNodeSelect'
import { QueryLogs } from './QueryLogs'
import { ViewLogs } from './ViewLogs'
import type { QueryLogsRequest } from '../../app/handoff'
@@ -36,6 +38,8 @@ export interface LogsProps {
canDeleteLogs?: boolean
/** `Dashboard.canDelete`: deleting all the statistics. */
canDeleteStats?: boolean
/** `Allowed.canModify` and `Blocked.canModify`: the Allow and Block actions of a Query Logs row. */
canBlock?: boolean
/** The cluster node. Empty means "this server". */
node?: string
/** `showQueryLogs` from another screen: Query Logs filtered and run on arrival. */
@@ -50,14 +54,24 @@ export function Logs({
sub,
canDeleteLogs = true,
canDeleteStats = true,
canBlock = true,
request,
}: LogsProps) {
/* Which node this screen reads. Upstream mounts a selector here
and the parameter was already travelling empty. `showQueryLogs` picks one
only when it was given one (logs.js:634-635). */
const [node, setNode] = useState<string>(() => request?.node ?? '')
const hub = useContext(HubMode)
const defaultNode = useHubDefaultNode(nodes)
/* Behind a hub the empty node is the hub, not a server. Query Logs merges every server's rows
("All servers", where it starts); a log file belongs to one server, so View Logs always shows
one (the first unless chosen) and never labels one server's file as all of them. */
const [picked, setNode] = useState<string>(() => request?.node ?? (hub ? AGGREGATE : ''))
const requested = (sub ?? 'View Logs') as SubTab
const active: SubTab = SUB_TABS.includes(requested) ? requested : 'View Logs'
const aggregateShown = hub && active === 'Query Logs'
const node = hub && !aggregateShown && (picked === '' || picked === AGGREGATE) ? defaultNode : picked
/* The Allow and Block actions of a row: one server at a time. */
const write = useHubWrite(node, canBlock)
/*
The bar is built once here and handed to whichever sub-screen is drawn: the same
@@ -83,10 +97,14 @@ export function Logs({
initialised={clusterInitialised}
value={node}
onChange={setNode}
aggregate={aggregateShown}
label="Cluster Node"
/>
{active === 'Query Logs' && <HubWriteHint write={write} />}
{active === 'Query Logs' ? (
<QueryLogs tabs={tabs} token={token} node={node} request={request} />
<HubNode.Provider value={hub ? node : ''}>
<QueryLogs tabs={tabs} token={token} node={node} request={request} canBlock={write.allowed} />
</HubNode.Provider>
) : (
<ViewLogs tabs={tabs}
token={token}
+4 -2
View File
@@ -319,11 +319,13 @@ export interface QueryLogsProps {
tabs?: ReactNode
token: string | null
node?: string
/** Whether a row may be allowed or blocked (behind a hub: a server is chosen and writable). */
canBlock?: boolean
/** `showQueryLogs` from another screen: these filters, queried on arrival. */
request?: QueryLogsRequest
}
export function QueryLogs({ tabs, token, node = '', request }: QueryLogsProps) {
export function QueryLogs({ tabs, token, node = '', request, canBlock = true }: QueryLogsProps) {
const [apps, setApps] = useState<{ name: string; classPaths: string[] }[] | null>(null)
const [f, setF] = useState<Filters>(() => defaultFilters('', ''))
const [page, setPage] = useState<QueryLogPage | null>(null)
@@ -1041,7 +1043,7 @@ export function QueryLogs({ tabs, token, node = '', request }: QueryLogsProps) {
</button>
<button
type="button"
disabled={action.isBusy(key)}
disabled={action.isBusy(key) || !canBlock}
onClick={() => {
close()
void action.run(key, verb, domain)
+21 -12
View File
@@ -1,5 +1,7 @@
import { ClusterNodeSelect } from '../../ui/ClusterNodeSelect'
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { ClusterNodeSelect, HubMode } from '../../ui/ClusterNodeSelect'
import { useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { nodeArg, useHubWrite } from '../../lib/hub-write'
import { HubWriteHint } from '../../ui/HubWriteHint'
import { PermissionButton } from '../../ui/PermissionButton'
import { SectionHeader } from '../../ui/SectionHeader'
import { SubTabs } from '../../ui/SubTabs'
@@ -19,7 +21,7 @@ import {
type DnsSettings,
type SettingsEnvelope,
} from '../../api/settings'
import { buildBody, formFromSettings, enabled, selectedNode, type SettingsForm } from './model'
import { buildBody, formFromSettings, enabled, hubSelectedNode, selectedNode, type SettingsForm } from './model'
import { pendingByPane, pendingText } from './pending'
import { detectReverseProxy, REDIRECT_DELAY_MS, webConsoleRedirection } from './redirect'
import { General } from './panes/General'
@@ -193,13 +195,19 @@ export function Settings({
See `selectedNode`. Upstream stores the held value on every load
(main.js:905), so a standalone server remembers `""`.
*/
const hub = useContext(HubMode)
const [remembered, setNode] = useState<string | null>(() =>
localStorage.getItem('settingsClusterNode'),
)
const node = selectedNode(remembered, clusterInitialised, nodes)
/* Behind a hub there is no "All servers" here: every setting belongs to one server, so the
remembered one if it still exists, else the first. A save sends the whole form to it. */
const node = hub ? hubSelectedNode(remembered, nodes) : selectedNode(remembered, clusterInitialised, nodes)
useEffect(() => {
localStorage.setItem('settingsClusterNode', node)
}, [node])
/* Behind a hub, a change is allowed only on a chosen server the hub can write to. */
const write = useHubWrite(node, canModify)
const flushWrite = useHubWrite(node, canFlushCache)
/*
Two pieces of upstream's global state this screen reads and writes.
@@ -317,7 +325,7 @@ export function Settings({
async function save() {
if (form == null) return
const result = buildBody(form, node)
const result = buildBody(form, node, hub)
if (result.error) {
const { title, text, tab } = result.error
@@ -379,7 +387,7 @@ export function Settings({
const minutes = form.temporaryDisableBlockingMinutes
setConfirm(null)
setBusy(true)
const till = await temporaryDisableBlocking(token, minutes)
const till = await temporaryDisableBlocking(token, minutes, ...nodeArg(hub ? node : ''))
setBusy(false)
if (till == null) return
@@ -398,7 +406,7 @@ export function Settings({
async function updateLists() {
setConfirm(null)
setBusy(true)
const ok = await forceUpdateBlockLists(token)
const ok = await forceUpdateBlockLists(token, ...nodeArg(hub ? node : ''))
setBusy(false)
if (!ok) return
// main.js:2356. The label becomes "Updating Now" without reloading the settings.
@@ -462,7 +470,7 @@ export function Settings({
const props = { f: form, set, en }
/* What the save would change, pane by pane (`pending.ts`). */
const pending = loadedForm == null ? [] : pendingByPane(loadedForm, form, node, SUB_TABS)
const pending = loadedForm == null ? [] : pendingByPane(loadedForm, form, node, SUB_TABS, hub)
const pendingWords = pendingText(pending)
return (
@@ -494,7 +502,7 @@ export function Settings({
<ClusterNodeSelect
nodes={nodes}
initialised={clusterInitialised}
aggregate
aggregate={!hub}
value={node}
onChange={setNode}
label="Cluster Node"
@@ -521,7 +529,7 @@ export function Settings({
</PermissionButton>
<PermissionButton
variant="primary"
disabled={busy}
disabled={busy || !write.allowed}
permission={canModify ? undefined : 'Settings.canModify'}
onClick={() => void save()}
>
@@ -529,7 +537,7 @@ export function Settings({
</PermissionButton>
<PermissionButton
variant="danger"
disabled={busy}
disabled={busy || !flushWrite.allowed}
permission={canFlushCache ? undefined : 'Cache.canDelete'}
onClick={() => setConfirm('flush')}
>
@@ -539,6 +547,7 @@ export function Settings({
}
/>
<HubWriteHint write={write} />
<Notifier notice={notice} onClose={() => setNotice(null)} />
{/*
@@ -566,7 +575,7 @@ export function Settings({
onTemporaryDisable: askDisableBlocking,
onUpdateNow: () => setConfirm('update'),
hasSavedBlockLists: settings.blockListUrls != null,
busy,
busy: busy || !write.allowed,
}}
/>
)}
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest'
import { hubSelectedNode, nodeScope } from './model'
import { withoutWrites } from '../blocking/permissions'
/* Behind a hub every setting belongs to the one server chosen. */
describe('hubSelectedNode', () => {
const nodes = [{ name: 'mdns' }, { name: 'ndns' }]
it('keeps the remembered server and never offers the aggregate', () => {
expect(hubSelectedNode('ndns', nodes)).toBe('ndns')
expect(hubSelectedNode('cluster', nodes)).toBe('mdns')
expect(hubSelectedNode(null, nodes)).toBe('mdns')
expect(hubSelectedNode('gone', nodes)).toBe('mdns')
})
})
describe('nodeScope with the whole form', () => {
it('sends both blocks to a named server, where a cluster would send only its node blocks', () => {
expect(nodeScope('mdns')).toEqual({ cluster: false, node: true })
expect(nodeScope('mdns', true)).toEqual({ cluster: true, node: true })
})
})
describe('withoutWrites', () => {
it('switches off modify and delete on the blocking sections and leaves the rest', () => {
const perms = {
Allowed: { canView: true, canModify: true, canDelete: true },
Blocked: { canView: true, canModify: true, canDelete: true },
Settings: { canView: true, canModify: true, canDelete: false },
Dashboard: { canView: true, canModify: false, canDelete: false },
}
const off = withoutWrites(perms)!
expect(off.Allowed).toEqual({ canView: true, canModify: false, canDelete: false })
expect(off.Blocked.canModify).toBe(false)
expect(off.Settings).toEqual({ canView: true, canModify: false, canDelete: false })
expect(off.Dashboard).toEqual(perms.Dashboard)
expect(withoutWrites(undefined)).toBeUndefined()
})
})
+13 -3
View File
@@ -500,6 +500,13 @@ export function selectedNode(
return nodes.length > 0 ? nodes[0].name : wanted
}
/* Behind a hub: the remembered server if it is still listed, else the first. No aggregate, so the
remembered "cluster" of a console used without a hub is not honoured. */
export function hubSelectedNode(remembered: string | null, nodes: readonly { name: string }[]): string {
if (remembered != null && nodes.some((n) => n.name === remembered)) return remembered
return nodes.length > 0 ? nodes[0].name : ''
}
/*
Which blocks a save carries depends on the node chosen in the selector
(main.js:1639-1644):
@@ -513,17 +520,20 @@ The skipped blocks are skipped whole, VALIDATIONS INCLUDED: with a node chosen,
an empty Resolver Retries does not stop the save, because the Recursion block
never runs. The comparison is upstream's literal `"cluster"`.
*/
export function nodeScope(node: string): { cluster: boolean; node: boolean } {
export function nodeScope(node: string, whole = false): { cluster: boolean; node: boolean } {
/* Behind a hub there is no cluster: every setting belongs to the one server chosen, so a save
sends both blocks (`whole`), with that server as `node`. */
if (whole) return { cluster: true, node: true }
const includeClusterParameters = node === '' || node === 'cluster'
const includeNodeParameters = node === '' || !includeClusterParameters
return { cluster: includeClusterParameters, node: includeNodeParameters }
}
export function buildBody(f: SettingsForm, node = ''): BodyResult {
export function buildBody(f: SettingsForm, node = '', whole = false): BodyResult {
// `node` is always the first parameter, even when empty (main.js:1644).
const body: Record<string, string> = { node }
const sanitised: Partial<SettingsForm> = {}
const include = nodeScope(node)
const include = nodeScope(node, whole)
const missing = (text: string, tab: string, field: string): BodyResult => ({
error: { title: 'Missing!', text, tab, field },
+4 -3
View File
@@ -249,8 +249,8 @@ export const FIELDS: Record<Key, Field> = {
} as Record<Key, Field>
/** Whether a save with this form, to this node, would send the field. */
export function sends(field: Field, f: SettingsForm, node: string): boolean {
return nodeScope(node)[field.scope] && (field.when == null || field.when(f))
export function sends(field: Field, f: SettingsForm, node: string, whole = false): boolean {
return nodeScope(node, whole)[field.scope] && (field.when == null || field.when(f))
}
/** The panes with something a save would change, in their tab order, and how many fields each. */
@@ -259,10 +259,11 @@ export function pendingByPane(
form: SettingsForm,
node: string,
panes: readonly SubTab[],
whole = false,
): { pane: SubTab; fields: number }[] {
const count = new Map<SubTab, number>()
for (const field of Object.values(FIELDS)) {
if (!sends(field, form, node)) continue
if (!sends(field, form, node, whole)) continue
if (field.sent(form) === field.sent(saved)) continue
count.set(field.pane, (count.get(field.pane) ?? 0) + 1)
}
+17 -6
View File
@@ -1,5 +1,8 @@
import { ClusterNodeSelect } from '../../ui/ClusterNodeSelect'
import { useState } from 'react'
import { useContext, useState } from 'react'
import { HubMode } from '../../ui/ClusterNodeSelect'
import { useHubDefaultNode, useHubWrite } from '../../lib/hub-write'
import { HubWriteHint } from '../../ui/HubWriteHint'
import type { ResourceRecord, ZoneDetails } from '../../api/records'
import { Confirm } from '../../ui/Confirm'
import { ZoneList } from './ZoneList'
@@ -70,8 +73,8 @@ export function Zones({
token,
nodes = [],
clusterInitialised = false,
canModify,
canDelete,
canModify: mayModify,
canDelete: mayDelete,
useSoaSerialDateScheme = false,
dnssecValidation = false,
recordTtls = DEFAULT_TTLS,
@@ -79,7 +82,14 @@ export function Zones({
}: ZonesProps) {
/* Upstream mounts a selector here (`optZonesClusterNode`) with no aggregate
and no persistence; the parameter was already travelling. */
const [node, setNode] = useState<string>('')
const hub = useContext(HubMode)
/* Behind a hub the empty node is the hub, not a server: the first one is chosen, and a change
is allowed only on a server the hub can write to. */
const defaultNode = useHubDefaultNode(nodes)
const [node, setNode] = useState<string>(defaultNode)
const write = useHubWrite(node, mayModify)
const canModify = write.allowed
const canDelete = useHubWrite(node, mayDelete).allowed
const [open, setOpen] = useState<string | null>(null)
const [notice, setNotice] = useState<Notice | null>(null)
const [confirmation, setConfirmation] = useState<Confirmation | null>(null)
@@ -152,6 +162,7 @@ export function Zones({
disabled={open != null}
label="Cluster Node"
/>
<HubWriteHint write={write} />
{open == null ? (
<ZoneList
@@ -170,7 +181,7 @@ export function Zones({
onImport={(z) => openModal('import', z)}
onConvert={(z, t) => openModal('convert', z, t)}
onClone={(z) => openModal('clone', z)}
onPermissions={(z) => openModal('permissions', z)}
onPermissions={(z) => (hub ? setNotice({ type: 'info', title: 'Note!', text: "Zone permissions are changed in that server's own console." }) : openModal('permissions', z))}
onOptions={(z) => openModal('options', z)}
refresh={listRefresh}
/>
@@ -205,7 +216,7 @@ export function Zones({
onImport={(z) => openModal('import', z)}
onConvert={(z, t) => openModal('convert', z, t)}
onClone={(z) => openModal('clone', z)}
onPermissions={(z) => openModal('permissions', z)}
onPermissions={(z) => (hub ? setNotice({ type: 'info', title: 'Note!', text: "Zone permissions are changed in that server's own console." }) : openModal('permissions', z))}
onOptions={(z) => openModal('options', z)}
onSign={(z) => openModal('sign', z)}
onUnsign={(z) => openModal('unsign', z)}
+16
View File
@@ -0,0 +1,16 @@
import { CHOOSE_A_SERVER, type HubWrite } from '../lib/hub-write'
/*
The line under a server selector that says why the controls are switched off behind a hub:
no server is chosen ("All servers"), or the hub holds no admin token for the one chosen.
Nothing is drawn when the change is allowed, and nothing when the role is what says no (the
controls then carry their own "Requires ..." tooltip).
*/
export function HubWriteHint({ write }: { write: HubWrite }) {
if (write.reason == null) return null
return (
<p className="m-0 text-xs text-muted-foreground" role="status">
{write.reason === CHOOSE_A_SERVER ? 'Choose a server to make changes. Changes are made on one server at a time.' : `${write.reason}, so this server is read only.`}
</p>
)
}