Hub stage 0: own users with roles, login limit, audit log, permission table

Users in HUB_DATA/users.json with scrypt hashes and roles (viewer, operator,
apps-admin, admin), managed by hub/users-cli.ts; HUB_USER/HUB_PASSWORD remain a
bootstrap admin while the file does not exist. Five failed logins in 15 minutes lock
the address or the name for 15 minutes. Logins, logouts and every refused request go
to a JSON Lines audit log that admins read through hub/audit/list. Endpoints are
listed in routes.ts with their kind and capability; writes stay refused for now.
Optional <ID>_ADMIN_TOKEN is read for the coming write stages. The management plan
is in docs/hub-management-plan.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-10-31 15:25:38 +07:00
1 parent 042cb116ea
commit 0ef2d53a65
17 files changed
+1497 -79

No files matched your search

+5 -2
View File
@@ -3,7 +3,7 @@
# See hub/README.md. Self-contained: it does not use the console image. # See hub/README.md. Self-contained: it does not use the console image.
# #
# docker run --rm -p 8080:8080 -e HUB_SERVERS=dns=https://dns.example.net \ # docker run --rm -p 8080:8080 -e HUB_SERVERS=dns=https://dns.example.net \
# -e DNS_TOKEN=... -e HUB_USER=admin -e HUB_PASSWORD=... \ # -e DNS_TOKEN=... -e HUB_USER=admin -e HUB_PASSWORD=... -v hub-data:/data \
# git.shx.one/denozord/technitium-hub:latest # git.shx.one/denozord/technitium-hub:latest
FROM node:22-alpine AS build FROM node:22-alpine AS build
WORKDIR /src WORKDIR /src
@@ -22,7 +22,10 @@ FROM node:22-alpine
WORKDIR /app WORKDIR /app
COPY --from=build /out/ ./ COPY --from=build /out/ ./
COPY --from=build /src/dist ./dist COPY --from=build /src/dist ./dist
ENV HUB_STATIC=/app/dist HUB_PORT=8080 NODE_ENV=production ENV HUB_STATIC=/app/dist HUB_PORT=8080 HUB_DATA=/data NODE_ENV=production
# The users file and the audit log live here: mount a volume on it.
RUN mkdir -p /data && chown node:node /data
VOLUME /data
USER node USER node
EXPOSE 8080 EXPOSE 8080
# No curl in alpine: node asks the hub's public endpoint itself. # No curl in alpine: node asks the hub's public endpoint itself.
+10 -1
View File
@@ -12,7 +12,7 @@ version: "3.8"
# MDNS_TOKEN=... # MDNS_TOKEN=...
# NDNS_TOKEN=... # NDNS_TOKEN=...
# HUB_USER=... # логин в сам хаб # HUB_USER=... # логин в сам хаб
# HUB_PASSWORD=... # длинный пароль: у хаба нет ограничения числа попыток входа # HUB_PASSWORD=... # первый вход; затем пользователи в /data/users.json (docs/hub-deploy.md, шаг 4a)
# #
# Обновление: docker compose pull technitium-hub && docker compose up -d technitium-hub # Обновление: docker compose pull technitium-hub && docker compose up -d technitium-hub
@@ -23,13 +23,19 @@ services:
restart: unless-stopped restart: unless-stopped
networks: networks:
- dns-net - dns-net
volumes:
- technitium-hub-data:/data
environment: environment:
# id=адрес, по порядку; токен каждого берётся из <ID>_TOKEN # id=адрес, по порядку; токен каждого берётся из <ID>_TOKEN
- HUB_SERVERS=mdns=https://mdns.shnt.top,ndns=https://ndns.shnt.top - HUB_SERVERS=mdns=https://mdns.shnt.top,ndns=https://ndns.shnt.top
- MDNS_TOKEN=${MDNS_TOKEN} - MDNS_TOKEN=${MDNS_TOKEN}
- NDNS_TOKEN=${NDNS_TOKEN} - NDNS_TOKEN=${NDNS_TOKEN}
# Только пока нет /data/users.json; потом пользователи создаются через users-cli (docs/hub-deploy.md)
- HUB_USER=${HUB_USER} - HUB_USER=${HUB_USER}
- HUB_PASSWORD=${HUB_PASSWORD} - HUB_PASSWORD=${HUB_PASSWORD}
- HUB_DATA=/data
# За Traefik: брать адрес клиента из X-Forwarded-For (для ограничения попыток входа и журнала)
- HUB_TRUST_PROXY=1
- HUB_DOMAIN=dnss.shnt.top - HUB_DOMAIN=dnss.shnt.top
- TZ=Asia/Novosibirsk - TZ=Asia/Novosibirsk
labels: labels:
@@ -42,6 +48,9 @@ services:
- "traefik.http.routers.hub.service=hub-svc" - "traefik.http.routers.hub.service=hub-svc"
- "traefik.http.services.hub-svc.loadbalancer.server.port=8080" - "traefik.http.services.hub-svc.loadbalancer.server.port=8080"
volumes:
technitium-hub-data:
networks: networks:
dns-net: dns-net:
external: true external: true
+7
View File
@@ -181,14 +181,20 @@ services:
restart: unless-stopped restart: unless-stopped
networks: networks:
- dns-net - dns-net
volumes:
- technitium-hub-data:/data
environment: environment:
# id=адрес по порядку; токен каждого из <ID>_TOKEN. Сервер на ЭТОМ же хосте можно # id=адрес по порядку; токен каждого из <ID>_TOKEN. Сервер на ЭТОМ же хосте можно
# указать изнутри сети, мимо Traefik: ndns=http://technitium-smart:5380 # указать изнутри сети, мимо Traefik: ndns=http://technitium-smart:5380
- HUB_SERVERS=mdns=https://mdns.shnt.top,ndns=https://ndns.shnt.top - HUB_SERVERS=mdns=https://mdns.shnt.top,ndns=https://ndns.shnt.top
- MDNS_TOKEN=${MDNS_TOKEN} - MDNS_TOKEN=${MDNS_TOKEN}
- NDNS_TOKEN=${NDNS_TOKEN} - NDNS_TOKEN=${NDNS_TOKEN}
# Только пока нет /data/users.json; потом пользователи создаются через users-cli (docs/hub-deploy.md)
- HUB_USER=${HUB_USER} - HUB_USER=${HUB_USER}
- HUB_PASSWORD=${HUB_PASSWORD} - HUB_PASSWORD=${HUB_PASSWORD}
- HUB_DATA=/data
# За Traefik: брать адрес клиента из X-Forwarded-For (для ограничения попыток входа и журнала)
- HUB_TRUST_PROXY=1
- HUB_DOMAIN=dnss.shnt.top - HUB_DOMAIN=dnss.shnt.top
- TZ=Asia/Novosibirsk - TZ=Asia/Novosibirsk
labels: labels:
@@ -209,3 +215,4 @@ networks:
volumes: volumes:
technitium-data: technitium-data:
technitium-console: technitium-console:
technitium-hub-data:
+50 -7
View File
@@ -4,7 +4,7 @@
- запрос «All servers» хаб отправляет на оба сервера и складывает ответы; - запрос «All servers» хаб отправляет на оба сервера и складывает ответы;
- запрос к одному серверу хаб пересылает только ему; - запрос к одному серверу хаб пересылает только ему;
- запись хаб не выполняет никогда. - запись хаб пока не выполняет: любой запрос на изменение получает отказ и попадает в журнал.
Серверы Technitium не меняются, кластер не нужен. В инструкции хаб ставится на хост, где уже работают Traefik и `technitium-smart` (`docs/compose.shnt.example.yaml`). Адрес хаба: `https://dnss.shnt.top`. Серверы Technitium не меняются, кластер не нужен. В инструкции хаб ставится на хост, где уже работают Traefik и `technitium-smart` (`docs/compose.shnt.example.yaml`). Адрес хаба: `https://dnss.shnt.top`.
@@ -91,12 +91,12 @@ git tag hub-v1.0.0 && git push origin hub-v1.0.0
MDNS_TOKEN=вставьте_токен_mdns MDNS_TOKEN=вставьте_токен_mdns
NDNS_TOKEN=вставьте_токен_ndns NDNS_TOKEN=вставьте_токен_ndns
# Вход в сам хаб (это НЕ логин Technitium) # Первый вход в хаб (это НЕ логин Technitium). Нужен, пока не создан файл пользователей (шаг 4a)
HUB_USER=admin HUB_USER=admin
HUB_PASSWORD=длинный_случайный_пароль HUB_PASSWORD=длинный_случайный_пароль
``` ```
Пароль хаба нужен длинный: у хаба нет ограничения числа попыток входа. Сгенерировать можно так: Пароль лучше длинный. После 5 неудачных попыток за 15 минут вход блокируется на 15 минут, но это не замена хорошему паролю. Сгенерировать можно так:
```bash ```bash
openssl rand -base64 24 openssl rand -base64 24
``` ```
@@ -126,8 +126,12 @@ chmod 600 .env
- NDNS_TOKEN=${NDNS_TOKEN} - NDNS_TOKEN=${NDNS_TOKEN}
- HUB_USER=${HUB_USER} - HUB_USER=${HUB_USER}
- HUB_PASSWORD=${HUB_PASSWORD} - HUB_PASSWORD=${HUB_PASSWORD}
- HUB_DATA=/data
- HUB_TRUST_PROXY=1
- HUB_DOMAIN=dnss.shnt.top - HUB_DOMAIN=dnss.shnt.top
- TZ=Asia/Novosibirsk - TZ=Asia/Novosibirsk
volumes:
- technitium-hub-data:/data
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.docker.network=dns-net" - "traefik.docker.network=dns-net"
@@ -138,6 +142,13 @@ chmod 600 .env
- "traefik.http.services.hub-svc.loadbalancer.server.port=8080" - "traefik.http.services.hub-svc.loadbalancer.server.port=8080"
``` ```
Том `technitium-hub-data` нужно объявить в разделе `volumes:` того же файла:
```yaml
volumes:
technitium-hub-data:
```
В нём лежат файл пользователей `users.json` и журнал `audit.log`. `HUB_TRUST_PROXY=1` нужен за Traefik: хаб берёт адрес клиента из заголовка `X-Forwarded-For` для ограничения попыток входа и журнала. Если до хаба можно достучаться в обход Traefik, включать эту переменную нельзя: заголовок можно подделать.
Если хаб стоит в том же файле, что Traefik, отдельный блок `networks:` с `external: true` из примера не нужен: сеть `dns-net` в этом файле уже объявлена. Если хаб стоит в том же файле, что Traefik, отдельный блок `networks:` с `external: true` из примера не нужен: сеть `dns-net` в этом файле уже объявлена.
Про имена в `HUB_SERVERS`: Про имена в `HUB_SERVERS`:
@@ -149,6 +160,32 @@ chmod 600 .env
--- ---
## Шаг 4a. Пользователи хаба
У хаба свои пользователи с ролями. Они лежат в `/data/users.json`, пароли в виде хешей.
| Роль | Что может |
|---|---|
| `viewer` | смотреть |
| `operator` | смотреть, блокировка и кеш (после появления записи) |
| `apps-admin` | смотреть, управлять приложениями (после появления записи) |
| `admin` | всё, включая журнал действий |
Пока файла нет, в хаб входит один `admin` из `HUB_USER` / `HUB_PASSWORD`. Когда файл появился, эти две переменные игнорируются (в логе будет строка об этом). Если нет ни файла, ни переменных, хаб не запустится.
После запуска (шаг 5) создайте пользователей. Пароль команда спросит дважды, при вводе он не показывается:
```bash
docker exec -it technitium-hub node --experimental-strip-types /app/hub/users-cli.ts add denis admin
docker exec -it technitium-hub node --experimental-strip-types /app/hub/users-cli.ts add alice viewer
```
Остальные команды: `passwd <имя>`, `role <имя> <роль>`, `disable <имя>`, `enable <имя>`, `list`. Хаб замечает изменение файла на следующем запросе и завершает сессии затронутых пользователей. Перезапуск не нужен.
После создания своего `admin` уберите `HUB_USER` и `HUB_PASSWORD` из `.env` и compose, затем `docker compose up -d technitium-hub`.
Журнал: войдите админом и откройте `/api/hub/audit/list?limit=200` (JSON, новые записи первыми) или смотрите файл `docker exec technitium-hub tail -n 50 /data/audit.log`. Пароли и токены в журнал не пишутся.
---
## Шаг 5. Запуск ## Шаг 5. Запуск
Если реестр закрытый, один раз войдите в него. Нужен токен Gitea с правом `read:package`: Если реестр закрытый, один раз войдите в него. Нужен токен Gitea с правом `read:package`:
@@ -218,7 +255,10 @@ docker compose rm -f technitium-hub # удалить контейнер
|---|---|---| |---|---|---|
| `HUB_SERVERS` | обязательна | Серверы по порядку: `id=https://адрес,...` | | `HUB_SERVERS` | обязательна | Серверы по порядку: `id=https://адрес,...` |
| `<ID>_TOKEN` | обязательна для каждого | API-токен сервера (`MDNS_TOKEN`, `NDNS_TOKEN`, ...) | | `<ID>_TOKEN` | обязательна для каждого | API-токен сервера (`MDNS_TOKEN`, `NDNS_TOKEN`, ...) |
| `HUB_USER`, `HUB_PASSWORD` | обязательны | Вход в хаб | | `HUB_USER`, `HUB_PASSWORD` | нужны, пока нет `users.json` | Первый `admin`; при наличии файла игнорируются |
| `HUB_DATA` | `/data` | Папка `users.json` и `audit.log` (на ней том) |
| `HUB_TRUST_PROXY` | выключено | `1`: адрес клиента из `X-Forwarded-For` (только за Traefik) |
| `<ID>_ADMIN_TOKEN` | нет | Токен записи сервера (`MDNS_ADMIN_TOKEN`). Пока не используется, в логи не пишется |
| `HUB_DOMAIN` | `hub` | Имя, которое консоль показывает как имя сервера | | `HUB_DOMAIN` | `hub` | Имя, которое консоль показывает как имя сервера |
| `HUB_CACHE_SECONDS` | `15` | Сколько секунд хранить ответы серверов (0 выключает кеш) | | `HUB_CACHE_SECONDS` | `15` | Сколько секунд хранить ответы серверов (0 выключает кеш) |
| `HUB_TIMEOUT_MS` | `8000` | Сколько ждать ответа сервера, мс | | `HUB_TIMEOUT_MS` | `8000` | Сколько ждать ответа сервера, мс |
@@ -239,7 +279,10 @@ docker compose rm -f technitium-hub # удалить контейнер
| Контейнер сразу завершается | В `docker compose logs technitium-hub` будет названа недостающая переменная. Проверьте `.env` и что compose его видит (`docker compose config \| grep TOKEN`). | | Контейнер сразу завершается | В `docker compose logs technitium-hub` будет названа недостающая переменная. Проверьте `.env` и что compose его видит (`docker compose config \| grep TOKEN`). |
| `https://dnss.shnt.top` не открывается, ошибка сертификата | Нет DNS-записи `dnss` (шаг 2) или Traefik ещё получает сертификат: смотрите `docker logs traefik`. | | `https://dnss.shnt.top` не открывается, ошибка сертификата | Нет DNS-записи `dnss` (шаг 2) или Traefik ещё получает сертификат: смотрите `docker logs traefik`. |
| 404 от Traefik | Контейнер не в сети `dns-net` или опечатка в `Host(\`dnss.shnt.top\`)`. | | 404 от Traefik | Контейнер не в сети `dns-net` или опечатка в `Host(\`dnss.shnt.top\`)`. |
| Вход в хаб: «Invalid username or password» | Неверные `HUB_USER`/`HUB_PASSWORD` в `.env`. После правки `.env` нужен `docker compose up -d technitium-hub`. | | Вход в хаб: «Invalid username or password» | Неверный логин или пароль. Если есть `users.json`, работают только его пользователи (`users-cli.ts list`), а `HUB_USER` игнорируется. После правки `.env` нужен `docker compose up -d technitium-hub`. |
| «Too many failed logins. Try again in N minutes.» | 5 неудач за 15 минут с одного адреса или на одно имя. Подождите; блокировка хранится в памяти, её снимает перезапуск контейнера. |
| Все пользователи блокируются разом | Не включён `HUB_TRUST_PROXY=1`: хаб видит только адрес Traefik. |
| Контейнер пишет `audit log is not writable` | Нет тома на `/data` или папка не принадлежит `node`. Журнал живёт в памяти (последние 1000 записей) до исправления. |
| В шапке «1 server not answering», в подсказке `ndns: its API token was refused` | Токен `NDNS_TOKEN` отозван или скопирован с ошибкой. Создайте новый (шаг 1.4) и перезапустите хаб. | | В шапке «1 server not answering», в подсказке `ndns: its API token was refused` | Токен `NDNS_TOKEN` отозван или скопирован с ошибкой. Создайте новый (шаг 1.4) и перезапустите хаб. |
| В подсказке `... did not answer: ...` или `ECONNREFUSED` / `timeout` | Хаб не достучался до сервера. Проверьте с хоста хаба: `docker exec technitium-hub node -e "fetch('https://ndns.shnt.top/api/status').then(r=>console.log(r.status))"`. | | В подсказке `... did not answer: ...` или `ECONNREFUSED` / `timeout` | Хаб не достучался до сервера. Проверьте с хоста хаба: `docker exec technitium-hub node -e "fetch('https://ndns.shnt.top/api/status').then(r=>console.log(r.status))"`. |
| В подсказке `ndns: ...` с сообщением об отказе в доступе | У группы `hub-readers` на этом сервере не хватает View в каком-то разделе (шаг 1.2). | | В подсказке `ndns: ...` с сообщением об отказе в доступе | У группы `hub-readers` на этом сервере не хватает View в каком-то разделе (шаг 1.2). |
@@ -249,6 +292,6 @@ docker compose rm -f technitium-hub # удалить контейнер
## Безопасность ## Безопасность
- Хаб только читает: любая запись получает отказ ещё в хабе, а токены выданы пользователю с одними правами View. - Хаб только читает: любая запись получает отказ ещё в хабе (и запись в журнале), а токены выданы пользователю с одними правами View.
- Токены лежат только в `.env` на хосте и в памяти контейнера. В браузер они не попадают, в ответы и логи хаба не пишутся. - Токены лежат только в `.env` на хосте и в памяти контейнера. В браузер они не попадают, в ответы и логи хаба не пишутся.
- У хаба один общий логин и нет ограничения попыток входа. Нужен длинный пароль, а если хаб не должен быть виден из интернета, добавьте к роутеру `hub` в Traefik `ipallowlist` или `basicAuth`. - У каждого человека свой логин и роль. Вход защищён блокировкой после 5 неудач. Нужны длинные пароли, а если хаб не должен быть виден из интернета, добавьте к роутеру `hub` в Traefik `ipallowlist` или `basicAuth`.
+181
View File
@@ -0,0 +1,181 @@
# Хаб: централизованное управление серверами Technitium без кластера
Общий план. Внутри два пласта:
- **общее управление**: каждый сервер из одной консоли, действия «на все серверы», сравнение и выравнивание;
- **упор на Apps**: приложения на всех серверах и синхронизация их конфигов.
Статус: план, ничего из этого ещё не сделано. Сейчас хаб только читает (`docs/hub-deploy.md`).
---
## 1. Контекст и цель
- Есть два независимых сервера Technitium DNS: `mdns.shnt.top` и `ndns.shnt.top`. Кластера нет и не будет: серверы должны оставаться самостоятельными.
- Хаб на `dnss.shnt.top` уже показывает статистику обоих: Dashboard, Logs, About, переключатель «All servers / mdns / ndns», предупреждение об отказе сервера.
- **Цель:** управлять обоими серверами из хаба. Прежде всего приложениями (Apps): ставить, обновлять и держать их конфиги одинаковыми там, где нужно, не трогая того, что у каждого сервера своё.
---
## 2. Принципы
1. **Два режима записи.**
- **На один сервер:** действие пересылается выбранному серверу (`node=mdns` или `node=ndns`). Так уже работает чтение.
- **На все серверы:** хаб выполняет действие на каждом выбранном сервере и показывает итог по каждому отдельно. Тихого «ок» при частичном успехе не бывает.
2. **Чтение и запись разделены.**
- Токены `hub-reader` (только View) остаются для чтения.
- Для записи на каждом сервере заводится отдельный `hub-admin` только с теми правами, которые нужны этапу.
3. **Хаб защищается до того, как получает запись.** Роли, ограничение попыток входа, журнал действий (этап 0).
4. **Конфиги синхронизируются, данные нет.**
- Синхронизируются тексты конфигов приложений и настройки.
- Базы логов, кеш и статистика у каждого сервера свои.
5. **Общая часть плюс локальные поля.**
- В конфигах приложений есть поля, свои для каждого сервера: сети Split Horizon, путь к базе Query Logs, локальные группы клиентов Advanced Blocking.
- Синхронизируется только общая часть. Локальные поля задаются по каждому серверу.
6. **Источник правды для конфигов: эталон в хабе.**
- Хаб хранит желаемый конфиг в своём томе (`/data`), серверы приводятся к нему.
- Первый эталон берётся импортом с любого сервера.
- Альтернатива: ведущий сервер (mdns) как эталон. Она проще, но хуже: зависит от живучести одного сервера и не даёт истории.
7. **Зоны DNS лучше синхронизировать средствами Technitium.** Primary-зона на одном сервере и secondary с передачей зоны (AXFR) на другом надёжнее рассылки каждой правки из хаба. Хаб помогает это настроить и показывает расхождения, но не дублирует записи сам.
8. **Перед изменением предпросмотр, после него итог и откат.** Каждое применение конфига сохраняет прежнюю версию.
---
## 3. Этапы
Сначала безопасность, потом Apps (главный упор), потом общее управление.
### Этап 0. Безопасность записи (обязателен первым)
- **Пользователи хаба с ролями**, в файле `/data/users.json`, пароли в виде хешей:
- `viewer`: только смотрит;
- `apps-admin`: управляет приложениями;
- `operator`: блокировка и кеш;
- `admin`: всё.
- **Защита входа:**
- ограничение попыток входа и временная блокировка после серии неудач;
- по желанию: вход через ваш auth-портал (Traefik `forwardAuth`).
- **Журнал действий** (`/data/audit.log`, раздел Audit в консоли хаба): кто, когда, на каком сервере, что сделал, итог.
- **Токены записи** `MDNS_ADMIN_TOKEN` и `NDNS_ADMIN_TOKEN`. На старте права только Modify и Delete на Apps, другие разделы добавляются по этапам.
- **Права в хабе:** вместо списка «только чтение»: таблица «эндпоинт → чтение / запись на один сервер / запись на все / нужная роль».
- **Доступ к хабу по сети:** закрыть по IP (`ipallowlist` в Traefik) или через auth-портал.
### Этап 1. Apps: обзор по серверам (только чтение)
Матрица «приложение × сервер» в разделе Apps при «All servers»:
- установлено или нет, версия, есть ли обновление;
- конфиг: «совпадает», «отличается (N полей)» или «отличается только в локальных полях»;
- фильтры: «отличаются версии», «отличаются конфиги», «есть обновление».
Отвечает на вопрос «что где стоит и где расхождения» без единой записи.
### Этап 2. Apps: установка, обновление, удаление на нескольких серверах
- Выбор цели у действий: mdns, ndns или все серверы.
- **Из магазина:** `apps/downloadAndInstall` и `apps/downloadAndUpdate` на каждом выбранном сервере.
- **Свой zip:** загружается в хаб один раз, хаб отправляет его через `apps/install` или `apps/update` на каждый сервер.
- Окно итога по серверам и кнопка «Повторить на неудавшихся».
- **«Выровнять версии»:** обновить приложение везде до самой новой версии.
- **Удаление на всех:** отдельное подтверждение. Хаб перед удалением сохраняет конфиг в историю.
### Этап 3. Apps: сравнение и синхронизация конфигов
- **Сравнение рядом:** конфиг на mdns и на ndns. Для JSON: по полям (добавлено, удалено, изменено), для не-JSON: построчно.
- **Эталон в хабе:**
- импорт эталона с выбранного сервера;
- правка эталона в редакторе хаба;
- хранение в `/data/apps/<приложение>/baseline.json`.
- **Локальные поля:**
- список путей полей, которые не синхронизируются (например, `networks`, `dbPath`), плюс их значения по каждому серверу;
- хранение в `/data/apps/<приложение>/overrides.json`;
- итоговый конфиг сервера = эталон + его локальные значения.
- **Предпросмотр:** что изменится на каждом сервере, затем применение через `apps/config/set` на выбранных серверах с итогом по каждому.
- **Проверка до записи:** JSON разбирается в хабе. Если сервер отверг конфиг, показывается его ошибка, а сервер остаётся со старым конфигом.
### Этап 4. Apps: история, откат, контроль расхождений
- **История:** каждое применение сохраняет прежний конфиг каждого сервера (`/data/apps/<приложение>/history/`). Можно посмотреть любую версию и откатить один сервер или все.
- **Проверка расхождений по расписанию** (по умолчанию раз в 15 минут). Если конфиг поменяли прямо в консоли сервера, в шапке появляется предупреждение с действиями «Принять как эталон» и «Вернуть к эталону».
- **Экспорт и импорт всех эталонов одним файлом:** резервная копия и подготовка нового сервера.
### Этап 5. Управление каждым сервером из одной консоли
- Запись на выбранный сервер открывается для разделов Zones, Cache, Blocking, DNS Client, Settings, DHCP, Logs (кроме удаления логов). Права токена `hub-admin` расширяются под эти разделы.
- В «All servers» запись по-прежнему закрыта: кнопки неактивны с подсказкой «выберите сервер».
- Администрирование пользователей, SSO и LDAP серверов остаётся в их собственных консолях.
### Этап 6. Действия «на все серверы» вне Apps
- **Блокировка:** добавить или удалить домен в Blocked и Allowed, временно отключить блокировку, обновить списки, изменить URL списков.
- **Кеш:** сбросить весь кеш или удалить домен.
- **Итог по каждому серверу** и повтор на неудавшихся.
### Этап 7. Сравнение и выравнивание остального
- **Zones в «All servers»:**
- объединённый список с отметкой, где зона есть, и признаком расхождения (разный SOA serial);
- действия «Скопировать зону на другой сервер» (экспорт и импорт) и «Сделать secondary на другом сервере» (передача зоны).
- **Blocking и Settings:** отчёт о различиях и «Принять как эталон» с предпросмотром, по той же схеме «общая часть плюс локальные поля», что и для Apps.
### Этап 8. Удобство (по желанию)
- Профили: набор приложений с конфигами и правил, который применяется к новому серверу.
- Уведомления о расхождениях наружу (webhook).
---
## 4. Что меняется в коде
**Хаб (`hub/`):**
- `server.ts`:
- таблица прав по эндпоинтам;
- пересылка POST и multipart (zip приложений, импорт зон);
- рассылка «на все» с итогами по серверам.
- Новые модули:
- `users.ts`: роли, хеши паролей, ограничение попыток входа;
- `audit.ts`: журнал действий;
- `store.ts`: файлы в `/data` (эталоны, локальные поля, история), запись атомарно через временный файл;
- `apps-sync.ts`: сравнение конфигов, слияние эталона с локальными полями, предпросмотр, применение;
- `drift.ts`: проверка расхождений по расписанию.
- `config.ts`: `<ID>_ADMIN_TOKEN`, `HUB_DATA` (по умолчанию `/data`), `HUB_DRIFT_MINUTES`.
**Консоль (`src/`), только в режиме хаба:**
- права разделов приходят от хаба по роли пользователя;
- Apps: матрица по серверам, выбор цели у действий, окно итогов;
- экран «Конфиг приложения»: сравнение рядом, эталон, локальные поля, предпросмотр, история и откат;
- предупреждение о расхождениях в шапке;
- раздел Audit.
**Развёртывание:**
- в compose: том `technitium-hub-data:/data` и токены `MDNS_ADMIN_TOKEN`, `NDNS_ADMIN_TOKEN` в `.env`;
- на каждом сервере пользователь `hub-admin` в отдельной группе с правами записи только на нужные разделы.
---
## 5. Риски и как их закрыть
| Риск | Что делаем |
|---|---|
| Хаб получает ключи на запись к обоим серверам | Этап 0 первым; токены только на нужные разделы; хаб закрыт по IP или через auth-портал |
| Частичный успех при рассылке | Итог по каждому серверу, повтор на неудавшихся, откат из истории |
| Разные версии приложения ждут разный формат конфига | Хаб показывает версии и предлагает сначала выровнять их |
| Неверно заданные локальные поля перенесут настройки одного сервера на другой | Предпросмотр перед каждым применением, история и откат |
| Конфиг поменяли мимо хаба | Проверка расхождений по расписанию и предупреждение |
| Разные версии Technitium понимают API по-разному | Версия каждого сервера в хабе, предупреждение при расхождении |
---
## 6. Как проверяется каждый этап
- **Модульные тесты хаба** (vitest): права по эндпоинтам, роли и защита входа, слияние эталона с локальными полями, сравнение JSON, история и откат.
- **Тест с двумя фейковыми серверами:** рассылка, частичный отказ, итоги.
- **Мок** (`dev/mock-api.mjs`) получает приложения и конфиги, разные на двух портах. В браузере проверяются матрица, сравнение, применение и откат.
- **Порядок работы:** код по этапам пишет Sonnet, Opus проверяет дифф, тесты и браузер, коммит после каждого принятого этапа.
- **На серверах:** сначала на одном приложении с безобидным конфигом, потом шире.
---
## 7. Решения перед стартом
1. **Источник правды для конфигов:** эталон в хабе (рекомендация) или ведущий сервер mdns.
2. **Вход в хаб:** свои пользователи хаба с ролями или ваш auth-портал через Traefik `forwardAuth`.
3. **Какие приложения стоят на серверах** и какие поля в их конфигах заведомо локальные. Это станет начальными списками локальных полей.
+54 -6
View File
@@ -8,7 +8,7 @@ read-only, Technitium-compatible `/api/*`:
- No `node` (or `node=cluster`) asks every server in parallel and merges the answers - 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 (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`. answer, the others are shown and the response carries `hubWarnings`.
- Anything that would change data is refused with `403`. The hub has its own sign-in. - Anything 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 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 for a user whose group has View, and nothing else, on Dashboard, Logs, Apps (the logging
@@ -20,22 +20,70 @@ apps Query Logs reads from) and Settings (the blocking state).
|---|---|---| |---|---|---|
| `HUB_SERVERS` | required | Ordered `id=url` list, e.g. `dns=https://dns.example.net,ndns=https://ndns.example.net`. The first server is shown as the Primary node. | | `HUB_SERVERS` | required | Ordered `id=url` list, e.g. `dns=https://dns.example.net,ndns=https://ndns.example.net`. The first server is shown as the Primary node. |
| `<ID>_TOKEN` | required | API token of each server; the id is uppercased with non-alphanumerics as `_` (`dns` gives `DNS_TOKEN`, `my-dns` gives `MY_DNS_TOKEN`). | | `<ID>_TOKEN` | required | API token of each server; the id is uppercased with non-alphanumerics as `_` (`dns` gives `DNS_TOKEN`, `my-dns` gives `MY_DNS_TOKEN`). |
| `HUB_USER`, `HUB_PASSWORD` | required | The hub's own sign-in. It refuses to start without them. | | `HUB_USER`, `HUB_PASSWORD` | see Users | Bootstrap: one `admin`, used only while `${HUB_DATA}/users.json` does not exist. Set both or neither. |
| `HUB_DATA` | `/data` | Folder of `users.json` and `audit.log`. Mount a volume on it. |
| `HUB_TRUST_PROXY` | off | `1` believes the first `X-Forwarded-For` entry as the client address (set it behind Traefik). Leave it off when clients reach the hub directly, or anyone can fake their address. |
| `<ID>_ADMIN_TOKEN` | optional | A write-capable token per server (`MDNS_ADMIN_TOKEN`). Read into the config for the coming write stages; not used yet and never logged. |
| `HUB_PORT` | `8080` | Listening port. | | `HUB_PORT` | `8080` | Listening port. |
| `HUB_CACHE_SECONDS` | `15` | How long successful answers of the servers are cached. | | `HUB_CACHE_SECONDS` | `15` | How long successful answers of the servers are cached. |
| `HUB_TIMEOUT_MS` | `8000` | Per-request timeout towards a server. | | `HUB_TIMEOUT_MS` | `8000` | Per-request timeout towards a server. |
| `HUB_DOMAIN` | `hub` | Shown as the server's domain in the console. | | `HUB_DOMAIN` | `hub` | Shown as the server's domain in the console. |
| `HUB_STATIC` | `./dist` | The console's build to serve. | | `HUB_STATIC` | `./dist` | The console's build to serve. |
## Users and roles
The hub has its own accounts in `${HUB_DATA}/users.json` (`{ "users": [{ "name", "role", "hash", "disabled"? }] }`,
hash `scrypt$N$r$p$salt$key`). Names are compared case-insensitively.
- If the file exists, it is the only source; `HUB_USER` / `HUB_PASSWORD` are ignored (a line says so at start).
- If it does not exist and `HUB_USER` / `HUB_PASSWORD` are set, that one user signs in as `admin`. The file is not written.
- If neither exists, the hub refuses to start.
Manage the file with the CLI (`npm run hub:user -- <command>` locally):
```sh
docker exec -it technitium-hub node --experimental-strip-types /app/hub/users-cli.ts add alice apps-admin
```
`add <name> <role>`, `passwd <name>`, `role <name> <role>`, `disable <name>`, `enable <name>`, `list`. The password is asked
twice without echo; for scripts use `HUB_NEW_PASSWORD=... users-cli.ts add ...` (minimum 8 characters). The file is written
atomically with mode 0600. The running hub notices a change on the next request and ends the sessions of the users it touched
(disabled, removed, new role or password).
| Role | Capabilities |
|---|---|
| `viewer` | read |
| `operator` | read, operate (blocking, cache) |
| `apps-admin` | read, apps.write |
| `admin` | everything, including audit.read |
Only reads exist today; the write capabilities are the machinery for the next stages. Every response also carries
`hubRole` (and `info.hubRole`).
## Login protection
After 5 failed logins within 15 minutes from one client address, or for one user name, further attempts are refused for 15
minutes with HTTP 429 and `Too many failed logins. Try again in N minutes.` (the console shows that message). A good login
resets the user's counter. The state is in memory.
## Audit log
`${HUB_DATA}/audit.log` is JSON Lines: `{ts, user, role, ip, action, server?, target?, outcome, detail?}`. It records login
(ok, failed, locked), logout, and every refused request outside the permission table as `refused-write` with its api path.
Passwords, tokens and request bodies are never written. At 10 MB it rotates to `audit.log.1` (one kept). If the folder is not
writable the hub says so once on stderr and keeps the last 1000 entries in memory. Admins read it with
`GET /api/hub/audit/list?limit=200&before=<ISO time>`, newest first.
## Endpoints ## Endpoints
Public: `status`, `user/login`. After signing in: `user/session/get`, `user/logout`, Public: `status`, `user/login`. After signing in: `user/session/get`, `user/logout`,
`user/checkForUpdate` (always "no update") and these reads: `user/checkForUpdate` (always "no update") and these reads:
`dashboard/stats/get`, `dashboard/stats/getTop`, `dashboard/metrics/json`, `apps/list`, `dashboard/stats/get`, `dashboard/stats/getTop`, `dashboard/metrics/json`, `apps/list`,
`logs/query`, `logs/list`, `settings/get`, `admin/cluster/state` (synthesized). `logs/query`, `logs/list`, `settings/get`, `admin/cluster/state` (synthesized), and the hub's own `hub/audit/list` (role with
`audit.read`). They are listed in `routes.ts` with a kind (`read`, `write-one`, `write-all`) and the capability they need.
Everything else answers `403 The hub is read-only.` Everything else answers `403 The hub is read-only.` and is audited.
## Run locally ## Run locally
@@ -44,7 +92,7 @@ npm run build
node dev/mock-api.mjs --port 5380 & node dev/mock-api.mjs --port 5380 &
node dev/mock-api.mjs --port 5381 --scale 2 & node dev/mock-api.mjs --port 5381 --scale 2 &
HUB_SERVERS=a=http://127.0.0.1:5380,b=http://127.0.0.1:5381 A_TOKEN=x B_TOKEN=y \ HUB_SERVERS=a=http://127.0.0.1:5380,b=http://127.0.0.1:5381 A_TOKEN=x B_TOKEN=y \
HUB_USER=admin HUB_PASSWORD=admin HUB_PORT=5390 npm run hub HUB_USER=admin HUB_PASSWORD=admin HUB_DATA=./hub-data HUB_PORT=5390 npm run hub
``` ```
Then open http://localhost:5390. Tests: `npx vitest run hub`. Then open http://localhost:5390. Tests: `npx vitest run hub`.
@@ -54,7 +102,7 @@ Then open http://localhost:5390. Tests: `npx vitest run hub`.
```sh ```sh
docker run --rm -p 8080:8080 \ docker run --rm -p 8080:8080 \
-e HUB_SERVERS=dns=https://dns.example.net,ndns=https://ndns.example.net \ -e HUB_SERVERS=dns=https://dns.example.net,ndns=https://ndns.example.net \
-e DNS_TOKEN=... -e NDNS_TOKEN=... -e HUB_USER=admin -e HUB_PASSWORD=... \ -e DNS_TOKEN=... -e NDNS_TOKEN=... -e HUB_USER=admin -e HUB_PASSWORD=... -v hub-data:/data \
git.shx.one/denozord/technitium-hub:latest git.shx.one/denozord/technitium-hub:latest
``` ```
+125
View File
@@ -0,0 +1,125 @@
/*
The audit log: JSON Lines in `${HUB_DATA}/audit.log`, one entry per line, newest last on
disk and newest first from `list`. Rotated at 10 MB to audit.log.1 (one kept). If the
folder is not writable, one line goes to stderr and the log lives in memory (last 1000).
Entries carry fixed fields only. Callers never pass passwords, tokens or request bodies.
*/
import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync } from 'node:fs'
import path from 'node:path'
export const MAX_BYTES = 10 * 1024 * 1024
const RING = 1000
const DETAIL_MAX = 300
export interface AuditEntry {
ts: string
user: string
role?: string
ip: string
action: string
server?: string
target?: string
outcome: 'ok' | 'failed' | 'refused'
detail?: string
}
export type AuditInput = Omit<AuditEntry, 'ts'>
const cut = (value: string, max: number): string => value.slice(0, max)
export class Audit {
#file: string
#maxBytes: number
#now: () => number
#ring: AuditEntry[] = []
#size = 0
#memoryOnly = false
constructor(dir: string, options: { maxBytes?: number; now?: () => number } = {}) {
this.#maxBytes = options.maxBytes ?? MAX_BYTES
this.#now = options.now ?? Date.now
this.#file = path.join(dir, 'audit.log')
try {
mkdirSync(dir, { recursive: true })
this.#size = existsSync(this.#file) ? statSync(this.#file).size : 0
appendFileSync(this.#file, '')
} catch (e) {
this.#fallBack(e)
}
}
get inMemory(): boolean {
return this.#memoryOnly
}
#fallBack(e: unknown): void {
if (!this.#memoryOnly) {
console.error(
`hub: audit log is not writable (${e instanceof Error ? e.message : String(e)}); keeping the last ${RING} entries in memory.`,
)
}
this.#memoryOnly = true
}
log(input: AuditInput): void {
const entry: AuditEntry = {
ts: new Date(this.#now()).toISOString(),
user: cut(input.user, 64),
...(input.role === undefined ? {} : { role: input.role }),
ip: cut(input.ip, 64),
action: input.action,
...(input.server === undefined ? {} : { server: input.server }),
...(input.target === undefined ? {} : { target: cut(input.target, 200) }),
outcome: input.outcome,
...(input.detail === undefined ? {} : { detail: cut(input.detail, DETAIL_MAX) }),
}
this.#ring.push(entry)
if (this.#ring.length > RING) this.#ring.shift()
if (this.#memoryOnly) return
const line = JSON.stringify(entry) + '\n'
try {
if (this.#size + Buffer.byteLength(line) > this.#maxBytes) this.#rotate()
appendFileSync(this.#file, line)
this.#size += Buffer.byteLength(line)
} catch (e) {
this.#fallBack(e)
}
}
#rotate(): void {
rmSync(`${this.#file}.1`, { force: true })
renameSync(this.#file, `${this.#file}.1`)
this.#size = 0
}
#read(file: string): AuditEntry[] {
if (!existsSync(file)) return []
const out: AuditEntry[] = []
for (const line of readFileSync(file, 'utf8').split('\n')) {
if (line === '') continue
try {
out.push(JSON.parse(line) as AuditEntry)
} catch {
// A torn line is skipped.
}
}
return out
}
/** Newest first. `before` is an ISO time or epoch milliseconds: only older entries are returned. */
list(limit = 200, before?: string): AuditEntry[] {
const max = Math.min(Math.max(Math.trunc(limit) || 200, 1), 1000)
let cutoff = Infinity
if (before !== undefined && before !== '') {
const t = /^\d+$/.test(before) ? Number(before) : Date.parse(before)
if (Number.isFinite(t)) cutoff = t
}
const all = this.#memoryOnly ? this.#ring : [...this.#read(`${this.#file}.1`), ...this.#read(this.#file)]
const result: AuditEntry[] = []
for (let i = all.length - 1; i >= 0 && result.length < max; i--) {
if (Date.parse(all[i].ts) < cutoff) result.push(all[i])
}
return result
}
}
+26 -6
View File
@@ -9,12 +9,24 @@ export interface HubServer {
id: string id: string
url: string url: string
token: string token: string
/** Optional write-capable token (`<ID>_ADMIN_TOKEN`). Not used yet; never logged. */
adminToken?: string
}
/** The single admin from HUB_USER and HUB_PASSWORD, used only while there is no users file. */
export interface Bootstrap {
user: string
password: string
} }
export interface HubConfig { export interface HubConfig {
servers: HubServer[] servers: HubServer[]
user: string /** From HUB_USER and HUB_PASSWORD; null when they are not set. Whether it is used depends on the users file. */
password: string bootstrap: Bootstrap | null
/** Where users.json and audit.log live (HUB_DATA). */
dataDir: string
/** Believe the first X-Forwarded-For entry as the client address (HUB_TRUST_PROXY=1). */
trustProxy: boolean
port: number port: number
cacheSeconds: number cacheSeconds: number
timeoutMs: number timeoutMs: number
@@ -31,6 +43,11 @@ export function tokenVariable(id: string): string {
return `${id.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_TOKEN` return `${id.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_TOKEN`
} }
/* `ndns` -> `NDNS_ADMIN_TOKEN`. */
export function adminTokenVariable(id: string): string {
return `${id.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_ADMIN_TOKEN`
}
function number(env: Env, name: string, fallback: number, min: number): number { function number(env: Env, name: string, fallback: number, min: number): number {
const raw = env[name] const raw = env[name]
if (raw === undefined || raw.trim() === '') return fallback if (raw === undefined || raw.trim() === '') return fallback
@@ -40,9 +57,10 @@ function number(env: Env, name: string, fallback: number, min: number): number {
} }
export function parseConfig(env: Env): HubConfig { export function parseConfig(env: Env): HubConfig {
// The users file decides whether these are needed; the config does not read the file.
const user = env.HUB_USER ?? '' const user = env.HUB_USER ?? ''
const password = env.HUB_PASSWORD ?? '' const password = env.HUB_PASSWORD ?? ''
if (user === '' || password === '') throw new Error('HUB_USER and HUB_PASSWORD are required.') if ((user === '') !== (password === '')) throw new Error('HUB_USER and HUB_PASSWORD must be set together.')
const servers: HubServer[] = [] const servers: HubServer[] = []
for (const part of (env.HUB_SERVERS ?? '').split(',')) { for (const part of (env.HUB_SERVERS ?? '').split(',')) {
@@ -58,7 +76,8 @@ export function parseConfig(env: Env): HubConfig {
const variable = tokenVariable(id) const variable = tokenVariable(id)
const token = env[variable] ?? '' const token = env[variable] ?? ''
if (token === '') throw new Error(`${variable} is required for the server "${id}".`) if (token === '') throw new Error(`${variable} is required for the server "${id}".`)
servers.push({ id, url, token }) const adminToken = env[adminTokenVariable(id)] ?? ''
servers.push(adminToken === '' ? { id, url, token } : { id, url, token, adminToken })
} }
if (servers.length === 0) { if (servers.length === 0) {
throw new Error('HUB_SERVERS is required, e.g. dns=https://dns.example.net,ndns=https://ndns.example.net.') throw new Error('HUB_SERVERS is required, e.g. dns=https://dns.example.net,ndns=https://ndns.example.net.')
@@ -66,8 +85,9 @@ export function parseConfig(env: Env): HubConfig {
return { return {
servers, servers,
user, bootstrap: user === '' ? null : { user, password },
password, dataDir: env.HUB_DATA?.trim() || '/data',
trustProxy: ['1', 'true'].includes((env.HUB_TRUST_PROXY ?? '').trim().toLowerCase()),
port: number(env, 'HUB_PORT', 8080, 0), port: number(env, 'HUB_PORT', 8080, 0),
cacheSeconds: number(env, 'HUB_CACHE_SECONDS', 15, 0), cacheSeconds: number(env, 'HUB_CACHE_SECONDS', 15, 0),
timeoutMs: number(env, 'HUB_TIMEOUT_MS', 8000, 1), timeoutMs: number(env, 'HUB_TIMEOUT_MS', 8000, 1),
+71
View File
@@ -0,0 +1,71 @@
/*
Login rate limiting. Failures are counted per client address and per user name: after
MAX_FAILURES within WINDOW_MS, further attempts are refused for LOCK_MS. A success clears
the user's counter. In memory; `sweep` drops what has expired.
*/
export const MAX_FAILURES = 5
export const WINDOW_MS = 15 * 60_000
export const LOCK_MS = 15 * 60_000
interface Entry {
failures: number[]
lockedUntil: number
}
const userKey = (user: string): string => user.trim().toLowerCase().slice(0, 128)
export class LoginLimiter {
#now: () => number
#ips = new Map<string, Entry>()
#users = new Map<string, Entry>()
constructor(now: () => number = Date.now) {
this.#now = now
}
/** Milliseconds left of a lock on this address or this user, or 0 when attempts are allowed. */
lockedFor(ip: string, user: string): number {
const now = this.#now()
const until = Math.max(this.#ips.get(ip)?.lockedUntil ?? 0, this.#users.get(userKey(user))?.lockedUntil ?? 0)
return until > now ? until - now : 0
}
failure(ip: string, user: string): void {
this.#record(this.#ips, ip)
this.#record(this.#users, userKey(user))
this.sweep()
}
/** A good login clears the user's counter (the address keeps its own). */
success(user: string): void {
this.#users.delete(userKey(user))
}
#record(map: Map<string, Entry>, key: string): void {
const now = this.#now()
const entry = map.get(key) ?? { failures: [], lockedUntil: 0 }
entry.failures = entry.failures.filter((t) => now - t < WINDOW_MS)
entry.failures.push(now)
if (entry.failures.length >= MAX_FAILURES) {
entry.lockedUntil = now + LOCK_MS
entry.failures = []
}
map.set(key, entry)
}
sweep(): void {
const now = this.#now()
for (const map of [this.#ips, this.#users]) {
for (const [key, entry] of map) {
const recent = entry.failures.some((t) => now - t < WINDOW_MS)
if (entry.lockedUntil <= now && !recent) map.delete(key)
}
}
}
}
/** Technitium-like message for a locked attempt. */
export const lockedMessage = (ms: number): string => {
const minutes = Math.max(1, Math.ceil(ms / 60_000))
return `Too many failed logins. Try again in ${minutes} minute${minutes === 1 ? '' : 's'}.`
}
+38
View File
@@ -0,0 +1,38 @@
/*
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. Today every entry is a read.
Not in the table: `status`, `user/login`, `user/session/get` and `user/logout`, which the
server answers itself before the table is consulted.
*/
import type { Capability } from './users.ts'
export type RouteKind = 'read' | 'write-one' | 'write-all'
export interface Route {
kind: RouteKind
capability: Capability
}
const read = (capability: Capability = 'read'): Route => ({ kind: 'read', capability })
export const ROUTES: Record<string, Route> = {
'dashboard/stats/get': read(),
'dashboard/stats/getTop': read(),
'dashboard/metrics/json': read(),
'apps/list': read(),
'logs/query': read(),
'logs/list': read(),
'settings/get': read(),
'user/checkForUpdate': read(),
'admin/cluster/state': read(),
// The hub's own namespace; never forwarded to a server.
'hub/audit/list': read('audit.read'),
}
export function routeFor(apiPath: string): Route | undefined {
return Object.hasOwn(ROUTES, apiPath) ? ROUTES[apiPath] : undefined
}
export const isHubPath = (apiPath: string): boolean => apiPath.startsWith('hub/')
+376
View File
@@ -0,0 +1,376 @@
// @vitest-environment node
import http from 'node:http'
import type { AddressInfo } from 'node:net'
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { parseConfig } from './config.ts'
import { createHub } from './server.ts'
import { Audit } from './audit.ts'
import { LoginLimiter, LOCK_MS, lockedMessage } from './ratelimit.ts'
import { ROUTES, routeFor } from './routes.ts'
import { run } from './users-cli.ts'
import {
Users,
addUser,
can,
hashPassword,
parseUsersFile,
readUsersFile,
requires,
setDisabled,
setPassword,
setRole,
verifyPassword,
writeUsersFile,
type Role,
} from './users.ts'
let dir: string
let servers: http.Server[] = []
beforeEach(() => {
dir = mkdtempSync(path.join(tmpdir(), 'hub-sec-'))
})
afterEach(() => {
for (const s of servers) s.close()
servers = []
rmSync(dir, { recursive: true, force: true })
})
const env = (over: Record<string, string> = {}) => ({
HUB_SERVERS: 'a=http://127.0.0.1:1',
A_TOKEN: 'token-a',
A_ADMIN_TOKEN: 'admin-secret-a',
HUB_DATA: dir,
HUB_STATIC: dir,
...over,
})
async function start(over: Record<string, string> = {}): Promise<string> {
const hub = createHub(parseConfig(env(over)))
servers.push(hub)
await new Promise<void>((resolve) => hub.listen(0, '127.0.0.1', resolve))
return `http://127.0.0.1:${(hub.address() as AddressInfo).port}`
}
async function call(base: string, apiPath: string, params: Record<string, string> = {}, token?: string, headers: Record<string, string> = {}) {
const headerSet: Record<string, string> = { ...headers }
if (token) headerSet.authorization = `Bearer ${token}`
const isLogin = apiPath === 'user/login'
const res = await fetch(`${base}/api/${apiPath}${isLogin ? '' : `?${new URLSearchParams(params)}`}`, {
method: isLogin ? 'POST' : 'GET',
headers: isLogin ? { ...headerSet, 'content-type': 'application/x-www-form-urlencoded' } : headerSet,
body: isLogin ? new URLSearchParams(params).toString() : undefined,
})
return { code: res.status, body: (await res.json()) as Record<string, any> }
}
const login = (base: string, user: string, pass: string, headers: Record<string, string> = {}) =>
call(base, 'user/login', { user, pass }, undefined, headers)
function seed(...rows: [string, Role, string][]): void {
let users = readUsersFile(dir) ?? []
for (const [name, role, pass] of rows) users = addUser(users, name, role, pass)
writeUsersFile(dir, users)
}
describe('roles and capabilities', () => {
it('ranks the roles and answers capabilities', () => {
expect(requires('admin', 'operator')).toBe(true)
expect(requires('viewer', 'operator')).toBe(false)
expect(requires('apps-admin', 'operator')).toBe(true)
expect(can('viewer', 'read')).toBe(true)
expect(can('viewer', 'operate')).toBe(false)
expect(can('operator', 'operate')).toBe(true)
expect(can('operator', 'apps.write')).toBe(false)
expect(can('apps-admin', 'apps.write')).toBe(true)
expect(can('apps-admin', 'audit.read')).toBe(false)
for (const c of ['read', 'apps.write', 'operate', 'audit.read', 'admin'] as const) expect(can('admin', c)).toBe(true)
})
})
describe('passwords and the users file', () => {
it('hashes with scrypt and verifies', async () => {
const hash = hashPassword('correct horse')
expect(hash).toMatch(/^scrypt\$16384\$8\$1\$[A-Za-z0-9+/=]+\$[A-Za-z0-9+/=]+$/)
expect(hash.split('$')[5].length).toBeGreaterThan(80)
expect(await verifyPassword('correct horse', hash)).toBe(true)
expect(await verifyPassword('wrong', hash)).toBe(false)
expect(await verifyPassword('x', 'not a hash')).toBe(false)
expect(hashPassword('correct horse')).not.toBe(hash)
})
it('parses and rejects bad files', () => {
const hash = hashPassword('longenough')
const ok = parseUsersFile(JSON.stringify({ users: [{ name: 'Alice', role: 'admin', hash, disabled: true }] }))
expect(ok).toEqual([{ name: 'Alice', role: 'admin', hash, disabled: true }])
expect(() => parseUsersFile('nope')).toThrow(/JSON/)
expect(() => parseUsersFile('{}')).toThrow(/users/)
expect(() => parseUsersFile(JSON.stringify({ users: [{ name: 'a', role: 'root', hash }] }))).toThrow(/role/)
expect(() => parseUsersFile(JSON.stringify({ users: [{ name: 'a', role: 'admin', hash: 'x' }] }))).toThrow(/hash/)
expect(() =>
parseUsersFile(JSON.stringify({ users: [{ name: 'a', role: 'admin', hash }, { name: 'A', role: 'viewer', hash }] })),
).toThrow(/twice/)
})
it('edits users (the functions the CLI calls)', async () => {
let users = addUser([], 'bob', 'viewer', 'password1')
expect(() => addUser(users, 'BOB', 'viewer', 'password1')).toThrow(/exists/)
expect(() => addUser(users, 'x', 'root', 'password1')).toThrow(/role/)
expect(() => addUser(users, 'x', 'viewer', 'short')).toThrow(/8/)
users = setRole(users, 'Bob', 'operator')
expect(users[0].role).toBe('operator')
const before = users[0].hash
users = setPassword(users, 'bob', 'password2')
expect(users[0].hash).not.toBe(before)
expect(await verifyPassword('password2', users[0].hash)).toBe(true)
users = setDisabled(users, 'bob', true)
expect(users[0].disabled).toBe(true)
users = setDisabled(users, 'bob', false)
expect('disabled' in users[0]).toBe(false)
expect(() => setRole(users, 'nobody', 'viewer')).toThrow(/No user/)
})
it('writes atomically, leaving no temp file', () => {
writeUsersFile(dir, addUser([], 'bob', 'viewer', 'password1'))
const file = path.join(dir, 'users.json')
expect(readUsersFile(dir)?.[0].name).toBe('bob')
expect(existsSync(`${file}.${process.pid}.tmp`)).toBe(false)
if (process.platform !== 'win32') expect(statSync(file).mode & 0o777).toBe(0o600)
})
it('runs CLI commands', async () => {
const pw = async () => 'password1'
expect(await run(['list'], dir, pw)).toEqual(['No users.'])
await run(['add', 'alice', 'apps-admin'], dir, pw)
await run(['role', 'alice', 'operator'], dir, pw)
await run(['disable', 'alice'], dir, pw)
expect(await run(['list'], dir, pw)).toEqual(['alice\toperator\tdisabled'])
await run(['enable', 'alice'], dir, pw)
await run(['passwd', 'alice'], dir, async () => 'password2')
expect(await verifyPassword('password2', readUsersFile(dir)![0].hash)).toBe(true)
await expect(run(['bogus'], dir, pw)).rejects.toThrow(/Usage/)
await expect(run(['add', 'bob'], dir, pw)).rejects.toThrow(/role/)
})
})
describe('bootstrap', () => {
it('uses the environment user as admin when there is no file, and does not write it', async () => {
const users = Users.load(dir, { user: 'Root', password: 'secret' })
expect(existsSync(path.join(dir, 'users.json'))).toBe(false)
expect(users.notice).toMatch(/HUB_USER/)
expect((await users.authenticate('root', 'secret'))?.role).toBe('admin')
expect(await users.authenticate('root', 'nope')).toBeNull()
})
it('ignores the environment user when the file exists', async () => {
seed(['alice', 'viewer', 'password1'])
const users = Users.load(dir, { user: 'root', password: 'secret' })
expect(users.notice).toMatch(/ignored/)
expect(await users.authenticate('root', 'secret')).toBeNull()
expect((await users.authenticate('ALICE', 'password1'))?.role).toBe('viewer')
})
it('refuses to start with neither', () => {
expect(() => Users.load(dir, null)).toThrow(/No users/)
expect(() => createHub(parseConfig(env()))).toThrow(/No users/)
})
it('config reads no file and takes the optional variables', () => {
const c = parseConfig(env({ HUB_TRUST_PROXY: '1' }))
expect(c.bootstrap).toBeNull()
expect(c.dataDir).toBe(dir)
expect(c.trustProxy).toBe(true)
expect(c.servers[0].adminToken).toBe('admin-secret-a')
expect(parseConfig({ ...env(), HUB_DATA: '' }).dataDir).toBe('/data')
expect(parseConfig(env()).trustProxy).toBe(false)
})
})
describe('rate limit', () => {
it('locks after 5 failures, expires, and a success resets the user', () => {
let now = 1_000_000
const limiter = new LoginLimiter(() => now)
for (let i = 0; i < 4; i++) limiter.failure('1.1.1.1', 'Bob')
expect(limiter.lockedFor('1.1.1.1', 'bob')).toBe(0)
limiter.success('bob')
for (let i = 0; i < 4; i++) limiter.failure('2.2.2.2', 'bob')
expect(limiter.lockedFor('2.2.2.2', 'bob')).toBe(0)
limiter.failure('2.2.2.2', 'bob')
expect(limiter.lockedFor('3.3.3.3', 'BOB')).toBe(LOCK_MS)
expect(limiter.lockedFor('2.2.2.2', 'other')).toBe(LOCK_MS)
now += LOCK_MS + 1
expect(limiter.lockedFor('2.2.2.2', 'bob')).toBe(0)
expect(lockedMessage(LOCK_MS)).toBe('Too many failed logins. Try again in 15 minutes.')
expect(lockedMessage(1000)).toBe('Too many failed logins. Try again in 1 minute.')
})
it('forgets failures older than the window', () => {
let now = 0
const limiter = new LoginLimiter(() => now)
for (let i = 0; i < 4; i++) limiter.failure('ip', 'u')
now += 16 * 60_000
limiter.failure('ip', 'u')
expect(limiter.lockedFor('ip', 'u')).toBe(0)
})
it('locks the hub login with 429 and the message, even for the right password', async () => {
seed(['alice', 'admin', 'password1'])
const base = await start()
for (let i = 0; i < 5; i++) expect((await login(base, 'alice', 'bad')).body.status).toBe('error')
const locked = await login(base, 'alice', 'password1')
expect(locked.code).toBe(429)
expect(locked.body).toEqual({ status: 'error', errorMessage: 'Too many failed logins. Try again in 15 minutes.' })
})
it('counts X-Forwarded-For only when the proxy is trusted', async () => {
seed(['alice', 'admin', 'password1'])
// Untrusted: the header is ignored, so five attempts from one socket lock that socket.
const plain = await start()
for (let i = 0; i < 5; i++) await login(plain, `u${i}`, 'bad', { 'x-forwarded-for': `9.9.9.${i}` })
expect((await login(plain, 'alice', 'password1', { 'x-forwarded-for': '8.8.8.8' })).code).toBe(429)
// Trusted: each forwarded address has its own count.
const trusted = await start({ HUB_TRUST_PROXY: '1' })
for (let i = 0; i < 5; i++) await login(trusted, `u${i}`, 'bad', { 'x-forwarded-for': `9.9.9.${i}` })
expect((await login(trusted, 'alice', 'password1', { 'x-forwarded-for': '8.8.8.8, 10.0.0.1' })).code).toBe(200)
})
})
describe('audit', () => {
it('appends JSON lines, lists newest first and filters with before', () => {
let now = Date.parse('2026-01-01T00:00:00Z')
const audit = new Audit(dir, { now: () => now })
for (const action of ['one', 'two', 'three']) {
audit.log({ user: 'u', ip: 'i', action, outcome: 'ok' })
now += 1000
}
const lines = readFileSync(path.join(dir, 'audit.log'), 'utf8').trim().split('\n')
expect(lines).toHaveLength(3)
expect(JSON.parse(lines[0])).toMatchObject({ user: 'u', ip: 'i', action: 'one', outcome: 'ok' })
expect(audit.list().map((e) => e.action)).toEqual(['three', 'two', 'one'])
expect(audit.list(1).map((e) => e.action)).toEqual(['three'])
expect(audit.list(200, '2026-01-01T00:00:02.000Z').map((e) => e.action)).toEqual(['two', 'one'])
})
it('rotates once the file is too big and keeps one old file', () => {
const audit = new Audit(dir, { maxBytes: 600 })
for (let i = 0; i < 12; i++) audit.log({ user: 'u', ip: 'i', action: `a${i}`, outcome: 'ok' })
expect(existsSync(path.join(dir, 'audit.log.1'))).toBe(true)
expect(existsSync(path.join(dir, 'audit.log.2'))).toBe(false)
expect(statSync(path.join(dir, 'audit.log')).size).toBeLessThanOrEqual(600)
expect(audit.list(1)[0].action).toBe('a11')
})
it('falls back to memory when the folder is not writable', () => {
const file = path.join(dir, 'blocker')
writeFileSync(file, 'x')
const err = vi.spyOn(console, 'error').mockImplementation(() => {})
const audit = new Audit(path.join(file, 'sub'))
audit.log({ user: 'u', ip: 'i', action: 'x', outcome: 'ok' })
audit.log({ user: 'u', ip: 'i', action: 'y', outcome: 'ok' })
expect(err).toHaveBeenCalledTimes(1)
err.mockRestore()
expect(audit.inMemory).toBe(true)
expect(audit.list().map((e) => e.action)).toEqual(['y', 'x'])
})
it('records login, logout and refusals without passwords or tokens, and is for audit.read only', async () => {
seed(['alice', 'admin', 'password1'], ['vic', 'viewer', 'password2'])
const base = await start()
await login(base, 'alice', 'wrong-password-xyz')
const adminLogin = await login(base, 'alice', 'password1')
const admin = adminLogin.body.token as string
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, 'hub/audit/list', {}, viewer)).code).toBe(403)
await call(base, 'user/logout', {}, viewer)
const list = await call(base, 'hub/audit/list', { limit: '50' }, admin)
expect(list.body.status).toBe('ok')
const entries = list.body.response.entries as Record<string, string>[]
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' })
const ts = entries.map((e) => e.ts)
expect([...ts].sort().reverse()).toEqual(ts)
const raw = readFileSync(path.join(dir, 'audit.log'), 'utf8')
for (const secret of ['wrong-password-xyz', 'password1', 'password2', admin, viewer, 'token-a', 'admin-secret-a']) {
expect(raw).not.toContain(secret)
}
})
it('logs a locked attempt', async () => {
seed(['alice', 'admin', 'password1'])
const base = await start()
for (let i = 0; i < 6; i++) await login(base, 'alice', 'bad')
const lines = readFileSync(path.join(dir, 'audit.log'), 'utf8').trim().split('\n').map((l) => JSON.parse(l))
expect(lines.filter((l) => l.outcome === 'failed')).toHaveLength(5)
expect(lines[5]).toMatchObject({ action: 'login', outcome: 'refused', detail: 'locked' })
})
})
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',
'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('constructor')).toBeUndefined()
expect(routeFor('hub/nothing')).toBeUndefined()
})
})
describe('sessions by role', () => {
it('puts hubRole in the body and in info', async () => {
seed(['vic', 'viewer', 'password2'], ['op', 'operator', 'password3'])
const base = await start()
const a = await login(base, 'vic', 'password2')
expect(a.body.hubRole).toBe('viewer')
expect(a.body.info.hubRole).toBe('viewer')
expect(a.body.info.permissions.Dashboard.canView).toBe(true)
const b = await call(base, 'user/session/get', {}, (await login(base, 'op', 'password3')).body.token)
expect(b.body.hubRole).toBe('operator')
expect(b.body.info.hubRole).toBe('operator')
})
it('drops the sessions of a disabled user and refuses a new login', async () => {
seed(['alice', 'admin', 'password1'], ['vic', 'viewer', 'password2'])
const base = await start()
const vic = (await login(base, 'vic', 'password2')).body.token as string
const alice = (await login(base, 'alice', 'password1')).body.token as string
expect((await call(base, 'user/session/get', {}, vic)).body.status).toBe('ok')
// The file changes: vic is disabled, alice is untouched.
const users = setDisabled(readUsersFile(dir)!, 'vic', true)
writeUsersFile(dir, users)
const future = new Date(Date.now() + 5000)
const { utimesSync } = await import('node:fs')
utimesSync(path.join(dir, 'users.json'), future, future)
expect((await call(base, 'user/session/get', {}, vic)).body.status).toBe('invalid-token')
expect((await call(base, 'user/session/get', {}, alice)).body.status).toBe('ok')
expect((await login(base, 'vic', 'password2')).body.status).toBe('error')
})
it('drops sessions when the role or password changes', async () => {
seed(['alice', 'admin', 'password1'])
const base = await start()
const t = (await login(base, 'alice', 'password1')).body.token as string
writeUsersFile(dir, setRole(readUsersFile(dir)!, 'alice', 'viewer'))
const { utimesSync } = await import('node:fs')
const future = new Date(Date.now() + 5000)
utimesSync(path.join(dir, 'users.json'), future, future)
expect((await call(base, 'user/session/get', {}, t)).body.status).toBe('invalid-token')
const again = await login(base, 'ALICE', 'password1')
expect(again.body.hubRole).toBe('viewer')
})
})
+6 -2
View File
@@ -77,6 +77,7 @@ let b: Fake
let hub: http.Server let hub: http.Server
let base: string let base: string
let staticDir: string let staticDir: string
let dataDir: string
function makeConfig(over: Record<string, string> = {}) { function makeConfig(over: Record<string, string> = {}) {
return parseConfig({ return parseConfig({
@@ -86,6 +87,7 @@ function makeConfig(over: Record<string, string> = {}) {
HUB_USER: 'admin', HUB_USER: 'admin',
HUB_PASSWORD: 'secret', HUB_PASSWORD: 'secret',
HUB_STATIC: staticDir, HUB_STATIC: staticDir,
HUB_DATA: dataDir,
HUB_CACHE_SECONDS: '15', HUB_CACHE_SECONDS: '15',
HUB_TIMEOUT_MS: '2000', HUB_TIMEOUT_MS: '2000',
...over, ...over,
@@ -117,6 +119,7 @@ async function login(): Promise<string> {
beforeAll(async () => { beforeAll(async () => {
a = await fakeUpstream(canned(100, ['2026-01-01T10:00:04Z', '2026-01-01T10:00:02Z'])) a = await fakeUpstream(canned(100, ['2026-01-01T10:00:04Z', '2026-01-01T10:00:02Z']))
b = await fakeUpstream(canned(300, ['2026-01-01T10:00:03Z', '2026-01-01T10:00:01Z'])) b = await fakeUpstream(canned(300, ['2026-01-01T10:00:03Z', '2026-01-01T10:00:01Z']))
dataDir = mkdtempSync(path.join(tmpdir(), 'hub-data-'))
staticDir = mkdtempSync(path.join(tmpdir(), 'hub-static-')) staticDir = mkdtempSync(path.join(tmpdir(), 'hub-static-'))
writeFileSync(path.join(staticDir, 'index.html'), '<html>root</html>') writeFileSync(path.join(staticDir, 'index.html'), '<html>root</html>')
mkdirSync(path.join(staticDir, 'logs', 'query-logs'), { recursive: true }) mkdirSync(path.join(staticDir, 'logs', 'query-logs'), { recursive: true })
@@ -129,6 +132,7 @@ afterAll(() => {
a.server.close() a.server.close()
b.server.close() b.server.close()
rmSync(staticDir, { recursive: true, force: true }) rmSync(staticDir, { recursive: true, force: true })
rmSync(dataDir, { recursive: true, force: true })
}) })
beforeEach(async () => { beforeEach(async () => {
@@ -142,9 +146,9 @@ beforeEach(async () => {
}) })
describe('config', () => { describe('config', () => {
it('refuses to start without credentials or a token', () => { it('refuses to start with half the credentials or without a token', () => {
const env = { HUB_SERVERS: 'a=http://x', A_TOKEN: 't' } const env = { HUB_SERVERS: 'a=http://x', A_TOKEN: 't' }
expect(() => parseConfig(env)).toThrow(/HUB_USER/) expect(() => parseConfig({ ...env, HUB_USER: 'u' })).toThrow(/HUB_USER/)
expect(() => parseConfig({ ...env, HUB_USER: 'u', HUB_PASSWORD: 'p', A_TOKEN: '' })).toThrow(/A_TOKEN/) expect(() => parseConfig({ ...env, HUB_USER: 'u', HUB_PASSWORD: 'p', A_TOKEN: '' })).toThrow(/A_TOKEN/)
}) })
+74 -22
View File
@@ -17,6 +17,10 @@ import { fileURLToPath } from 'node:url'
import { loadConfig, HUB_VERSION, type HubConfig, type HubServer } from './config.ts' import { loadConfig, HUB_VERSION, type HubConfig, type HubServer } from './config.ts'
import { Upstream, UpstreamError, type Params } from './upstream.ts' import { Upstream, UpstreamError, type Params } from './upstream.ts'
import { Sessions } from './session.ts' 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 { import {
mergeQueryLogs, mergeQueryLogs,
mergeSettled, mergeSettled,
@@ -32,19 +36,6 @@ type Json = Record<string, unknown>
const MAX_BODY = 64 * 1024 const MAX_BODY = 64 * 1024
/* Paths relative to `/api/`. Anything not listed here is refused with 403. */
const READ_ENDPOINTS = new Set([
'dashboard/stats/get',
'dashboard/stats/getTop',
'dashboard/metrics/json',
'apps/list',
'logs/query',
'logs/list',
'settings/get',
'user/checkForUpdate',
'admin/cluster/state',
])
const TYPES: Record<string, string> = { const TYPES: Record<string, string> = {
'.html': 'text/html; charset=utf-8', '.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8', '.js': 'text/javascript; charset=utf-8',
@@ -102,9 +93,20 @@ function positive(value: string | undefined, fallback: number): number {
return Number.isInteger(n) && n > 0 ? n : fallback return Number.isInteger(n) && n > 0 ? n : fallback
} }
export function createHub(config: HubConfig): http.Server { /* The client's address: the socket's, or with HUB_TRUST_PROXY the first X-Forwarded-For entry. */
function clientIp(req: http.IncomingMessage, trustProxy: boolean): string {
if (trustProxy) {
const forwarded = String(req.headers['x-forwarded-for'] ?? '').split(',')[0].trim()
if (forwarded !== '') return forwarded
}
return req.socket.remoteAddress ?? 'unknown'
}
export function createHub(config: HubConfig, users: Users = Users.load(config.dataDir, config.bootstrap)): http.Server {
const upstream = new Upstream(config) const upstream = new Upstream(config)
const sessions = new Sessions(config) const sessions = new Sessions(config, users)
const limiter = new LoginLimiter()
const audit = new Audit(config.dataDir)
const byId = new Map(config.servers.map((s) => [s.id, s])) const byId = new Map(config.servers.map((s) => [s.id, s]))
/* Ask every server; the ones that fail become warnings (see mergeSettled). */ /* Ask every server; the ones that fail become warnings (see mergeSettled). */
@@ -186,30 +188,66 @@ export function createHub(config: HubConfig): http.Server {
send(res, 200, { status: 'ok', hasDefaultCredentials: false, ssoEnabled: false }) send(res, 200, { status: 'ok', hasDefaultCredentials: false, ssoEnabled: false })
return return
} }
const ip = clientIp(req, config.trustProxy)
if (apiPath === 'user/login') { if (apiPath === 'user/login') {
const token = sessions.login(params.user ?? '', params.pass ?? '') const name = params.user ?? ''
send(res, 200, token === null ? fail(`Invalid username or password for user: ${params.user ?? ''}`) : sessions.body(token)) const locked = limiter.lockedFor(ip, name)
if (locked > 0) {
audit.log({ user: name, ip, action: 'login', outcome: 'refused', detail: 'locked' })
send(res, 429, fail(lockedMessage(locked)))
return
}
const user = await users.authenticate(name, params.pass ?? '')
if (user === null) {
limiter.failure(ip, name)
audit.log({ user: name, ip, action: 'login', outcome: 'failed', detail: users.isDisabled(name) ? 'disabled' : undefined })
send(res, 200, fail(`Invalid username or password for user: ${name}`))
return
}
limiter.success(name)
audit.log({ user: user.name, role: user.role, ip, action: 'login', outcome: 'ok' })
const token = sessions.open(user)
send(res, 200, sessions.body(token, { user: user.name, role: user.role }))
return return
} }
const token = bearer(req) ?? params.token ?? null const token = bearer(req) ?? params.token ?? null
if (!sessions.touch(token)) { const who = sessions.touch(token)
if (who === null) {
send(res, 200, { status: 'invalid-token', errorMessage: 'Invalid token or session expired.' }) send(res, 200, { status: 'invalid-token', errorMessage: 'Invalid token or session expired.' })
return return
} }
if (apiPath === 'user/session/get') { if (apiPath === 'user/session/get') {
send(res, 200, sessions.body(token as string)) send(res, 200, sessions.body(token as string, who))
return return
} }
if (apiPath === 'user/logout') { if (apiPath === 'user/logout') {
sessions.logout(token as string) sessions.logout(token as string)
audit.log({ user: who.user, role: who.role, ip, action: 'logout', outcome: 'ok' })
send(res, 200, { status: 'ok' }) send(res, 200, { status: 'ok' })
return return
} }
if (!READ_ENDPOINTS.has(apiPath)) { const route = routeFor(apiPath)
if (route === undefined) {
audit.log({
user: who.user, role: who.role, ip, action: 'refused-write', target: apiPath,
server: params.node || undefined, outcome: 'refused', detail: 'not in the permission table',
})
send(res, 403, fail('The hub is read-only.')) send(res, 403, fail('The hub is read-only.'))
return return
} }
if (!can(who.role, route.capability)) {
audit.log({
user: who.user, role: who.role, ip, action: route.kind === 'read' ? 'denied-read' : 'denied-write',
target: apiPath, outcome: 'refused', detail: `needs ${route.capability}`,
})
send(res, 403, fail('Your role does not allow this.'))
return
}
if (apiPath === 'hub/audit/list') {
send(res, 200, ok({ entries: audit.list(Number(params.limit ?? 200), params.before) }))
return
}
if (apiPath === 'user/checkForUpdate') { if (apiPath === 'user/checkForUpdate') {
send(res, 200, ok({ updateAvailable: false })) send(res, 200, ok({ updateAvailable: false }))
return return
@@ -302,7 +340,10 @@ export function createHub(config: HubConfig): http.Server {
createReadStream(file).on('error', () => res.destroy()).pipe(res) createReadStream(file).on('error', () => res.destroy()).pipe(res)
} }
return http.createServer((req, res) => { const sweeper = setInterval(() => limiter.sweep(), 5 * 60_000)
sweeper.unref()
const server = http.createServer((req, res) => {
void (async () => { void (async () => {
try { try {
const url = new URL(req.url ?? '/', 'http://hub') const url = new URL(req.url ?? '/', 'http://hub')
@@ -317,6 +358,8 @@ export function createHub(config: HubConfig): http.Server {
} }
})() })()
}) })
server.on('close', () => clearInterval(sweeper))
return server
} }
/* Run directly (not imported by the tests). */ /* Run directly (not imported by the tests). */
@@ -328,7 +371,16 @@ if (process.argv[1] !== undefined && path.resolve(process.argv[1]) === fileURLTo
console.error(`hub: ${e instanceof Error ? e.message : String(e)}`) console.error(`hub: ${e instanceof Error ? e.message : String(e)}`)
process.exit(1) process.exit(1)
} }
createHub(config).listen(config.port, () => { let hub: http.Server
try {
const users = Users.load(config.dataDir, config.bootstrap)
if (users.notice !== null) console.log(`hub: ${users.notice}`)
hub = createHub(config, users)
} catch (e) {
console.error(`hub: ${e instanceof Error ? e.message : String(e)}`)
process.exit(1)
}
hub.listen(config.port, () => {
console.log(`Hub ${HUB_VERSION} on :${config.port}, servers: ${config.servers.map((s) => s.id).join(', ')}`) console.log(`Hub ${HUB_VERSION} on :${config.port}, servers: ${config.servers.map((s) => s.id).join(', ')}`)
}) })
} }
+40 -33
View File
@@ -1,18 +1,23 @@
/* /*
The hub's own sign-in. One user from the environment, sessions kept in memory with an The hub's own sign-in. Sessions are kept in memory with an idle expiry; each remembers who
idle expiry, and the `info` object the console reads from `user/login` and signed in and what that user looked like (role, hash, disabled flag) at that moment, so a
`user/session/get` (flat, as the real server sends it). change to the users file ends the sessions of the users it touched. `info` is the object
the console reads from `user/login` and `user/session/get` (flat, as the real server sends it).
*/ */
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto' import { randomBytes } from 'node:crypto'
import { HUB_VERSION, type HubConfig } from './config.ts' import { HUB_VERSION, type HubConfig } from './config.ts'
import { signature, type Role, type UserRecord, type Users } from './users.ts'
export const IDLE_MS = 30 * 60_000 export const IDLE_MS = 30 * 60_000
/* Compared as hashes so the lengths always match and the time does not depend on the content. */ export interface Identity {
function sameSecret(a: string, b: string): boolean { user: string
const ha = createHash('sha256').update(a).digest() role: Role
const hb = createHash('sha256').update(b).digest() }
return timingSafeEqual(ha, hb)
interface Session extends Identity {
last: number
sig: string
} }
/* /*
@@ -28,39 +33,39 @@ const VIEWABLE = new Set(['Dashboard', 'Logs'])
export class Sessions { export class Sessions {
#config: HubConfig #config: HubConfig
#users: Users
#now: () => number #now: () => number
#started: string #started: string
#seen = new Map<string, number>() #seen = new Map<string, Session>()
constructor(config: HubConfig, now: () => number = Date.now) { constructor(config: HubConfig, users: Users, now: () => number = Date.now) {
this.#config = config this.#config = config
this.#users = users
this.#now = now this.#now = now
this.#started = new Date(now()).toISOString() this.#started = new Date(now()).toISOString()
} }
/** A new session token, or null when the credentials are wrong. */ /** Opens a session for a user already authenticated by `Users.authenticate`. */
login(user: string, password: string): string | null { open(user: UserRecord): string {
// Both are always compared, so a wrong user and a wrong password cost the same.
const userOk = sameSecret(user.trim().toLowerCase(), this.#config.user.toLowerCase())
const passOk = sameSecret(password, this.#config.password)
if (!(userOk && passOk)) return null
this.#sweep() this.#sweep()
const token = randomBytes(32).toString('hex') const token = randomBytes(32).toString('hex')
this.#seen.set(token, this.#now()) this.#seen.set(token, { user: user.name, role: user.role, last: this.#now(), sig: signature(user) })
return token return token
} }
/** True while the token is valid; each use extends it. */ /* The session's identity while it is valid (extending it), else null. A user who was removed,
touch(token: string | null | undefined): boolean { disabled or edited since sign-in ends the session. */
if (!token) return false touch(token: string | null | undefined): Identity | null {
const last = this.#seen.get(token) if (!token) return null
if (last === undefined) return false const session = this.#seen.get(token)
if (this.#now() - last > IDLE_MS) { if (session === undefined) return null
const record = this.#users.find(session.user)
if (this.#now() - session.last > IDLE_MS || record === undefined || signature(record) !== session.sig) {
this.#seen.delete(token) this.#seen.delete(token)
return false return null
} }
this.#seen.set(token, this.#now()) session.last = this.#now()
return true return { user: session.user, role: session.role }
} }
logout(token: string): void { logout(token: string): void {
@@ -69,10 +74,10 @@ export class Sessions {
#sweep(): void { #sweep(): void {
const now = this.#now() const now = this.#now()
for (const [token, last] of this.#seen) if (now - last > IDLE_MS) this.#seen.delete(token) for (const [token, session] of this.#seen) if (now - session.last > IDLE_MS) this.#seen.delete(token)
} }
info() { info(role: Role) {
const { servers, domain } = this.#config const { servers, domain } = this.#config
return { return {
version: HUB_VERSION, version: HUB_VERSION,
@@ -92,20 +97,22 @@ export class Sessions {
useSoaSerialDateScheme: true, useSoaSerialDateScheme: true,
dnssecValidation: true, dnssecValidation: true,
hub: true, hub: true,
hubRole: role,
} }
} }
/** The flat body of `user/login` and `user/session/get`. */ /** The flat body of `user/login` and `user/session/get`. */
body(token: string) { body(token: string, identity: Identity) {
return { return {
status: 'ok', status: 'ok',
token, token,
username: this.#config.user, username: identity.user,
displayName: this.#config.user, displayName: identity.user,
type: 'Local', type: 'Local',
isSsoUser: false, isSsoUser: false,
totpEnabled: false, totpEnabled: false,
info: this.info(), hubRole: identity.role,
info: this.info(identity.role),
} }
} }
} }
+134
View File
@@ -0,0 +1,134 @@
/*
Manage the hub's users file.
node --experimental-strip-types hub/users-cli.ts add <name> <role>
node --experimental-strip-types hub/users-cli.ts passwd <name>
node --experimental-strip-types hub/users-cli.ts role <name> <role>
node --experimental-strip-types hub/users-cli.ts disable <name>
node --experimental-strip-types hub/users-cli.ts enable <name>
node --experimental-strip-types hub/users-cli.ts list
The file is `${HUB_DATA}/users.json` (default /data). On a terminal the password is asked
twice without echo; in a script, put it in HUB_NEW_PASSWORD. In the container:
docker exec -it technitium-hub node --experimental-strip-types /app/hub/users-cli.ts add alice apps-admin
*/
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import {
ROLES,
addUser,
describeUsers,
readUsersFile,
setDisabled,
setPassword,
setRole,
writeUsersFile,
type UserRecord,
} from './users.ts'
const USAGE = `Usage: users-cli <command>
add <name> <role> roles: ${ROLES.join(', ')}
passwd <name>
role <name> <role>
disable <name>
enable <name>
list
The password is asked twice, or taken from HUB_NEW_PASSWORD.`
type Env = Record<string, string | undefined>
/* Reads one line from the terminal without echoing it. */
function askHidden(prompt: string): Promise<string> {
return new Promise((resolve, reject) => {
const stdin = process.stdin
process.stderr.write(prompt)
let line = ''
stdin.setRawMode(true)
stdin.resume()
stdin.setEncoding('utf8')
const done = (): void => {
stdin.setRawMode(false)
stdin.pause()
stdin.off('data', onData)
process.stderr.write('\n')
}
const onData = (chunk: string): void => {
for (const ch of chunk) {
if (ch === '\r' || ch === '\n') {
done()
resolve(line)
return
}
if (ch === '\u0003') {
done()
reject(new Error('Cancelled.'))
return
}
if (ch === '\u007f' || ch === '\b') line = line.slice(0, -1)
else line += ch
}
}
stdin.on('data', onData)
})
}
async function newPassword(env: Env): Promise<string> {
if (env.HUB_NEW_PASSWORD !== undefined && env.HUB_NEW_PASSWORD !== '') return env.HUB_NEW_PASSWORD
if (!process.stdin.isTTY) throw new Error('No terminal: set HUB_NEW_PASSWORD.')
const first = await askHidden('New password: ')
const second = await askHidden('Repeat it: ')
if (first !== second) throw new Error('The passwords differ.')
return first
}
/*
Runs one command against the users in `dir` and returns the lines to print. `password`
supplies the password for add and passwd, so the tests need no terminal.
*/
export async function run(
args: string[],
dir: string,
password: () => Promise<string>,
): Promise<string[]> {
const [command, name, extra] = args
const users: UserRecord[] = readUsersFile(dir) ?? []
const need = (value: string | undefined, what: string): string => {
if (value === undefined || value === '') throw new Error(`Missing ${what}.\n${USAGE}`)
return value
}
switch (command) {
case 'add':
writeUsersFile(dir, addUser(users, need(name, 'name'), need(extra, 'role'), await password()))
return [`Added ${name} (${extra}).`]
case 'passwd':
writeUsersFile(dir, setPassword(users, need(name, 'name'), await password()))
return [`Password of ${name} changed. Their sessions end on their next request.`]
case 'role':
writeUsersFile(dir, setRole(users, need(name, 'name'), need(extra, 'role')))
return [`${name} is now ${extra}.`]
case 'disable':
writeUsersFile(dir, setDisabled(users, need(name, 'name'), true))
return [`Disabled ${name}.`]
case 'enable':
writeUsersFile(dir, setDisabled(users, need(name, 'name'), false))
return [`Enabled ${name}.`]
case 'list':
return users.length === 0 ? ['No users.'] : describeUsers(users)
default:
throw new Error(USAGE)
}
}
if (process.argv[1] !== undefined && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const dir = process.env.HUB_DATA?.trim() || '/data'
run(process.argv.slice(2), dir, () => newPassword(process.env)).then(
(lines) => {
for (const line of lines) console.log(line)
},
(e: unknown) => {
console.error(`users: ${e instanceof Error ? e.message : String(e)}`)
process.exit(1)
},
)
}
+299
View File
@@ -0,0 +1,299 @@
/*
The hub's own accounts: roles, password hashes and the users file.
`${HUB_DATA}/users.json` holds `{ users: [{ name, role, hash, disabled? }] }`, where hash is
`scrypt$N$r$p$saltB64$keyB64`. The pure functions below are what the CLI (users-cli.ts)
and the tests call; `Users` is what the running hub asks.
*/
import { randomBytes, scrypt, scryptSync, timingSafeEqual } from 'node:crypto'
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync, chmodSync, rmSync } from 'node:fs'
import path from 'node:path'
import type { Bootstrap } from './config.ts'
export type Role = 'viewer' | 'operator' | 'apps-admin' | 'admin'
export type Capability = 'read' | 'apps.write' | 'operate' | 'audit.read' | 'admin'
export const ROLES: readonly Role[] = ['viewer', 'operator', 'apps-admin', 'admin']
/* A simple rank. operator and apps-admin are siblings: use `can` to ask about a capability. */
const RANK: Record<Role, number> = { viewer: 1, operator: 2, 'apps-admin': 2, admin: 3 }
const CAPABILITIES: Record<Role, readonly Capability[]> = {
viewer: ['read'],
operator: ['read', 'operate'],
'apps-admin': ['read', 'apps.write'],
admin: ['read', 'apps.write', 'operate', 'audit.read', 'admin'],
}
export const isRole = (value: unknown): value is Role => typeof value === 'string' && (ROLES as readonly string[]).includes(value)
export const rank = (role: Role): number => RANK[role]
/** True when `role` ranks at least as high as `min`. */
export const requires = (role: Role, min: Role): boolean => RANK[role] >= RANK[min]
export const can = (role: Role, capability: Capability): boolean => CAPABILITIES[role].includes(capability)
export interface UserRecord {
name: string
role: Role
hash: string
disabled?: boolean
}
/* ---- Passwords ---- */
const N = 16384
const R = 8
const P = 1
const KEYLEN = 64
export function hashPassword(password: string): string {
const salt = randomBytes(16)
const key = scryptSync(password, salt, KEYLEN, { N, r: R, p: P })
return `scrypt$${N}$${R}$${P}$${salt.toString('base64')}$${key.toString('base64')}`
}
interface Parsed {
n: number
r: number
p: number
salt: Buffer
key: Buffer
}
function parseHash(stored: string): Parsed | null {
const parts = stored.split('$')
if (parts.length !== 6 || parts[0] !== 'scrypt') return null
const [n, r, p] = [Number(parts[1]), Number(parts[2]), Number(parts[3])]
if (![n, r, p].every((v) => Number.isInteger(v) && v > 0) || n > 1 << 20 || r > 32 || p > 16) return null
const salt = Buffer.from(parts[4], 'base64')
const key = Buffer.from(parts[5], 'base64')
return salt.length === 0 || key.length === 0 ? null : { n, r, p, salt, key }
}
export const isHash = (stored: string): boolean => parseHash(stored) !== null
function derive(password: string, h: Parsed): Promise<Buffer> {
return new Promise((resolve, reject) => {
scrypt(password, h.salt, h.key.length, { N: h.n, r: h.r, p: h.p, maxmem: 256 * h.n * h.r }, (err, key) =>
err ? reject(err) : resolve(key),
)
})
}
/** Checks a password against a stored hash; false for a malformed hash. Does not block the loop. */
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
const h = parseHash(stored)
if (h === null) return false
try {
return timingSafeEqual(await derive(password, h), h.key)
} catch {
return false
}
}
/* ---- The file: parse and pure edits ---- */
const NAME = /^[A-Za-z0-9._@-]{1,64}$/
export function validateName(name: string): void {
if (!NAME.test(name)) throw new Error('A user name is 1 to 64 letters, digits, . _ @ -.')
}
const same = (a: string, b: string): boolean => a.toLowerCase() === b.toLowerCase()
export function parseUsersFile(text: string): UserRecord[] {
let data: unknown
try {
data = JSON.parse(text)
} catch {
throw new Error('users.json is not valid JSON.')
}
const list = (data as { users?: unknown } | null)?.users
if (!Array.isArray(list)) throw new Error('users.json must be {"users": [...]}.')
const users: UserRecord[] = []
for (const item of list as Record<string, unknown>[]) {
const name = item?.name
if (typeof name !== 'string' || !NAME.test(name)) throw new Error(`users.json: invalid user name ${JSON.stringify(name)}.`)
if (!isRole(item.role)) throw new Error(`users.json: user "${name}" has an unknown role.`)
if (typeof item.hash !== 'string' || !isHash(item.hash)) throw new Error(`users.json: user "${name}" has no valid hash.`)
if (users.some((u) => same(u.name, name))) throw new Error(`users.json lists "${name}" twice.`)
const record: UserRecord = { name, role: item.role, hash: item.hash }
if (item.disabled === true) record.disabled = true
users.push(record)
}
return users
}
export const serializeUsers = (users: UserRecord[]): string => JSON.stringify({ users }, null, 2) + '\n'
export const findUser = (users: UserRecord[], name: string): UserRecord | undefined => users.find((u) => same(u.name, name))
function must(users: UserRecord[], name: string): UserRecord {
const found = findUser(users, name)
if (found === undefined) throw new Error(`No user "${name}".`)
return found
}
export function checkPassword(password: string): void {
if (password.length < 8) throw new Error('The password must be at least 8 characters.')
}
function checkRole(role: string): Role {
if (!isRole(role)) throw new Error(`The role must be one of: ${ROLES.join(', ')}.`)
return role
}
/* Each edit returns a new list; the caller writes it. */
export function addUser(users: UserRecord[], name: string, role: string, password: string): UserRecord[] {
validateName(name)
const checked = checkRole(role)
checkPassword(password)
if (findUser(users, name) !== undefined) throw new Error(`User "${name}" already exists.`)
return [...users, { name, role: checked, hash: hashPassword(password) }]
}
export function setPassword(users: UserRecord[], name: string, password: string): UserRecord[] {
checkPassword(password)
const target = must(users, name)
return users.map((u) => (u === target ? { ...u, hash: hashPassword(password) } : u))
}
export function setRole(users: UserRecord[], name: string, role: string): UserRecord[] {
const checked = checkRole(role)
const target = must(users, name)
return users.map((u) => (u === target ? { ...u, role: checked } : u))
}
export function setDisabled(users: UserRecord[], name: string, disabled: boolean): UserRecord[] {
const target = must(users, name)
return users.map((u) => {
if (u !== target) return u
const { disabled: _old, ...rest } = u
return disabled ? { ...rest, disabled: true } : rest
})
}
/** One line per user, never the hash. */
export const describeUsers = (users: UserRecord[]): string[] =>
users.map((u) => `${u.name}\t${u.role}${u.disabled === true ? '\tdisabled' : ''}`)
/* ---- The file: disk ---- */
export const usersPath = (dir: string): string => path.join(dir, 'users.json')
/** The users, or null when the file does not exist. Throws when it exists but is invalid. */
export function readUsersFile(dir: string): UserRecord[] | null {
const file = usersPath(dir)
if (!existsSync(file)) return null
return parseUsersFile(readFileSync(file, 'utf8'))
}
/** Writes a temp file and renames it over users.json; mode 0600 where the platform honours it. */
export function writeUsersFile(dir: string, users: UserRecord[]): void {
mkdirSync(dir, { recursive: true })
const file = usersPath(dir)
const temp = `${file}.${process.pid}.tmp`
try {
writeFileSync(temp, serializeUsers(users), { mode: 0o600 })
try {
chmodSync(temp, 0o600)
} catch {
// Not every platform has modes.
}
renameSync(temp, file)
} catch (e) {
rmSync(temp, { force: true })
throw e
}
}
/* ---- The running hub's view ---- */
/* Compared when the role, hash or disabled flag changes, so a session can tell its user was edited. */
export const signature = (u: UserRecord): string => `${u.role}|${u.hash}|${u.disabled === true ? 'x' : ''}`
export class Users {
#dir: string
#users: UserRecord[]
#fromFile: boolean
#mtime = 0
#warned = false
/** A one-line notice for the start-up log, or null. */
readonly notice: string | null
/* Checked when a name is unknown, so an unknown name costs the same as a wrong password. */
#dummy = hashPassword(randomBytes(8).toString('hex'))
private constructor(dir: string, users: UserRecord[], fromFile: boolean, notice: string | null) {
this.#dir = dir
this.#users = users
this.#fromFile = fromFile
this.notice = notice
if (fromFile) this.#mtime = this.#stat()
}
/*
users.json when it exists (HUB_USER and HUB_PASSWORD are then ignored); else the one admin
from the environment; else an error. The file is never written here.
*/
static load(dir: string, bootstrap: Bootstrap | null): Users {
const fromFile = readUsersFile(dir)
if (fromFile !== null) {
const notice =
bootstrap === null ? null : `${usersPath(dir)} exists: HUB_USER and HUB_PASSWORD are ignored.`
return new Users(dir, fromFile, true, notice)
}
if (bootstrap === null) {
throw new Error(
`No users: ${usersPath(dir)} does not exist and HUB_USER / HUB_PASSWORD are not set. ` +
'Create a user with: node --experimental-strip-types hub/users-cli.ts add <name> admin',
)
}
validateName(bootstrap.user)
const record: UserRecord = { name: bootstrap.user, role: 'admin', hash: hashPassword(bootstrap.password) }
return new Users(dir, [record], false, `No ${usersPath(dir)}: the single admin from HUB_USER / HUB_PASSWORD is in use.`)
}
#stat(): number {
try {
return statSync(usersPath(this.#dir)).mtimeMs
} catch {
return 0
}
}
/* Cheap enough to run on every request: one stat. A broken file keeps the last good list. */
refresh(): void {
if (!this.#fromFile) return
const mtime = this.#stat()
if (mtime === this.#mtime) return
this.#mtime = mtime
try {
const next = readUsersFile(this.#dir)
this.#users = next ?? []
this.#warned = false
} catch (e) {
if (!this.#warned) console.error(`hub: ${e instanceof Error ? e.message : String(e)} Keeping the previous users.`)
this.#warned = true
}
}
find(name: string): UserRecord | undefined {
this.refresh()
return findUser(this.#users, name)
}
/** The enabled user when the password is right, else null. Costs the same either way. */
async authenticate(name: string, password: string): Promise<UserRecord | null> {
const user = this.find(name.trim())
const good = await verifyPassword(password, user?.hash ?? this.#dummy)
return user !== undefined && good && user.disabled !== true ? user : null
}
/** True when the user was found and is disabled (for the audit log only). */
isDisabled(name: string): boolean {
return this.find(name.trim())?.disabled === true
}
}
+1
View File
@@ -10,6 +10,7 @@
"dev": "vite", "dev": "vite",
"mock": "node dev/mock-api.mjs", "mock": "node dev/mock-api.mjs",
"hub": "node --experimental-strip-types hub/server.ts", "hub": "node --experimental-strip-types hub/server.ts",
"hub:user": "node --experimental-strip-types hub/users-cli.ts",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "oxlint", "lint": "oxlint",
"lint:language": "node dev/check-language.mjs", "lint:language": "node dev/check-language.mjs",