diff --git a/docker/Dockerfile.hub b/docker/Dockerfile.hub index ee22f84..7274a34 100644 --- a/docker/Dockerfile.hub +++ b/docker/Dockerfile.hub @@ -3,7 +3,7 @@ # 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 \ -# -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 FROM node:22-alpine AS build WORKDIR /src @@ -22,7 +22,10 @@ FROM node:22-alpine WORKDIR /app COPY --from=build /out/ ./ 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 EXPOSE 8080 # No curl in alpine: node asks the hub's public endpoint itself. diff --git a/docs/compose.hub.example.yaml b/docs/compose.hub.example.yaml index 06e90c2..0792687 100644 --- a/docs/compose.hub.example.yaml +++ b/docs/compose.hub.example.yaml @@ -12,7 +12,7 @@ version: "3.8" # MDNS_TOKEN=... # NDNS_TOKEN=... # 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 @@ -23,13 +23,19 @@ services: restart: unless-stopped networks: - dns-net + volumes: + - technitium-hub-data:/data environment: # id=адрес, по порядку; токен каждого берётся из _TOKEN - HUB_SERVERS=mdns=https://mdns.shnt.top,ndns=https://ndns.shnt.top - MDNS_TOKEN=${MDNS_TOKEN} - NDNS_TOKEN=${NDNS_TOKEN} + # Только пока нет /data/users.json; потом пользователи создаются через users-cli (docs/hub-deploy.md) - HUB_USER=${HUB_USER} - HUB_PASSWORD=${HUB_PASSWORD} + - HUB_DATA=/data + # За Traefik: брать адрес клиента из X-Forwarded-For (для ограничения попыток входа и журнала) + - HUB_TRUST_PROXY=1 - HUB_DOMAIN=dnss.shnt.top - TZ=Asia/Novosibirsk labels: @@ -42,6 +48,9 @@ services: - "traefik.http.routers.hub.service=hub-svc" - "traefik.http.services.hub-svc.loadbalancer.server.port=8080" +volumes: + technitium-hub-data: + networks: dns-net: external: true diff --git a/docs/compose.shnt.example.yaml b/docs/compose.shnt.example.yaml index 140af40..ab5da89 100644 --- a/docs/compose.shnt.example.yaml +++ b/docs/compose.shnt.example.yaml @@ -181,14 +181,20 @@ services: restart: unless-stopped networks: - dns-net + volumes: + - technitium-hub-data:/data environment: # id=адрес по порядку; токен каждого из _TOKEN. Сервер на ЭТОМ же хосте можно # указать изнутри сети, мимо Traefik: ndns=http://technitium-smart:5380 - HUB_SERVERS=mdns=https://mdns.shnt.top,ndns=https://ndns.shnt.top - MDNS_TOKEN=${MDNS_TOKEN} - NDNS_TOKEN=${NDNS_TOKEN} + # Только пока нет /data/users.json; потом пользователи создаются через users-cli (docs/hub-deploy.md) - HUB_USER=${HUB_USER} - HUB_PASSWORD=${HUB_PASSWORD} + - HUB_DATA=/data + # За Traefik: брать адрес клиента из X-Forwarded-For (для ограничения попыток входа и журнала) + - HUB_TRUST_PROXY=1 - HUB_DOMAIN=dnss.shnt.top - TZ=Asia/Novosibirsk labels: @@ -209,3 +215,4 @@ networks: volumes: technitium-data: technitium-console: + technitium-hub-data: diff --git a/docs/hub-deploy.md b/docs/hub-deploy.md index 8dcda5f..a220bd7 100644 --- a/docs/hub-deploy.md +++ b/docs/hub-deploy.md @@ -4,7 +4,7 @@ - запрос «All servers» хаб отправляет на оба сервера и складывает ответы; - запрос к одному серверу хаб пересылает только ему; -- запись хаб не выполняет никогда. +- запись хаб пока не выполняет: любой запрос на изменение получает отказ и попадает в журнал. Серверы 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 NDNS_TOKEN=вставьте_токен_ndns -# Вход в сам хаб (это НЕ логин Technitium) +# Первый вход в хаб (это НЕ логин Technitium). Нужен, пока не создан файл пользователей (шаг 4a) HUB_USER=admin HUB_PASSWORD=длинный_случайный_пароль ``` -Пароль хаба нужен длинный: у хаба нет ограничения числа попыток входа. Сгенерировать можно так: +Пароль лучше длинный. После 5 неудачных попыток за 15 минут вход блокируется на 15 минут, но это не замена хорошему паролю. Сгенерировать можно так: ```bash openssl rand -base64 24 ``` @@ -126,8 +126,12 @@ chmod 600 .env - NDNS_TOKEN=${NDNS_TOKEN} - HUB_USER=${HUB_USER} - HUB_PASSWORD=${HUB_PASSWORD} + - HUB_DATA=/data + - HUB_TRUST_PROXY=1 - HUB_DOMAIN=dnss.shnt.top - TZ=Asia/Novosibirsk + volumes: + - technitium-hub-data:/data labels: - "traefik.enable=true" - "traefik.docker.network=dns-net" @@ -138,6 +142,13 @@ chmod 600 .env - "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` в этом файле уже объявлена. Про имена в `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. Запуск Если реестр закрытый, один раз войдите в него. Нужен токен Gitea с правом `read:package`: @@ -218,7 +255,10 @@ docker compose rm -f technitium-hub # удалить контейнер |---|---|---| | `HUB_SERVERS` | обязательна | Серверы по порядку: `id=https://адрес,...` | | `_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) | +| `_ADMIN_TOKEN` | нет | Токен записи сервера (`MDNS_ADMIN_TOKEN`). Пока не используется, в логи не пишется | | `HUB_DOMAIN` | `hub` | Имя, которое консоль показывает как имя сервера | | `HUB_CACHE_SECONDS` | `15` | Сколько секунд хранить ответы серверов (0 выключает кеш) | | `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`). | | `https://dnss.shnt.top` не открывается, ошибка сертификата | Нет DNS-записи `dnss` (шаг 2) или Traefik ещё получает сертификат: смотрите `docker logs traefik`. | | 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) и перезапустите хаб. | | В подсказке `... 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). | @@ -249,6 +292,6 @@ docker compose rm -f technitium-hub # удалить контейнер ## Безопасность -- Хаб только читает: любая запись получает отказ ещё в хабе, а токены выданы пользователю с одними правами View. +- Хаб только читает: любая запись получает отказ ещё в хабе (и запись в журнале), а токены выданы пользователю с одними правами View. - Токены лежат только в `.env` на хосте и в памяти контейнера. В браузер они не попадают, в ответы и логи хаба не пишутся. -- У хаба один общий логин и нет ограничения попыток входа. Нужен длинный пароль, а если хаб не должен быть виден из интернета, добавьте к роутеру `hub` в Traefik `ipallowlist` или `basicAuth`. +- У каждого человека свой логин и роль. Вход защищён блокировкой после 5 неудач. Нужны длинные пароли, а если хаб не должен быть виден из интернета, добавьте к роутеру `hub` в Traefik `ipallowlist` или `basicAuth`. diff --git a/docs/hub-management-plan.md b/docs/hub-management-plan.md new file mode 100644 index 0000000..ee91ba0 --- /dev/null +++ b/docs/hub-management-plan.md @@ -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`: `_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. **Какие приложения стоят на серверах** и какие поля в их конфигах заведомо локальные. Это станет начальными списками локальных полей. diff --git a/hub/README.md b/hub/README.md index f79438f..6246912 100644 --- a/hub/README.md +++ b/hub/README.md @@ -8,7 +8,7 @@ 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`. -- 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 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. | | `_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. | +| `_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_CACHE_SECONDS` | `15` | How long successful answers of the servers are cached. | | `HUB_TIMEOUT_MS` | `8000` | Per-request timeout towards a server. | | `HUB_DOMAIN` | `hub` | Shown as the server's domain in the console. | | `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 -- ` locally): + +```sh +docker exec -it technitium-hub node --experimental-strip-types /app/hub/users-cli.ts add alice apps-admin +``` + +`add `, `passwd `, `role `, `disable `, `enable `, `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=`, newest first. + ## Endpoints Public: `status`, `user/login`. After signing in: `user/session/get`, `user/logout`, `user/checkForUpdate` (always "no update") and these reads: `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 @@ -44,7 +92,7 @@ npm run build node dev/mock-api.mjs --port 5380 & 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_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`. @@ -54,7 +102,7 @@ Then open http://localhost:5390. Tests: `npx vitest run hub`. ```sh docker run --rm -p 8080:8080 \ -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 ``` diff --git a/hub/audit.ts b/hub/audit.ts new file mode 100644 index 0000000..97312eb --- /dev/null +++ b/hub/audit.ts @@ -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 + +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 + } +} diff --git a/hub/config.ts b/hub/config.ts index 94a302e..c6ebc0c 100644 --- a/hub/config.ts +++ b/hub/config.ts @@ -9,12 +9,24 @@ export interface HubServer { id: string url: string token: string + /** Optional write-capable token (`_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 { servers: HubServer[] - user: string - password: string + /** From HUB_USER and HUB_PASSWORD; null when they are not set. Whether it is used depends on the users file. */ + 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 cacheSeconds: number timeoutMs: number @@ -31,6 +43,11 @@ export function tokenVariable(id: string): string { 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 { const raw = env[name] 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 { + // The users file decides whether these are needed; the config does not read the file. const user = env.HUB_USER ?? '' 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[] = [] for (const part of (env.HUB_SERVERS ?? '').split(',')) { @@ -58,7 +76,8 @@ export function parseConfig(env: Env): HubConfig { const variable = tokenVariable(id) const token = env[variable] ?? '' 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) { 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 { servers, - user, - password, + bootstrap: user === '' ? null : { user, password }, + dataDir: env.HUB_DATA?.trim() || '/data', + trustProxy: ['1', 'true'].includes((env.HUB_TRUST_PROXY ?? '').trim().toLowerCase()), port: number(env, 'HUB_PORT', 8080, 0), cacheSeconds: number(env, 'HUB_CACHE_SECONDS', 15, 0), timeoutMs: number(env, 'HUB_TIMEOUT_MS', 8000, 1), diff --git a/hub/ratelimit.ts b/hub/ratelimit.ts new file mode 100644 index 0000000..2d0fe77 --- /dev/null +++ b/hub/ratelimit.ts @@ -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() + #users = new Map() + + 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, 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'}.` +} diff --git a/hub/routes.ts b/hub/routes.ts new file mode 100644 index 0000000..0cf8904 --- /dev/null +++ b/hub/routes.ts @@ -0,0 +1,38 @@ +/* +What each `/api/` does and who may call it. `kind` says what it touches: +`read` (no change), `write-one` (changes one server) and `write-all` (changes every server). +A path that is not listed is refused. 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 = { + '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/') diff --git a/hub/security.test.ts b/hub/security.test.ts new file mode 100644 index 0000000..2d69ece --- /dev/null +++ b/hub/security.test.ts @@ -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 = {}) => ({ + 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 = {}): Promise { + const hub = createHub(parseConfig(env(over))) + servers.push(hub) + await new Promise((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 = {}, token?: string, headers: Record = {}) { + const headerSet: Record = { ...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 } +} + +const login = (base: string, user: string, pass: string, headers: Record = {}) => + 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[] + 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') + }) +}) diff --git a/hub/server.test.ts b/hub/server.test.ts index 38aa3f5..5054777 100644 --- a/hub/server.test.ts +++ b/hub/server.test.ts @@ -77,6 +77,7 @@ let b: Fake let hub: http.Server let base: string let staticDir: string +let dataDir: string function makeConfig(over: Record = {}) { return parseConfig({ @@ -86,6 +87,7 @@ function makeConfig(over: Record = {}) { HUB_USER: 'admin', HUB_PASSWORD: 'secret', HUB_STATIC: staticDir, + HUB_DATA: dataDir, HUB_CACHE_SECONDS: '15', HUB_TIMEOUT_MS: '2000', ...over, @@ -117,6 +119,7 @@ async function login(): Promise { beforeAll(async () => { 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'])) + dataDir = mkdtempSync(path.join(tmpdir(), 'hub-data-')) staticDir = mkdtempSync(path.join(tmpdir(), 'hub-static-')) writeFileSync(path.join(staticDir, 'index.html'), 'root') mkdirSync(path.join(staticDir, 'logs', 'query-logs'), { recursive: true }) @@ -129,6 +132,7 @@ afterAll(() => { a.server.close() b.server.close() rmSync(staticDir, { recursive: true, force: true }) + rmSync(dataDir, { recursive: true, force: true }) }) beforeEach(async () => { @@ -142,9 +146,9 @@ beforeEach(async () => { }) 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' } - 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/) }) diff --git a/hub/server.ts b/hub/server.ts index 66e9ad2..53088bb 100644 --- a/hub/server.ts +++ b/hub/server.ts @@ -17,6 +17,10 @@ import { fileURLToPath } from 'node:url' import { loadConfig, HUB_VERSION, type HubConfig, type HubServer } from './config.ts' import { Upstream, UpstreamError, type Params } from './upstream.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 { mergeQueryLogs, mergeSettled, @@ -32,19 +36,6 @@ type Json = Record 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 = { '.html': 'text/html; 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 } -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 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])) /* 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 }) return } + const ip = clientIp(req, config.trustProxy) if (apiPath === 'user/login') { - const token = sessions.login(params.user ?? '', params.pass ?? '') - send(res, 200, token === null ? fail(`Invalid username or password for user: ${params.user ?? ''}`) : sessions.body(token)) + const name = params.user ?? '' + 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 } 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.' }) return } if (apiPath === 'user/session/get') { - send(res, 200, sessions.body(token as string)) + send(res, 200, sessions.body(token as string, who)) return } if (apiPath === 'user/logout') { sessions.logout(token as string) + audit.log({ user: who.user, role: who.role, ip, action: 'logout', outcome: 'ok' }) send(res, 200, { status: 'ok' }) 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.')) 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') { send(res, 200, ok({ updateAvailable: false })) return @@ -302,7 +340,10 @@ export function createHub(config: HubConfig): http.Server { 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 () => { try { 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). */ @@ -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)}`) 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(', ')}`) }) } diff --git a/hub/session.ts b/hub/session.ts index afb959a..c69d6ae 100644 --- a/hub/session.ts +++ b/hub/session.ts @@ -1,18 +1,23 @@ /* -The hub's own sign-in. One user from the environment, sessions kept in memory with an -idle expiry, and the `info` object the console reads from `user/login` and -`user/session/get` (flat, as the real server sends it). +The hub's own sign-in. Sessions are kept in memory with an idle expiry; each remembers who +signed in and what that user looked like (role, hash, disabled flag) at that moment, so a +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 { signature, type Role, type UserRecord, type Users } from './users.ts' export const IDLE_MS = 30 * 60_000 -/* Compared as hashes so the lengths always match and the time does not depend on the content. */ -function sameSecret(a: string, b: string): boolean { - const ha = createHash('sha256').update(a).digest() - const hb = createHash('sha256').update(b).digest() - return timingSafeEqual(ha, hb) +export interface Identity { + user: string + role: Role +} + +interface Session extends Identity { + last: number + sig: string } /* @@ -28,39 +33,39 @@ const VIEWABLE = new Set(['Dashboard', 'Logs']) export class Sessions { #config: HubConfig + #users: Users #now: () => number #started: string - #seen = new Map() + #seen = new Map() - constructor(config: HubConfig, now: () => number = Date.now) { + constructor(config: HubConfig, users: Users, now: () => number = Date.now) { this.#config = config + this.#users = users this.#now = now this.#started = new Date(now()).toISOString() } - /** A new session token, or null when the credentials are wrong. */ - login(user: string, password: string): string | null { - // 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 + /** Opens a session for a user already authenticated by `Users.authenticate`. */ + open(user: UserRecord): string { this.#sweep() 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 } - /** True while the token is valid; each use extends it. */ - touch(token: string | null | undefined): boolean { - if (!token) return false - const last = this.#seen.get(token) - if (last === undefined) return false - if (this.#now() - last > IDLE_MS) { + /* The session's identity while it is valid (extending it), else null. A user who was removed, + disabled or edited since sign-in ends the session. */ + touch(token: string | null | undefined): Identity | null { + if (!token) return null + const session = this.#seen.get(token) + 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) - return false + return null } - this.#seen.set(token, this.#now()) - return true + session.last = this.#now() + return { user: session.user, role: session.role } } logout(token: string): void { @@ -69,10 +74,10 @@ export class Sessions { #sweep(): void { 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 return { version: HUB_VERSION, @@ -92,20 +97,22 @@ export class Sessions { useSoaSerialDateScheme: true, dnssecValidation: true, hub: true, + hubRole: role, } } /** The flat body of `user/login` and `user/session/get`. */ - body(token: string) { + body(token: string, identity: Identity) { return { status: 'ok', token, - username: this.#config.user, - displayName: this.#config.user, + username: identity.user, + displayName: identity.user, type: 'Local', isSsoUser: false, totpEnabled: false, - info: this.info(), + hubRole: identity.role, + info: this.info(identity.role), } } } diff --git a/hub/users-cli.ts b/hub/users-cli.ts new file mode 100644 index 0000000..a78fb05 --- /dev/null +++ b/hub/users-cli.ts @@ -0,0 +1,134 @@ +/* +Manage the hub's users file. + + node --experimental-strip-types hub/users-cli.ts add + node --experimental-strip-types hub/users-cli.ts passwd + node --experimental-strip-types hub/users-cli.ts role + node --experimental-strip-types hub/users-cli.ts disable + node --experimental-strip-types hub/users-cli.ts enable + 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 + add roles: ${ROLES.join(', ')} + passwd + role + disable + enable + list +The password is asked twice, or taken from HUB_NEW_PASSWORD.` + +type Env = Record + +/* Reads one line from the terminal without echoing it. */ +function askHidden(prompt: string): Promise { + 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 { + 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, +): Promise { + 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) + }, + ) +} diff --git a/hub/users.ts b/hub/users.ts new file mode 100644 index 0000000..076e3f0 --- /dev/null +++ b/hub/users.ts @@ -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 = { viewer: 1, operator: 2, 'apps-admin': 2, admin: 3 } + +const CAPABILITIES: Record = { + 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 { + 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 { + 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[]) { + 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 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 { + 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 + } +} diff --git a/package.json b/package.json index 62f84c5..962f962 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "dev": "vite", "mock": "node dev/mock-api.mjs", "hub": "node --experimental-strip-types hub/server.ts", + "hub:user": "node --experimental-strip-types hub/users-cli.ts", "build": "tsc -b && vite build", "lint": "oxlint", "lint:language": "node dev/check-language.mjs",