diff --git a/dev/mock-api.mjs b/dev/mock-api.mjs index 4ebebe3..ca66be7 100644 --- a/dev/mock-api.mjs +++ b/dev/mock-api.mjs @@ -202,11 +202,18 @@ const appConfigs = { 'Query Logs (Sqlite)': { enableLogging: true, maxLogDays: 365, sqliteDbPath: VARIANT === 'b' ? '/var/lib/dns/ndns/querylogs.db' : '/var/lib/dns/mdns/querylogs.db' }, 'Split Horizon': { domainGroupMap: { 'lan.example.net': 'internal' }, networkGroupMap: { '10.0.0.0/8': 'internal' } }, } -const store = [ +const catalogRows = [ ['Failover', 'Fails over to a backup address when the primary is down', '8.0', '58 KB'], ['Geo Country', 'Answers by the country of the client', '7.2', '1.2 MB'], ['Wild IP', 'Answers with the IP address in the name', '5.1', '22 KB'], ['Auto PTR', 'Answers PTR queries from the forward zones', '6.0', '31 KB'], ['Drop Requests', 'Drops requests from the listed networks', '7.0', '19 KB'], ['NX Domain', 'Answers NXDOMAIN for the listed domains', '6.1', '17 KB'], -].map(([name, description, version, size]) => ({ name, description, version, size, url: `https://download.technitium.com/dns/apps/${name.replace(/ /g, '')}App-v${version}.zip`, installed: false })) + ['Advanced Blocking', 'Group based blocking with regex', '10.2', '240 KB'], ['Query Logs (Sqlite)', 'Logs DNS queries into a SQLite database', '7.2', '310 KB'], + ['Split Horizon', 'Different answers for different networks', '9.0', '120 KB'], +].map(([name, description, version, size]) => ({ name, description, version, size, url: `https://download.technitium.com/dns/apps/${name.replace(/[ ()]/g, '')}App-v${version}.zip` })) +const catalog = () => catalogRows +const storeListing = () => catalogRows.map((c) => { + const have = apps.find((a) => a.name === c.name) + return have ? { ...c, installed: true, installedVersion: have.version, updateAvailable: have.version !== c.version } : { ...c, installed: false } +}) // ── Query logs ───────────────────────────────────────────────────────────── const RTYPES = ['Recursive', 'Cached', 'Authoritative', 'Blocked', 'Cached', 'Recursive'] @@ -279,7 +286,27 @@ function route(path, q) { case 'dhcp/scopes/enable': case 'dhcp/scopes/disable': scopes = scopes.map((s) => (s.name === q.get('name') ? { ...s, enabled: path === 'dhcp/scopes/enable' } : s)); return ok() case 'dhcp/scopes/delete': scopes = scopes.filter((s) => s.name !== q.get('name')); return ok() case 'apps/list': return ok({ apps }) - case 'apps/listStoreApps': return ok({ storeApps: store }) + case 'apps/listStoreApps': return ok({ storeApps: storeListing() }) + case 'apps/downloadAndInstall': case 'apps/downloadAndUpdate': { + const entry = catalog().find((c) => c.name === q.get('name') && c.url === q.get('url')) + if (!entry) return { status: 'error', errorMessage: 'The app was not found in the store.' } + const update = path === 'apps/downloadAndUpdate' + const have = apps.find((a) => a.name === entry.name) + if (update !== Boolean(have)) return { status: 'error', errorMessage: update ? `DNS application '${entry.name}' was not found.` : `DNS application '${entry.name}' already exists.` } + const installed = { ...(have ?? { name: entry.name, description: entry.description, dnsApps: [app(false)] }), version: entry.version } + delete installed.updateAvailable; delete installed.updateVersion; delete installed.updateUrl + apps = update ? apps.map((a) => (a.name === entry.name ? installed : a)) : [...apps, installed] + return ok(update ? { updatedApp: installed } : { installedApp: installed }) + } + case 'apps/install': case 'apps/update': { + const name = q.get('name') ?? '' + const have = apps.find((a) => a.name === name) + const update = path === 'apps/update' + if (update !== Boolean(have)) return { status: 'error', errorMessage: update ? `DNS application '${name}' was not found.` : `DNS application '${name}' already exists.` } + const installed = { ...(have ?? { name, description: 'Installed from a zip file', dnsApps: [app(false)] }), version: update ? `${have.version}.1` : '1.0' } + apps = update ? apps.map((a) => (a.name === name ? installed : a)) : [...apps, installed] + return ok(update ? { updatedApp: installed } : { installedApp: installed }) + } case 'apps/config/get': return ok({ config: appConfigs[q.get('name')] ? JSON.stringify(appConfigs[q.get('name')], null, 2) : JSON.stringify({ enabled: true }, null, 2) }) case 'apps/uninstall': apps = apps.filter((a) => a.name !== q.get('name')); return ok() case 'logs/list': return ok({ logFiles: [{ fileName: '2026-10-26', size: '412.8 KB' }, { fileName: '2026-10-25', size: '1.2 MB' }, { fileName: '2026-10-24', size: '980.4 KB' }] }) diff --git a/docs/compose.hub.example.yaml b/docs/compose.hub.example.yaml index 0792687..51fa2d1 100644 --- a/docs/compose.hub.example.yaml +++ b/docs/compose.hub.example.yaml @@ -11,6 +11,8 @@ version: "3.8" # Токены кладутся в .env рядом с этим файлом (не в git): # MDNS_TOKEN=... # NDNS_TOKEN=... +# MDNS_ADMIN_TOKEN=... # токены hub-admin (Modify и Delete только на Apps): нужны, чтобы хаб менял приложения +# NDNS_ADMIN_TOKEN=... # HUB_USER=... # логин в сам хаб # HUB_PASSWORD=... # первый вход; затем пользователи в /data/users.json (docs/hub-deploy.md, шаг 4a) # @@ -30,6 +32,8 @@ services: - HUB_SERVERS=mdns=https://mdns.shnt.top,ndns=https://ndns.shnt.top - MDNS_TOKEN=${MDNS_TOKEN} - NDNS_TOKEN=${NDNS_TOKEN} + - MDNS_ADMIN_TOKEN=${MDNS_ADMIN_TOKEN} + - NDNS_ADMIN_TOKEN=${NDNS_ADMIN_TOKEN} # Только пока нет /data/users.json; потом пользователи создаются через users-cli (docs/hub-deploy.md) - HUB_USER=${HUB_USER} - HUB_PASSWORD=${HUB_PASSWORD} diff --git a/docs/compose.shnt.example.yaml b/docs/compose.shnt.example.yaml index ab5da89..12fae75 100644 --- a/docs/compose.shnt.example.yaml +++ b/docs/compose.shnt.example.yaml @@ -189,6 +189,9 @@ services: - HUB_SERVERS=mdns=https://mdns.shnt.top,ndns=https://ndns.shnt.top - MDNS_TOKEN=${MDNS_TOKEN} - NDNS_TOKEN=${NDNS_TOKEN} + # Токены hub-admin: без них хаб только читает. Нужны для установки и обновления приложений + - MDNS_ADMIN_TOKEN=${MDNS_ADMIN_TOKEN} + - NDNS_ADMIN_TOKEN=${NDNS_ADMIN_TOKEN} # Только пока нет /data/users.json; потом пользователи создаются через users-cli (docs/hub-deploy.md) - HUB_USER=${HUB_USER} - HUB_PASSWORD=${HUB_PASSWORD} diff --git a/docs/hub-deploy.md b/docs/hub-deploy.md index a220bd7..1a8d5f0 100644 --- a/docs/hub-deploy.md +++ b/docs/hub-deploy.md @@ -4,7 +4,7 @@ - запрос «All servers» хаб отправляет на оба сервера и складывает ответы; - запрос к одному серверу хаб пересылает только ему; -- запись хаб пока не выполняет: любой запрос на изменение получает отказ и попадает в журнал. +- менять хаб умеет только приложения (установка, обновление, удаление), и только для пользователей с ролью `apps-admin` или `admin`. Остальные запросы на изменение получают отказ и попадают в журнал. Серверы Technitium не меняются, кластер не нужен. В инструкции хаб ставится на хост, где уже работают Traefik и `technitium-smart` (`docs/compose.shnt.example.yaml`). Адрес хаба: `https://dnss.shnt.top`. @@ -72,6 +72,19 @@ git tag hub-v1.0.0 && git push origin hub-v1.0.0 Результат шага: два токена, `MDNS_TOKEN` (с `mdns.shnt.top`) и `NDNS_TOKEN` (с `ndns.shnt.top`). +### Шаг 1а. Пользователь для записи (нужен, чтобы менять приложения) + +Без этого шага хаб только читает. Токены записи отдельные, у отдельного пользователя. Сделайте это **на обоих серверах**: + +1. **Группа** `hub-admins`. Права группы (Administration › Permissions): + - **Apps**: View, Modify и Delete; + - **Dashboard**, **Logs**, **Settings**: только View, и то лишь если хотите, чтобы этот токен мог читать то же, что `hub-reader`. Для записи приложений достаточно Apps. + - остальные разделы без прав. +2. **Пользователь** `hub-admin` в этой группе, длинный случайный пароль. +3. **API-токен** для `hub-admin` (так же, как в шаге 1.4). Это `MDNS_ADMIN_TOKEN` и `NDNS_ADMIN_TOKEN`. + +Хаб использует эти токены только для записи приложений и для чтения конфига приложения перед удалением. Если для сервера токена нет, хаб показывает его «только для чтения» и не вызывает для записи. + --- ## Шаг 2. DNS-запись для dnss.shnt.top @@ -91,6 +104,10 @@ git tag hub-v1.0.0 && git push origin hub-v1.0.0 MDNS_TOKEN=вставьте_токен_mdns NDNS_TOKEN=вставьте_токен_ndns +# Токены hub-admin с шага 1а (без них хаб не меняет приложения) +MDNS_ADMIN_TOKEN=вставьте_токен_записи_mdns +NDNS_ADMIN_TOKEN=вставьте_токен_записи_ndns + # Первый вход в хаб (это НЕ логин Technitium). Нужен, пока не создан файл пользователей (шаг 4a) HUB_USER=admin HUB_PASSWORD=длинный_случайный_пароль @@ -124,6 +141,8 @@ chmod 600 .env - HUB_SERVERS=mdns=https://mdns.shnt.top,ndns=https://ndns.shnt.top - MDNS_TOKEN=${MDNS_TOKEN} - NDNS_TOKEN=${NDNS_TOKEN} + - MDNS_ADMIN_TOKEN=${MDNS_ADMIN_TOKEN} + - NDNS_ADMIN_TOKEN=${NDNS_ADMIN_TOKEN} - HUB_USER=${HUB_USER} - HUB_PASSWORD=${HUB_PASSWORD} - HUB_DATA=/data @@ -168,7 +187,7 @@ volumes: |---|---| | `viewer` | смотреть | | `operator` | смотреть, блокировка и кеш (после появления записи) | -| `apps-admin` | смотреть, управлять приложениями (после появления записи) | +| `apps-admin` | смотреть, устанавливать, обновлять и удалять приложения на выбранных серверах | | `admin` | всё, включая журнал действий | Пока файла нет, в хаб входит один `admin` из `HUB_USER` / `HUB_PASSWORD`. Когда файл появился, эти две переменные игнорируются (в логе будет строка об этом). Если нет ни файла, ни переменных, хаб не запустится. @@ -258,7 +277,7 @@ docker compose rm -f technitium-hub # удалить контейнер | `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`). Пока не используется, в логи не пишется | +| `_ADMIN_TOKEN` | нет | Токен записи сервера (`MDNS_ADMIN_TOKEN`). Им хаб меняет приложения; без него сервер только для чтения. В логи не пишется | | `HUB_DOMAIN` | `hub` | Имя, которое консоль показывает как имя сервера | | `HUB_CACHE_SECONDS` | `15` | Сколько секунд хранить ответы серверов (0 выключает кеш) | | `HUB_TIMEOUT_MS` | `8000` | Сколько ждать ответа сервера, мс | @@ -288,10 +307,28 @@ docker compose rm -f technitium-hub # удалить контейнер | В подсказке `ndns: ...` с сообщением об отказе в доступе | У группы `hub-readers` на этом сервере не хватает View в каком-то разделе (шаг 1.2). | | Query Logs пустой или «нет приложения» | На сервере не установлено приложение логирования (Query Logs (Sqlite) и подобные) или нет права View на Apps. | | Цифры «All servers» больше суммы на 1-2 клиента | Clients считается по каждому серверу отдельно: клиент, который ходит на оба сервера, учтён дважды. Это ожидаемо. | -| Нажатие на что-то пишет «The hub is read-only.» | Хаб только читает. Изменения делаются в консоли нужного сервера. | +| Нажатие на что-то пишет «The hub is read-only.» | Хаб меняет только приложения. Остальные изменения делаются в консоли нужного сервера. | + +## Приложения: установка, обновление, удаление + +Роль `apps-admin` (или `admin`) видит в разделе Apps при «All servers» меню действий в каждой строке и кнопки **App Store** и **Install from file**: + +- **Update on all**: обновить с сервера, где есть обновление; +- **Install on missing servers**: поставить из магазина там, где приложения нет; +- **Align versions**: подтянуть отстающие серверы до самой новой установленной версии (если магазин её предлагает); +- **Uninstall from…**: удалить с выбранных серверов. + +Каждое действие сначала открывает выбор серверов (сервер без токена записи недоступен, с причиной), потом показывает итог по каждому серверу. Если на каком-то сервере не вышло, кнопка **Retry failed** повторит только его. + +Что хаб делает для безопасности: +- ссылку на скачивание берёт из магазина самого сервера, произвольный адрес не принимает; +- перед удалением сохраняет конфиг приложения в `/data/apps/<имя>/history/` (50 последних на приложение) и не удаляет, если сохранить не удалось. Откат: вставить сохранённый текст в Config приложения на сервере; +- каждое действие по каждому серверу пишется в журнал (`apps.update-store`, `apps.uninstall` и т. д.). + +Права на запись у `viewer` и `operator` нет: они получают «Your role does not allow this.». ## Безопасность -- Хаб только читает: любая запись получает отказ ещё в хабе (и запись в журнале), а токены выданы пользователю с одними правами View. +- Читает хаб токенами с одними правами View. Менять он умеет только приложения, и только токенами `*_ADMIN_TOKEN` (права Apps) по запросу пользователя с ролью `apps-admin` или `admin`. Всё остальное получает отказ в хабе и запись в журнале. - Токены лежат только в `.env` на хосте и в памяти контейнера. В браузер они не попадают, в ответы и логи хаба не пишутся. - У каждого человека свой логин и роль. Вход защищён блокировкой после 5 неудач. Нужны длинные пароли, а если хаб не должен быть виден из интернета, добавьте к роутеру `hub` в Traefik `ipallowlist` или `basicAuth`. diff --git a/hub/README.md b/hub/README.md index 6246912..1e181e1 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` (and written to the audit log). The hub has its own accounts with roles, a login rate limit and an audit log. +- Reads go with each server's read token. The only writes are the app changes below, which use a separate admin token and a role with `apps.write`. Anything else that would change data is refused with `403` (and written to the audit log). The hub has its own accounts with roles, a login rate limit and an audit log. 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 @@ -23,7 +23,7 @@ apps Query Logs reads from) and Settings (the blocking state). | `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. | +| `_ADMIN_TOKEN` | optional | A write-capable token per server (`MDNS_ADMIN_TOKEN`). Only app writes use it, and only it: a server without one is read-only for the hub (the matrix and the session list the writable servers). Never logged or returned. | | `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. | @@ -57,7 +57,7 @@ atomically with mode 0600. The running hub notices a change on the next request | `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 +`apps.write` is what the app writes below need; `operate` and the rest are still unused. Every response also carries `hubRole` (and `info.hubRole`). ## Login protection @@ -83,7 +83,27 @@ Public: `status`, `user/login`. After signing in: `user/session/get`, `user/logo `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.` and is audited. +Everything else answers `403 The hub is read-only.` and is audited. `apps/config/get` and `apps/config/set` are not available yet. + +## App writes + +Needs the `apps.write` role (`apps-admin`, `admin`); others get `403 Your role does not allow this.` and a `denied-write` audit line. +Every write goes to a server with its `_ADMIN_TOKEN`; a server without one answers "no admin token configured" and is not called. + +- `POST hub/apps/run` (form or JSON): `action` = `install-store` | `update-store` | `uninstall`, `name`, `targets` (list or + comma-separated server ids). Runs on every target in parallel and answers `{ results: [{ server, ok, message? }] }`. + The download URL is taken from each target's own `apps/listStoreApps`; a `url` field is only accepted when it matches. +- `POST hub/apps/upload` (multipart, at most 20 MB, kept in memory only): `action` = `install` | `update`, `name`, `targets` + (comma-separated), file field `fileAppZip`. The same zip is forwarded to every target. +- `apps/downloadAndInstall`, `apps/downloadAndUpdate`, `apps/uninstall`, `apps/install`, `apps/update` with `node=`: + the console's own single-server calls, forwarded to that server (store URLs checked as above). Refused without a node, and + with `node=cluster`. +- `apps/listStoreApps`: the first server's store, each app tagged with `installedOn`. + +Before an uninstall the app's config is saved to `${HUB_DATA}/apps//history/