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/