From 6acd2d656d57e8626c1bd76219a2bb9ad89001bd Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sat, 31 Oct 2026 16:11:40 +0700 Subject: [PATCH] Hub stage 2: install, update and uninstall apps on several servers Writes use only _ADMIN_TOKEN and need the apps.write capability. Store URLs are resolved from each server's own listing. The config is saved to history before an uninstall. Zip uploads are fanned out to the servers. Every target's result is audited. The console gets a row menu on the matrix, a target picker, a results dialog with Retry failed, the store and install from file in All servers mode, and single-server actions for writers. Co-Authored-By: Claude Opus 5.5 --- dev/mock-api.mjs | 33 +- docs/compose.hub.example.yaml | 4 + docs/compose.shnt.example.yaml | 3 + docs/hub-deploy.md | 47 ++- hub/README.md | 28 +- hub/apps-matrix.test.ts | 8 +- hub/apps-matrix.ts | 6 +- hub/apps-write.test.ts | 513 +++++++++++++++++++++++++++ hub/apps-write.ts | 185 ++++++++++ hub/multipart.ts | 91 +++++ hub/routes.ts | 13 +- hub/server.ts | 166 ++++++++- hub/session.ts | 13 +- hub/upstream.ts | 48 ++- src/api/apps.ts | 58 ++- src/app/Shell.tsx | 4 +- src/screens/apps/AppCard.tsx | 11 +- src/screens/apps/Apps.tsx | 68 +++- src/screens/apps/AppsMatrix.test.tsx | 6 +- src/screens/apps/AppsMatrix.tsx | 97 ++++- src/screens/apps/AppsWrite.test.tsx | 288 +++++++++++++++ src/screens/apps/HubInstallFile.tsx | 76 ++++ src/screens/apps/HubStore.tsx | 133 +++++++ src/screens/apps/InstallApp.tsx | 5 +- src/screens/apps/RunFlow.tsx | 142 ++++++++ src/screens/apps/StoreApps.tsx | 11 +- src/screens/apps/UpdateApp.tsx | 5 +- 27 files changed, 1991 insertions(+), 71 deletions(-) create mode 100644 hub/apps-write.test.ts create mode 100644 hub/apps-write.ts create mode 100644 hub/multipart.ts create mode 100644 src/screens/apps/AppsWrite.test.tsx create mode 100644 src/screens/apps/HubInstallFile.tsx create mode 100644 src/screens/apps/HubStore.tsx create mode 100644 src/screens/apps/RunFlow.tsx 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/