From 0e050392024d3e628cf7e0b9c363c750e70976c8 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sat, 31 Oct 2026 16:46:15 +0700 Subject: [PATCH] Hub stage 3: compare and sync app configs across servers The hub keeps a baseline per app in /data/apps//baseline.json. Local fields (overrides.json) are left out of the sync and out of the matrix comparison. Each server gets the baseline plus its own values. Applying goes target picker, preview with per-server diffs and warnings, then apply. Apply refuses a server whose config changed since the preview, saves the old config to history, then reads the new one back to check it. Large config bodies are read only for signed-in callers. The App config dialog has Compare, Baseline and Local fields tabs and an empty state that offers an import. Co-Authored-By: Claude Opus 5.5 --- dev/mock-api.mjs | 10 +- docs/hub-deploy.md | 18 + hub/README.md | 33 +- hub/apps-config.ts | 338 +++++++++++++++ hub/apps-matrix.ts | 8 +- hub/apps-sync.test.ts | 529 ++++++++++++++++++++++++ hub/apps-sync.ts | 273 ++++++++++++ hub/apps-write.test.ts | 4 +- hub/apps-write.ts | 45 +- hub/routes.ts | 12 +- hub/server.ts | 98 ++++- hub/store.ts | 109 +++++ hub/upstream.ts | 2 +- src/api/apps.ts | 99 ++++- src/screens/apps/AppConfig.tsx | 5 +- src/screens/apps/AppConfigSync.test.tsx | 281 +++++++++++++ src/screens/apps/AppConfigSync.tsx | 392 ++++++++++++++++++ src/screens/apps/Apps.tsx | 4 +- src/screens/apps/AppsMatrix.tsx | 24 +- src/screens/apps/AppsWrite.test.tsx | 14 +- src/screens/apps/ConfigDiffView.tsx | 131 ++++++ src/screens/apps/RunFlow.tsx | 63 ++- 22 files changed, 2434 insertions(+), 58 deletions(-) create mode 100644 hub/apps-config.ts create mode 100644 hub/apps-sync.test.ts create mode 100644 hub/apps-sync.ts create mode 100644 hub/store.ts create mode 100644 src/screens/apps/AppConfigSync.test.tsx create mode 100644 src/screens/apps/AppConfigSync.tsx create mode 100644 src/screens/apps/ConfigDiffView.tsx diff --git a/dev/mock-api.mjs b/dev/mock-api.mjs index ca66be7..e1d54e9 100644 --- a/dev/mock-api.mjs +++ b/dev/mock-api.mjs @@ -197,8 +197,8 @@ if (VARIANT === 'b') { } const appConfigs = { 'Advanced Blocking': VARIANT === 'b' - ? { enableBlocking: true, blockingAnswerTtl: 60, networkGroupMap: { '0.0.0.0/0': 'default' }, groups: [{ name: 'default', enableBlocking: false, blockAsNxDomain: false }] } - : { enableBlocking: true, blockingAnswerTtl: 30, networkGroupMap: { '0.0.0.0/0': 'default' }, groups: [{ name: 'default', enableBlocking: true, blockAsNxDomain: false }] }, + ? { enableBlocking: true, blockingAnswerTtl: 60, localGroup: 'office', networkGroupMap: { '0.0.0.0/0': 'default' }, groups: [{ name: 'default', enableBlocking: false, blockAsNxDomain: false }] } + : { enableBlocking: true, blockingAnswerTtl: 30, localGroup: 'home', networkGroupMap: { '0.0.0.0/0': 'default' }, groups: [{ name: 'default', enableBlocking: true, blockAsNxDomain: false }] }, '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' } }, } @@ -307,6 +307,12 @@ function route(path, q) { apps = update ? apps.map((a) => (a.name === name ? installed : a)) : [...apps, installed] return ok(update ? { updatedApp: installed } : { installedApp: installed }) } + case 'apps/config/set': { + const name = q.get('name') ?? '' + if (!apps.some((a) => a.name === name)) return { status: 'error', errorMessage: `DNS application '${name}' was not found.` } + try { appConfigs[name] = JSON.parse(q.get('config') ?? '') } catch (e) { return { status: 'error', errorMessage: `The config is not valid JSON: ${e.message}` } } + return ok() + } 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/hub-deploy.md b/docs/hub-deploy.md index 1a8d5f0..c0cce62 100644 --- a/docs/hub-deploy.md +++ b/docs/hub-deploy.md @@ -309,6 +309,24 @@ docker compose rm -f technitium-hub # удалить контейнер | Цифры «All servers» больше суммы на 1-2 клиента | Clients считается по каждому серверу отдельно: клиент, который ходит на оба сервера, учтён дважды. Это ожидаемо. | | Нажатие на что-то пишет «The hub is read-only.» | Хаб меняет только приложения. Остальные изменения делаются в консоли нужного сервера. | +## Конфиги приложений: эталон и синхронизация + +Чтобы конфиги приложений на серверах были одинаковыми, кроме полей, которые у каждого сервера свои (путь к базе, сети): + +1. Для чтения конфигов `hub-reader` нужно право **View на Apps** (оно уже есть в таблице шага 1). Если сервер откажет, хаб попробует токен `hub-admin`. +2. Откройте Apps › All servers, в меню строки приложения выберите **Config…** (или нажмите значок «Different · N»). Читать сравнение могут все роли. +3. **Эталон.** Вкладка Baseline: **Import from mdns** (или другого сервера, на котором конфиг правильный). Эталон можно поправить в редакторе и сохранить. Если текст начинается с `{` или `[`, он должен быть корректным JSON, иначе хаб покажет ошибку. +4. **Локальные поля.** Вкладка Local fields: добавьте путь поля, например `sqliteDbPath` или `networks.0.cidr`, нажмите **Fill from the current server configs** (подставит текущие значения каждого сервера) и сохраните. Такие поля не сравниваются и не рассылаются: в матрице серверы, различающиеся только ими, показываются как «Same». +5. **Применение.** Кнопка **Apply…**: выберите серверы, посмотрите предпросмотр (что изменится, предупреждения, например «версии отличаются, сначала выровняйте версии»), нажмите **Apply**. По каждому серверу будет итог; **Retry failed** повторит только неудавшиеся. + +Что хаб делает для безопасности: +- перед записью сохраняет прежний конфиг сервера в `/data/apps/<имя>/history/` (50 последних) и не пишет, если сохранить не удалось; +- если конфиг на сервере изменили после предпросмотра, этот сервер пропускается с пояснением; +- после записи читает конфиг обратно и проверяет, что он совпал; если сервер отклонил конфиг, остаётся старый; +- в журнал попадают кто, на каком сервере и размер с коротким хешем конфига, но не сам текст. + +Файлы эталона и локальных полей лежат в `/data/apps/<имя>/` (`baseline.json`, `overrides.json`). Они содержат конфиги, поэтому том `/data` нужно беречь так же, как `users.json`. + ## Приложения: установка, обновление, удаление Роль `apps-admin` (или `admin`) видит в разделе Apps при «All servers» меню действий в каждой строке и кнопки **App Store** и **Install from file**: diff --git a/hub/README.md b/hub/README.md index 1e181e1..db4797a 100644 --- a/hub/README.md +++ b/hub/README.md @@ -83,7 +83,7 @@ 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. `apps/config/get` and `apps/config/set` are not available yet. +Everything else answers `403 The hub is read-only.` and is audited. ## App writes @@ -99,6 +99,37 @@ Every write goes to a server with its `_ADMIN_TOKEN`; a server without one a 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`. +- `apps/config/get` (read, `node` required) and `apps/config/set` (`apps.write`, `node` required; the old config is saved to the + history first and the call is refused if that fails): the console's own per-server Config dialog. + +## Config sync + +Keeps one app's config the same on every server, except for the fields that must differ. Per app the hub stores, under +`${HUB_DATA}/apps//`: + +- `baseline.json`: `{ app, text, importedFrom?, updatedAt, updatedBy }`. The config as TEXT, so non-JSON configs work too. +- `overrides.json`: `{ paths: [...], values: { : { : } } }`. Paths are dotted (`db.path`, + `networks.0.cidr`); at most 50; only for a JSON baseline. +- `history/