diff --git a/dev/mock-api.mjs b/dev/mock-api.mjs index e1d54e9..d26d011 100644 --- a/dev/mock-api.mjs +++ b/dev/mock-api.mjs @@ -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))) }) diff --git a/docs/compose.hub.example.yaml b/docs/compose.hub.example.yaml index 0d9663c..542dde8 100644 --- a/docs/compose.hub.example.yaml +++ b/docs/compose.hub.example.yaml @@ -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) diff --git a/docs/hub-deploy.md b/docs/hub-deploy.md index b35e48f..9f25a89 100644 --- a/docs/hub-deploy.md +++ b/docs/hub-deploy.md @@ -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.» --- diff --git a/docs/hub-management-plan.md b/docs/hub-management-plan.md index ee91ba0..28f6f16 100644 --- a/docs/hub-management-plan.md +++ b/docs/hub-management-plan.md @@ -4,7 +4,7 @@ - **общее управление**: каждый сервер из одной консоли, действия «на все серверы», сравнение и выравнивание; - **упор на Apps**: приложения на всех серверах и синхронизация их конфигов. -Статус: план, ничего из этого ещё не сделано. Сейчас хаб только читает (`docs/hub-deploy.md`). +Статус: этапы 0 и 1 (безопасность, Apps по серверам), 2 (установка и обновление), 3 (сравнение и синхронизация конфигов), 4 (история, откат, расхождения) и 5 (управление каждым сервером) сделаны. Этапы 6 и далее пока планы (`docs/hub-deploy.md`). --- diff --git a/hub/README.md b/hub/README.md index 48541a0..edc92ea 100644 --- a/hub/README.md +++ b/hub/README.md @@ -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=` 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. diff --git a/hub/apps-write.test.ts b/hub/apps-write.test.ts index b7df3db..3b93a75 100644 --- a/hub/apps-write.test.ts +++ b/hub/apps-write.test.ts @@ -40,8 +40,12 @@ function fake(): Promise { 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 { 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() diff --git a/hub/guards.ts b/hub/guards.ts new file mode 100644 index 0000000..8056125 --- /dev/null +++ b/hub/guards.ts @@ -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, current: unknown): string[] { + const now = (current !== null && typeof current === 'object' ? current : {}) as Record + 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) } + 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 | undefined { + for (const key of IDENTIFIERS) { + const value = params[key] + if (typeof value === 'string' && value.trim() !== '') return value.trim().slice(0, 120) + } + return undefined +} diff --git a/hub/routes.ts b/hub/routes.ts index d2805ef..5913eee 100644 --- a/hub/routes.ts +++ b/hub/routes.ts @@ -3,8 +3,19 @@ What each `/api/` 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=`) 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=` 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 = { 'dashboard/stats/get': read(), @@ -25,10 +47,81 @@ export const ROUTES: Record = { '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): 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 = { + '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/') diff --git a/hub/security.test.ts b/hub/security.test.ts index 2d69ece..edeb042 100644 --- a/hub/security.test.ts +++ b/hub/security.test.ts @@ -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() }) diff --git a/hub/server-write.test.ts b/hub/server-write.test.ts new file mode 100644 index 0000000..5d4b65c --- /dev/null +++ b/hub/server-write.test.ts @@ -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 + 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 { + 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 = {} +const ROLES = ['viewer', 'operator', 'apps-admin', 'admin'] + +async function start(extra: Record = {}): Promise { + 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((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 + method?: string + body?: string | FormData + headers?: Record + auth?: boolean +} + +async function api(role: string, apiPath: string, init: Init = {}) { + const query = new URLSearchParams(init.params ?? {}) + const headers: Record = { ...(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 = {} + try { + json = JSON.parse(text) as Record + } 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) => ({ + body: new URLSearchParams(params).toString(), + headers: { 'content-type': 'application/x-www-form-urlencoded' }, +}) + +async function auditEntries(): Promise[]> { + 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() + }) +}) diff --git a/hub/server.test.ts b/hub/server.test.ts index 5054777..f9e57c0 100644 --- a/hub/server.test.ts +++ b/hub/server.test.ts @@ -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) diff --git a/hub/server.ts b/hub/server.ts index c122bea..949cd82 100644 --- a/hub/server.ts +++ b/hub/server.ts @@ -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=`) 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=`) 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 { - if (kind === 'write-all') { + async function write(req: http.IncomingMessage, res: http.ServerResponse, apiPath: string, route: Route, params: Params, who: Who, ip: string): Promise { + 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=`). 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 { + 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 { + 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 { - // 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 { diff --git a/hub/session.ts b/hub/session.ts index b70892d..f13e095 100644 --- a/hub/session.ts +++ b/hub/session.ts @@ -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 = { + Apps: 'apps.write', Zones: 'admin', Cache: 'operate', Allowed: 'operate', Blocked: 'operate', + DnsClient: 'admin', Settings: 'admin', DhcpServer: 'admin', +} +const DELETES: Record = { + 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() + #single = new Map() #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). */ diff --git a/hub/upstream.ts b/hub/upstream.ts index 0b8b08d..7cb3942 100644 --- a/hub/upstream.ts +++ b/hub/upstream.ts @@ -21,6 +21,15 @@ export class UpstreamError extends Error { export type Params = Record +/* What `raw` hands back: the headers worth passing on and the body as a stream. */ +export interface RawAnswer { + contentType: string + disposition?: string + body: ReadableStream | 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 { + async write(server: HubServer, path: string, params: Params = {}, body?: FormData | URLSearchParams | string): Promise { 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 { + 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 | 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) diff --git a/src/api/blocking.ts b/src/api/blocking.ts index 42678e6..6ea6431 100644 --- a/src/api/blocking.ts +++ b/src/api/blocking.ts @@ -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 { - const removed = await deleteDomain('blocked', token, domain) +export async function allowDomain(token: string | null, domain: string, node = ''): Promise { + 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 { - const removed = await deleteDomain('allowed', token, domain) +export async function blockDomain(token: string | null, domain: string, node = ''): Promise { + 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)) } diff --git a/src/api/settings.ts b/src/api/settings.ts index 1a3e090..24dc045 100644 --- a/src/api/settings.ts +++ b/src/api/settings.ts @@ -277,8 +277,8 @@ export async function setSettings( } /** `settings/forceUpdateBlockLists`. No parameters: forces the download now. */ -export async function forceUpdateBlockLists(token: string | null): Promise { - const outcome = await apiRequest('settings/forceUpdateBlockLists', { token }) +export async function forceUpdateBlockLists(token: string | null, node = ''): Promise { + 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 { 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 diff --git a/src/api/zonelists.ts b/src/api/zonelists.ts index 394c226..f6b12dc 100644 --- a/src/api/zonelists.ts +++ b/src/api/zonelists.ts @@ -172,8 +172,9 @@ export function addDomain( list: DomainList, token: string | null, domain: string, + node = '', ): Promise { - 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 { - 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 { - 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 { + return apiRequest(`${list}/flush`, { token, ...(node ? { node } : {}) }) } /* @@ -200,9 +202,10 @@ export function importDomains( list: DomainList, token: string | null, zones: string, + node = '', ): Promise { 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 } : {}) } diff --git a/src/app/Shell.tsx b/src/app/Shell.tsx index 389fad6..4171e1d 100644 --- a/src/app/Shell.tsx +++ b/src/app/Shell.tsx @@ -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 = { 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({ + {(slot) => (
@@ -602,7 +609,7 @@ export function Shell({ {c} ))}
- {hub && } + {hub && } 0} aria-label="Server status"> 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' ? ( ) : current?.id === 'about' ? ( ) : current?.id === 'apps' ? ( - + ) : current?.id === 'cache' ? ( - + ) : current?.id === 'blocking' ? ( )}
+
diff --git a/src/lib/allow-block.ts b/src/lib/allow-block.ts index c584925..641e304 100644 --- a/src/lib/allow-block.ts +++ b/src/lib/allow-block.ts @@ -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>(new Set()) /* Read by `run` so two clicks inside one render cannot both pass the guard. */ const inFlight = useRef(new Set()) @@ -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') { diff --git a/src/lib/hub-write.test.ts b/src/lib/hub-write.test.ts new file mode 100644 index 0000000..f6e69c6 --- /dev/null +++ b/src/lib/hub-write.test.ts @@ -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']) + }) +}) diff --git a/src/lib/hub-write.ts b/src/lib/hub-write.ts new file mode 100644 index 0000000..2d0a7b7 --- /dev/null +++ b/src/lib/hub-write.ts @@ -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=`), 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([]) + +/* 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]) diff --git a/src/screens/blocking/AddDomainBar.tsx b/src/screens/blocking/AddDomainBar.tsx index 220bb80..d2fec9e 100644 --- a/src/screens/blocking/AddDomainBar.tsx +++ b/src/screens/blocking/AddDomainBar.tsx @@ -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(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)) diff --git a/src/screens/blocking/BlockLists.tsx b/src/screens/blocking/BlockLists.tsx index b1bbcef..d865db1 100644 --- a/src/screens/blocking/BlockLists.tsx +++ b/src/screens/blocking/BlockLists.tsx @@ -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. diff --git a/src/screens/blocking/Blocking.tsx b/src/screens/blocking/Blocking.tsx index 68d5ff5..05e0570 100644 --- a/src/screens/blocking/Blocking.tsx +++ b/src/screens/blocking/Blocking.tsx @@ -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 {canModify && ( - {}} /> + {}} node={node} /> )} @@ -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' && } + {hub && active !== 'Overview' && ( + <> + + + + )} + {active !== 'Overview' && } + return ( + + + + ) } if (active === 'Lists') { return ( + + ) } return ( diff --git a/src/screens/blocking/Overview.tsx b/src/screens/blocking/Overview.tsx index 244105f..692a902 100644 --- a/src/screens/blocking/Overview.tsx +++ b/src/screens/blocking/Overview.tsx @@ -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('LastHour') const [node, setNode] = useState(() => 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(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 ( - <> + + - + ) } diff --git a/src/screens/blocking/Rules.tsx b/src/screens/blocking/Rules.tsx index 1f56903..e1b8ac4 100644 --- a/src/screens/blocking/Rules.tsx +++ b/src/screens/blocking/Rules.tsx @@ -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. */
setImporting(null)} onDone={(n) => { setNotice(n) diff --git a/src/screens/blocking/StatusPanel.tsx b/src/screens/blocking/StatusPanel.tsx index 4d74370..da0c88e 100644 --- a/src/screens/blocking/StatusPanel.tsx +++ b/src/screens/blocking/StatusPanel.tsx @@ -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 @@ -109,7 +113,7 @@ export function StatusPanel({ {modifyNeed != null ? ( {verb} ) : ( - + )}
)} diff --git a/src/screens/blocking/blocking-state.ts b/src/screens/blocking/blocking-state.ts index d204c38..e4c0162 100644 --- a/src/screens/blocking/blocking-state.ts +++ b/src/screens/blocking/blocking-state.ts @@ -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(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. diff --git a/src/screens/blocking/check-domain.ts b/src/screens/blocking/check-domain.ts index e667d41..acfd911 100644 --- a/src/screens/blocking/check-domain.ts +++ b/src/screens/blocking/check-domain.ts @@ -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> { 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 { diff --git a/src/screens/blocking/permissions.ts b/src/screens/blocking/permissions.ts index bc9d19a..8d31d6d 100644 --- a/src/screens/blocking/permissions.ts +++ b/src/screens/blocking/permissions.ts @@ -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 = { ...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('.') diff --git a/src/screens/dashboard/BlockingMenu.tsx b/src/screens/dashboard/BlockingMenu.tsx index b6ec07e..195a8d8 100644 --- a/src/screens/dashboard/BlockingMenu.tsx +++ b/src/screens/dashboard/BlockingMenu.tsx @@ -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 ( + + + + ) + } + return ( <> void loadState()}> diff --git a/src/screens/dashboard/Dashboard.tsx b/src/screens/dashboard/Dashboard.tsx index 66ba9b5..0d68dd5 100644 --- a/src/screens/dashboard/Dashboard.tsx +++ b/src/screens/dashboard/Dashboard.tsx @@ -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={} + beforeMore={} menu={(f) => } /> - setTop(null)} /> + + setTop(null)} /> + ) } diff --git a/src/screens/dhcp/Dhcp.hub.test.tsx b/src/screens/dhcp/Dhcp.hub.test.tsx new file mode 100644 index 0000000..a9c2374 --- /dev/null +++ b/src/screens/dhcp/Dhcp.hub.test.tsx @@ -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( + + + + + , + ) +} + +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() + await screen.findByText('No Scope Found') + expect(api.listScopes).toHaveBeenCalledWith('t', '') + expect(screen.getByRole('button', { name: 'Add Scope' })).toBeInTheDocument() + }) +}) diff --git a/src/screens/dhcp/Dhcp.tsx b/src/screens/dhcp/Dhcp.tsx index 3dc7c51..39a4132 100644 --- a/src/screens/dhcp/Dhcp.tsx +++ b/src/screens/dhcp/Dhcp.tsx @@ -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(() => '') + const defaultNode = useHubDefaultNode(nodes) + const [node, setNode] = useState(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" /> + {active === 'Scopes' ? ( (() => request?.node ?? '') + const defaultNode = useHubDefaultNode(nodes) + const [clusterNode, setClusterNode] = useState(() => (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" /> + setAlert(null)} /> @@ -223,9 +233,9 @@ export function DnsClient({ - + diff --git a/src/screens/lists/Cache.hub.test.tsx b/src/screens/lists/Cache.hub.test.tsx new file mode 100644 index 0000000..7e23b01 --- /dev/null +++ b/src/screens/lists/Cache.hub.test.tsx @@ -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( + + + + + , + ) +} + +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() + }) +}) diff --git a/src/screens/lists/Lists.tsx b/src/screens/lists/Lists.tsx index e1530ad..380aaf8 100644 --- a/src/screens/lists/Lists.tsx +++ b/src/screens/lists/Lists.tsx @@ -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('') + /* 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(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(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 && } {/* 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 ? ( - ) : undefined @@ -630,7 +646,7 @@ export function Lists({ @@ -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 + return } diff --git a/src/screens/logs/Logs.tsx b/src/screens/logs/Logs.tsx index 0a2011a..41ad266 100644 --- a/src/screens/logs/Logs.tsx +++ b/src/screens/logs/Logs.tsx @@ -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(() => 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(() => 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' && } {active === 'Query Logs' ? ( - + + + ) : ( (null) const [f, setF] = useState(() => defaultFilters('', '')) const [page, setPage] = useState(null) @@ -1041,7 +1043,7 @@ export function QueryLogs({ tabs, token, node = '', request }: QueryLogsProps) {