Hub stage 5: manage each server from one console
Zones, Cache, Blocking, DNS Client, Settings, DHCP and Logs can now be changed on one chosen server through the hub, with that server's admin token. Operators manage cache and blocking; admins also zones, settings and DHCP. Under "All servers", or on a server without an admin token, the controls are disabled with a hint. The hub refuses settings/set changes to a server's web service fields and anything about users, SSO or LDAP; backup, restore, log and stats deletion and admin/* stay refused. Non-admins get settings with secrets blanked. Write params travel in the POST body, downloads pass through with a single-use token, and the audit log names no values. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
57e492cceb
commit
8bb1e01936
45 files changed
+1851
-190
No files matched your search
+36
-2
@@ -244,6 +244,14 @@ let sessions = [
|
||||
const userDetails = (u) => ({ ...u, sessionTimeoutSeconds: 1800, remotelyManagedGroups: false, memberOfGroups: u.username === 'admin' ? ['Administrators'] : ['DNS Administrators', 'Everyone'], sessions: sessions.filter((s) => s.username === u.username) })
|
||||
|
||||
// ── Router ─────────────────────────────────────────────────────────────────
|
||||
const addedRecords = {}
|
||||
// Answers that are files, not JSON (the downloads and the Rules table's reads).
|
||||
const fileFor = (path) => ({
|
||||
'allowed/export': [...allowed].join('\n') + '\n',
|
||||
'blocked/export': [...blocked].join('\n') + '\n',
|
||||
'zones/export': '$ORIGIN example.net.\n@ IN SOA ns1 hostmaster 1 900 300 604800 900\n',
|
||||
'logs/download': '[2026-10-26 10:00:00 UTC] [127.0.0.1:5380] [] DNS Server was started.\n',
|
||||
})[path]
|
||||
const ok = (response = {}) => ({ status: 'ok', response })
|
||||
function route(path, q) {
|
||||
const domain = q.get('domain') ?? q.get('zone') ?? ''
|
||||
@@ -262,7 +270,16 @@ function route(path, q) {
|
||||
const per = Number(q.get('zonesPerPage') ?? 10), page = Math.max(1, Number(q.get('pageNumber') ?? 1))
|
||||
return ok({ zones: zones.slice((page - 1) * per, page * per), pageNumber: page, totalPages: Math.max(1, Math.ceil(zones.length / per)), totalZones: zones.length })
|
||||
}
|
||||
case 'zones/records/get': { const z = zones.find((x) => x.name === domain) ?? zones[0]; return ok({ zone: { name: z.name, type: z.type, disabled: z.disabled, dnssecStatus: z.dnssecStatus, soaSerial: z.soaSerial, catalog: z.catalog ?? null }, records: recordsFor(z) }) }
|
||||
case 'zones/records/get': { const z = zones.find((x) => x.name === domain) ?? zones[0]; return ok({ zone: { name: z.name, type: z.type, disabled: z.disabled, dnssecStatus: z.dnssecStatus, soaSerial: z.soaSerial, catalog: z.catalog ?? null }, records: [...recordsFor(z), ...(addedRecords[z.name] ?? [])] }) }
|
||||
case 'zones/records/add': {
|
||||
// One record added for the session: it comes back in the zone's list. `type` decides what the data is called.
|
||||
const type = q.get('type') ?? 'A'
|
||||
const rData = type === 'TXT' ? { text: q.get('text') ?? '', splitText: false } : type === 'CNAME' ? { cname: q.get('cname') ?? '' } : { ipAddress: q.get('ipAddress') ?? '' }
|
||||
const added = { name: q.get('domain') ?? domain, type, ttl: Number(q.get('ttl') ?? 3600), ttlString: `${q.get('ttl') ?? 3600}`, disabled: false, rData, dnssecStatus: 'Disabled', lastUsedOn: '0001-01-01T00:00:00Z', lastModified: iso(Date.now()), expiryTtl: 0, expiryTtlString: '0' }
|
||||
const z = zones.find((x) => x.name === (q.get('zone') ?? domain)) ?? zones[0]
|
||||
addedRecords[z.name] = [...(addedRecords[z.name] ?? []), added]
|
||||
return ok({ addedRecord: added, zone: { name: z.name, type: z.type, disabled: z.disabled } })
|
||||
}
|
||||
case 'zones/enable': case 'zones/disable': zones = zones.map((z) => (q.get('zone') === z.name ? { ...z, disabled: path === 'zones/disable' } : z)); return ok()
|
||||
case 'zones/delete': { const gone = (q.get('zones') ?? q.get('zone') ?? '').split(',').filter(Boolean); zones = zones.filter((z) => !gone.includes(z.name)); return ok({ deleted: gone, failed: {} }) }
|
||||
case 'zones/create': zones = [{ name: domain, type: q.get('type') ?? 'Primary', dnssecStatus: 'Unsigned', soaSerial: 1, disabled: false, lastModified: iso(Date.now()), notifyFailed: false, notifyFailedFor: [] }, ...zones]; return ok({ domain })
|
||||
@@ -278,7 +295,17 @@ function route(path, q) {
|
||||
case 'allowed/flush': allowed.clear(); return ok()
|
||||
case 'blocked/flush': blocked.clear(); return ok()
|
||||
case 'settings/get': return ok(settings)
|
||||
case 'settings/set': case 'settings/forceUpdateBlockLists': case 'settings/temporaryDisableBlocking': return ok(path === 'settings/set' ? settings : { temporaryDisableBlockingTill: iso(now + 10 * 60_000) })
|
||||
case 'settings/set': {
|
||||
// Changes only what is sent and already there, by its kind (a flag, a number, a list or text); the rest is kept.
|
||||
for (const [k, v] of q) {
|
||||
if (!(k in settings) || k === 'node' || k === 'token') continue
|
||||
const old = settings[k]
|
||||
settings[k] = typeof old === 'boolean' ? v === 'true' : typeof old === 'number' ? Number(v) : Array.isArray(old) ? v.split(/[,\n]/).map((x) => x.trim()).filter((x) => x && x !== 'false') : v
|
||||
}
|
||||
return ok(settings)
|
||||
}
|
||||
case 'settings/forceUpdateBlockLists': case 'settings/temporaryDisableBlocking': return ok(path === 'settings/set' ? settings : { temporaryDisableBlockingTill: iso(now + 10 * 60_000) })
|
||||
case 'user/createSingleUseToken': return ok({ token: 'mock-once' })
|
||||
case 'settings/getTsigKeyNames': return ok({ tsigKeyNames: ['transfer.example.net'] })
|
||||
case 'dhcp/leases/list': return ok({ leases })
|
||||
case 'dhcp/scopes/list': return ok({ scopes })
|
||||
@@ -348,6 +375,13 @@ http.createServer((req, res) => {
|
||||
req.on('end', () => {
|
||||
const q = new URLSearchParams(url.search)
|
||||
if ((req.headers['content-type'] ?? '').includes('application/x-www-form-urlencoded')) for (const [k, v] of new URLSearchParams(body)) q.set(k, v)
|
||||
const file = fileFor(path)
|
||||
if (file !== undefined) {
|
||||
res.setHeader('content-type', 'text/plain')
|
||||
res.setHeader('content-disposition', `attachment; filename="${path.replace('/', '-')}.txt"`)
|
||||
res.end(file)
|
||||
return
|
||||
}
|
||||
res.setHeader('content-type', 'application/json')
|
||||
res.end(JSON.stringify(route(path, q)))
|
||||
})
|
||||
|
||||
@@ -11,7 +11,7 @@ version: "3.8"
|
||||
# Токены кладутся в .env рядом с этим файлом (не в git):
|
||||
# MDNS_TOKEN=...
|
||||
# NDNS_TOKEN=...
|
||||
# MDNS_ADMIN_TOKEN=... # токены hub-admin (Modify и Delete только на Apps): нужны, чтобы хаб менял приложения
|
||||
# MDNS_ADMIN_TOKEN=... # токены hub-admin (Modify и Delete только в разделах, которыми управляете из хаба, см. docs/hub-deploy.md): нужны, чтобы хаб менял приложения и серверы
|
||||
# NDNS_ADMIN_TOKEN=...
|
||||
# HUB_USER=... # логин в сам хаб
|
||||
# HUB_PASSWORD=... # первый вход; затем пользователи в /data/users.json (docs/hub-deploy.md, шаг 4a)
|
||||
|
||||
+34
-8
@@ -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.»
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
- **общее управление**: каждый сервер из одной консоли, действия «на все серверы», сравнение и выравнивание;
|
||||
- **упор на Apps**: приложения на всех серверах и синхронизация их конфигов.
|
||||
|
||||
Статус: план, ничего из этого ещё не сделано. Сейчас хаб только читает (`docs/hub-deploy.md`).
|
||||
Статус: этапы 0 и 1 (безопасность, Apps по серверам), 2 (установка и обновление), 3 (сравнение и синхронизация конфигов), 4 (история, откат, расхождения) и 5 (управление каждым сервером) сделаны. Этапы 6 и далее пока планы (`docs/hub-deploy.md`).
|
||||
|
||||
---
|
||||
|
||||
|
||||
+31
-4
@@ -8,11 +8,11 @@ read-only, Technitium-compatible `/api/*`:
|
||||
- No `node` (or `node=cluster`) asks every server in parallel and merges the answers
|
||||
(statistics are summed, top lists and query logs are merged). If a server does not
|
||||
answer, the others are shown and the response carries `hubWarnings`.
|
||||
- Reads go with each server's read token. The only writes are the app changes below, which use a separate admin token and a role with `apps.write`. Anything else that would change data is refused with `403` (and written to the audit log). The hub has its own accounts with roles, a login rate limit and an audit log.
|
||||
- Reads go with each server's read token. Writes use a separate admin token: the app changes below (role with `apps.write`) and the per-server changes of "Managing one server" (roles with `operate` or `admin`). Anything else that would change data is refused with `403` (and written to the audit log). The hub has its own accounts with roles, a login rate limit and an audit log.
|
||||
|
||||
The servers themselves are not changed: each only needs a read-only API token. Create it
|
||||
for a user whose group has View, and nothing else, on Dashboard, Logs, Apps (the logging
|
||||
apps Query Logs reads from) and Settings (the blocking state).
|
||||
The servers themselves are not changed: each needs a read-only API token (View, and nothing else, on Dashboard, Logs,
|
||||
Apps, Settings, Zones, Cache, Allowed, Blocked, DnsClient and DhcpServer) and, for writes, an admin token whose group has
|
||||
Modify and Delete only where the table in `docs/hub-deploy.md` grants them.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -86,6 +86,33 @@ Public: `status`, `user/login`. After signing in: `user/session/get`, `user/logo
|
||||
|
||||
Everything else answers `403 The hub is read-only.` and is audited.
|
||||
|
||||
## Managing one server
|
||||
|
||||
The sections Zones, Cache, Blocking (Blocked and Allowed), DNS Client, Settings and DHCP are managed on ONE server at a
|
||||
time. `routes.ts` lists exactly the paths the console calls; an `operate` role (`operator`, `admin`) may change the cache,
|
||||
Blocked and Allowed, the temporary blocking switch and the list update; zones (records, DNSSEC, import), settings and DHCP
|
||||
need `admin`. `viewer` and `apps-admin` only read.
|
||||
|
||||
- A write needs `node=<id>` of a server with an admin token. No node, `cluster` or an unknown id answers `400`
|
||||
("Choose a server: this change is made on one server at a time."); a server without an admin token answers `403`.
|
||||
- The parameters travel to the server in a form body (only `token` stays in the query). A zone import goes on as
|
||||
`multipart/form-data` or `text/plain` (up to 20 MB); `settings/set`, record and scope writes take up to 2 MB and the
|
||||
Blocked/Allowed imports up to 5 MB, for a signed-in caller only.
|
||||
- `dnsClient/resolve` is a read, unless `import` is set: then it writes into a zone and needs `admin`.
|
||||
- `settings/set` is refused when it changes a `webService*` field (compared with the server's current `settings/get`; the
|
||||
console sends the block unchanged with every save) or carries a user, SSO or LDAP field. `settings/get` blanks the TSIG
|
||||
shared secrets and the passwords for every role but `admin`.
|
||||
- Reads that have no merger (`zones/list`, `cache/list`, `allowed|blocked/list`, `dhcp/*`, `logs/list`, `dnsClient/resolve`,
|
||||
the exports) are refused without a node instead of being answered by the first server. `settings/get` is the exception:
|
||||
the header's blocking mark reads it without a node.
|
||||
- Downloads (`zones/export`, `logs/download`, `logs/export`, `allowed|blocked/export`) are passed through (type, file name,
|
||||
body) with the read token. A new tab has no Authorization header, so `user/createSingleUseToken` gives a token that works
|
||||
once, for 60 seconds, for these paths only.
|
||||
- Refused, even for `admin`: `admin/*`, `user/*` (except the hub's own sign-in), `logs/delete`, `logs/deleteAll`,
|
||||
`dashboard/stats/deleteAll`, `settings/backup`, `settings/restore` and `zones/permissions/*`.
|
||||
- The audit line holds the user, role, address, path (dots for slashes), server, one identifier (zone, domain, name or scope)
|
||||
and the outcome. Never a value.
|
||||
|
||||
## App writes
|
||||
|
||||
Needs the `apps.write` role (`apps-admin`, `admin`); others get `403 Your role does not allow this.` and a `denied-write` audit line.
|
||||
|
||||
@@ -40,8 +40,12 @@ function fake(): Promise<Fake> {
|
||||
const url = new URL(req.url ?? '/', 'http://fake')
|
||||
const apiPath = url.pathname.replace(/^\/api\//, '')
|
||||
const token = url.searchParams.get('token') ?? ''
|
||||
const name = url.searchParams.get('name') ?? ''
|
||||
f.calls.push({ path: apiPath, token, method: req.method ?? '', query: url.searchParams, body: Buffer.concat(chunks), contentType: String(req.headers['content-type'] ?? '') })
|
||||
const contentType = String(req.headers['content-type'] ?? '')
|
||||
// Technitium reads query or form alike; `query` here is both (`token` is the only one the hub leaves in the URL).
|
||||
const query = new URLSearchParams(url.searchParams)
|
||||
if (contentType.includes('application/x-www-form-urlencoded')) for (const [k, v] of new URLSearchParams(Buffer.concat(chunks).toString('utf8'))) query.set(k, v)
|
||||
const name = query.get('name') ?? ''
|
||||
f.calls.push({ path: apiPath, token, method: req.method ?? '', query, body: Buffer.concat(chunks), contentType })
|
||||
res.setHeader('content-type', 'application/json')
|
||||
const ok = (response: unknown = {}) => res.end(JSON.stringify({ status: 'ok', response }))
|
||||
const error = (m: string) => res.end(JSON.stringify({ status: 'error', errorMessage: m }))
|
||||
@@ -58,7 +62,7 @@ function fake(): Promise<Fake> {
|
||||
return f.apps[name] ? ok({ config: f.apps[name].config }) : error('not found')
|
||||
case 'apps/downloadAndInstall':
|
||||
case 'apps/downloadAndUpdate': {
|
||||
const entry = f.store.find((s) => s.name === name && s.url === url.searchParams.get('url'))
|
||||
const entry = f.store.find((s) => s.name === name && s.url === query.get('url'))
|
||||
if (!entry) return error('not in store')
|
||||
f.apps[name] = { version: entry.version, config: f.apps[name]?.config ?? null }
|
||||
return ok()
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
Checks the hub makes on what passes through it to a server (stage 5): which `settings/set`
|
||||
fields it will not forward, which values of `settings/get` a role may see, and which single
|
||||
identifier of a write may go into the audit log.
|
||||
|
||||
Pure functions, so the tests feed them any input.
|
||||
*/
|
||||
|
||||
/*
|
||||
`settings/set` and the hub's own way in.
|
||||
|
||||
The hub reaches a server through that server's web service, with an API token. Changing the
|
||||
service's bind addresses, ports, TLS or reverse-proxy settings from here can cut the hub off with
|
||||
no way back except the server's own console, so the hub refuses a change to any `webService*`
|
||||
field. The console sends the whole Web Service block with every save, unchanged values included,
|
||||
so a field is refused only when its value DIFFERS from what the server holds now (see
|
||||
`webServiceChanges`). Users, SSO and LDAP are not part of `settings/set`; a field that looks like
|
||||
one (`user*`, `sso*`, `ldap*`, `auth*`) is refused outright, in case a server version adds it.
|
||||
*/
|
||||
export const WEB_SERVICE_MESSAGE = "Change the web service settings in that server's own console."
|
||||
|
||||
const FORBIDDEN_FIELD = /^(user|sso|ldap|auth)/i
|
||||
const WEB_FIELD = /^webService/
|
||||
|
||||
/** True for a field the hub never forwards, whatever its value. */
|
||||
export const isForbiddenSettingsField = (name: string): boolean => FORBIDDEN_FIELD.test(name)
|
||||
|
||||
/* A list as the console sends it (`a,b`, one per line, or `false` for none) or as the server holds it. */
|
||||
function listOf(value: unknown): string[] {
|
||||
const items = Array.isArray(value) ? value.map(String) : String(value ?? '').split(/[,\n]/)
|
||||
return items.map((v) => v.trim()).filter((v) => v !== '' && v !== 'false')
|
||||
}
|
||||
|
||||
/* Whether a submitted text means what the server holds. Booleans and numbers by value, lists as sets, text trimmed. */
|
||||
function sameSetting(submitted: string, current: unknown): boolean {
|
||||
if (typeof current === 'boolean') return submitted.trim().toLowerCase() === String(current)
|
||||
if (typeof current === 'number') return submitted.trim() !== '' && Number(submitted) === current
|
||||
if (Array.isArray(current)) {
|
||||
const a = listOf(submitted).sort()
|
||||
const b = listOf(current).sort()
|
||||
return a.length === b.length && a.every((v, i) => v === b[i])
|
||||
}
|
||||
return submitted.trim() === String(current ?? '').trim()
|
||||
}
|
||||
|
||||
/*
|
||||
The `webService*` fields of a `settings/set` whose value differs from the server's current
|
||||
settings (`settings/get`). A field the server does not report is not compared: it would be ignored
|
||||
there too.
|
||||
*/
|
||||
export function webServiceChanges(submitted: Record<string, string>, current: unknown): string[] {
|
||||
const now = (current !== null && typeof current === 'object' ? current : {}) as Record<string, unknown>
|
||||
const changed: string[] = []
|
||||
for (const [name, value] of Object.entries(submitted)) {
|
||||
if (!WEB_FIELD.test(name) || !Object.hasOwn(now, name)) continue
|
||||
if (!sameSetting(value, now[name])) changed.push(name)
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
/*
|
||||
`settings/get` carries secrets: the TSIG shared secrets, the forward proxy password and the two
|
||||
certificate passwords. A role without `admin` gets a copy with those values blanked (a viewer
|
||||
needs the screen, not the keys); an admin gets them, since saving the form sends them back. The
|
||||
blanked copy is for display only and `settings/set` is `admin`-only, so a blank cannot be saved
|
||||
over a real secret by someone who may not read it.
|
||||
*/
|
||||
export function redactSettings(response: unknown): unknown {
|
||||
if (response === null || typeof response !== 'object') return response
|
||||
const copy = { ...(response as Record<string, unknown>) }
|
||||
for (const key of ['webServiceTlsCertificatePassword', 'dnsTlsCertificatePassword']) {
|
||||
if (typeof copy[key] === 'string' && copy[key] !== '') copy[key] = ''
|
||||
}
|
||||
if (Array.isArray(copy.tsigKeys)) {
|
||||
copy.tsigKeys = copy.tsigKeys.map((k: unknown) => (k !== null && typeof k === 'object' ? { ...k, sharedSecret: '' } : k))
|
||||
}
|
||||
const proxy = copy.proxy
|
||||
if (proxy !== null && typeof proxy === 'object' && typeof (proxy as { password?: unknown }).password === 'string') {
|
||||
copy.proxy = { ...proxy, password: '' }
|
||||
}
|
||||
return copy
|
||||
}
|
||||
|
||||
/*
|
||||
The one identifier an audit line may carry for a write: a zone, a domain, a name or a scope. Never
|
||||
the other parameters (records carry data, settings carry passwords and TSIG secrets). Cut short.
|
||||
*/
|
||||
const IDENTIFIERS = ['zone', 'domain', 'name', 'scope', 'zones']
|
||||
|
||||
export function safeTarget(params: Record<string, string>): string | undefined {
|
||||
for (const key of IDENTIFIERS) {
|
||||
const value = params[key]
|
||||
if (typeof value === 'string' && value.trim() !== '') return value.trim().slice(0, 120)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
+128
-3
@@ -3,8 +3,19 @@ What each `/api/<path>` does and who may call it. `kind` says what it touches:
|
||||
`read` (no change), `write-one` (changes one server) and `write-all` (changes every server).
|
||||
A path that is not listed is refused. The app writes need `apps.write`.
|
||||
|
||||
Not in the table: `status`, `user/login`, `user/session/get` and `user/logout`, which the
|
||||
server answers itself before the table is consulted.
|
||||
The sections a server is managed from (stage 5) are listed by what the console calls. A
|
||||
write there goes to ONE server (`node=<id>`) with that server's admin token. Capabilities:
|
||||
cache and Blocked/Allowed writes, the temporary blocking switch and the list update need
|
||||
`operate`; zones (records and DNSSEC too), settings and DHCP need `admin`.
|
||||
|
||||
Left out on purpose, so they are refused: everything under `admin/` and `user/` (users,
|
||||
groups, sessions, SSO, LDAP, tokens: those stay in each server's own console), `logs/delete`,
|
||||
`logs/deleteAll`, `dashboard/stats/deleteAll`, `settings/backup` and `settings/restore` (a
|
||||
backup holds the users and their tokens), and `zones/permissions/*` (they name users).
|
||||
|
||||
Not in the table: `status`, `user/login`, `user/session/get`, `user/logout` and
|
||||
`user/createSingleUseToken` (the hub's own, for downloads), which the server answers itself
|
||||
before the table is consulted.
|
||||
*/
|
||||
import type { Capability } from './users.ts'
|
||||
|
||||
@@ -13,11 +24,22 @@ export type RouteKind = 'read' | 'write-one' | 'write-all'
|
||||
export interface Route {
|
||||
kind: RouteKind
|
||||
capability: Capability
|
||||
/* A read whose answer is a file, not JSON: it is passed through (see `Upstream.raw`). */
|
||||
raw?: boolean
|
||||
/* A read that has no merger: without `node=<id>` it is refused, not answered by the first server. */
|
||||
perServer?: boolean
|
||||
/* A write that may carry a file or a text body (a zone import). */
|
||||
upload?: boolean
|
||||
}
|
||||
|
||||
const read = (capability: Capability = 'read'): Route => ({ kind: 'read', capability })
|
||||
const writeOne = (capability: Capability): Route => ({ kind: 'write-one', capability })
|
||||
const writeAll = (capability: Capability): Route => ({ kind: 'write-all', capability })
|
||||
/* A read that only makes sense for one server. */
|
||||
const readOne = (): Route => ({ kind: 'read', capability: 'read', perServer: true })
|
||||
/* A download: one server's file. */
|
||||
const download = (): Route => ({ kind: 'read', capability: 'read', perServer: true, raw: true })
|
||||
const upload = (capability: Capability): Route => ({ ...writeOne(capability), upload: true })
|
||||
|
||||
export const ROUTES: Record<string, Route> = {
|
||||
'dashboard/stats/get': read(),
|
||||
@@ -25,10 +47,81 @@ export const ROUTES: Record<string, Route> = {
|
||||
'dashboard/metrics/json': read(),
|
||||
'apps/list': read(),
|
||||
'logs/query': read(),
|
||||
'logs/list': read(),
|
||||
'logs/list': readOne(),
|
||||
'settings/get': read(),
|
||||
'user/checkForUpdate': read(),
|
||||
'admin/cluster/state': read(),
|
||||
// Zones. Reads need a server; writes need `admin`.
|
||||
'zones/list': readOne(),
|
||||
'zones/catalogs/list': readOne(),
|
||||
'zones/options/get': readOne(),
|
||||
'zones/records/get': readOne(),
|
||||
'zones/dnssec/properties/get': readOne(),
|
||||
'zones/dnssec/viewDS': readOne(),
|
||||
'zones/export': download(),
|
||||
'zones/create': upload('admin'),
|
||||
'zones/import': upload('admin'),
|
||||
'zones/delete': writeOne('admin'),
|
||||
'zones/enable': writeOne('admin'),
|
||||
'zones/disable': writeOne('admin'),
|
||||
'zones/resync': writeOne('admin'),
|
||||
'zones/clone': writeOne('admin'),
|
||||
'zones/convert': writeOne('admin'),
|
||||
'zones/options/set': writeOne('admin'),
|
||||
'zones/records/add': writeOne('admin'),
|
||||
'zones/records/update': writeOne('admin'),
|
||||
'zones/records/delete': writeOne('admin'),
|
||||
'zones/dnssec/sign': writeOne('admin'),
|
||||
'zones/dnssec/unsign': writeOne('admin'),
|
||||
'zones/dnssec/properties/addPrivateKey': writeOne('admin'),
|
||||
'zones/dnssec/properties/updatePrivateKey': writeOne('admin'),
|
||||
'zones/dnssec/properties/deletePrivateKey': writeOne('admin'),
|
||||
'zones/dnssec/properties/publishAllPrivateKeys': writeOne('admin'),
|
||||
'zones/dnssec/properties/activateKskDnsKey': writeOne('admin'),
|
||||
'zones/dnssec/properties/rolloverDnsKey': writeOne('admin'),
|
||||
'zones/dnssec/properties/retireDnsKey': writeOne('admin'),
|
||||
'zones/dnssec/properties/convertToNSEC': writeOne('admin'),
|
||||
'zones/dnssec/properties/convertToNSEC3': writeOne('admin'),
|
||||
'zones/dnssec/properties/updateNSEC3Params': writeOne('admin'),
|
||||
'zones/dnssec/properties/updateDnsKeyTtl': writeOne('admin'),
|
||||
// Cache.
|
||||
'cache/list': readOne(),
|
||||
'cache/flush': writeOne('operate'),
|
||||
'cache/delete': writeOne('operate'),
|
||||
// Blocked and Allowed.
|
||||
'allowed/list': readOne(),
|
||||
'blocked/list': readOne(),
|
||||
'allowed/export': download(),
|
||||
'blocked/export': download(),
|
||||
'allowed/add': writeOne('operate'),
|
||||
'allowed/delete': writeOne('operate'),
|
||||
'allowed/flush': writeOne('operate'),
|
||||
'allowed/import': writeOne('operate'),
|
||||
'blocked/add': writeOne('operate'),
|
||||
'blocked/delete': writeOne('operate'),
|
||||
'blocked/flush': writeOne('operate'),
|
||||
'blocked/import': writeOne('operate'),
|
||||
// DHCP.
|
||||
'dhcp/leases/list': readOne(),
|
||||
'dhcp/scopes/list': readOne(),
|
||||
'dhcp/scopes/get': readOne(),
|
||||
'dhcp/scopes/set': writeOne('admin'),
|
||||
'dhcp/scopes/enable': writeOne('admin'),
|
||||
'dhcp/scopes/disable': writeOne('admin'),
|
||||
'dhcp/scopes/delete': writeOne('admin'),
|
||||
'dhcp/leases/remove': writeOne('admin'),
|
||||
'dhcp/leases/convertToReserved': writeOne('admin'),
|
||||
'dhcp/leases/convertToDynamic': writeOne('admin'),
|
||||
// DNS Client. `import=true` turns a resolve into a zone write: see `effectiveRoute`.
|
||||
'dnsClient/resolve': readOne(),
|
||||
// Settings. `settings/get` (above) is not `perServer`: the header's blocking mark reads it without a node.
|
||||
'settings/getTsigKeyNames': readOne(),
|
||||
'settings/set': writeOne('admin'),
|
||||
'settings/temporaryDisableBlocking': writeOne('operate'),
|
||||
'settings/forceUpdateBlockLists': writeOne('operate'),
|
||||
// Logs (no delete).
|
||||
'logs/download': download(),
|
||||
'logs/export': download(),
|
||||
// The hub's own namespace; never forwarded to a server.
|
||||
'hub/audit/list': read('audit.read'),
|
||||
'hub/apps/matrix': read(),
|
||||
@@ -69,4 +162,36 @@ export function routeFor(apiPath: string): Route | undefined {
|
||||
return Object.hasOwn(ROUTES, apiPath) ? ROUTES[apiPath] : undefined
|
||||
}
|
||||
|
||||
/*
|
||||
The route for THIS call. `dnsClient/resolve` reads, but with `import=true` it also imports the
|
||||
answer into a zone of the server: that variant is a zone write (`admin`, one server). Any value
|
||||
but an absent or `false` one counts, so a spelling the server might still accept does not slip
|
||||
through as a read.
|
||||
*/
|
||||
export function effectiveRoute(apiPath: string, params: Record<string, string>): Route | undefined {
|
||||
if (apiPath === 'dnsClient/resolve') {
|
||||
const flag = (params.import ?? '').trim().toLowerCase()
|
||||
if (flag !== '' && flag !== 'false') return writeOne('admin')
|
||||
}
|
||||
return routeFor(apiPath)
|
||||
}
|
||||
|
||||
/*
|
||||
The largest body a signed-in caller may send to a path outside `hub/apps/`. The servers read the
|
||||
rest with the small default, so a long TXT record or a block list import has to be listed here.
|
||||
*/
|
||||
const MB = 1024 * 1024
|
||||
const LARGE_BODIES: Record<string, number> = {
|
||||
'settings/set': 2 * MB,
|
||||
'zones/records/add': 2 * MB,
|
||||
'zones/records/update': 2 * MB,
|
||||
'zones/options/set': 2 * MB,
|
||||
'dhcp/scopes/set': 2 * MB,
|
||||
'allowed/import': 5 * MB,
|
||||
'blocked/import': 5 * MB,
|
||||
}
|
||||
|
||||
export const largeBodyLimit = (apiPath: string): number | undefined =>
|
||||
Object.hasOwn(LARGE_BODIES, apiPath) ? LARGE_BODIES[apiPath] : undefined
|
||||
|
||||
export const isHubPath = (apiPath: string): boolean => apiPath.startsWith('hub/')
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -0,0 +1,596 @@
|
||||
// @vitest-environment node
|
||||
/*
|
||||
Stage 5: managing one chosen server from the hub (zones, cache, blocking, DNS client, settings,
|
||||
DHCP, logs). The servers are fakes that record how they were called.
|
||||
*/
|
||||
import http from 'node:http'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { parseConfig } from './config.ts'
|
||||
import { createHub } from './server.ts'
|
||||
import { addUser, writeUsersFile } from './users.ts'
|
||||
import { webServiceChanges, redactSettings, safeTarget } from './guards.ts'
|
||||
import { effectiveRoute, routeFor } from './routes.ts'
|
||||
|
||||
interface Call {
|
||||
path: string
|
||||
method: string
|
||||
token: string
|
||||
/* The URL's query alone. */
|
||||
query: URLSearchParams
|
||||
/* The form body, when it is one. */
|
||||
form: URLSearchParams
|
||||
contentType: string
|
||||
body: Buffer
|
||||
}
|
||||
|
||||
interface Fake {
|
||||
server: http.Server
|
||||
url: string
|
||||
calls: Call[]
|
||||
settings: Record<string, unknown>
|
||||
down: boolean
|
||||
failWith: string | null
|
||||
}
|
||||
|
||||
const SETTINGS = {
|
||||
dnsServerDomain: 'a.example',
|
||||
webServiceLocalAddresses: ['0.0.0.0', '[::]'],
|
||||
webServiceHttpPort: 5380,
|
||||
webServiceEnableTls: false,
|
||||
webServiceTlsCertificatePath: null,
|
||||
webServiceTlsCertificatePassword: '************',
|
||||
tsigKeys: [{ keyName: 'k', sharedSecret: 'TOPSECRET', algorithmName: 'hmac-sha256' }],
|
||||
proxy: { type: 'Http', address: 'p', port: 3128, username: 'u', password: 'PROXYPASS', bypass: [] },
|
||||
dnsTlsCertificatePassword: 'CERTPASS',
|
||||
enableBlocking: true,
|
||||
}
|
||||
|
||||
function fake(): Promise<Fake> {
|
||||
return new Promise((resolve) => {
|
||||
const f: Fake = { server: http.createServer(), url: '', calls: [], settings: { ...SETTINGS }, down: false, failWith: null }
|
||||
f.server.on('request', (req, res) => {
|
||||
const chunks: Buffer[] = []
|
||||
req.on('data', (c: Buffer) => chunks.push(c))
|
||||
req.on('end', () => {
|
||||
if (f.down) return req.socket.destroy()
|
||||
const url = new URL(req.url ?? '/', 'http://fake')
|
||||
const apiPath = url.pathname.replace(/^\/api\//, '')
|
||||
const body = Buffer.concat(chunks)
|
||||
const contentType = String(req.headers['content-type'] ?? '')
|
||||
const form = new URLSearchParams(contentType.includes('x-www-form-urlencoded') ? body.toString('utf8') : '')
|
||||
f.calls.push({ path: apiPath, method: req.method ?? '', token: url.searchParams.get('token') ?? '', query: url.searchParams, form, contentType, body })
|
||||
if (f.failWith !== null && req.method === 'POST') {
|
||||
res.setHeader('content-type', 'application/json')
|
||||
return res.end(JSON.stringify({ status: 'error', errorMessage: f.failWith }))
|
||||
}
|
||||
if (apiPath === 'zones/export' || apiPath === 'logs/download' || apiPath === 'blocked/export') {
|
||||
if (url.searchParams.get('zone') === 'missing.example') {
|
||||
res.setHeader('content-type', 'application/json')
|
||||
return res.end(JSON.stringify({ status: 'error', errorMessage: 'No such zone: missing.example' }))
|
||||
}
|
||||
res.writeHead(200, { 'content-type': 'text/plain', 'content-disposition': `attachment; filename="${apiPath.replace('/', '-')}.txt"` })
|
||||
return res.end(`line one\nline two (${apiPath})\n`)
|
||||
}
|
||||
res.setHeader('content-type', 'application/json')
|
||||
const ok = (response: unknown = {}) => res.end(JSON.stringify({ status: 'ok', response }))
|
||||
if (apiPath === 'settings/get') return ok(f.settings)
|
||||
if (apiPath === 'zones/list') return ok({ zones: [{ name: 'example.com' }], pageNumber: 1, totalPages: 1 })
|
||||
ok({ done: apiPath })
|
||||
})
|
||||
})
|
||||
f.server.listen(0, '127.0.0.1', () => {
|
||||
f.url = `http://127.0.0.1:${(f.server.address() as AddressInfo).port}`
|
||||
resolve(f)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
let a: Fake
|
||||
let b: Fake
|
||||
let dataDir: string
|
||||
let staticDir: string
|
||||
let hub: http.Server | undefined
|
||||
let base: string
|
||||
const tokens: Record<string, string> = {}
|
||||
const ROLES = ['viewer', 'operator', 'apps-admin', 'admin']
|
||||
|
||||
async function start(extra: Record<string, string> = {}): Promise<void> {
|
||||
hub?.close()
|
||||
hub = createHub(
|
||||
parseConfig({
|
||||
HUB_SERVERS: `a=${a.url},b=${b.url}`,
|
||||
A_TOKEN: 'read-a',
|
||||
B_TOKEN: 'read-b',
|
||||
A_ADMIN_TOKEN: 'admin-a',
|
||||
B_ADMIN_TOKEN: 'admin-b',
|
||||
HUB_DATA: dataDir,
|
||||
HUB_STATIC: staticDir,
|
||||
HUB_CACHE_SECONDS: '0',
|
||||
HUB_TIMEOUT_MS: '2000',
|
||||
...extra,
|
||||
}),
|
||||
)
|
||||
await new Promise<void>((resolve) => hub!.listen(0, '127.0.0.1', resolve))
|
||||
base = `http://127.0.0.1:${(hub.address() as AddressInfo).port}`
|
||||
for (const role of ROLES) {
|
||||
const res = await fetch(`${base}/api/user/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: `user=${role}&pass=password-${role}`,
|
||||
})
|
||||
tokens[role] = ((await res.json()) as { token: string }).token
|
||||
}
|
||||
}
|
||||
|
||||
interface Init {
|
||||
params?: Record<string, string>
|
||||
method?: string
|
||||
body?: string | FormData
|
||||
headers?: Record<string, string>
|
||||
auth?: boolean
|
||||
}
|
||||
|
||||
async function api(role: string, apiPath: string, init: Init = {}) {
|
||||
const query = new URLSearchParams(init.params ?? {})
|
||||
const headers: Record<string, string> = { ...(init.auth === false ? {} : { authorization: `Bearer ${tokens[role]}` }), ...(init.headers ?? {}) }
|
||||
const res = await fetch(`${base}/api/${apiPath}?${query}`, { method: init.method ?? (init.body === undefined ? 'GET' : 'POST'), headers, body: init.body })
|
||||
const text = await res.text()
|
||||
let json: Record<string, any> = {}
|
||||
try {
|
||||
json = JSON.parse(text) as Record<string, any>
|
||||
} catch {
|
||||
// A file.
|
||||
}
|
||||
return { code: res.status, body: json, text, headers: res.headers }
|
||||
}
|
||||
|
||||
const posts = (f: Fake) => f.calls.filter((c) => c.method === 'POST')
|
||||
const form = (params: Record<string, string>) => ({
|
||||
body: new URLSearchParams(params).toString(),
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
})
|
||||
|
||||
async function auditEntries(): Promise<Record<string, string>[]> {
|
||||
return (await api('admin', 'hub/audit/list', { params: { limit: '200' } })).body.response.entries
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
a = await fake()
|
||||
b = await fake()
|
||||
dataDir = mkdtempSync(path.join(tmpdir(), 'hub-s5-data-'))
|
||||
staticDir = mkdtempSync(path.join(tmpdir(), 'hub-s5-static-'))
|
||||
let users = addUser([], 'viewer', 'viewer', 'password-viewer')
|
||||
users = addUser(users, 'operator', 'operator', 'password-operator')
|
||||
users = addUser(users, 'apps-admin', 'apps-admin', 'password-apps-admin')
|
||||
users = addUser(users, 'admin', 'admin', 'password-admin')
|
||||
writeUsersFile(dataDir, users)
|
||||
await start()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
for (const f of [a, b]) {
|
||||
f.calls = []
|
||||
f.down = false
|
||||
f.failWith = null
|
||||
f.settings = { ...SETTINGS }
|
||||
}
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
hub?.close()
|
||||
a.server.close()
|
||||
b.server.close()
|
||||
rmSync(dataDir, { recursive: true, force: true })
|
||||
rmSync(staticDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/* One write of each area, with the lowest capability that may make it. */
|
||||
const OPERATE = [
|
||||
['cache/flush', {}],
|
||||
['cache/delete', { domain: 'x.example' }],
|
||||
['blocked/add', { domain: 'ads.example' }],
|
||||
['allowed/delete', { domain: 'ok.example' }],
|
||||
['settings/temporaryDisableBlocking', { minutes: '5' }],
|
||||
['settings/forceUpdateBlockLists', {}],
|
||||
] as const
|
||||
const ADMIN = [
|
||||
['zones/records/add', { zone: 'example.com', domain: 'www.example.com', type: 'A', ipAddress: '192.0.2.1' }],
|
||||
['zones/delete', { zone: 'example.com' }],
|
||||
['zones/dnssec/sign', { zone: 'example.com', algorithm: 'ECDSA' }],
|
||||
['dhcp/scopes/enable', { name: 'Default' }],
|
||||
['settings/set', { enableBlocking: 'true' }],
|
||||
] as const
|
||||
|
||||
describe('who may write what', () => {
|
||||
it('refuses a viewer on every write, without calling a server', async () => {
|
||||
for (const [p, params] of [...OPERATE, ...ADMIN]) {
|
||||
const r = await api('viewer', p, { params: { node: 'a', ...params } })
|
||||
expect(r.code, p).toBe(403)
|
||||
expect(r.body.errorMessage).toBe('Your role does not allow this.')
|
||||
}
|
||||
expect(posts(a)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('lets an operator operate (cache, blocking) but not change zones, settings or DHCP', async () => {
|
||||
for (const [p, params] of OPERATE) expect((await api('operator', p, { params: { node: 'a', ...params } })).body.status, p).toBe('ok')
|
||||
for (const [p, params] of ADMIN) expect((await api('operator', p, { params: { node: 'a', ...params } })).code, p).toBe(403)
|
||||
expect(posts(a).map((c) => c.path)).toEqual(OPERATE.map(([p]) => p))
|
||||
})
|
||||
|
||||
it('lets an apps-admin do neither', async () => {
|
||||
for (const [p, params] of [...OPERATE, ...ADMIN]) expect((await api('apps-admin', p, { params: { node: 'a', ...params } })).code, p).toBe(403)
|
||||
expect(posts(a)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('lets an admin do all of it', async () => {
|
||||
for (const [p, params] of [...OPERATE, ...ADMIN]) expect((await api('admin', p, { params: { node: 'a', ...params } })).body.status, p).toBe('ok')
|
||||
expect(posts(a)).toHaveLength(OPERATE.length + ADMIN.length)
|
||||
})
|
||||
|
||||
it('tells the console what each role may modify', async () => {
|
||||
const info = async (role: string) => (await api(role, 'user/session/get')).body.info.permissions
|
||||
const viewer = await info('viewer')
|
||||
const operator = await info('operator')
|
||||
const admin = await info('admin')
|
||||
expect(viewer.Zones).toEqual({ canView: true, canModify: false, canDelete: false })
|
||||
expect(operator.Zones.canModify).toBe(false)
|
||||
expect(operator.Cache).toEqual({ canView: true, canModify: true, canDelete: true })
|
||||
expect(operator.Blocked.canModify).toBe(true)
|
||||
expect(operator.Settings.canModify).toBe(false)
|
||||
expect(admin.Zones).toEqual({ canView: true, canModify: true, canDelete: true })
|
||||
expect(admin.DhcpServer).toEqual({ canView: true, canModify: true, canDelete: true })
|
||||
// Backup and restore, deleting logs and stats: nobody.
|
||||
for (const p of [viewer, operator, admin]) {
|
||||
expect(p.Logs).toEqual({ canView: true, canModify: false, canDelete: false })
|
||||
expect(p.Dashboard.canDelete).toBe(false)
|
||||
expect(p.Settings.canDelete).toBe(false)
|
||||
expect(p.Administration.canView).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('what stays refused', () => {
|
||||
const REFUSED = [
|
||||
'admin/users/list', 'admin/users/create', 'admin/sso/set', 'admin/ldap/set', 'admin/groups/list', 'admin/sessions/createToken',
|
||||
'user/createToken', 'user/changePassword', 'user/profile/get',
|
||||
'logs/delete', 'logs/deleteAll', 'dashboard/stats/deleteAll',
|
||||
'settings/restore', 'settings/backup', 'zones/permissions/get', 'zones/permissions/set',
|
||||
]
|
||||
|
||||
it('answers "read-only" to each, even to an admin, and calls no server', async () => {
|
||||
for (const p of REFUSED) {
|
||||
const r = await api('admin', p, { params: { node: 'a' }, method: 'POST', body: '' })
|
||||
expect(r.code, p).toBe(403)
|
||||
expect(r.body.errorMessage, p).toBe('The hub is read-only.')
|
||||
}
|
||||
expect(a.calls).toHaveLength(0)
|
||||
const refused = (await auditEntries()).filter((e) => e.action === 'refused-write')
|
||||
expect(refused.map((e) => e.target)).toEqual(expect.arrayContaining(['logs/delete', 'settings/backup']))
|
||||
})
|
||||
})
|
||||
|
||||
describe('choosing the server', () => {
|
||||
it('refuses a write with no node, the aggregate or an unknown node (400) and calls no server', async () => {
|
||||
for (const node of [undefined, '', 'cluster', 'zzz']) {
|
||||
const r = await api('admin', 'cache/flush', { params: node === undefined ? {} : { node } })
|
||||
expect(r.code).toBe(400)
|
||||
expect(r.body.errorMessage).toBe(node === 'zzz' ? 'Unknown node: zzz' : 'Choose a server: this change is made on one server at a time.')
|
||||
}
|
||||
expect(a.calls).toHaveLength(0)
|
||||
expect(b.calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('refuses a server the hub has no admin token for (403), and audits it', async () => {
|
||||
await start({ B_ADMIN_TOKEN: '' })
|
||||
const r = await api('admin', 'cache/flush', { params: { node: 'b' } })
|
||||
expect(r.code).toBe(403)
|
||||
expect(r.body.errorMessage).toMatch(/no admin token/)
|
||||
expect(b.calls).toHaveLength(0)
|
||||
expect((await api('admin', 'cache/flush', { params: { node: 'a' } })).body.status).toBe('ok')
|
||||
expect((await auditEntries()).find((e) => e.detail === 'no admin token')).toMatchObject({ server: 'b', outcome: 'refused' })
|
||||
await start()
|
||||
})
|
||||
|
||||
it('writes only to the chosen server', async () => {
|
||||
await api('admin', 'cache/flush', { params: { node: 'b' } })
|
||||
expect(posts(a)).toHaveLength(0)
|
||||
expect(posts(b)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('refuses a read that has no merger when no server is named', async () => {
|
||||
for (const p of ['zones/list', 'cache/list', 'blocked/list', 'dhcp/scopes/list', 'logs/list', 'dnsClient/resolve', 'zones/export']) {
|
||||
const r = await api('viewer', p)
|
||||
expect(r.code, p).toBe(400)
|
||||
expect(r.body.errorMessage).toBe('Choose a server: this is read from one server at a time.')
|
||||
expect((await api('viewer', p, { params: { node: 'cluster' } })).code, p).toBe(400)
|
||||
}
|
||||
expect(a.calls).toHaveLength(0)
|
||||
const one = await api('viewer', 'zones/list', { params: { node: 'a', pageNumber: '1' } })
|
||||
expect(one.body.response.zones[0].name).toBe('example.com')
|
||||
expect(a.calls[0]).toMatchObject({ method: 'GET', token: 'read-a' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('how the hub calls the server', () => {
|
||||
it('sends the parameters in a form body and leaves only the token in the query', async () => {
|
||||
const r = await api('admin', 'zones/records/add', {
|
||||
params: { node: 'a', zone: 'example.com' },
|
||||
...form({ domain: 'www.example.com', type: 'TXT', text: 'v=spf1 secret-value' }),
|
||||
})
|
||||
expect(r.body.status).toBe('ok')
|
||||
const [call] = posts(a)
|
||||
expect(call.path).toBe('zones/records/add')
|
||||
expect(call.token).toBe('admin-a')
|
||||
expect([...call.query.keys()]).toEqual(['token'])
|
||||
expect(call.contentType).toContain('application/x-www-form-urlencoded')
|
||||
expect(Object.fromEntries(call.form)).toEqual({ zone: 'example.com', domain: 'www.example.com', type: 'TXT', text: 'v=spf1 secret-value' })
|
||||
})
|
||||
|
||||
it('forwards a GET the console sent as a POST', async () => {
|
||||
await api('admin', 'zones/enable', { params: { node: 'a', zone: 'example.com' } })
|
||||
expect(posts(a)[0].form.get('zone')).toBe('example.com')
|
||||
expect(posts(a)[0].form.has('node')).toBe(false)
|
||||
})
|
||||
|
||||
it('never forwards the caller token or node, and uses the admin token alone', async () => {
|
||||
await api('admin', 'cache/flush', { params: { node: 'a', token: 'evil' } })
|
||||
const call = posts(a)[0]
|
||||
expect(call.token).toBe('admin-a')
|
||||
expect(call.form.has('token')).toBe(false)
|
||||
expect(call.form.has('node')).toBe(false)
|
||||
})
|
||||
|
||||
it('relays the server refusal as an error naming the server', async () => {
|
||||
a.failWith = 'Zone already exists.'
|
||||
const r = await api('admin', 'zones/create', { params: { node: 'a', zone: 'example.com', type: 'Primary' }, method: 'POST' })
|
||||
expect(r.code).toBe(200)
|
||||
expect(r.body).toEqual({ status: 'error', errorMessage: 'a: Zone already exists.' })
|
||||
})
|
||||
|
||||
it('says so when the server does not answer', async () => {
|
||||
a.down = true
|
||||
const r = await api('admin', 'cache/flush', { params: { node: 'a' } })
|
||||
expect(r.body.status).toBe('error')
|
||||
expect(r.body.errorMessage).toMatch(/^a did not answer/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the audit line', () => {
|
||||
it('has the path, the server, one identifier and the outcome, and no value', async () => {
|
||||
await api('operator', 'blocked/add', { params: { node: 'a' }, ...form({ domain: 'ads.example', note: 'PRIVATE-NOTE' }) })
|
||||
await api('admin', 'zones/records/add', { params: { node: 'b' }, ...form({ zone: 'example.com', domain: 'www.example.com', type: 'TXT', text: 'SECRET-TXT' }) })
|
||||
await api('admin', 'settings/set', { params: { node: 'a' }, ...form({ tsigKeys: 'k|TOPSECRET|hmac-sha256', proxyPassword: 'PROXYPASS2' }) })
|
||||
b.failWith = 'Denied for SECRET-TXT.'
|
||||
await api('admin', 'zones/delete', { params: { node: 'b', zone: 'example.com' } })
|
||||
const entries = await auditEntries()
|
||||
const mine = entries.filter((e) => /^(blocked|zones|settings)\./.test(e.action))
|
||||
expect(mine.find((e) => e.action === 'blocked.add')).toMatchObject({ user: 'operator', role: 'operator', server: 'a', target: 'ads.example', outcome: 'ok' })
|
||||
expect(mine.find((e) => e.action === 'zones.records.add')).toMatchObject({ user: 'admin', server: 'b', target: 'example.com', outcome: 'ok' })
|
||||
expect(mine.find((e) => e.action === 'settings.set')).toMatchObject({ server: 'a', outcome: 'ok' })
|
||||
expect(mine.find((e) => e.action === 'zones.delete')).toMatchObject({ outcome: 'failed', detail: 'refused by the server' })
|
||||
expect(mine.find((e) => e.action === 'blocked.add')?.ip).toBeTruthy()
|
||||
const raw = readFileSync(path.join(dataDir, 'audit.log'), 'utf8')
|
||||
for (const value of ['PRIVATE-NOTE', 'SECRET-TXT', 'TOPSECRET', 'PROXYPASS2', 'admin-a', 'admin-b', 'ads.example"}']) expect(raw).not.toContain(value)
|
||||
})
|
||||
})
|
||||
|
||||
describe('settings/set', () => {
|
||||
const unchanged = {
|
||||
webServiceLocalAddresses: '0.0.0.0,[::]',
|
||||
webServiceHttpPort: '5380',
|
||||
webServiceEnableTls: 'false',
|
||||
webServiceTlsCertificatePath: '',
|
||||
webServiceTlsCertificatePassword: '************',
|
||||
}
|
||||
|
||||
it('passes a save that carries the web service block unchanged, as the console sends it', async () => {
|
||||
const r = await api('admin', 'settings/set', { params: { node: 'a' }, ...form({ ...unchanged, enableBlocking: 'false', dnsServerDomain: 'a2.example' }) })
|
||||
expect(r.body.status).toBe('ok')
|
||||
expect(r.body.server).toBe('a')
|
||||
expect(posts(a)[0].form.get('enableBlocking')).toBe('false')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['webServiceHttpPort', '5381'],
|
||||
['webServiceLocalAddresses', '127.0.0.1'],
|
||||
['webServiceEnableTls', 'true'],
|
||||
['webServiceTlsCertificatePassword', 'new-pass'],
|
||||
])('refuses a changed %s and forwards nothing', async (name, value) => {
|
||||
const r = await api('admin', 'settings/set', { params: { node: 'a' }, ...form({ ...unchanged, [name]: value, enableBlocking: 'false' }) })
|
||||
expect(r.code).toBe(400)
|
||||
expect(r.body.errorMessage).toBe("Change the web service settings in that server's own console.")
|
||||
expect(posts(a)).toHaveLength(0)
|
||||
const entry = (await auditEntries()).find((e) => e.action === 'settings.set' && e.outcome === 'refused')
|
||||
expect(entry?.detail).toContain(name)
|
||||
expect(entry?.detail).not.toContain(value)
|
||||
})
|
||||
|
||||
it('refuses user, SSO and LDAP fields whatever their value', async () => {
|
||||
for (const field of ['ssoEnabled', 'ldapServer', 'userName', 'authSessionTimeout']) {
|
||||
const r = await api('admin', 'settings/set', { params: { node: 'a' }, ...form({ [field]: 'x' }) })
|
||||
expect(r.code, field).toBe(400)
|
||||
}
|
||||
expect(posts(a)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not save when the current settings cannot be read first', async () => {
|
||||
a.down = true
|
||||
const r = await api('admin', 'settings/set', { params: { node: 'a' }, ...form({ enableBlocking: 'false' }) })
|
||||
expect(r.body.status).toBe('error')
|
||||
expect(r.body.errorMessage).toMatch(/nothing was changed/)
|
||||
})
|
||||
|
||||
it('compares as the console writes (lists, numbers, booleans)', () => {
|
||||
const current = { webServiceLocalAddresses: ['0.0.0.0', '[::]'], webServiceHttpPort: 5380, webServiceEnableTls: false, webServiceRealIpHeader: 'X-Real-IP', other: 'x' }
|
||||
expect(webServiceChanges({ webServiceLocalAddresses: '[::]\n0.0.0.0', webServiceHttpPort: '5380', webServiceEnableTls: 'False', webServiceRealIpHeader: 'X-Real-IP', other: 'changed' }, current)).toEqual([])
|
||||
expect(webServiceChanges({ webServiceHttpPort: '' }, current)).toEqual(['webServiceHttpPort'])
|
||||
expect(webServiceChanges({ webServiceLocalAddresses: 'false' }, current)).toEqual(['webServiceLocalAddresses'])
|
||||
expect(webServiceChanges({ webServiceUnknownField: 'x' }, current)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('settings/get', () => {
|
||||
it('blanks the secrets for every role but admin', async () => {
|
||||
for (const role of ['viewer', 'operator', 'apps-admin']) {
|
||||
const r = await api(role, 'settings/get', { params: { node: 'a' } })
|
||||
expect(r.body.status).toBe('ok')
|
||||
const s = r.body.response
|
||||
expect(s.tsigKeys).toEqual([{ keyName: 'k', sharedSecret: '', algorithmName: 'hmac-sha256' }])
|
||||
expect(s.proxy).toMatchObject({ address: 'p', username: 'u', password: '' })
|
||||
expect(s.dnsTlsCertificatePassword).toBe('')
|
||||
expect(s.webServiceTlsCertificatePassword).toBe('')
|
||||
expect(s.enableBlocking).toBe(true)
|
||||
expect(r.text).not.toMatch(/TOPSECRET|PROXYPASS|CERTPASS/)
|
||||
}
|
||||
const admin = (await api('admin', 'settings/get', { params: { node: 'a' } })).body.response
|
||||
expect(admin.tsigKeys[0].sharedSecret).toBe('TOPSECRET')
|
||||
expect(admin.proxy.password).toBe('PROXYPASS')
|
||||
})
|
||||
|
||||
it('also blanks the answer given without a node (the first server)', async () => {
|
||||
const r = await api('viewer', 'settings/get')
|
||||
expect(r.body.response.tsigKeys[0].sharedSecret).toBe('')
|
||||
})
|
||||
|
||||
it('does not change what it is given', () => {
|
||||
const input = { tsigKeys: [{ sharedSecret: 's' }], proxy: null }
|
||||
expect(redactSettings(input)).toEqual({ tsigKeys: [{ sharedSecret: '' }], proxy: null })
|
||||
expect(input.tsigKeys[0].sharedSecret).toBe('s')
|
||||
expect(redactSettings(null)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('dnsClient/resolve', () => {
|
||||
it('is a plain read, but with import it is a zone write needing admin', async () => {
|
||||
expect(effectiveRoute('dnsClient/resolve', {})).toMatchObject({ kind: 'read', capability: 'read' })
|
||||
expect(effectiveRoute('dnsClient/resolve', { import: 'false' })?.kind).toBe('read')
|
||||
for (const flag of ['true', 'True', ' TRUE ', '1', 'yes']) {
|
||||
expect(effectiveRoute('dnsClient/resolve', { import: flag })).toMatchObject({ kind: 'write-one', capability: 'admin' })
|
||||
}
|
||||
|
||||
expect((await api('viewer', 'dnsClient/resolve', { params: { node: 'a', domain: 'example.com', type: 'A' } })).body.status).toBe('ok')
|
||||
expect(a.calls[0]).toMatchObject({ method: 'GET', token: 'read-a' })
|
||||
for (const role of ['viewer', 'operator', 'apps-admin']) {
|
||||
expect((await api(role, 'dnsClient/resolve', { params: { node: 'a', domain: 'example.com', import: 'true' } })).code, role).toBe(403)
|
||||
}
|
||||
expect(posts(a)).toHaveLength(0)
|
||||
expect((await api('admin', 'dnsClient/resolve', { params: { node: 'a', domain: 'example.com', import: 'true' } })).body.status).toBe('ok')
|
||||
expect(posts(a)[0]).toMatchObject({ token: 'admin-a' })
|
||||
expect(posts(a)[0].form.get('import')).toBe('true')
|
||||
})
|
||||
})
|
||||
|
||||
describe('downloads', () => {
|
||||
it('passes a file through with the reader token', async () => {
|
||||
const r = await api('viewer', 'zones/export', { params: { node: 'a', zone: 'example.com' } })
|
||||
expect(r.code).toBe(200)
|
||||
expect(r.text).toBe('line one\nline two (zones/export)\n')
|
||||
expect(r.headers.get('content-type')).toBe('text/plain')
|
||||
expect(r.headers.get('content-disposition')).toContain('attachment')
|
||||
expect(r.headers.get('x-content-type-options')).toBe('nosniff')
|
||||
expect(a.calls[0]).toMatchObject({ method: 'GET', token: 'read-a' })
|
||||
})
|
||||
|
||||
it('turns the server error envelope into the hub one', async () => {
|
||||
const r = await api('viewer', 'zones/export', { params: { node: 'a', zone: 'missing.example' } })
|
||||
expect(r.body).toEqual({ status: 'error', errorMessage: 'a: No such zone: missing.example' })
|
||||
})
|
||||
|
||||
it('opens a download with a single-use token, once', async () => {
|
||||
const issued = await api('viewer', 'user/createSingleUseToken')
|
||||
const once = issued.body.response.token as string
|
||||
expect(once).toMatch(/^[0-9a-f]{64}$/)
|
||||
const first = await api('viewer', 'logs/download', { auth: false, params: { node: 'a', fileName: '2026-01-01', token: once } })
|
||||
expect(first.text).toContain('logs/download')
|
||||
const second = await api('viewer', 'logs/download', { auth: false, params: { node: 'a', fileName: '2026-01-01', token: once } })
|
||||
expect(second.body.status).toBe('invalid-token')
|
||||
})
|
||||
|
||||
it('does not accept a single-use token for anything but a download', async () => {
|
||||
const once = (await api('admin', 'user/createSingleUseToken')).body.response.token as string
|
||||
const r = await api('admin', 'cache/flush', { auth: false, params: { node: 'a', token: once } })
|
||||
expect(r.body.status).toBe('invalid-token')
|
||||
expect(posts(a)).toHaveLength(0)
|
||||
expect((await api('admin', 'zones/list', { auth: false, params: { node: 'a', token: once } })).body.status).toBe('invalid-token')
|
||||
})
|
||||
|
||||
it('gives no single-use token without a session', async () => {
|
||||
expect((await api('viewer', 'user/createSingleUseToken', { auth: false })).body.status).toBe('invalid-token')
|
||||
})
|
||||
})
|
||||
|
||||
describe('uploads and large bodies', () => {
|
||||
it('forwards a pasted zone file as text/plain, with the options in the query', async () => {
|
||||
const r = await api('admin', 'zones/import', {
|
||||
params: { node: 'a', zone: 'example.com', overwrite: 'true' },
|
||||
body: '$ORIGIN example.com.\n@ IN SOA ns hostmaster 1 2 3 4 5\n',
|
||||
headers: { 'content-type': 'text/plain' },
|
||||
})
|
||||
expect(r.body.status).toBe('ok')
|
||||
const call = posts(a)[0]
|
||||
expect(call.token).toBe('admin-a')
|
||||
expect(call.contentType).toContain('text/plain')
|
||||
expect(call.body.toString()).toContain('$ORIGIN example.com.')
|
||||
expect(call.query.get('zone')).toBe('example.com')
|
||||
expect(call.query.get('overwrite')).toBe('true')
|
||||
})
|
||||
|
||||
it('forwards a zone file sent as multipart, and a create with no body', async () => {
|
||||
const data = new FormData()
|
||||
data.append('fileImportZone', new Blob(['zone text'], { type: 'text/plain' }), 'example.com.zone')
|
||||
const r = await api('admin', 'zones/create', { params: { node: 'b', zone: 'example.com', type: 'Primary' }, body: data })
|
||||
expect(r.body.status).toBe('ok')
|
||||
const call = posts(b)[0]
|
||||
expect(call.contentType).toContain('multipart/form-data')
|
||||
expect(call.body.toString()).toContain('zone text')
|
||||
expect(call.body.toString()).toContain('name="fileImportZone"')
|
||||
expect(call.query.get('zone')).toBe('example.com')
|
||||
|
||||
await api('admin', 'zones/create', { params: { node: 'a', zone: 'plain.example', type: 'Primary' }, method: 'POST' })
|
||||
expect(posts(a)[0].form.get('zone')).toBe('plain.example')
|
||||
})
|
||||
|
||||
it('does not take an upload from a role that may not write zones', async () => {
|
||||
const r = await api('operator', 'zones/import', { params: { node: 'a', zone: 'example.com' }, body: 'x', headers: { 'content-type': 'text/plain' } })
|
||||
expect(r.code).toBe(403)
|
||||
expect(posts(a)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('takes a long TXT record from a signed-in caller, and a small limit from anyone else', async () => {
|
||||
const text = 'v=spf1 ' + 'a'.repeat(200_000)
|
||||
const r = await api('admin', 'zones/records/add', { params: { node: 'a' }, ...form({ zone: 'example.com', domain: 'www.example.com', type: 'TXT', text }) })
|
||||
expect(r.body.status).toBe('ok')
|
||||
expect(posts(a)[0].form.get('text')).toBe(text)
|
||||
|
||||
// Not signed in: the small default applies (the hub drops the connection), and nothing reaches the server.
|
||||
const anon = await api('admin', 'zones/records/add', { auth: false, params: { node: 'a' }, ...form({ zone: 'example.com', text }) }).catch(() => null)
|
||||
expect(anon?.body.status).not.toBe('ok')
|
||||
expect(posts(a)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('takes a block list import of a few MB', async () => {
|
||||
const list = Array.from({ length: 150_000 }, (_, i) => `d${i}.example`).join(',')
|
||||
const r = await api('operator', 'blocked/import', { params: { node: 'a' }, ...form({ blockedZones: list }) })
|
||||
expect(r.body.status).toBe('ok')
|
||||
expect(posts(a)[0].form.get('blockedZones')).toBe(list)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the table', () => {
|
||||
it('has every area and none of the refused paths', () => {
|
||||
expect(routeFor('zones/records/add')).toMatchObject({ kind: 'write-one', capability: 'admin' })
|
||||
expect(routeFor('cache/flush')).toMatchObject({ kind: 'write-one', capability: 'operate' })
|
||||
expect(routeFor('settings/temporaryDisableBlocking')).toMatchObject({ kind: 'write-one', capability: 'operate' })
|
||||
expect(routeFor('settings/get')).toEqual({ kind: 'read', capability: 'read' })
|
||||
expect(routeFor('zones/export')).toMatchObject({ kind: 'read', raw: true, perServer: true })
|
||||
for (const p of ['logs/delete', 'logs/deleteAll', 'dashboard/stats/deleteAll', 'settings/backup', 'settings/restore', 'admin/users/list', 'user/createToken']) {
|
||||
expect(routeFor(p), p).toBeUndefined()
|
||||
}
|
||||
expect(safeTarget({ zone: 'example.com', text: 'x' })).toBe('example.com')
|
||||
expect(safeTarget({ text: 'x' })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
+3
-2
@@ -170,7 +170,8 @@ describe('session', () => {
|
||||
expect(body.info.clusterNodes).toEqual([{ name: 'a', type: 'Primary' }, { name: 'b', type: 'Secondary' }])
|
||||
expect(body.info.permissions.Dashboard.canView).toBe(true)
|
||||
expect(body.info.permissions.Logs.canView).toBe(true)
|
||||
expect(body.info.permissions.Zones).toEqual({ canView: false, canModify: false, canDelete: false })
|
||||
expect(body.info.permissions.Zones).toEqual({ canView: true, canModify: true, canDelete: true })
|
||||
expect(body.info.permissions.Administration).toEqual({ canView: false, canModify: false, canDelete: false })
|
||||
})
|
||||
|
||||
it('refuses a wrong password', async () => {
|
||||
@@ -196,7 +197,7 @@ describe('session', () => {
|
||||
describe('read-only', () => {
|
||||
it('refuses everything that is not a listed read', async () => {
|
||||
const token = await login()
|
||||
const { code, body } = await call('zones/delete', { zone: 'x' }, token, 'POST')
|
||||
const { code, body } = await call('admin/users/delete', { user: 'x' }, token, 'POST')
|
||||
expect(code).toBe(403)
|
||||
expect(body).toEqual({ status: 'error', errorMessage: 'The hub is read-only.' })
|
||||
expect(a.calls).toHaveLength(0)
|
||||
|
||||
+156
-22
@@ -4,15 +4,19 @@ answers from several real ones.
|
||||
|
||||
node --experimental-strip-types hub/server.ts
|
||||
|
||||
It serves the console's static build and a read-only, Technitium-compatible `/api/*`.
|
||||
A call aimed at one server (`node=<id>`) is forwarded to it with that server's token; a
|
||||
call with no node (or `node=cluster`) asks every server in parallel and merges the
|
||||
answers (see merge.ts). Anything that would change data is refused.
|
||||
It serves the console's static build and a Technitium-compatible `/api/*`. A read aimed at
|
||||
one server (`node=<id>`) is forwarded to it with that server's token; a read with no node (or
|
||||
`node=cluster`) asks every server in parallel and merges the answers (see merge.ts), and the
|
||||
reads that have no merger (`perServer` in routes.ts) are refused without a node. A change
|
||||
is made on ONE chosen server with its admin token, or by the app and config endpoints; whatever
|
||||
is not in the table of routes.ts is refused.
|
||||
*/
|
||||
import http from 'node:http'
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { Readable } from 'node:stream'
|
||||
import type { ReadableStream as WebStream } from 'node:stream/web'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { loadConfig, HUB_VERSION, type HubConfig, type HubServer } from './config.ts'
|
||||
import { Upstream, UpstreamError, type Params } from './upstream.ts'
|
||||
@@ -20,7 +24,8 @@ import { Sessions } from './session.ts'
|
||||
import { Users, can } from './users.ts'
|
||||
import { LoginLimiter, lockedMessage } from './ratelimit.ts'
|
||||
import { Audit } from './audit.ts'
|
||||
import { routeFor } from './routes.ts'
|
||||
import { effectiveRoute, largeBodyLimit, routeFor, type Route } from './routes.ts'
|
||||
import { WEB_SERVICE_MESSAGE, isForbiddenSettingsField, redactSettings, safeTarget, webServiceChanges } from './guards.ts'
|
||||
import { buildMatrix } from './apps-matrix.ts'
|
||||
import { AppsWriter, actionForPath, type FileAction, type TargetResult } from './apps-write.ts'
|
||||
import { TooLarge, parseMultipart, readLimited, type UploadedFile } from './multipart.ts'
|
||||
@@ -363,8 +368,8 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
|
||||
}
|
||||
|
||||
/* The write endpoints. The role has already been checked against the route's capability. */
|
||||
async function write(req: http.IncomingMessage, res: http.ServerResponse, apiPath: string, kind: string, params: Params, who: Who, ip: string): Promise<void> {
|
||||
if (kind === 'write-all') {
|
||||
async function write(req: http.IncomingMessage, res: http.ServerResponse, apiPath: string, route: Route, params: Params, who: Who, ip: string): Promise<void> {
|
||||
if (route.kind === 'write-all') {
|
||||
if (req.method !== 'POST') {
|
||||
send(res, 405, fail('Use POST.'))
|
||||
return
|
||||
@@ -421,12 +426,12 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
|
||||
|
||||
// write-one: an upstream endpoint aimed at ONE server (`node=<id>`).
|
||||
const action = actionForPath(apiPath)
|
||||
const node = params.node ?? ''
|
||||
const server = byId.get(node)
|
||||
if (action === null) {
|
||||
send(res, 403, fail('The hub is read-only.'))
|
||||
await serverWrite(req, res, apiPath, route, params, who, ip)
|
||||
return
|
||||
}
|
||||
const node = params.node ?? ''
|
||||
const server = byId.get(node)
|
||||
if (server === undefined) {
|
||||
send(res, 400, fail(node === '' || node === 'cluster' ? 'Choose one server: this change cannot be made on every server at once here.' : `Unknown node: ${node}`))
|
||||
return
|
||||
@@ -462,12 +467,129 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
|
||||
send(res, 200, result.ok ? ok(response) : fail(`${server.id}: ${result.message}`))
|
||||
}
|
||||
|
||||
const textBody = (req: http.IncomingMessage): boolean => /^text\/plain/i.test(String(req.headers['content-type'] ?? ''))
|
||||
|
||||
/*
|
||||
A change to ONE server from the sections it is managed from (zones, cache, blocking, DNS client,
|
||||
settings, DHCP). The role has been checked against the route. The server is the one named by
|
||||
`node`, and its admin token does the work. What is forwarded is exactly the parameters that were
|
||||
looked at here, in a form body (see `Upstream.write`), minus `node` and `token`. The audit line
|
||||
holds the path, the server, one identifier and the outcome, never a value.
|
||||
*/
|
||||
async function serverWrite(req: http.IncomingMessage, res: http.ServerResponse, apiPath: string, route: Route, params: Params, who: Who, ip: string): Promise<void> {
|
||||
const node = params.node ?? ''
|
||||
const server = byId.get(node)
|
||||
const line = { user: who.user, role: who.role, ip, action: apiPath.replace(/\//g, '.') }
|
||||
if (server === undefined) {
|
||||
send(res, 400, fail(node === '' || node === 'cluster' ? 'Choose a server: this change is made on one server at a time.' : `Unknown node: ${node}`))
|
||||
return
|
||||
}
|
||||
if (server.adminToken === undefined) {
|
||||
audit.log({ ...line, server: server.id, target: safeTarget(params), outcome: 'refused', detail: 'no admin token' })
|
||||
send(res, 403, fail(`${server.id}: the hub has no admin token for this server, so it cannot change it.`))
|
||||
return
|
||||
}
|
||||
|
||||
const forward: Params = {}
|
||||
for (const [k, v] of Object.entries(params)) if (k !== 'node' && k !== 'token') forward[k] = v
|
||||
|
||||
// A file or a text body (a zone import). The parameters may also arrive as multipart fields.
|
||||
let body: FormData | string | undefined
|
||||
if (route.upload === true && (isMultipart(req) || textBody(req))) {
|
||||
let data: Buffer
|
||||
try {
|
||||
data = await readLimited(req)
|
||||
} catch (e) {
|
||||
if (e instanceof TooLarge) {
|
||||
res.setHeader('connection', 'close')
|
||||
send(res, 413, fail(e.message))
|
||||
} else send(res, 400, fail('The upload could not be read.'))
|
||||
return
|
||||
}
|
||||
if (isMultipart(req)) {
|
||||
let parsed
|
||||
try {
|
||||
parsed = parseMultipart(data, String(req.headers['content-type'] ?? ''))
|
||||
} catch {
|
||||
send(res, 400, fail('The upload could not be read.'))
|
||||
return
|
||||
}
|
||||
for (const [k, v] of Object.entries(parsed.fields)) if (k !== 'node' && k !== 'token') forward[k] = v
|
||||
const file = parsed.files[0]
|
||||
if (file !== undefined) {
|
||||
const form = new FormData()
|
||||
form.append(file.field, new Blob([file.data], { type: file.type }), file.filename)
|
||||
body = form
|
||||
}
|
||||
} else body = data.toString('utf8')
|
||||
}
|
||||
|
||||
// settings/set: no change to the web service the hub itself uses, and nothing about users (guards.ts).
|
||||
if (apiPath === 'settings/set') {
|
||||
if (Object.keys(forward).some(isForbiddenSettingsField)) {
|
||||
audit.log({ ...line, server: server.id, outcome: 'refused', detail: 'user, SSO or LDAP field' })
|
||||
send(res, 400, fail("User, SSO and LDAP settings are changed in that server's own console."))
|
||||
return
|
||||
}
|
||||
try {
|
||||
upstream.invalidate(server.id)
|
||||
const changed = webServiceChanges(forward, await upstream.call(server, 'settings/get', {}, server.adminToken))
|
||||
if (changed.length > 0) {
|
||||
audit.log({ ...line, server: server.id, outcome: 'refused', detail: `web service: ${changed.join(', ').slice(0, 200)}` })
|
||||
send(res, 400, fail(WEB_SERVICE_MESSAGE))
|
||||
return
|
||||
}
|
||||
} catch (e) {
|
||||
if (!(e instanceof UpstreamError)) throw e
|
||||
audit.log({ ...line, server: server.id, outcome: 'failed', detail: 'could not read the current settings' })
|
||||
send(res, 200, fail(`${server.id}: the web service settings could not be checked first (${e.message}); nothing was changed.`))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await upstream.write(server, apiPath, forward, body)
|
||||
audit.log({ ...line, server: server.id, target: safeTarget(forward), outcome: 'ok' })
|
||||
send(res, 200, apiPath === 'settings/set' ? { ...ok(response), server: server.id } : ok(response))
|
||||
} catch (e) {
|
||||
if (!(e instanceof UpstreamError)) throw e
|
||||
audit.log({ ...line, server: server.id, target: safeTarget(forward), outcome: 'failed', detail: e.kind === 'unreachable' ? 'no answer' : 'refused by the server' })
|
||||
send(res, 200, fail(e.kind === 'unreachable' ? `${server.id} did not answer: ${e.message}` : `${server.id}: ${e.message}`))
|
||||
}
|
||||
}
|
||||
|
||||
/* A file from one server (a zone, a log, a block list), passed on as it comes. */
|
||||
async function download(res: http.ServerResponse, server: HubServer, apiPath: string, params: Params): Promise<void> {
|
||||
try {
|
||||
const file = await upstream.raw(server, apiPath, params)
|
||||
res.writeHead(200, {
|
||||
'content-type': file.contentType,
|
||||
...(file.disposition === undefined ? {} : { 'content-disposition': file.disposition }),
|
||||
'cache-control': 'no-store',
|
||||
'x-content-type-options': 'nosniff',
|
||||
'content-security-policy': "default-src 'none'; sandbox",
|
||||
})
|
||||
if (file.body === null) {
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
res.on('close', () => file.abort())
|
||||
Readable.fromWeb(file.body as WebStream).on('error', () => res.destroy()).pipe(res)
|
||||
} catch (e) {
|
||||
if (!(e instanceof UpstreamError)) throw e
|
||||
send(res, 200, fail(e.kind === 'unreachable' ? `${server.id} did not answer: ${e.message}` : `${server.id}: ${e.message}`))
|
||||
}
|
||||
}
|
||||
|
||||
async function api(req: http.IncomingMessage, res: http.ServerResponse, apiPath: string, url: URL): Promise<void> {
|
||||
// A multipart body (an app zip) is read later, once the caller is known to be allowed to send it.
|
||||
// A multipart body (an app zip, a zone file) and a text body (a pasted zone file) are read later,
|
||||
// once the caller is known to be allowed to send them.
|
||||
// A config body may be large, but only for a signed-in caller (token in the header or the query).
|
||||
const large = (apiPath.startsWith('hub/apps/') || apiPath === 'apps/config/set')
|
||||
&& sessions.touch(bearer(req) ?? url.searchParams.get('token')) !== null
|
||||
const form = isMultipart(req) ? new URLSearchParams() : await readForm(req, !large ? MAX_BODY : apiPath === 'hub/apps/import' ? MAX_IMPORT_BODY : MAX_CONFIG_BODY)
|
||||
const own = apiPath.startsWith('hub/apps/') || apiPath === 'apps/config/set'
|
||||
const wanted = own ? (apiPath === 'hub/apps/import' ? MAX_IMPORT_BODY : MAX_CONFIG_BODY) : largeBodyLimit(apiPath)
|
||||
const large = wanted !== undefined && sessions.touch(bearer(req) ?? url.searchParams.get('token')) !== null
|
||||
const deferred = isMultipart(req) || (routeFor(apiPath)?.upload === true && textBody(req))
|
||||
const form = deferred ? new URLSearchParams() : await readForm(req, large ? (wanted as number) : MAX_BODY)
|
||||
const params: Params = {}
|
||||
for (const [k, v] of url.searchParams) params[k] = v
|
||||
for (const [k, v] of form) params[k] = v
|
||||
@@ -501,7 +623,8 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
|
||||
}
|
||||
|
||||
const token = bearer(req) ?? params.token ?? null
|
||||
const who = sessions.touch(token)
|
||||
// A download opened in a new tab carries a single-use token instead of the session's.
|
||||
const who = sessions.touch(token) ?? (req.method === 'GET' && routeFor(apiPath)?.raw === true ? sessions.takeSingleUse(token) : null)
|
||||
if (who === null) {
|
||||
send(res, 200, { status: 'invalid-token', errorMessage: 'Invalid token or session expired.' })
|
||||
return
|
||||
@@ -516,7 +639,11 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
|
||||
send(res, 200, { status: 'ok' })
|
||||
return
|
||||
}
|
||||
const route = routeFor(apiPath)
|
||||
if (apiPath === 'user/createSingleUseToken') {
|
||||
send(res, 200, ok({ token: sessions.openSingleUse(token as string) }))
|
||||
return
|
||||
}
|
||||
const route = effectiveRoute(apiPath, params)
|
||||
if (route === undefined) {
|
||||
audit.log({
|
||||
user: who.user, role: who.role, ip, action: 'refused-write', target: apiPath,
|
||||
@@ -538,7 +665,7 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
|
||||
return
|
||||
}
|
||||
if (route.kind !== 'read') {
|
||||
await write(req, res, apiPath, route.kind, params, who, ip)
|
||||
await write(req, res, apiPath, route, params, who, ip)
|
||||
return
|
||||
}
|
||||
if (apiPath.startsWith('hub/apps/config/') || apiPath.startsWith('hub/apps/history/') || apiPath === 'hub/apps/overrides/current' || apiPath === 'hub/apps/export') {
|
||||
@@ -591,15 +718,22 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
|
||||
// What the real servers get: the caller's params minus the hub's own.
|
||||
const { node = '', token: _token, ...forward } = params
|
||||
const server = byId.get(node)
|
||||
if (server !== undefined) {
|
||||
send(res, 200, await single(server, apiPath, forward))
|
||||
// A read with no merger is never answered by "the first server" for the aggregate: it needs a server.
|
||||
if (route.perServer === true && server === undefined) {
|
||||
send(res, 400, fail(node === '' || node === 'cluster' ? 'Choose a server: this is read from one server at a time.' : `Unknown node: ${node}`))
|
||||
return
|
||||
}
|
||||
if (node !== '' && node !== 'cluster') {
|
||||
send(res, 200, fail(`Unknown node: ${node}`))
|
||||
if (route.raw === true && server !== undefined) {
|
||||
await download(res, server, apiPath, forward)
|
||||
return
|
||||
}
|
||||
send(res, 200, await aggregate(apiPath, forward))
|
||||
let answer: Json
|
||||
if (server !== undefined) answer = await single(server, apiPath, forward)
|
||||
else if (node !== '' && node !== 'cluster') answer = fail(`Unknown node: ${node}`)
|
||||
else answer = await aggregate(apiPath, forward)
|
||||
// The settings hold secrets (TSIG keys, passwords): only an admin sees them (guards.ts).
|
||||
if (apiPath === 'settings/get' && !can(who.role, 'admin') && answer.status === 'ok') answer = { ...answer, response: redactSettings(answer.response) }
|
||||
send(res, 200, answer)
|
||||
}
|
||||
|
||||
async function serveStatic(req: http.IncomingMessage, res: http.ServerResponse, url: URL): Promise<void> {
|
||||
|
||||
+56
-9
@@ -6,9 +6,11 @@ the console reads from `user/login` and `user/session/get` (flat, as the real se
|
||||
*/
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { HUB_VERSION, type HubConfig } from './config.ts'
|
||||
import { can, signature, type Role, type UserRecord, type Users } from './users.ts'
|
||||
import { can, signature, type Capability, type Role, type UserRecord, type Users } from './users.ts'
|
||||
|
||||
export const IDLE_MS = 30 * 60_000
|
||||
/* A download link carries a token that works once, within this time. */
|
||||
export const SINGLE_USE_MS = 60_000
|
||||
|
||||
export interface Identity {
|
||||
user: string
|
||||
@@ -20,16 +22,34 @@ interface Session extends Identity {
|
||||
sig: string
|
||||
}
|
||||
|
||||
interface SingleUse extends Identity {
|
||||
until: number
|
||||
sig: string
|
||||
}
|
||||
|
||||
/*
|
||||
Every key `src/app/sections.ts` reads, spelled out: the console hides a section only
|
||||
when `canView === false`, so a missing key would leave it visible. The hub shows the
|
||||
Dashboard, the Logs and the Apps; About has no permission and is always there. Only the Apps can be
|
||||
modified or deleted, and only by a role with `apps.write`.
|
||||
when `canView === false`, so a missing key would leave it visible. About has no permission
|
||||
and is always there. Administration (users, SSO, LDAP of each server) is not offered.
|
||||
|
||||
What a role may change comes from its capabilities, per key. Settings never get `canDelete`
|
||||
(that is the backup and restore, which the hub does not forward), nor do the Logs and the
|
||||
Dashboard (deleting logs and stats stays in each server's own console). DNS Client needs
|
||||
`admin` to modify because its Import writes a zone. The Settings key also gates the
|
||||
temporary blocking switch in the console, which the hub itself would let an operator use:
|
||||
the console is the stricter of the two there.
|
||||
*/
|
||||
const PERMISSION_KEYS = [
|
||||
'Dashboard', 'Zones', 'Cache', 'Allowed', 'Blocked', 'Apps', 'DnsClient', 'Settings', 'DhcpServer', 'Administration', 'Logs',
|
||||
]
|
||||
const VIEWABLE = new Set(['Dashboard', 'Logs', 'Apps'])
|
||||
const VIEWABLE = new Set(['Dashboard', 'Logs', 'Apps', 'Zones', 'Cache', 'Allowed', 'Blocked', 'DnsClient', 'Settings', 'DhcpServer'])
|
||||
const MODIFIES: Record<string, Capability> = {
|
||||
Apps: 'apps.write', Zones: 'admin', Cache: 'operate', Allowed: 'operate', Blocked: 'operate',
|
||||
DnsClient: 'admin', Settings: 'admin', DhcpServer: 'admin',
|
||||
}
|
||||
const DELETES: Record<string, Capability> = {
|
||||
Apps: 'apps.write', Zones: 'admin', Cache: 'operate', Allowed: 'operate', Blocked: 'operate', DhcpServer: 'admin',
|
||||
}
|
||||
|
||||
export class Sessions {
|
||||
#config: HubConfig
|
||||
@@ -37,6 +57,7 @@ export class Sessions {
|
||||
#now: () => number
|
||||
#started: string
|
||||
#seen = new Map<string, Session>()
|
||||
#single = new Map<string, SingleUse>()
|
||||
|
||||
#drift: () => number
|
||||
|
||||
@@ -75,9 +96,31 @@ export class Sessions {
|
||||
this.#seen.delete(token)
|
||||
}
|
||||
|
||||
/* A token for one download (the console opens it in a new tab, which has no Authorization header). */
|
||||
openSingleUse(token: string): string | null {
|
||||
const session = this.#seen.get(token)
|
||||
if (session === undefined) return null
|
||||
const once = randomBytes(32).toString('hex')
|
||||
this.#single.set(once, { user: session.user, role: session.role, sig: session.sig, until: this.#now() + SINGLE_USE_MS })
|
||||
return once
|
||||
}
|
||||
|
||||
/* The identity behind a single-use token, which is spent by asking; null when it is unknown, used,
|
||||
expired, or its user was removed or edited since. */
|
||||
takeSingleUse(token: string | null | undefined): Identity | null {
|
||||
if (!token) return null
|
||||
const once = this.#single.get(token)
|
||||
if (once === undefined) return null
|
||||
this.#single.delete(token)
|
||||
const record = this.#users.find(once.user)
|
||||
if (this.#now() > once.until || record === undefined || signature(record) !== once.sig) return null
|
||||
return { user: once.user, role: once.role }
|
||||
}
|
||||
|
||||
#sweep(): void {
|
||||
const now = this.#now()
|
||||
for (const [token, session] of this.#seen) if (now - session.last > IDLE_MS) this.#seen.delete(token)
|
||||
for (const [token, once] of this.#single) if (now > once.until) this.#single.delete(token)
|
||||
}
|
||||
|
||||
info(role: Role) {
|
||||
@@ -87,10 +130,14 @@ export class Sessions {
|
||||
uptimestamp: this.#started,
|
||||
dnsServerDomain: domain,
|
||||
permissions: Object.fromEntries(
|
||||
PERMISSION_KEYS.map((key) => {
|
||||
const writes = key === 'Apps' && can(role, 'apps.write')
|
||||
return [key, { canView: VIEWABLE.has(key), canModify: writes, canDelete: writes }]
|
||||
}),
|
||||
PERMISSION_KEYS.map((key) => [
|
||||
key,
|
||||
{
|
||||
canView: VIEWABLE.has(key),
|
||||
canModify: key in MODIFIES && can(role, MODIFIES[key]),
|
||||
canDelete: key in DELETES && can(role, DELETES[key]),
|
||||
},
|
||||
]),
|
||||
),
|
||||
/* The console offers its node selector when the cluster is initialised. The
|
||||
first server plays the Primary (`primaryNodeName` looks for one). */
|
||||
|
||||
+79
-12
@@ -21,6 +21,15 @@ export class UpstreamError extends Error {
|
||||
|
||||
export type Params = Record<string, string>
|
||||
|
||||
/* What `raw` hands back: the headers worth passing on and the body as a stream. */
|
||||
export interface RawAnswer {
|
||||
contentType: string
|
||||
disposition?: string
|
||||
body: ReadableStream<Uint8Array> | null
|
||||
/* Stops the download (the caller went away). */
|
||||
abort: () => void
|
||||
}
|
||||
|
||||
interface Cached {
|
||||
until: number
|
||||
response: unknown
|
||||
@@ -61,18 +70,73 @@ export class Upstream {
|
||||
/*
|
||||
A write: POST with the server's ADMIN token and never the read one, never cached. Whatever
|
||||
the outcome, what the hub remembers of this server is dropped afterwards, so the next read
|
||||
shows the state the write left. `body` is a multipart upload (the app zip).
|
||||
shows the state the write left.
|
||||
|
||||
The parameters travel in a form body (`application/x-www-form-urlencoded`) and only `token`
|
||||
stays in the query, so values (a record's data, a TSIG secret) do not end up in a proxy's
|
||||
access log. The server reads query or form alike. A `body` that is already a form gets the
|
||||
parameters added to it. Two bodies cannot be a form, so there the parameters stay in the
|
||||
query: a `FormData` (a multipart upload, the app zip or a zone file) and a string (a zone
|
||||
file as `text/plain`).
|
||||
*/
|
||||
async write(server: HubServer, path: string, params: Params = {}, body?: FormData | URLSearchParams): Promise<unknown> {
|
||||
async write(server: HubServer, path: string, params: Params = {}, body?: FormData | URLSearchParams | string): Promise<unknown> {
|
||||
if (server.adminToken === undefined) throw new UpstreamError('rejected', 'no admin token configured')
|
||||
let query = new URLSearchParams()
|
||||
const init: RequestInit = { method: 'POST' }
|
||||
if (body === undefined || body instanceof URLSearchParams) {
|
||||
const form = new URLSearchParams(body)
|
||||
for (const [k, v] of Object.entries(params)) form.set(k, v)
|
||||
init.body = form
|
||||
} else {
|
||||
query = new URLSearchParams(params)
|
||||
init.body = body
|
||||
if (typeof body === 'string') init.headers = { 'content-type': 'text/plain; charset=utf-8' }
|
||||
}
|
||||
try {
|
||||
const init: RequestInit = body === undefined ? { method: 'POST' } : { method: 'POST', body }
|
||||
return await this.#exec(server, path, new URLSearchParams(params), server.adminToken, init, Math.max(this.#config.timeoutMs, WRITE_TIMEOUT_MS))
|
||||
return await this.#exec(server, path, query, server.adminToken, init, Math.max(this.#config.timeoutMs, WRITE_TIMEOUT_MS))
|
||||
} finally {
|
||||
this.invalidate(server.id)
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
A file the server answers with (a zone export, a log, a block list): read with the READ token,
|
||||
never cached. A JSON answer is the usual envelope, so an error is raised as for any call; any
|
||||
other answer is handed back as it is, to be streamed to the caller. The timeout covers the wait
|
||||
for the headers only: a big log may take longer to arrive.
|
||||
*/
|
||||
async raw(server: HubServer, path: string, params: Params = {}): Promise<RawAnswer> {
|
||||
const query = new URLSearchParams(params)
|
||||
query.set('token', server.token)
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), this.#config.timeoutMs)
|
||||
let res: Response
|
||||
try {
|
||||
res = await this.#fetch(`${server.url}/api/${path}?${query.toString()}`, { method: 'GET', signal: controller.signal })
|
||||
} catch (e) {
|
||||
throw new UpstreamError('unreachable', this.#describe(e, [server.token], this.#config.timeoutMs))
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
const type = res.headers.get('content-type') ?? 'application/octet-stream'
|
||||
if (/json/i.test(type)) {
|
||||
let envelope: { status?: string; response?: unknown; errorMessage?: string }
|
||||
try {
|
||||
envelope = (await res.json()) as typeof envelope
|
||||
} catch (e) {
|
||||
throw new UpstreamError('unreachable', this.#describe(e, [server.token], this.#config.timeoutMs))
|
||||
}
|
||||
throw envelope?.status === 'ok' ? new UpstreamError('rejected', 'it sent JSON where a file was expected') : this.#rejection(envelope)
|
||||
}
|
||||
if (!res.ok) throw new UpstreamError('rejected', `status ${res.status}`)
|
||||
return {
|
||||
contentType: type,
|
||||
disposition: res.headers.get('content-disposition') ?? undefined,
|
||||
body: res.body,
|
||||
abort: () => controller.abort(),
|
||||
}
|
||||
}
|
||||
|
||||
/** Forget every cached answer of one server. */
|
||||
invalidate(serverId: string): void {
|
||||
const prefix = `${serverId}\n`
|
||||
@@ -89,7 +153,7 @@ export class Upstream {
|
||||
const res = await this.#fetch(`${server.url}/api/${path}?${query.toString()}`, {
|
||||
...init,
|
||||
signal: controller.signal,
|
||||
headers: { accept: 'application/json' },
|
||||
headers: { accept: 'application/json', ...(init.headers as Record<string, string> | undefined) },
|
||||
})
|
||||
envelope = (await res.json()) as typeof envelope
|
||||
} catch (e) {
|
||||
@@ -98,16 +162,19 @@ export class Upstream {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
|
||||
if (envelope?.status !== 'ok') {
|
||||
const why =
|
||||
envelope?.status === 'invalid-token'
|
||||
? 'its API token was refused'
|
||||
: (envelope?.errorMessage ?? `status ${String(envelope?.status)}`)
|
||||
throw new UpstreamError('rejected', why)
|
||||
}
|
||||
if (envelope?.status !== 'ok') throw this.#rejection(envelope)
|
||||
return envelope.response
|
||||
}
|
||||
|
||||
/* The error for an envelope that is not `ok`. */
|
||||
#rejection(envelope: { status?: string; errorMessage?: string } | undefined): UpstreamError {
|
||||
const why =
|
||||
envelope?.status === 'invalid-token'
|
||||
? 'its API token was refused'
|
||||
: (envelope?.errorMessage ?? `status ${String(envelope?.status)}`)
|
||||
return new UpstreamError('rejected', why)
|
||||
}
|
||||
|
||||
#prune(): void {
|
||||
const now = this.#now()
|
||||
for (const [key, entry] of this.#cache) if (entry.until <= now) this.#cache.delete(key)
|
||||
|
||||
+7
-6
@@ -2,6 +2,7 @@ import { urlApi } from '../app/base'
|
||||
import { envelopeOutcome, type ApiOutcome } from './client'
|
||||
import { queryLogs, type QueryLogEntry } from './logs'
|
||||
import { addDomain, deleteDomain, type DomainList } from './zonelists'
|
||||
import { nodeArg } from '../lib/hub-write'
|
||||
|
||||
/*
|
||||
The Blocking section's own reads. Every endpoint here already exists in the server;
|
||||
@@ -144,15 +145,15 @@ export async function recentBlocked(
|
||||
}
|
||||
|
||||
/* `allowDomain` (other-zones.js:637): out of Blocked first, then into Allowed. */
|
||||
export async function allowDomain(token: string | null, domain: string): Promise<ApiOutcome> {
|
||||
const removed = await deleteDomain('blocked', token, domain)
|
||||
export async function allowDomain(token: string | null, domain: string, node = ''): Promise<ApiOutcome> {
|
||||
const removed = await deleteDomain('blocked', token, domain, ...nodeArg(node))
|
||||
if (removed.kind !== 'ok') return removed
|
||||
return addDomain('allowed', token, domain)
|
||||
return addDomain('allowed', token, domain, ...nodeArg(node))
|
||||
}
|
||||
|
||||
/* `blockDomain` (other-zones.js:686): out of Allowed first, then into Blocked. */
|
||||
export async function blockDomain(token: string | null, domain: string): Promise<ApiOutcome> {
|
||||
const removed = await deleteDomain('allowed', token, domain)
|
||||
export async function blockDomain(token: string | null, domain: string, node = ''): Promise<ApiOutcome> {
|
||||
const removed = await deleteDomain('allowed', token, domain, ...nodeArg(node))
|
||||
if (removed.kind !== 'ok') return removed
|
||||
return addDomain('blocked', token, domain)
|
||||
return addDomain('blocked', token, domain, ...nodeArg(node))
|
||||
}
|
||||
+4
-3
@@ -277,8 +277,8 @@ export async function setSettings(
|
||||
}
|
||||
|
||||
/** `settings/forceUpdateBlockLists`. No parameters: forces the download now. */
|
||||
export async function forceUpdateBlockLists(token: string | null): Promise<boolean> {
|
||||
const outcome = await apiRequest('settings/forceUpdateBlockLists', { token })
|
||||
export async function forceUpdateBlockLists(token: string | null, node = ''): Promise<boolean> {
|
||||
const outcome = await apiRequest('settings/forceUpdateBlockLists', { token, ...(node ? { node } : {}) })
|
||||
return outcome.kind === 'ok'
|
||||
}
|
||||
|
||||
@@ -286,10 +286,11 @@ export async function forceUpdateBlockLists(token: string | null): Promise<boole
|
||||
export async function temporaryDisableBlocking(
|
||||
token: string | null,
|
||||
minutes: string,
|
||||
node = '',
|
||||
): Promise<string | null> {
|
||||
const outcome = await apiRequest<{ response: { temporaryDisableBlockingTill: string } }>(
|
||||
'settings/temporaryDisableBlocking',
|
||||
{ token, body: { minutes } },
|
||||
{ token, body: { minutes }, ...(node ? { node } : {}) },
|
||||
)
|
||||
if (outcome.kind === 'ok') settingsChanged()
|
||||
return outcome.kind === 'ok' ? (outcome.data.response?.temporaryDisableBlockingTill ?? null) : null
|
||||
|
||||
+11
-7
@@ -172,8 +172,9 @@ export function addDomain(
|
||||
list: DomainList,
|
||||
token: string | null,
|
||||
domain: string,
|
||||
node = '',
|
||||
): Promise<ApiOutcome> {
|
||||
return apiRequest(`${list}/add`, { token, body: { domain } })
|
||||
return apiRequest(`${list}/add`, { token, body: { domain }, ...(node ? { node } : {}) })
|
||||
}
|
||||
|
||||
/** `deleteAllowedZone` / `deleteBlockedZone`. */
|
||||
@@ -181,13 +182,14 @@ export function deleteDomain(
|
||||
list: DomainList,
|
||||
token: string | null,
|
||||
domain: string,
|
||||
node = '',
|
||||
): Promise<ApiOutcome> {
|
||||
return apiRequest(`${list}/delete`, { token, body: { domain } })
|
||||
return apiRequest(`${list}/delete`, { token, body: { domain }, ...(node ? { node } : {}) })
|
||||
}
|
||||
|
||||
/** `flushAllowedZone` / `flushBlockedZone`. No `node`, unlike the rest. */
|
||||
export function flushList(list: DomainList, token: string | null): Promise<ApiOutcome> {
|
||||
return apiRequest(`${list}/flush`, { token })
|
||||
/** `flushAllowedZone` / `flushBlockedZone`. No `node`, unlike the rest, except behind a hub (a change is made on one server). */
|
||||
export function flushList(list: DomainList, token: string | null, node = ''): Promise<ApiOutcome> {
|
||||
return apiRequest(`${list}/flush`, { token, ...(node ? { node } : {}) })
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -200,9 +202,10 @@ export function importDomains(
|
||||
list: DomainList,
|
||||
token: string | null,
|
||||
zones: string,
|
||||
node = '',
|
||||
): Promise<ApiOutcome> {
|
||||
const field = list === 'allowed' ? 'allowedZones' : 'blockedZones'
|
||||
return apiRequest(`${list}/import`, { token, method: 'POST', body: { [field]: zones } })
|
||||
return apiRequest(`${list}/import`, { token, method: 'POST', body: { [field]: zones }, ...(node ? { node } : {}) })
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -214,6 +217,7 @@ token.
|
||||
export function exportDomains(
|
||||
list: DomainList,
|
||||
token: string | null,
|
||||
node = '',
|
||||
): Promise<{ ok: boolean; url?: string }> {
|
||||
return openDownload(token, `${list}/export`)
|
||||
return openDownload(token, `${list}/export`, node ? { node } : {})
|
||||
}
|
||||
+14
-3
@@ -32,6 +32,7 @@ import { Menu } from '../ui/Menu'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Command, CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
|
||||
import { HubMode, ThisServerName } from '../ui/ClusterNodeSelect'
|
||||
import { HubWritable } from '../lib/hub-write'
|
||||
import { FooterLinks } from '../ui/FooterLinks'
|
||||
import { Versions } from './Versions'
|
||||
import { BlockingMark } from './BlockingMark'
|
||||
@@ -82,6 +83,9 @@ function keep(key: string, value: unknown) {
|
||||
}
|
||||
}
|
||||
|
||||
/* A stable empty list for a session without the field (outside a hub). */
|
||||
const NO_SERVERS: string[] = []
|
||||
|
||||
const ICONS: Record<string, IconName> = {
|
||||
dashboard: 'dashboard', zones: 'zones', cache: 'cache', blocking: 'blocked',
|
||||
apps: 'apps', dnsclient: 'dnsclient', settings: 'settings',
|
||||
@@ -135,6 +139,8 @@ export function Shell({
|
||||
}) {
|
||||
const permissions = session.info?.permissions
|
||||
const hub = session.info?.hub === true
|
||||
/* Behind a hub: the servers a change can be made on (the screens read it through `HubWritable`). */
|
||||
const hubWritable = session.info?.hubWritable ?? NO_SERVERS
|
||||
const hubWarnings = useHubWarnings()
|
||||
/* Behind a hub: the count comes with the session, the list is read now and then. */
|
||||
const hubDriftAtStart = session.info?.hubDrift
|
||||
@@ -422,6 +428,7 @@ export function Shell({
|
||||
<HandoffContext.Provider value={handoff}>
|
||||
<ThisServerName.Provider value={info?.dnsServerDomain ?? session.info?.dnsServerDomain ?? ''}>
|
||||
<HubMode.Provider value={hub}>
|
||||
<HubWritable.Provider value={hubWritable}>
|
||||
<SlotProvider>
|
||||
{(slot) => (
|
||||
<div className={styles.shell} data-rail={rail}>
|
||||
@@ -602,7 +609,7 @@ export function Shell({
|
||||
<span key={i} data-last={i === crumbs.length - 1}>{c}</span>
|
||||
))}
|
||||
</div>
|
||||
{hub && <DriftStatus token={session.token} canWrite={permissions?.Apps?.canModify === true} writable={session.info?.hubWritable ?? []} />}
|
||||
{hub && <DriftStatus token={session.token} canWrite={permissions?.Apps?.canModify === true} writable={hubWritable} />}
|
||||
<Popover>
|
||||
<PopoverTrigger className={styles.status} data-attention={blockingNow != null || hubWarnings.length > 0} aria-label="Server status">
|
||||
<i className={styles.pdot} data-tone={hubWarnings.length > 0 ? 'warn' : (blockingNow?.tone ?? 'ok')} />
|
||||
@@ -657,20 +664,22 @@ export function Shell({
|
||||
token={session.token}
|
||||
nodes={session.info?.clusterNodes ?? []}
|
||||
clusterInitialised={session.info?.clusterInitialized === true}
|
||||
canBlock={hub ? permissions?.Settings?.canModify !== false : true}
|
||||
/>
|
||||
) : current?.id === 'dnsclient' ? (
|
||||
<DnsClient
|
||||
token={session.token}
|
||||
nodes={session.info?.clusterNodes ?? []}
|
||||
clusterInitialised={session.info?.clusterInitialized === true}
|
||||
canImport={permissions?.DnsClient?.canModify !== false}
|
||||
request={pending != null && 'dnsclient' in pending ? pending.dnsclient : undefined}
|
||||
/>
|
||||
) : current?.id === 'about' ? (
|
||||
<About info={info} />
|
||||
) : current?.id === 'apps' ? (
|
||||
<Apps token={session.token} nodes={session.info?.clusterNodes ?? []} clusterInitialised={session.info?.clusterInitialized === true} canWrite={permissions?.Apps?.canModify === true} writable={session.info?.hubWritable ?? []} />
|
||||
<Apps token={session.token} nodes={session.info?.clusterNodes ?? []} clusterInitialised={session.info?.clusterInitialized === true} canWrite={permissions?.Apps?.canModify === true} writable={hubWritable} />
|
||||
) : current?.id === 'cache' ? (
|
||||
<Cache token={session.token} nodes={session.info?.clusterNodes ?? []} clusterInitialised={session.info?.clusterInitialized === true} />
|
||||
<Cache token={session.token} nodes={session.info?.clusterNodes ?? []} clusterInitialised={session.info?.clusterInitialized === true} canDelete={hub ? permissions?.Cache?.canDelete !== false : undefined} />
|
||||
) : current?.id === 'blocking' ? (
|
||||
<Blocking
|
||||
token={session.token}
|
||||
@@ -716,6 +725,7 @@ export function Shell({
|
||||
clusterInitialised={session.info?.clusterInitialized === true}
|
||||
sub={currentSub ?? 'View Logs'}
|
||||
onSubChange={setSub}
|
||||
canBlock={hub ? permissions?.Blocked?.canModify !== false && permissions?.Allowed?.canModify !== false : true}
|
||||
canDeleteLogs={permissions?.Logs?.canDelete !== false}
|
||||
canDeleteStats={permissions?.Dashboard?.canDelete !== false}
|
||||
request={pending != null && 'logs' in pending ? pending.logs : undefined}
|
||||
@@ -837,6 +847,7 @@ export function Shell({
|
||||
</div>
|
||||
)}
|
||||
</SlotProvider>
|
||||
</HubWritable.Provider>
|
||||
</HubMode.Provider>
|
||||
</ThisServerName.Provider>
|
||||
</HandoffContext.Provider>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { useContext, useRef, useState } from 'react'
|
||||
import { HubNode, nodeArg } from './hub-write'
|
||||
import { allowDomain, blockDomain, BLOCKED_TYPES } from '../api/blocking'
|
||||
import { noticeFromFailure, type Notice } from './notice'
|
||||
|
||||
@@ -32,7 +33,11 @@ export function verbFor(responseType: string): DomainVerb {
|
||||
return BLOCKED.includes(responseType.toLowerCase()) ? 'Allow Domain' : 'Block Domain'
|
||||
}
|
||||
|
||||
export function useDomainAction(token: string | null, onNotice: (n: Notice) => void) {
|
||||
/* `node` is the server the change is made on, for the hub; by default the one the section around it has chosen
|
||||
(`HubNode`, empty outside a hub: the server the console talks to). */
|
||||
export function useDomainAction(token: string | null, onNotice: (n: Notice) => void, node?: string) {
|
||||
const section = useContext(HubNode)
|
||||
const target = node ?? section
|
||||
const [busy, setBusy] = useState<ReadonlySet<string>>(new Set())
|
||||
/* Read by `run` so two clicks inside one render cannot both pass the guard. */
|
||||
const inFlight = useRef(new Set<string>())
|
||||
@@ -41,7 +46,7 @@ export function useDomainAction(token: string | null, onNotice: (n: Notice) => v
|
||||
if (inFlight.current.has(key)) return false
|
||||
inFlight.current.add(key)
|
||||
setBusy(new Set(inFlight.current))
|
||||
const outcome = await (verb === 'Allow Domain' ? allowDomain(token, domain) : blockDomain(token, domain))
|
||||
const outcome = await (verb === 'Allow Domain' ? allowDomain(token, domain, ...nodeArg(target)) : blockDomain(token, domain, ...nodeArg(target)))
|
||||
inFlight.current.delete(key)
|
||||
setBusy(new Set(inFlight.current))
|
||||
if (outcome.kind !== 'ok') {
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import { createContext, useContext } from 'react'
|
||||
import { HubMode, type ClusterNode } from '../ui/ClusterNodeSelect'
|
||||
|
||||
/*
|
||||
Behind a hub a change is made on ONE server at a time (`node=<id>`), and only on a server the
|
||||
hub holds an admin token for. These are the pieces every screen that changes something shares,
|
||||
so the rule is written once.
|
||||
|
||||
· `HubWritable`: the servers the hub can write to (`hubWritable` of the session), given by the shell.
|
||||
· `hubWriteState`: whether a change is allowed on `node`, and if not, the sentence that says why.
|
||||
· `useHubWrite`: that, read from the contexts, for a screen's own node.
|
||||
· `useHubDefaultNode`: the server a screen without an "All servers" choice starts on.
|
||||
· `HubNode`: the server a section's controls act on, for the controls that are not handed it.
|
||||
|
||||
Outside a hub none of this applies: `allowed` is the permission and there is no reason.
|
||||
*/
|
||||
export const HubWritable = createContext<readonly string[]>([])
|
||||
|
||||
/* The server the controls of a section act on (`''` outside a hub, or when none is chosen). */
|
||||
export const HubNode = createContext('')
|
||||
|
||||
export const CHOOSE_A_SERVER = 'Choose a server'
|
||||
export const NO_ADMIN_TOKEN = 'No admin token configured on the hub'
|
||||
|
||||
export interface HubWrite {
|
||||
/** The role may change it, a server is chosen and the hub can write to it. */
|
||||
allowed: boolean
|
||||
/** Why not, when it is the node and not the role; `null` when allowed or when the role says no. */
|
||||
reason: string | null
|
||||
}
|
||||
|
||||
export function hubWriteState({
|
||||
hub,
|
||||
node,
|
||||
writable,
|
||||
allowed,
|
||||
}: {
|
||||
hub: boolean
|
||||
node: string
|
||||
writable: readonly string[]
|
||||
/** What the role allows (`canModify`). */
|
||||
allowed: boolean
|
||||
}): HubWrite {
|
||||
if (!hub) return { allowed, reason: null }
|
||||
if (!allowed) return { allowed: false, reason: null }
|
||||
if (node === '' || node === 'cluster') return { allowed: false, reason: CHOOSE_A_SERVER }
|
||||
if (!writable.includes(node)) return { allowed: false, reason: NO_ADMIN_TOKEN }
|
||||
return { allowed: true, reason: null }
|
||||
}
|
||||
|
||||
/* The write state of a screen on its own `node`. `allowed` is the role's permission, true when it has none. */
|
||||
export function useHubWrite(node: string, allowed = true): HubWrite {
|
||||
const hub = useContext(HubMode)
|
||||
const writable = useContext(HubWritable)
|
||||
return hubWriteState({ hub, node, writable, allowed })
|
||||
}
|
||||
|
||||
/*
|
||||
Where a screen without an "All servers" choice starts. Outside a hub that is the empty string (this
|
||||
server, which the real console takes as its own node). Behind a hub the empty string is the hub, not
|
||||
a server, so the first server is named: the selector shows it and the requests carry it.
|
||||
*/
|
||||
export function hubDefaultNode(hub: boolean, nodes: readonly ClusterNode[]): string {
|
||||
return hub && nodes.length > 0 ? nodes[0].name : ''
|
||||
}
|
||||
|
||||
export function useHubDefaultNode(nodes: readonly ClusterNode[]): string {
|
||||
return hubDefaultNode(useContext(HubMode), nodes)
|
||||
}
|
||||
|
||||
/*
|
||||
The optional `node` argument of an API function, spread into its call: nothing when no server is
|
||||
chosen, so a console used without a hub makes exactly the calls it always made.
|
||||
*/
|
||||
export const nodeArg = (node: string): [] | [string] => (node === '' ? [] : [node])
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useId, useRef, useState } from 'react'
|
||||
import { useContext, useId, useRef, useState } from 'react'
|
||||
import { HubNode, nodeArg } from '../../lib/hub-write'
|
||||
import { addDomain, type DomainList } from '../../api/zonelists'
|
||||
import { Panel, Body } from '../../ui/Panel'
|
||||
import { Field, Input } from '../../ui/Field'
|
||||
@@ -51,6 +52,8 @@ export function AddDomainBar({
|
||||
onNotice: (n: Notice) => void
|
||||
onChanged: (list: DomainList, domain: string) => void
|
||||
}) {
|
||||
/* Behind a hub: the server the section has chosen (empty elsewhere, which sends no `node`). */
|
||||
const node = useContext(HubNode)
|
||||
const [field, setField] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const entry = useRef<HTMLInputElement>(null)
|
||||
@@ -82,7 +85,7 @@ export function AddDomainBar({
|
||||
checkDomain(token, domain, {
|
||||
settings: missing(permissions, 'Settings.canView'),
|
||||
allowed: missing(permissions, 'Allowed.canView'),
|
||||
}),
|
||||
}, ...nodeArg(node)),
|
||||
/* The names the Lists tab gives its rows: Technitium's catalogue, then ours. */
|
||||
loadQuickList('quick-block-lists').then((c) => [...c, ...moreLists(c)]),
|
||||
])
|
||||
@@ -120,7 +123,7 @@ export function AddDomainBar({
|
||||
}
|
||||
|
||||
setBusy(true)
|
||||
const outcome = await addDomain(list, token, domain)
|
||||
const outcome = await addDomain(list, token, domain, ...nodeArg(node))
|
||||
setBusy(false)
|
||||
if (outcome.kind !== 'ok') {
|
||||
onNotice(noticeFromFailure(outcome))
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useContext, useEffect, useState } from 'react'
|
||||
import { ClusterNodeSelect, HubMode } from '../../ui/ClusterNodeSelect'
|
||||
import { HubNode, useHubDefaultNode, useHubWrite } from '../../lib/hub-write'
|
||||
import { HubWriteHint } from '../../ui/HubWriteHint'
|
||||
import { getSettings } from '../../api/settings'
|
||||
import { getDashboardStats } from '../../api/dashboard'
|
||||
import { BlockingMenu } from '../dashboard/BlockingMenu'
|
||||
import { Tag } from '../../ui/Tag'
|
||||
import { missing } from './permissions'
|
||||
import { missing, withoutWrites } from './permissions'
|
||||
import { untilText, useBlockingState } from './blocking-state'
|
||||
import { SubTabs } from '../../ui/SubTabs'
|
||||
import { ruleSearch } from './rules-model'
|
||||
@@ -36,12 +39,12 @@ function dropRule(): void {
|
||||
|
||||
/* What the tabs count: the rules (blocked and allowed zones) and the lists. Each is read
|
||||
once and only when the session may read it; a failed read leaves the tab without one. */
|
||||
function useCounts(token: string | null, permissions: Permissions) {
|
||||
function useCounts(token: string | null, permissions: Permissions, node = '') {
|
||||
const [counts, setCounts] = useState<{ Rules?: number; Lists?: number }>({})
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
if (missing(permissions, 'Dashboard.canView') == null) {
|
||||
void getDashboardStats(token).then((o) => {
|
||||
void getDashboardStats(token, undefined, undefined, node).then((o) => {
|
||||
if (alive && o.kind === 'ok') {
|
||||
const s = o.data.stats
|
||||
setCounts((c) => ({ ...c, Rules: s.blockedZones + s.allowedZones }))
|
||||
@@ -49,7 +52,7 @@ function useCounts(token: string | null, permissions: Permissions) {
|
||||
})
|
||||
}
|
||||
if (missing(permissions, 'Settings.canView') == null) {
|
||||
void getSettings(token).then((s) => {
|
||||
void getSettings(token, node).then((s) => {
|
||||
if (alive && s != null) setCounts((c) => ({ ...c, Lists: s.blockListUrls?.length ?? 0 }))
|
||||
})
|
||||
}
|
||||
@@ -58,14 +61,14 @@ function useCounts(token: string | null, permissions: Permissions) {
|
||||
}
|
||||
// The permission object is read once: the counts are a hint, not a live figure.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [token])
|
||||
}, [token, node])
|
||||
return counts
|
||||
}
|
||||
|
||||
/* The state strip of the canvas, above the tabs. Overview carries the full status block
|
||||
itself, so the strip is for the other two. */
|
||||
function Banner({ token, permissions }: { token: string | null; permissions: Permissions }) {
|
||||
const { state } = useBlockingState(token, permissions)
|
||||
function Banner({ token, permissions, node }: { token: string | null; permissions: Permissions; node: string }) {
|
||||
const { state } = useBlockingState(token, permissions, true, node)
|
||||
if (state == null || state === 'failed') return null
|
||||
const canModify = missing(permissions, 'Settings.canModify') == null
|
||||
return (
|
||||
@@ -82,7 +85,7 @@ function Banner({ token, permissions }: { token: string | null; permissions: Per
|
||||
</span>
|
||||
{canModify && (
|
||||
<span className={shared.bannerAct}>
|
||||
<BlockingMenu token={token} text={state.kind === 'on' ? 'Temporarily Disable Blocking' : 'Enable Blocking'} onNotice={() => {}} />
|
||||
<BlockingMenu token={token} text={state.kind === 'on' ? 'Temporarily Disable Blocking' : 'Enable Blocking'} onNotice={() => {}} node={node} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -131,11 +134,30 @@ export function Blocking({
|
||||
|
||||
const [listsDirty, setListsDirty] = useState(false)
|
||||
|
||||
const counts = useCounts(token, permissions)
|
||||
/*
|
||||
Behind a hub the Rules and Lists tabs act on ONE server, chosen here (the first at the start;
|
||||
Overview keeps its own selector, which also offers "All servers" for the figures). Where the
|
||||
hub cannot write (no admin token) the writes are switched off and the line under the selector
|
||||
says why. Outside a hub none of this exists: `hubNode` is empty and `permissions` are the role's.
|
||||
*/
|
||||
const hub = useContext(HubMode)
|
||||
const defaultNode = useHubDefaultNode(nodes)
|
||||
const [chosen, setChosen] = useState(defaultNode)
|
||||
const hubNode = hub ? chosen : ''
|
||||
const write = useHubWrite(hubNode)
|
||||
const effective = write.allowed ? permissions : withoutWrites(permissions)
|
||||
|
||||
const counts = useCounts(token, permissions, hubNode)
|
||||
|
||||
const tabs = (
|
||||
<>
|
||||
{active !== 'Overview' && <Banner token={token} permissions={permissions} />}
|
||||
{hub && active !== 'Overview' && (
|
||||
<>
|
||||
<ClusterNodeSelect nodes={nodes} initialised={clusterInitialised} value={chosen} onChange={setChosen} />
|
||||
<HubWriteHint write={write} />
|
||||
</>
|
||||
)}
|
||||
{active !== 'Overview' && <Banner token={token} permissions={effective} node={hubNode} />}
|
||||
<SubTabs
|
||||
label="Blocking sections"
|
||||
section="blocking"
|
||||
@@ -149,17 +171,23 @@ export function Blocking({
|
||||
)
|
||||
|
||||
if (active === 'Rules') {
|
||||
return <Rules tabs={tabs} token={token} permissions={permissions} nodes={nodes} clusterInitialised={clusterInitialised} />
|
||||
return (
|
||||
<HubNode.Provider value={hubNode}>
|
||||
<Rules tabs={tabs} token={token} permissions={effective} nodes={nodes} clusterInitialised={clusterInitialised} />
|
||||
</HubNode.Provider>
|
||||
)
|
||||
}
|
||||
if (active === 'Lists') {
|
||||
return (
|
||||
<HubNode.Provider value={hubNode}>
|
||||
<BlockLists
|
||||
tabs={tabs}
|
||||
token={token}
|
||||
permissions={permissions}
|
||||
permissions={effective}
|
||||
clusterInitialised={clusterInitialised}
|
||||
onDirty={setListsDirty}
|
||||
/>
|
||||
</HubNode.Provider>
|
||||
)
|
||||
}
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
getDashboardStats, RANGES, RANGE_LABEL,
|
||||
type ChartData, type DashboardStats, type Range,
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
import { percentage, hasData, Legend, useHidden } from '../dashboard/Dashboard'
|
||||
import { Chart } from '../dashboard/Chart'
|
||||
import { InChrome } from '../../app/ChromeSlot'
|
||||
import { ClusterNodeSelect, AGGREGATE, type ClusterNode } from '../../ui/ClusterNodeSelect'
|
||||
import { ClusterNodeSelect, AGGREGATE, HubMode, type ClusterNode } from '../../ui/ClusterNodeSelect'
|
||||
import { SectionHeader } from '../../ui/SectionHeader'
|
||||
import { Segmented } from '../../ui/Segmented'
|
||||
import { Panel, Body } from '../../ui/Panel'
|
||||
@@ -20,7 +20,9 @@ import { TopTable } from './TopTable'
|
||||
import { RecentBlocked } from './RecentBlocked'
|
||||
import { LAST_HOUR_REFRESH_MS, useLiveView } from '../dashboard/useLive'
|
||||
import { Locked } from './Locked'
|
||||
import { missing, type Permissions } from './permissions'
|
||||
import { missing, withoutWrites, type Permissions } from './permissions'
|
||||
import { HubNode, useHubWrite } from '../../lib/hub-write'
|
||||
import { HubWriteHint } from '../../ui/HubWriteHint'
|
||||
import shared from './Blocking.module.css'
|
||||
import styles from './Overview.module.css'
|
||||
|
||||
@@ -181,7 +183,7 @@ function Gap({ failure, loading, announce = true }: { failure: boolean; loading:
|
||||
export function Overview({
|
||||
tabs,
|
||||
token,
|
||||
permissions,
|
||||
permissions: rolePermissions,
|
||||
nodes = [],
|
||||
clusterInitialised = false,
|
||||
serverDomain,
|
||||
@@ -193,9 +195,14 @@ export function Overview({
|
||||
clusterInitialised?: boolean
|
||||
serverDomain?: string
|
||||
}) {
|
||||
const statsNeed = missing(permissions, 'Dashboard.canView')
|
||||
const statsNeed = missing(rolePermissions, 'Dashboard.canView')
|
||||
const [range, setRange] = useState<Period>('LastHour')
|
||||
const [node, setNode] = useState<string>(() => localStorage.getItem('dashboardClusterNode') || AGGREGATE)
|
||||
/* Behind a hub, a change is allowed only on a chosen server the hub can write to: with "All servers"
|
||||
the controls are switched off like a missing permission, and the line under the selector says why. */
|
||||
const hub = useContext(HubMode)
|
||||
const write = useHubWrite(node)
|
||||
const permissions = write.allowed ? rolePermissions : withoutWrites(rolePermissions)
|
||||
const [data, setData] = useState<DashboardStats | null>(null)
|
||||
/** The first read of this period and node failed: there is nothing to show. */
|
||||
const [failure, setFailure] = useState(false)
|
||||
@@ -318,7 +325,7 @@ export function Overview({
|
||||
const loading = data == null && !failure
|
||||
|
||||
return (
|
||||
<>
|
||||
<HubNode.Provider value={hub ? node : ''}>
|
||||
<InChrome>
|
||||
<ClusterNodeSelect
|
||||
nodes={nodes}
|
||||
@@ -328,6 +335,7 @@ export function Overview({
|
||||
onChange={setNode}
|
||||
/>
|
||||
</InChrome>
|
||||
<HubWriteHint write={write} />
|
||||
|
||||
<SectionHeader
|
||||
title="Blocking"
|
||||
@@ -465,6 +473,6 @@ export function Overview({
|
||||
refreshOn={s?.totalBlocked}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
</HubNode.Provider>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { HubNode, nodeArg } from '../../lib/hub-write'
|
||||
import { readRuleExport } from '../../api/blocking'
|
||||
import { addDomain, deleteDomain, exportDomains, flushList, type DomainList } from '../../api/zonelists'
|
||||
import { SectionHeader } from '../../ui/SectionHeader'
|
||||
@@ -89,6 +90,8 @@ export function Rules({
|
||||
nodes?: { name: string; type: string }[]
|
||||
clusterInitialised?: boolean
|
||||
}) {
|
||||
/* Behind a hub: the server the section has chosen (empty elsewhere). */
|
||||
const hubNode = useContext(HubNode)
|
||||
const viewBlocked = missing(permissions, 'Blocked.canView') == null
|
||||
const viewAllowed = missing(permissions, 'Allowed.canView') == null
|
||||
/* Neither list can be read: there is nothing to count, so nothing is counted;
|
||||
@@ -162,7 +165,8 @@ export function Rules({
|
||||
change, the PRIMARY, where the change was made (other-zones.js:269, 434): a
|
||||
secondary still answers the old list until the cluster syncs. */
|
||||
const load = useCallback(async (fromPrimary = false) => {
|
||||
const node = fromPrimary ? primary.current : ''
|
||||
/* Behind a hub every read and change is on the server the section has chosen. */
|
||||
const node = hubNode !== '' ? hubNode : fromPrimary ? primary.current : ''
|
||||
const mine = ++asked.current
|
||||
const read = (list: DomainList, may: boolean) => (may ? readRuleExport(list, token, node) : null)
|
||||
const [b, a] = await Promise.all([read('blocked', viewBlocked), read('allowed', viewAllowed)])
|
||||
@@ -181,7 +185,7 @@ export function Rules({
|
||||
setFailed(false)
|
||||
setStale(false)
|
||||
setLastGood(new Date().toISOString())
|
||||
}, [token, viewBlocked, viewAllowed])
|
||||
}, [token, viewBlocked, viewAllowed, hubNode])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
@@ -252,7 +256,7 @@ export function Rules({
|
||||
label: 'Delete',
|
||||
action: () =>
|
||||
mutate(
|
||||
() => deleteDomain(rule.list, token, rule.domain),
|
||||
() => deleteDomain(rule.list, token, rule.domain, ...nodeArg(hubNode)),
|
||||
isAllowed
|
||||
? { type: 'success', title: 'Deleted!', text: `Domain '${rule.domain}' was deleted from Allowed Zone successfully.` }
|
||||
: { type: 'success', title: 'Deleted!', text: `Blocked zone '${rule.domain}' was deleted successfully.` },
|
||||
@@ -268,7 +272,7 @@ export function Rules({
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
const outcome = await deleteDomain(rule.list, token, rule.domain)
|
||||
const outcome = await deleteDomain(rule.list, token, rule.domain, ...nodeArg(hubNode))
|
||||
if (!mounted.current) return
|
||||
setBusy(false)
|
||||
if (outcome.kind !== 'ok') {
|
||||
@@ -285,7 +289,7 @@ export function Rules({
|
||||
const deleted = undo.rules
|
||||
setUndo({ rules: deleted, phase: 'restoring' })
|
||||
setBusy(true)
|
||||
const result = await restoreRules(deleted, (r) => addDomain(r.list, token, r.domain))
|
||||
const result = await restoreRules(deleted, (r) => addDomain(r.list, token, r.domain, ...nodeArg(hubNode)))
|
||||
if (!mounted.current) return
|
||||
setBusy(false)
|
||||
changed()
|
||||
@@ -309,7 +313,7 @@ export function Rules({
|
||||
label: 'Flush',
|
||||
action: () =>
|
||||
mutate(
|
||||
() => flushList(list, token),
|
||||
() => flushList(list, token, ...nodeArg(hubNode)),
|
||||
isAllowed
|
||||
? { type: 'success', title: 'Flushed!', text: 'Allowed zone was flushed successfully.' }
|
||||
: { type: 'success', title: 'Flushed!', text: 'Blocked zone was flushed successfully.' },
|
||||
@@ -319,7 +323,7 @@ export function Rules({
|
||||
|
||||
async function runExport(list: DomainList) {
|
||||
setBusy(true)
|
||||
const r = await exportDomains(list, token)
|
||||
const r = await exportDomains(list, token, ...nodeArg(hubNode))
|
||||
setBusy(false)
|
||||
if (!r.ok) return
|
||||
setNotice({
|
||||
@@ -454,7 +458,7 @@ export function Rules({
|
||||
are framed boxes of their own, and flush they doubled the panel's edge. */
|
||||
<div className={styles.tree}>
|
||||
<Lists
|
||||
key={`${treeList}:${generation}`}
|
||||
key={`${treeList}:${generation}:${hubNode}`}
|
||||
list={treeList}
|
||||
token={token}
|
||||
nodes={nodes}
|
||||
@@ -617,6 +621,7 @@ export function Rules({
|
||||
list={importing}
|
||||
open
|
||||
token={token}
|
||||
node={hubNode}
|
||||
onClose={() => setImporting(null)}
|
||||
onDone={(n) => {
|
||||
setNotice(n)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useContext } from 'react'
|
||||
import { HubNode } from '../../lib/hub-write'
|
||||
import { BlockingMenu } from '../dashboard/BlockingMenu'
|
||||
import { Panel, Body } from '../../ui/Panel'
|
||||
import { PermissionButton } from '../../ui/PermissionButton'
|
||||
@@ -44,7 +46,9 @@ export function StatusPanel({
|
||||
/* The reads (when, from where, and the one when a pause ends) are
|
||||
`useBlockingState`'s, shared with the sidebar's mark. A change made from the
|
||||
menu below reaches it as every settings write does (`lib/settings-signal.ts`). */
|
||||
const { state, retry } = useBlockingState(token, permissions)
|
||||
/* Behind a hub: the server the section has chosen. */
|
||||
const node = useContext(HubNode)
|
||||
const { state, retry } = useBlockingState(token, permissions, true, node)
|
||||
|
||||
if (viewNeed != null) return <Locked title="Blocking" need={viewNeed} />
|
||||
|
||||
@@ -109,7 +113,7 @@ export function StatusPanel({
|
||||
{modifyNeed != null ? (
|
||||
<PermissionButton permission={modifyNeed}>{verb}</PermissionButton>
|
||||
) : (
|
||||
<BlockingMenu token={token} text={verb} onNotice={onNotice} />
|
||||
<BlockingMenu token={token} text={verb} onNotice={onNotice} node={node} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -62,6 +62,8 @@ export function useBlockingState(
|
||||
/** False reads nothing, as a missing `Settings.canView` does: the sidebar without
|
||||
a Blocking entry to mark. */
|
||||
enabled = true,
|
||||
/** Behind a hub: the server read (a hub has no settings of its own). Empty elsewhere. */
|
||||
node = '',
|
||||
): { state: BlockingRead; reload: () => void; retry: () => void } {
|
||||
const viewNeed = enabled ? missing(permissions, 'Settings.canView') : 'Settings.canView'
|
||||
const [state, setState] = useState<BlockingRead>(null)
|
||||
@@ -70,11 +72,11 @@ export function useBlockingState(
|
||||
const reload = useCallback(() => {
|
||||
if (viewNeed != null) return
|
||||
const mine = ++asked.current
|
||||
void getSettings(token).then((s) => {
|
||||
void getSettings(token, node).then((s) => {
|
||||
if (mine !== asked.current) return
|
||||
setState(s == null ? 'failed' : stateOf(s.enableBlocking, s.temporaryDisableBlockingTill))
|
||||
})
|
||||
}, [token, viewNeed])
|
||||
}, [token, viewNeed, node])
|
||||
|
||||
useEffect(() => {
|
||||
// A read already travelling must not land after the permission went.
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ApiOutcome } from '../../api/client'
|
||||
import { readRuleExport } from '../../api/blocking'
|
||||
import { resolve } from '../../api/dnsclient'
|
||||
import { readSettings } from '../../api/settings'
|
||||
import { nodeArg } from '../../lib/hub-write'
|
||||
import type { QuickEntry } from '../../lib/quick-lists'
|
||||
import { listName } from './list-lines'
|
||||
|
||||
@@ -321,11 +322,13 @@ export async function checkDomain(
|
||||
token: string | null,
|
||||
domain: string,
|
||||
needs: { settings?: string; allowed?: string },
|
||||
/** Behind a hub: the server asked (a hub has no server of its own to ask). */
|
||||
node = '',
|
||||
): Promise<ApiOutcome<CheckInputs>> {
|
||||
const [answer, settings, allowed] = await Promise.all([
|
||||
resolve(token, { server: 'this-server', domain, type: 'A', protocol: 'UDP', dnssec: false, eDnsClientSubnet: '' }),
|
||||
needs.settings != null ? { need: needs.settings } : readSettings(token),
|
||||
needs.allowed != null ? { need: needs.allowed } : readRuleExport('allowed', token),
|
||||
resolve(token, { server: 'this-server', domain, type: 'A', protocol: 'UDP', dnssec: false, eDnsClientSubnet: '', ...(node === '' ? {} : { node }) }),
|
||||
needs.settings != null ? { need: needs.settings } : readSettings(token, ...nodeArg(node)),
|
||||
needs.allowed != null ? { need: needs.allowed } : readRuleExport('allowed', token, ...nodeArg(node)),
|
||||
])
|
||||
if (answer.kind !== 'ok') return answer
|
||||
return {
|
||||
|
||||
@@ -19,6 +19,20 @@ export function missing(perms: Permissions, ...needs: Need[]): Need | undefined
|
||||
return undefined
|
||||
}
|
||||
|
||||
/*
|
||||
Behind a hub, when the chosen node is not one a change can be made on ("All servers", or a server
|
||||
the hub holds no admin token for): the same permissions with every write switched off. The
|
||||
controls then render as they do for a role without the permission.
|
||||
*/
|
||||
export function withoutWrites(perms: Permissions): Permissions {
|
||||
if (perms == null) return perms
|
||||
const out: Record<string, Permission> = { ...perms }
|
||||
for (const section of ['Allowed', 'Blocked', 'Settings']) {
|
||||
out[section] = { canView: perms[section]?.canView ?? true, canModify: false, canDelete: false }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** The same sentence `PermissionButton` draws: `Requires Logs: View`. */
|
||||
export function requiresText(need: string): string {
|
||||
const [section, action] = need.split('.')
|
||||
|
||||
@@ -2,6 +2,9 @@ import { useState } from 'react'
|
||||
import { getSettings, setSettings, temporaryDisableBlocking } from '../../api/settings'
|
||||
import { Confirm } from '../../ui/Confirm'
|
||||
import { Menu } from '../../ui/Menu'
|
||||
import { Button } from '../../ui/Button'
|
||||
import { Tooltip } from '../../ui/Tooltip'
|
||||
import { nodeArg, useHubWrite } from '../../lib/hub-write'
|
||||
import type { AlertType } from '../../ui/Alert'
|
||||
import { noticeFromFailure } from '../../lib/notice'
|
||||
|
||||
@@ -53,8 +56,14 @@ export function BlockingMenu({
|
||||
onNotice,
|
||||
text = 'Blocking',
|
||||
onChanged,
|
||||
node = '',
|
||||
allowed = true,
|
||||
}: {
|
||||
token: string | null
|
||||
/** Behind a hub: the server the change is made on (empty or "cluster": none chosen, so it is switched off). */
|
||||
node?: string
|
||||
/** Behind a hub: what the role may do. */
|
||||
allowed?: boolean
|
||||
onNotice: (a: { type: AlertType; title: string; text: string }) => void
|
||||
/** The trigger text. The Dashboard keeps upstream's "Blocking". */
|
||||
text?: string
|
||||
@@ -67,7 +76,7 @@ export function BlockingMenu({
|
||||
|
||||
async function loadState() {
|
||||
setActive(null)
|
||||
const s = await getSettings(token)
|
||||
const s = await getSettings(token, ...nodeArg(node))
|
||||
if (s != null) setActive(s.enableBlocking)
|
||||
}
|
||||
|
||||
@@ -78,7 +87,7 @@ export function BlockingMenu({
|
||||
label: turnOn ? 'Enable' : 'Disable',
|
||||
variant: turnOn ? 'primary' : 'danger',
|
||||
perform: async () => {
|
||||
const r = await setSettings(token, { enableBlocking: String(turnOn) })
|
||||
const r = await setSettings(token, { enableBlocking: String(turnOn), ...(node ? { node } : {}) })
|
||||
// The same text as the other thirty-six: it was the only one that said
|
||||
// a bare "Session expired." for this very condition.
|
||||
if (r.kind !== 'ok') throw new Error(noticeFromFailure(r).text)
|
||||
@@ -99,7 +108,7 @@ export function BlockingMenu({
|
||||
label: 'Disable',
|
||||
variant: 'danger',
|
||||
perform: async () => {
|
||||
const till = await temporaryDisableBlocking(token, String(minutes))
|
||||
const till = await temporaryDisableBlocking(token, String(minutes), ...nodeArg(node))
|
||||
if (till == null) throw new Error('The request failed.')
|
||||
setActive(false)
|
||||
onNotice({
|
||||
@@ -126,6 +135,16 @@ export function BlockingMenu({
|
||||
}
|
||||
}
|
||||
|
||||
/* Behind a hub, only on a chosen server the hub can write to; the tooltip says why not. */
|
||||
const write = useHubWrite(node, allowed)
|
||||
if (!write.allowed) {
|
||||
return (
|
||||
<Tooltip text={write.reason ?? 'Requires Settings: Modify'}>
|
||||
<Button disabled>{text}</Button>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu label="Blocking options" text={text} onOpen={() => void loadState()}>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useContext, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { HubNode } from '../../lib/hub-write'
|
||||
import {
|
||||
RANGE_LABEL, RANGES, getDashboardStats,
|
||||
type DashboardStats, type Range, type Stats, type TopKind,
|
||||
@@ -13,7 +14,7 @@ import { Heatmap } from './Heatmap.tsx'
|
||||
import {
|
||||
bucketWindow, periodFilters, protocolFilter, queryTypeFilter, responseTypeFilter,
|
||||
} from './jump'
|
||||
import { ClusterNodeSelect, AGGREGATE, type ClusterNode } from '../../ui/ClusterNodeSelect'
|
||||
import { ClusterNodeSelect, AGGREGATE, HubMode, type ClusterNode } from '../../ui/ClusterNodeSelect'
|
||||
import { InChrome } from '../../app/ChromeSlot'
|
||||
import { TopStats } from './TopStats'
|
||||
import { TopRowMenu } from './TopRowMenu'
|
||||
@@ -52,11 +53,15 @@ export function Dashboard({
|
||||
token,
|
||||
nodes = [],
|
||||
clusterInitialised = false,
|
||||
canBlock = true,
|
||||
}: {
|
||||
token: string | null
|
||||
nodes?: ClusterNode[]
|
||||
clusterInitialised?: boolean
|
||||
/** Behind a hub: whether the role may switch blocking or allow and block a domain. */
|
||||
canBlock?: boolean
|
||||
}) {
|
||||
const hub = useContext(HubMode)
|
||||
/*
|
||||
Which node this screen is reading. Upstream offers the aggregate here and
|
||||
remembers the choice per screen (`cluster.js`, key `dashboardClusterNode`).
|
||||
@@ -96,7 +101,7 @@ export function Dashboard({
|
||||
const [notice, setNotice] = useState<{ type: AlertType; title: string; text: string } | null>(null)
|
||||
/* Allowing or blocking from a top list reports on the page (main.js:2825 and
|
||||
2853 pass no alert placeholder). */
|
||||
const action = useDomainAction(token, setNotice)
|
||||
const action = useDomainAction(token, setNotice, hub ? node : '')
|
||||
/*
|
||||
The custom range. `start`/`end` are what is typed into the two fields;
|
||||
`requested` is the last thing "Show" was pressed with, which is what triggers the
|
||||
@@ -490,7 +495,7 @@ export function Dashboard({
|
||||
rows={view?.topBlockedDomains ?? []}
|
||||
onMore={() => setTop('TopBlockedDomains')}
|
||||
failure={failure}
|
||||
beforeMore={<BlockingMenu token={token} onNotice={setNotice} />}
|
||||
beforeMore={<BlockingMenu token={token} onNotice={setNotice} node={hub ? node : ''} allowed={canBlock} />}
|
||||
menu={(f) => <TopRowMenu kind="TopBlockedDomains" name={f.name} node={node} action={action} />}
|
||||
/>
|
||||
<Breakdown
|
||||
@@ -505,7 +510,9 @@ export function Dashboard({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TopStats type={top} range={range} token={token} node={node} onClose={() => setTop(null)} />
|
||||
<HubNode.Provider value={hub ? node : ''}>
|
||||
<TopStats type={top} range={range} token={token} node={node} onClose={() => setTop(null)} />
|
||||
</HubNode.Provider>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { Dhcp } from './Dhcp'
|
||||
import * as api from '../../api/dhcp'
|
||||
import { HubMode } from '../../ui/ClusterNodeSelect'
|
||||
import { HubWritable } from '../../lib/hub-write'
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
/* Behind a hub the screen starts on the first server, and changes only where the hub can write. */
|
||||
|
||||
const NODES = [
|
||||
{ name: 'mdns', type: 'Primary' },
|
||||
{ name: 'ndns', type: 'Secondary' },
|
||||
]
|
||||
|
||||
function hubDhcp(writable: string[], canModify = true) {
|
||||
vi.spyOn(api, 'listScopes').mockResolvedValue({ kind: 'ok', data: [] })
|
||||
return render(
|
||||
<HubMode.Provider value>
|
||||
<HubWritable.Provider value={writable}>
|
||||
<Dhcp token="t" sub="Scopes" nodes={NODES} clusterInitialised canModify={canModify} canDelete={canModify} />
|
||||
</HubWritable.Provider>
|
||||
</HubMode.Provider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('DHCP behind a hub', () => {
|
||||
it('starts on the first server, named in the request and shown in the selector', async () => {
|
||||
hubDhcp(['mdns', 'ndns'])
|
||||
await screen.findByText('No Scope Found')
|
||||
expect(api.listScopes).toHaveBeenCalledWith('t', 'mdns')
|
||||
expect(screen.getByRole('combobox', { name: 'Server' })).toHaveTextContent('mdns')
|
||||
expect(screen.queryByRole('option', { name: 'All servers' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Add Scope' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('switches changes off on a server the hub holds no admin token for, and says why', async () => {
|
||||
const user = userEvent.setup()
|
||||
hubDhcp(['mdns'])
|
||||
await screen.findByText('No Scope Found')
|
||||
await user.click(screen.getByRole('combobox', { name: 'Server' }))
|
||||
await user.click(await screen.findByRole('option', { name: 'ndns' }))
|
||||
|
||||
expect(await screen.findByText(/No admin token configured on the hub/)).toBeInTheDocument()
|
||||
expect(api.listScopes).toHaveBeenLastCalledWith('t', 'ndns')
|
||||
expect(screen.queryByRole('button', { name: 'Add Scope' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('says nothing about the server when the role is what forbids the change', async () => {
|
||||
hubDhcp(['mdns'], false)
|
||||
await screen.findByText('No Scope Found')
|
||||
expect(screen.queryByRole('button', { name: 'Add Scope' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/admin token|Choose a server/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DHCP outside a hub', () => {
|
||||
it('keeps the empty node and no hint', async () => {
|
||||
vi.spyOn(api, 'listScopes').mockResolvedValue({ kind: 'ok', data: [] })
|
||||
render(<Dhcp token="t" sub="Scopes" />)
|
||||
await screen.findByText('No Scope Found')
|
||||
expect(api.listScopes).toHaveBeenCalledWith('t', '')
|
||||
expect(screen.getByRole('button', { name: 'Add Scope' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,8 @@ import { SubTabs } from '../../ui/SubTabs'
|
||||
import { ClusterNodeSelect } from '../../ui/ClusterNodeSelect'
|
||||
import { Leases } from './Leases'
|
||||
import { Scopes } from './Scopes'
|
||||
import { useHubDefaultNode, useHubWrite } from '../../lib/hub-write'
|
||||
import { HubWriteHint } from '../../ui/HubWriteHint'
|
||||
|
||||
/*
|
||||
DHCP. Two sub-tabs, with upstream's literal labels (index.html:2496-2497):
|
||||
@@ -48,12 +50,17 @@ export function Dhcp({
|
||||
clusterInitialised = false,
|
||||
token,
|
||||
sub,
|
||||
canModify = true,
|
||||
canDelete = true,
|
||||
canModify: mayModify = true,
|
||||
canDelete: mayDelete = true,
|
||||
}: DhcpProps) {
|
||||
/* Which node this screen reads. Upstream mounts a selector here
|
||||
and the parameter was already travelling empty. */
|
||||
const [node, setNode] = useState<string>(() => '')
|
||||
const defaultNode = useHubDefaultNode(nodes)
|
||||
const [node, setNode] = useState<string>(defaultNode)
|
||||
/* Behind a hub, a change is allowed only on a chosen server the hub can write to. */
|
||||
const write = useHubWrite(node, mayModify)
|
||||
const canModify = write.allowed
|
||||
const canDelete = useHubWrite(node, mayDelete).allowed
|
||||
const [nodeLocked, setNodeLocked] = useState(false)
|
||||
const requested = (sub ?? 'Leases') as SubTab
|
||||
const active: SubTab = SUB_TABS.includes(requested) ? requested : 'Leases'
|
||||
@@ -85,6 +92,7 @@ export function Dhcp({
|
||||
disabled={nodeLocked}
|
||||
label="Cluster Node"
|
||||
/>
|
||||
<HubWriteHint write={write} />
|
||||
{active === 'Scopes' ? (
|
||||
<Scopes
|
||||
tabs={tabs}
|
||||
|
||||
@@ -4,6 +4,9 @@ import type { DnsClientRequest } from '../../app/handoff'
|
||||
import { PROTOCOLS, TYPES, prepareServer, resolve } from '../../api/dnsclient'
|
||||
import { type AlertType } from '../../ui/Alert'
|
||||
import { Button } from '../../ui/Button'
|
||||
import { PermissionButton } from '../../ui/PermissionButton'
|
||||
import { useHubDefaultNode, useHubWrite } from '../../lib/hub-write'
|
||||
import { HubWriteHint } from '../../ui/HubWriteHint'
|
||||
import { Check } from '../../ui/Check'
|
||||
import { Field, Input, LabeledInput, LabeledSelect } from '../../ui/Field'
|
||||
import { Menu } from '../../ui/Menu'
|
||||
@@ -32,18 +35,24 @@ export function DnsClient({
|
||||
token,
|
||||
nodes = [],
|
||||
clusterInitialised = false,
|
||||
canImport = true,
|
||||
request,
|
||||
}: {
|
||||
token: string | null
|
||||
/** The cluster nodes, for the node selector. */
|
||||
nodes?: { name: string; type: string }[]
|
||||
clusterInitialised?: boolean
|
||||
/** `DnsClient.canModify`: Import writes the answer into a zone of the server. */
|
||||
canImport?: boolean
|
||||
/** A query another screen asked for: fill the form with it and run it. */
|
||||
request?: DnsClientRequest
|
||||
}) {
|
||||
/* Upstream mounts one here (`optDnsClientClusterNode`), no aggregate, no
|
||||
persistence. The jump picks a node only when it was given one. */
|
||||
const [clusterNode, setClusterNode] = useState<string>(() => request?.node ?? '')
|
||||
const defaultNode = useHubDefaultNode(nodes)
|
||||
const [clusterNode, setClusterNode] = useState<string>(() => (request?.node != null && request.node !== 'cluster' ? request.node : defaultNode))
|
||||
/* Behind a hub, Import is allowed only on a chosen server the hub can write to. */
|
||||
const write = useHubWrite(clusterNode, canImport)
|
||||
|
||||
const [server, setServer] = useState('This Server {this-server}')
|
||||
|
||||
@@ -155,6 +164,7 @@ export function DnsClient({
|
||||
onChange={setClusterNode}
|
||||
label="Cluster Node"
|
||||
/>
|
||||
<HubWriteHint write={write} />
|
||||
|
||||
<Notifier notice={alert} onClose={() => setAlert(null)} />
|
||||
|
||||
@@ -223,9 +233,9 @@ export function DnsClient({
|
||||
<Button variant="primary" disabled={busy} onClick={() => void fire(false)}>
|
||||
Resolve
|
||||
</Button>
|
||||
<Button disabled={busy} onClick={() => void fire(true)}>
|
||||
<PermissionButton permission={canImport ? undefined : 'DnsClient.canModify'} disabled={busy || !write.allowed} onClick={() => void fire(true)}>
|
||||
Import
|
||||
</Button>
|
||||
</PermissionButton>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest'
|
||||
import { render, screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { Cache } from './Lists'
|
||||
import * as api from '../../api/zonelists'
|
||||
import { HubMode } from '../../ui/ClusterNodeSelect'
|
||||
import { HubWritable } from '../../lib/hub-write'
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
const NODES = [
|
||||
{ name: 'mdns', type: 'Primary' },
|
||||
{ name: 'ndns', type: 'Secondary' },
|
||||
]
|
||||
|
||||
function hubCache(writable: string[], canDelete = true) {
|
||||
vi.spyOn(api, 'listNode').mockResolvedValue({ kind: 'ok', data: { domain: '', zones: ['com'], records: [] } })
|
||||
vi.spyOn(api, 'flushCache').mockResolvedValue({ kind: 'ok', data: {} })
|
||||
return render(
|
||||
<HubMode.Provider value>
|
||||
<HubWritable.Provider value={writable}>
|
||||
<Cache token="t" nodes={NODES} clusterInitialised canDelete={canDelete} />
|
||||
</HubWritable.Provider>
|
||||
</HubMode.Provider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('Cache behind a hub', () => {
|
||||
it('reads the first server and flushes it', async () => {
|
||||
const user = userEvent.setup()
|
||||
hubCache(['mdns', 'ndns'])
|
||||
const flush = await screen.findByRole('button', { name: 'Flush Cache' })
|
||||
expect(api.listNode).toHaveBeenCalledWith('cache', 't', '', undefined, 'mdns')
|
||||
expect(flush).toBeEnabled()
|
||||
await user.click(flush)
|
||||
await user.click(within(await screen.findByRole('alertdialog')).getByRole('button', { name: 'Flush Cache' }))
|
||||
expect(api.flushCache).toHaveBeenCalledWith('t', 'mdns')
|
||||
})
|
||||
|
||||
it('switches Flush Cache off on a server without an admin token, with the reason', async () => {
|
||||
const user = userEvent.setup()
|
||||
hubCache(['mdns'])
|
||||
await screen.findByRole('button', { name: 'Flush Cache' })
|
||||
await user.click(screen.getByRole('combobox', { name: 'Server' }))
|
||||
await user.click(await screen.findByRole('option', { name: 'ndns' }))
|
||||
expect(await screen.findByText(/No admin token configured on the hub/)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Flush Cache' })).toBeDisabled()
|
||||
})
|
||||
})
|
||||
+29
-11
@@ -1,6 +1,8 @@
|
||||
import { KpiStrip } from '../../ui/KpiStrip'
|
||||
import { ClusterNodeSelect, primaryNodeName } from '../../ui/ClusterNodeSelect'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useContext, useEffect, useRef, useState } from 'react'
|
||||
import { HubNode, useHubDefaultNode, useHubWrite } from '../../lib/hub-write'
|
||||
import { HubWriteHint } from '../../ui/HubWriteHint'
|
||||
import {
|
||||
deleteDomain,
|
||||
deleteCacheNode,
|
||||
@@ -87,12 +89,15 @@ export function ImportDomains({
|
||||
list,
|
||||
open,
|
||||
token,
|
||||
node = '',
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
list: DomainList
|
||||
open: boolean
|
||||
token: string | null
|
||||
/** Behind a hub: the server the lists are imported into. */
|
||||
node?: string
|
||||
onClose: () => void
|
||||
onDone: (n: Notice) => void
|
||||
}) {
|
||||
@@ -133,7 +138,7 @@ export function ImportDomains({
|
||||
}
|
||||
|
||||
setBusy(true)
|
||||
const outcome = await importDomains(list, token, zones)
|
||||
const outcome = await importDomains(list, token, zones, node)
|
||||
setBusy(false)
|
||||
|
||||
if (outcome.kind !== 'ok') {
|
||||
@@ -231,10 +236,19 @@ export function Lists({
|
||||
* read its own view of the list again. */
|
||||
onChanged?: () => void
|
||||
}) {
|
||||
const [clusterNode, setClusterNode] = useState<string>('')
|
||||
/* Behind a hub the empty node is the hub, not a server: the cache starts on the first one. */
|
||||
const defaultNode = useHubDefaultNode(nodes)
|
||||
const [clusterNode, setClusterNode] = useState<string>(defaultNode)
|
||||
/* Read by `load` through a ref, so choosing a node does not rebuild it (and
|
||||
re-run the mount effect that reads the root). */
|
||||
const cacheNode = useRef('')
|
||||
const cacheNode = useRef(defaultNode)
|
||||
/* Inside Blocking behind a hub: the server its Rules tab has chosen. The lists are that server's. */
|
||||
const sectionNode = useContext(HubNode)
|
||||
const sectionRef = useRef(sectionNode)
|
||||
const listServer = embedded ? sectionNode : ''
|
||||
/* A change is allowed only on a chosen server the hub can write to (outside a hub: the permission). */
|
||||
const cacheWrite = useHubWrite(clusterNode, canDelete)
|
||||
const listWrite = useHubWrite(listServer, canDelete)
|
||||
|
||||
const [node, setNode] = useState<ListNode | null>(null)
|
||||
const [field, setField] = useState('')
|
||||
@@ -280,7 +294,8 @@ export function Lists({
|
||||
const primary = useRef('')
|
||||
useEffect(() => {
|
||||
primary.current = primaryNodeName(nodes, clusterInitialised)
|
||||
}, [nodes, clusterInitialised])
|
||||
sectionRef.current = embedded ? sectionNode : ''
|
||||
}, [nodes, clusterInitialised, embedded, sectionNode])
|
||||
|
||||
/* Bumped whenever what the cache tree has read is out of date. */
|
||||
const [treeEpoch, setTreeEpoch] = useState(0)
|
||||
@@ -289,7 +304,7 @@ export function Lists({
|
||||
async (domain: string, up?: boolean, fromPrimary = false) => {
|
||||
/* other-zones.js:269 and 434: after a change, Allowed and Blocked are read
|
||||
back from the PRIMARY node, where the change was made. Cache never is. */
|
||||
const node = list === 'cache' ? cacheNode.current : fromPrimary ? primary.current : ''
|
||||
const node = list === 'cache' ? cacheNode.current : sectionRef.current !== '' ? sectionRef.current : fromPrimary ? primary.current : ''
|
||||
const outcome = await listNode(list, token, domain, up ? 'up' : undefined, node)
|
||||
if (outcome.kind === 'ok') {
|
||||
setNode(outcome.data)
|
||||
@@ -424,7 +439,7 @@ export function Lists({
|
||||
label: 'Delete',
|
||||
action: () =>
|
||||
mutate(
|
||||
() => deleteDomain(domainList, token, nodeTitle),
|
||||
() => deleteDomain(domainList, token, nodeTitle, listServer),
|
||||
isAllowed
|
||||
? {
|
||||
type: 'success',
|
||||
@@ -464,6 +479,7 @@ export function Lists({
|
||||
label="Cluster Node"
|
||||
/>
|
||||
)}
|
||||
{isCache && !embedded && <HubWriteHint write={cacheWrite} />}
|
||||
|
||||
{/* On Cache the identity band's second line moves up under the title: the
|
||||
screen is one panel now, and a band naming the panel under a header
|
||||
@@ -474,7 +490,7 @@ export function Lists({
|
||||
subtitle={isCache ? 'Records cached by the recursive resolver' : undefined}
|
||||
actions={
|
||||
isCache ? (
|
||||
<Button variant="danger" disabled={busy} onClick={askFlushCache}>
|
||||
<Button variant="danger" disabled={busy || !cacheWrite.allowed} onClick={askFlushCache}>
|
||||
Flush Cache
|
||||
</Button>
|
||||
) : undefined
|
||||
@@ -630,7 +646,7 @@ export function Lists({
|
||||
<PermissionButton
|
||||
size="sm"
|
||||
variant="danger"
|
||||
disabled={busy}
|
||||
disabled={busy || !(isCache ? cacheWrite : listWrite).allowed}
|
||||
permission={canDelete ? undefined : `${TITLE[list]}.canDelete`}
|
||||
onClick={isCache ? askDeleteCacheNode : askDeleteDomain}
|
||||
>
|
||||
@@ -676,10 +692,12 @@ export function Lists({
|
||||
)
|
||||
}
|
||||
|
||||
export function Cache({ token, nodes, clusterInitialised }: {
|
||||
export function Cache({ token, nodes, clusterInitialised, canDelete }: {
|
||||
token: string | null
|
||||
nodes?: { name: string; type: string }[]
|
||||
clusterInitialised?: boolean
|
||||
/** `Cache.canDelete`: Flush and Delete. */
|
||||
canDelete?: boolean
|
||||
}) {
|
||||
return <Lists list="cache" token={token} nodes={nodes} clusterInitialised={clusterInitialised} />
|
||||
return <Lists list="cache" token={token} nodes={nodes} clusterInitialised={clusterInitialised} canDelete={canDelete} />
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState } from 'react'
|
||||
import { useContext, useState } from 'react'
|
||||
import { HubNode, useHubDefaultNode, useHubWrite } from '../../lib/hub-write'
|
||||
import { HubWriteHint } from '../../ui/HubWriteHint'
|
||||
import { SubTabs } from '../../ui/SubTabs'
|
||||
import { ClusterNodeSelect } from '../../ui/ClusterNodeSelect'
|
||||
import { AGGREGATE, ClusterNodeSelect, HubMode } from '../../ui/ClusterNodeSelect'
|
||||
import { QueryLogs } from './QueryLogs'
|
||||
import { ViewLogs } from './ViewLogs'
|
||||
import type { QueryLogsRequest } from '../../app/handoff'
|
||||
@@ -36,6 +38,8 @@ export interface LogsProps {
|
||||
canDeleteLogs?: boolean
|
||||
/** `Dashboard.canDelete`: deleting all the statistics. */
|
||||
canDeleteStats?: boolean
|
||||
/** `Allowed.canModify` and `Blocked.canModify`: the Allow and Block actions of a Query Logs row. */
|
||||
canBlock?: boolean
|
||||
/** The cluster node. Empty means "this server". */
|
||||
node?: string
|
||||
/** `showQueryLogs` from another screen: Query Logs filtered and run on arrival. */
|
||||
@@ -50,14 +54,24 @@ export function Logs({
|
||||
sub,
|
||||
canDeleteLogs = true,
|
||||
canDeleteStats = true,
|
||||
canBlock = true,
|
||||
request,
|
||||
}: LogsProps) {
|
||||
/* Which node this screen reads. Upstream mounts a selector here
|
||||
and the parameter was already travelling empty. `showQueryLogs` picks one
|
||||
only when it was given one (logs.js:634-635). */
|
||||
const [node, setNode] = useState<string>(() => request?.node ?? '')
|
||||
const hub = useContext(HubMode)
|
||||
const defaultNode = useHubDefaultNode(nodes)
|
||||
/* Behind a hub the empty node is the hub, not a server. Query Logs merges every server's rows
|
||||
("All servers", where it starts); a log file belongs to one server, so View Logs always shows
|
||||
one (the first unless chosen) and never labels one server's file as all of them. */
|
||||
const [picked, setNode] = useState<string>(() => request?.node ?? (hub ? AGGREGATE : ''))
|
||||
const requested = (sub ?? 'View Logs') as SubTab
|
||||
const active: SubTab = SUB_TABS.includes(requested) ? requested : 'View Logs'
|
||||
const aggregateShown = hub && active === 'Query Logs'
|
||||
const node = hub && !aggregateShown && (picked === '' || picked === AGGREGATE) ? defaultNode : picked
|
||||
/* The Allow and Block actions of a row: one server at a time. */
|
||||
const write = useHubWrite(node, canBlock)
|
||||
|
||||
/*
|
||||
The bar is built once here and handed to whichever sub-screen is drawn: the same
|
||||
@@ -83,10 +97,14 @@ export function Logs({
|
||||
initialised={clusterInitialised}
|
||||
value={node}
|
||||
onChange={setNode}
|
||||
aggregate={aggregateShown}
|
||||
label="Cluster Node"
|
||||
/>
|
||||
{active === 'Query Logs' && <HubWriteHint write={write} />}
|
||||
{active === 'Query Logs' ? (
|
||||
<QueryLogs tabs={tabs} token={token} node={node} request={request} />
|
||||
<HubNode.Provider value={hub ? node : ''}>
|
||||
<QueryLogs tabs={tabs} token={token} node={node} request={request} canBlock={write.allowed} />
|
||||
</HubNode.Provider>
|
||||
) : (
|
||||
<ViewLogs tabs={tabs}
|
||||
token={token}
|
||||
|
||||
@@ -319,11 +319,13 @@ export interface QueryLogsProps {
|
||||
tabs?: ReactNode
|
||||
token: string | null
|
||||
node?: string
|
||||
/** Whether a row may be allowed or blocked (behind a hub: a server is chosen and writable). */
|
||||
canBlock?: boolean
|
||||
/** `showQueryLogs` from another screen: these filters, queried on arrival. */
|
||||
request?: QueryLogsRequest
|
||||
}
|
||||
|
||||
export function QueryLogs({ tabs, token, node = '', request }: QueryLogsProps) {
|
||||
export function QueryLogs({ tabs, token, node = '', request, canBlock = true }: QueryLogsProps) {
|
||||
const [apps, setApps] = useState<{ name: string; classPaths: string[] }[] | null>(null)
|
||||
const [f, setF] = useState<Filters>(() => defaultFilters('', ''))
|
||||
const [page, setPage] = useState<QueryLogPage | null>(null)
|
||||
@@ -1041,7 +1043,7 @@ export function QueryLogs({ tabs, token, node = '', request }: QueryLogsProps) {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={action.isBusy(key)}
|
||||
disabled={action.isBusy(key) || !canBlock}
|
||||
onClick={() => {
|
||||
close()
|
||||
void action.run(key, verb, domain)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { ClusterNodeSelect } from '../../ui/ClusterNodeSelect'
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { ClusterNodeSelect, HubMode } from '../../ui/ClusterNodeSelect'
|
||||
import { useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { nodeArg, useHubWrite } from '../../lib/hub-write'
|
||||
import { HubWriteHint } from '../../ui/HubWriteHint'
|
||||
import { PermissionButton } from '../../ui/PermissionButton'
|
||||
import { SectionHeader } from '../../ui/SectionHeader'
|
||||
import { SubTabs } from '../../ui/SubTabs'
|
||||
@@ -19,7 +21,7 @@ import {
|
||||
type DnsSettings,
|
||||
type SettingsEnvelope,
|
||||
} from '../../api/settings'
|
||||
import { buildBody, formFromSettings, enabled, selectedNode, type SettingsForm } from './model'
|
||||
import { buildBody, formFromSettings, enabled, hubSelectedNode, selectedNode, type SettingsForm } from './model'
|
||||
import { pendingByPane, pendingText } from './pending'
|
||||
import { detectReverseProxy, REDIRECT_DELAY_MS, webConsoleRedirection } from './redirect'
|
||||
import { General } from './panes/General'
|
||||
@@ -193,13 +195,19 @@ export function Settings({
|
||||
See `selectedNode`. Upstream stores the held value on every load
|
||||
(main.js:905), so a standalone server remembers `""`.
|
||||
*/
|
||||
const hub = useContext(HubMode)
|
||||
const [remembered, setNode] = useState<string | null>(() =>
|
||||
localStorage.getItem('settingsClusterNode'),
|
||||
)
|
||||
const node = selectedNode(remembered, clusterInitialised, nodes)
|
||||
/* Behind a hub there is no "All servers" here: every setting belongs to one server, so the
|
||||
remembered one if it still exists, else the first. A save sends the whole form to it. */
|
||||
const node = hub ? hubSelectedNode(remembered, nodes) : selectedNode(remembered, clusterInitialised, nodes)
|
||||
useEffect(() => {
|
||||
localStorage.setItem('settingsClusterNode', node)
|
||||
}, [node])
|
||||
/* Behind a hub, a change is allowed only on a chosen server the hub can write to. */
|
||||
const write = useHubWrite(node, canModify)
|
||||
const flushWrite = useHubWrite(node, canFlushCache)
|
||||
|
||||
/*
|
||||
Two pieces of upstream's global state this screen reads and writes.
|
||||
@@ -317,7 +325,7 @@ export function Settings({
|
||||
|
||||
async function save() {
|
||||
if (form == null) return
|
||||
const result = buildBody(form, node)
|
||||
const result = buildBody(form, node, hub)
|
||||
|
||||
if (result.error) {
|
||||
const { title, text, tab } = result.error
|
||||
@@ -379,7 +387,7 @@ export function Settings({
|
||||
const minutes = form.temporaryDisableBlockingMinutes
|
||||
setConfirm(null)
|
||||
setBusy(true)
|
||||
const till = await temporaryDisableBlocking(token, minutes)
|
||||
const till = await temporaryDisableBlocking(token, minutes, ...nodeArg(hub ? node : ''))
|
||||
setBusy(false)
|
||||
if (till == null) return
|
||||
|
||||
@@ -398,7 +406,7 @@ export function Settings({
|
||||
async function updateLists() {
|
||||
setConfirm(null)
|
||||
setBusy(true)
|
||||
const ok = await forceUpdateBlockLists(token)
|
||||
const ok = await forceUpdateBlockLists(token, ...nodeArg(hub ? node : ''))
|
||||
setBusy(false)
|
||||
if (!ok) return
|
||||
// main.js:2356. The label becomes "Updating Now" without reloading the settings.
|
||||
@@ -462,7 +470,7 @@ export function Settings({
|
||||
const props = { f: form, set, en }
|
||||
|
||||
/* What the save would change, pane by pane (`pending.ts`). */
|
||||
const pending = loadedForm == null ? [] : pendingByPane(loadedForm, form, node, SUB_TABS)
|
||||
const pending = loadedForm == null ? [] : pendingByPane(loadedForm, form, node, SUB_TABS, hub)
|
||||
const pendingWords = pendingText(pending)
|
||||
|
||||
return (
|
||||
@@ -494,7 +502,7 @@ export function Settings({
|
||||
<ClusterNodeSelect
|
||||
nodes={nodes}
|
||||
initialised={clusterInitialised}
|
||||
aggregate
|
||||
aggregate={!hub}
|
||||
value={node}
|
||||
onChange={setNode}
|
||||
label="Cluster Node"
|
||||
@@ -521,7 +529,7 @@ export function Settings({
|
||||
</PermissionButton>
|
||||
<PermissionButton
|
||||
variant="primary"
|
||||
disabled={busy}
|
||||
disabled={busy || !write.allowed}
|
||||
permission={canModify ? undefined : 'Settings.canModify'}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
@@ -529,7 +537,7 @@ export function Settings({
|
||||
</PermissionButton>
|
||||
<PermissionButton
|
||||
variant="danger"
|
||||
disabled={busy}
|
||||
disabled={busy || !flushWrite.allowed}
|
||||
permission={canFlushCache ? undefined : 'Cache.canDelete'}
|
||||
onClick={() => setConfirm('flush')}
|
||||
>
|
||||
@@ -539,6 +547,7 @@ export function Settings({
|
||||
}
|
||||
/>
|
||||
|
||||
<HubWriteHint write={write} />
|
||||
<Notifier notice={notice} onClose={() => setNotice(null)} />
|
||||
|
||||
{/*
|
||||
@@ -566,7 +575,7 @@ export function Settings({
|
||||
onTemporaryDisable: askDisableBlocking,
|
||||
onUpdateNow: () => setConfirm('update'),
|
||||
hasSavedBlockLists: settings.blockListUrls != null,
|
||||
busy,
|
||||
busy: busy || !write.allowed,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { hubSelectedNode, nodeScope } from './model'
|
||||
import { withoutWrites } from '../blocking/permissions'
|
||||
|
||||
/* Behind a hub every setting belongs to the one server chosen. */
|
||||
|
||||
describe('hubSelectedNode', () => {
|
||||
const nodes = [{ name: 'mdns' }, { name: 'ndns' }]
|
||||
|
||||
it('keeps the remembered server and never offers the aggregate', () => {
|
||||
expect(hubSelectedNode('ndns', nodes)).toBe('ndns')
|
||||
expect(hubSelectedNode('cluster', nodes)).toBe('mdns')
|
||||
expect(hubSelectedNode(null, nodes)).toBe('mdns')
|
||||
expect(hubSelectedNode('gone', nodes)).toBe('mdns')
|
||||
})
|
||||
})
|
||||
|
||||
describe('nodeScope with the whole form', () => {
|
||||
it('sends both blocks to a named server, where a cluster would send only its node blocks', () => {
|
||||
expect(nodeScope('mdns')).toEqual({ cluster: false, node: true })
|
||||
expect(nodeScope('mdns', true)).toEqual({ cluster: true, node: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('withoutWrites', () => {
|
||||
it('switches off modify and delete on the blocking sections and leaves the rest', () => {
|
||||
const perms = {
|
||||
Allowed: { canView: true, canModify: true, canDelete: true },
|
||||
Blocked: { canView: true, canModify: true, canDelete: true },
|
||||
Settings: { canView: true, canModify: true, canDelete: false },
|
||||
Dashboard: { canView: true, canModify: false, canDelete: false },
|
||||
}
|
||||
const off = withoutWrites(perms)!
|
||||
expect(off.Allowed).toEqual({ canView: true, canModify: false, canDelete: false })
|
||||
expect(off.Blocked.canModify).toBe(false)
|
||||
expect(off.Settings).toEqual({ canView: true, canModify: false, canDelete: false })
|
||||
expect(off.Dashboard).toEqual(perms.Dashboard)
|
||||
expect(withoutWrites(undefined)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -500,6 +500,13 @@ export function selectedNode(
|
||||
return nodes.length > 0 ? nodes[0].name : wanted
|
||||
}
|
||||
|
||||
/* Behind a hub: the remembered server if it is still listed, else the first. No aggregate, so the
|
||||
remembered "cluster" of a console used without a hub is not honoured. */
|
||||
export function hubSelectedNode(remembered: string | null, nodes: readonly { name: string }[]): string {
|
||||
if (remembered != null && nodes.some((n) => n.name === remembered)) return remembered
|
||||
return nodes.length > 0 ? nodes[0].name : ''
|
||||
}
|
||||
|
||||
/*
|
||||
Which blocks a save carries depends on the node chosen in the selector
|
||||
(main.js:1639-1644):
|
||||
@@ -513,17 +520,20 @@ The skipped blocks are skipped whole, VALIDATIONS INCLUDED: with a node chosen,
|
||||
an empty Resolver Retries does not stop the save, because the Recursion block
|
||||
never runs. The comparison is upstream's literal `"cluster"`.
|
||||
*/
|
||||
export function nodeScope(node: string): { cluster: boolean; node: boolean } {
|
||||
export function nodeScope(node: string, whole = false): { cluster: boolean; node: boolean } {
|
||||
/* Behind a hub there is no cluster: every setting belongs to the one server chosen, so a save
|
||||
sends both blocks (`whole`), with that server as `node`. */
|
||||
if (whole) return { cluster: true, node: true }
|
||||
const includeClusterParameters = node === '' || node === 'cluster'
|
||||
const includeNodeParameters = node === '' || !includeClusterParameters
|
||||
return { cluster: includeClusterParameters, node: includeNodeParameters }
|
||||
}
|
||||
|
||||
export function buildBody(f: SettingsForm, node = ''): BodyResult {
|
||||
export function buildBody(f: SettingsForm, node = '', whole = false): BodyResult {
|
||||
// `node` is always the first parameter, even when empty (main.js:1644).
|
||||
const body: Record<string, string> = { node }
|
||||
const sanitised: Partial<SettingsForm> = {}
|
||||
const include = nodeScope(node)
|
||||
const include = nodeScope(node, whole)
|
||||
|
||||
const missing = (text: string, tab: string, field: string): BodyResult => ({
|
||||
error: { title: 'Missing!', text, tab, field },
|
||||
|
||||
@@ -249,8 +249,8 @@ export const FIELDS: Record<Key, Field> = {
|
||||
} as Record<Key, Field>
|
||||
|
||||
/** Whether a save with this form, to this node, would send the field. */
|
||||
export function sends(field: Field, f: SettingsForm, node: string): boolean {
|
||||
return nodeScope(node)[field.scope] && (field.when == null || field.when(f))
|
||||
export function sends(field: Field, f: SettingsForm, node: string, whole = false): boolean {
|
||||
return nodeScope(node, whole)[field.scope] && (field.when == null || field.when(f))
|
||||
}
|
||||
|
||||
/** The panes with something a save would change, in their tab order, and how many fields each. */
|
||||
@@ -259,10 +259,11 @@ export function pendingByPane(
|
||||
form: SettingsForm,
|
||||
node: string,
|
||||
panes: readonly SubTab[],
|
||||
whole = false,
|
||||
): { pane: SubTab; fields: number }[] {
|
||||
const count = new Map<SubTab, number>()
|
||||
for (const field of Object.values(FIELDS)) {
|
||||
if (!sends(field, form, node)) continue
|
||||
if (!sends(field, form, node, whole)) continue
|
||||
if (field.sent(form) === field.sent(saved)) continue
|
||||
count.set(field.pane, (count.get(field.pane) ?? 0) + 1)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { ClusterNodeSelect } from '../../ui/ClusterNodeSelect'
|
||||
import { useState } from 'react'
|
||||
import { useContext, useState } from 'react'
|
||||
import { HubMode } from '../../ui/ClusterNodeSelect'
|
||||
import { useHubDefaultNode, useHubWrite } from '../../lib/hub-write'
|
||||
import { HubWriteHint } from '../../ui/HubWriteHint'
|
||||
import type { ResourceRecord, ZoneDetails } from '../../api/records'
|
||||
import { Confirm } from '../../ui/Confirm'
|
||||
import { ZoneList } from './ZoneList'
|
||||
@@ -70,8 +73,8 @@ export function Zones({
|
||||
token,
|
||||
nodes = [],
|
||||
clusterInitialised = false,
|
||||
canModify,
|
||||
canDelete,
|
||||
canModify: mayModify,
|
||||
canDelete: mayDelete,
|
||||
useSoaSerialDateScheme = false,
|
||||
dnssecValidation = false,
|
||||
recordTtls = DEFAULT_TTLS,
|
||||
@@ -79,7 +82,14 @@ export function Zones({
|
||||
}: ZonesProps) {
|
||||
/* Upstream mounts a selector here (`optZonesClusterNode`) with no aggregate
|
||||
and no persistence; the parameter was already travelling. */
|
||||
const [node, setNode] = useState<string>('')
|
||||
const hub = useContext(HubMode)
|
||||
/* Behind a hub the empty node is the hub, not a server: the first one is chosen, and a change
|
||||
is allowed only on a server the hub can write to. */
|
||||
const defaultNode = useHubDefaultNode(nodes)
|
||||
const [node, setNode] = useState<string>(defaultNode)
|
||||
const write = useHubWrite(node, mayModify)
|
||||
const canModify = write.allowed
|
||||
const canDelete = useHubWrite(node, mayDelete).allowed
|
||||
const [open, setOpen] = useState<string | null>(null)
|
||||
const [notice, setNotice] = useState<Notice | null>(null)
|
||||
const [confirmation, setConfirmation] = useState<Confirmation | null>(null)
|
||||
@@ -152,6 +162,7 @@ export function Zones({
|
||||
disabled={open != null}
|
||||
label="Cluster Node"
|
||||
/>
|
||||
<HubWriteHint write={write} />
|
||||
|
||||
{open == null ? (
|
||||
<ZoneList
|
||||
@@ -170,7 +181,7 @@ export function Zones({
|
||||
onImport={(z) => openModal('import', z)}
|
||||
onConvert={(z, t) => openModal('convert', z, t)}
|
||||
onClone={(z) => openModal('clone', z)}
|
||||
onPermissions={(z) => openModal('permissions', z)}
|
||||
onPermissions={(z) => (hub ? setNotice({ type: 'info', title: 'Note!', text: "Zone permissions are changed in that server's own console." }) : openModal('permissions', z))}
|
||||
onOptions={(z) => openModal('options', z)}
|
||||
refresh={listRefresh}
|
||||
/>
|
||||
@@ -205,7 +216,7 @@ export function Zones({
|
||||
onImport={(z) => openModal('import', z)}
|
||||
onConvert={(z, t) => openModal('convert', z, t)}
|
||||
onClone={(z) => openModal('clone', z)}
|
||||
onPermissions={(z) => openModal('permissions', z)}
|
||||
onPermissions={(z) => (hub ? setNotice({ type: 'info', title: 'Note!', text: "Zone permissions are changed in that server's own console." }) : openModal('permissions', z))}
|
||||
onOptions={(z) => openModal('options', z)}
|
||||
onSign={(z) => openModal('sign', z)}
|
||||
onUnsign={(z) => openModal('unsign', z)}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { CHOOSE_A_SERVER, type HubWrite } from '../lib/hub-write'
|
||||
|
||||
/*
|
||||
The line under a server selector that says why the controls are switched off behind a hub:
|
||||
no server is chosen ("All servers"), or the hub holds no admin token for the one chosen.
|
||||
Nothing is drawn when the change is allowed, and nothing when the role is what says no (the
|
||||
controls then carry their own "Requires ..." tooltip).
|
||||
*/
|
||||
export function HubWriteHint({ write }: { write: HubWrite }) {
|
||||
if (write.reason == null) return null
|
||||
return (
|
||||
<p className="m-0 text-xs text-muted-foreground" role="status">
|
||||
{write.reason === CHOOSE_A_SERVER ? 'Choose a server to make changes. Changes are made on one server at a time.' : `${write.reason}, so this server is read only.`}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user