diff --git a/.dockerignore b/.dockerignore index fbb94ad..c5a06a1 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,3 +5,5 @@ documentation/ *.log .env .env.* +# Lokalni data a klic. Do image nepatri, v containeru vznikne cista slozka. +data/ diff --git a/.gitignore b/.gitignore index fea4962..33528f6 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,6 @@ coverage/ .DS_Store Thumbs.db .idea/ + +# Data mimo databazi (JSON snapshot a klic). Nikdy do gitu. +data/ diff --git a/documentation/01-prehled-a-stav.md b/documentation/01-prehled-a-stav.md index 0908725..e00ea7d 100644 --- a/documentation/01-prehled-a-stav.md +++ b/documentation/01-prehled-a-stav.md @@ -36,8 +36,8 @@ React aplikaci ze slozky `dist/public`. | Sprava clenstvi z portalu | chybi | memberships jdou zmenit jen v kodu | | Bugs a wishes | chybi | vyvojarska agenda, samostatna evidence vedle ticketu | | Beh automatizaci | chybi | ulozeny strom se nevykonava, neni runtime | -| Databaze pro konektory | hotovo | Postgres, udaje sifrovane. Bez DATABASE_URL jede pamet | -| Databaze pro zbytek | chybi | automatizace, rozlozeni a tickety jsou v pameti | +| Uloziste konektoru | hotovo | Postgres, nebo JSON soubor. Udaje vzdy sifrovane | +| Uloziste pro zbytek | chybi | automatizace, rozlozeni a tickety jsou v pameti | | Odesilani e-mailu z formulare | chybi | poptavka se zatim jen loguje | ## Znama omezeni diff --git a/documentation/14-databaze.md b/documentation/14-databaze.md index c1ae772..cc89a95 100644 --- a/documentation/14-databaze.md +++ b/documentation/14-databaze.md @@ -1,30 +1,39 @@ # 14 - Databaze -Naprogramovano a overeno proti Postgresu 16. Zatim se do databaze ukladaji -**konektory**, tedy pristupove udaje k sluzbam. Zbytek je v pameti procesu, -poradi dalsich kroku je na konci. +Naprogramovano a overeno. Zatim se ukladaji **konektory**, tedy pristupove udaje +k sluzbam. Zbytek je v pameti procesu, poradi dalsich kroku je na konci. -## Databaze je volitelna, ale rezimy jsou oddelene +Ukladat jde tremi zpusoby a rezim se vybira sam podle toho, co je k dispozici. -Aplikace jede ve dvou rezimech a rozdil se resi **na jednom miste**, -v `src/data/connectorStore.ts`. Nikde jinde se nezjistuje, jestli databaze je - -kdyby se to rozlezlo po kodu, jedno misto by se zapomnelo. +## Tri rezimy, jedno rozhrani -| Rezim | Kdy | Prezije restart | -| ---------- | --------------------------------------- | --------------- | -| `postgres` | je `DATABASE_URL` **i** `SECRETS_KEY` | ano | -| `memory` | jinak | ne | +Rozdil se resi **na jednom miste**, v `src/data/connectorStore.ts`. Nikde jinde +se nezjistuje, ktery rezim jede - kdyby se to rozlezlo po kodu, jedno misto by +se zapomnelo a chovalo by se pak jinak nez zbytek. + +| Rezim | Kdy | Prezije restart | Prezije redeploy | +| ---------- | -------------------------------------- | --------------- | ---------------- | +| `postgres` | je `DATABASE_URL` i `SECRETS_KEY` | ano | ano | +| `file` | neni databaze, ale je `DATA_DIR` | ano | **ne** | +| `memory` | ani jedno, nebo nejde zapsat | ne | ne | + +Rezim `file` je pro mockup. Filesystem containeru je docasny, takze soubor +prezije restart procesu i containeru, ale nove nasazeni ho smaze. Je to +mezistupen, ne nahrada databaze. + +Nasazeny mockup tedy jede v rezimu `file` a v portalu je to napsane. Lokalne +s Postgresem jede `postgres`. Chybejici databaze **nesmi shodit start**. Container, ktery nenastartuje, je pro -AppFactory nefunkcni sluzba (AGENTS.md). Misto toho se do logu napise, proc se -jede v pameti, a portal to ukaze na strance Konektory. +AppFactory nefunkcni sluzba (AGENTS.md). Misto toho se do logu napise, ktery +rezim jede a proc, a portal to ukaze na strance Konektory. Databaze potrebuje **oboji**. Bez klice by se pristupove udaje ukladaly v plaintextu, a to je horsi nez ztratit je pri restartu - tabulku vidi kazda zaloha a kazdy dump pri ladeni. -Stejne tak: kdyz jsou migrace nastavene, ale selzou, jede se dal v pameti. -Psat do rozbiteho schematu je horsi nez neukladat. +Stejne tak: kdyz jsou migrace nastavene, ale selzou, jede se dal bez databaze. +Psat do rozbiteho schematu je horsi nez psat do souboru. ## Promenne @@ -34,6 +43,7 @@ Psat do rozbiteho schematu je horsi nez neukladat. | `SECRETS_KEY` | klic pro sifrovani pristupovych udaju, **secret** | | `DATABASE_POOL_MAX` | kolik spojeni si vezme jedna instance, vychozi 10 | | `DATABASE_SSL` | `true` u spravovanych databazi, ktere vyzaduji TLS | +| `DATA_DIR` | slozka pro JSON mimo databazi, vychozi `./data`. Prazdna hodnota vypne i soubor | `SECRETS_KEY` ma byt nahodny retezec, ne heslo: @@ -62,7 +72,40 @@ Migrace se pousti samy pri startu. Kontrola, ze to jede z databaze: curl -s http://localhost:3000/health/ready ``` -Bez promennych `npm run dev` funguje dal, jen v pameti. +Bez promennych `npm run dev` funguje dal, jen se uklada do `./data`. + +## Soubor misto databaze + +`src/data/snapshot.ts` a `src/data/connectors/local.ts`. + +Ukladani je **jeden kod pro pamet i soubor**, lisi se jen tim, kam se zapisuje. +Kdyby to byly dve implementace, jedna by se casem opravila a druha ne. + +| Vlastnost | Jak a proc | +| ---------------- | ----------------------------------------------------------- | +| Atomicky zapis | nejdriv `.tmp`, pak prejmenovani. Pad uprostred zapisu jinak nechá polovicni JSON, ktery se pri startu nenacte | +| Slucovani zapisu | deset uprav za sebou znamena jeden zapis na disk | +| Zapis pri ukonceni | `SIGTERM` dokonci rozepsany zapis, jinak by se posledni zmena ztratila | +| Rozbity soubor | prejmenuje se na `.broken`, zaloguje a jede se s prazdnymi daty. Aplikace, ktera nenastartuje, je pro AppFactory nefunkcni sluzba | +| Sifrovani | tajne hodnoty jsou v souboru zasifrovane, plaintext nikdy | + +### Klic mimo databazi + +Bez `SECRETS_KEY` si aplikace v rezimu `file` vygeneruje klic do +`DATA_DIR/secrets.key` (prava 0600). Diky tomu funguje sifrovani bez jakehokoliv +nastaveni. + +**Rekneme si nahlas, co to je a co ne.** Klic lezi ve stejne slozce jako data, +takze to chrani proti nahodnemu precteni JSONu, **ne** proti nekomu, kdo ma +pristup k disku serveru nebo k zaloze slozky. Do provozu patri klic ze secretu, +tedy `SECRETS_KEY`. + +U databaze se klic vedle dat **negeneruje vubec**. Nemelo by to smysl: kdo ma +zalohu tabulky, ma i klic ze stejneho stroje. Proto je `allowKeyFile` parametr, +ne automatika. + +Kdyz nejde ani jedno, tajne hodnoty se do souboru neukladaji a zbytek konektoru +ano. Radsi je zadat znovu nez je mit v souboru citelne. ## Migrace @@ -169,13 +212,16 @@ Proti Postgresu 16 v kontejneru: | `PATCH` bez tajneho pole tajne pole nesmaze | ano | | Prepnuti vychoziho konektoru | ano | | Smazani vychoziho preda priznak zbylemu | ano | -| Bez `DATABASE_URL` jede pametovy rezim a rekne to | ano | +| Bez `DATABASE_URL` jede souborovy rezim a rekne to | ano | +| Konektor v souborovem rezimu prezije restart procesu | ano | +| Tajne hodnoty jsou v JSONu sifrovane, plaintext nikde | ano | +| U databaze se klic vedle dat nevygeneruje | ano | ## Co chybi | Chybi | Poznamka | | ---------------------------- | ----------------------------------------------------- | -| Automatizace v databazi | dalsi na rade, je to to, co si clovek nastavi | +| Automatizace v ulozisti | dalsi na rade, je to to, co si clovek nastavi. Pujde do souboru i do databaze | | Rozlozeni dashboardu | male a samostatne, hned po automatizacich | | Tickety a incidenty | naposled, dnes je generuje simulace | | Uzivatele, firmy, resitele | v prototypu je to spis konfigurace nez data | diff --git a/documentation/99-zmeny.md b/documentation/99-zmeny.md index 6863abd..b7e916a 100644 --- a/documentation/99-zmeny.md +++ b/documentation/99-zmeny.md @@ -2,6 +2,32 @@ Nejnovejsi nahore. +## 2026-08-12 - soubor jako uloziste bez databaze + +Mockup se k databazi nedostane, takze pribyl treti rezim: JSON soubor. +Prezije restart procesu i containeru, ale ne redeploy. + +### Pridano + +- `src/data/snapshot.ts`: atomicky zapis (`.tmp` a prejmenovani), slucovani + zapisu a dokonceni rozepsaneho zapisu pri `SIGTERM`. Rozbity soubor se + prejmenuje na `.broken`, zaloguje a jede se s prazdnymi daty - aplikace, ktera + nenastartuje, je pro AppFactory nefunkcni sluzba. +- `src/data/connectors/local.ts`: jeden kod pro pamet i soubor, lisi se jen tim, + kam se zapisuje. Nahrazuje `memory.ts` - dve implementace by se casem rozesly. +- Klic k sifrovani se mimo databazi vygeneruje do `DATA_DIR/secrets.key`, takze + sifrovani funguje bez nastaveni. U databaze se negeneruje: kdo ma zalohu + tabulky, ma i klic ze stejneho stroje. +- `DATA_DIR` v konfiguraci, `data/` v `.gitignore` a `.dockerignore`. +- Hlaska v portalu rozlisuje tri nasledky: pamet (ztrata pri restartu), soubor + (ztrata pri redeployi) a databaze (bez ztraty, hlaska se nezobrazuje). + +### Overeno + +Bez databaze: konektor s vyplnenymi udaji prezil restart, v JSONu jsou hodnoty +sifrovane a plaintext v nem neni. S databazi: rezim `postgres` funguje dal +a klic vedle dat se nevygeneroval. + ## 2026-08-12 - databaze pro konektory Konektory se ukladaji do Postgresu, pristupove udaje sifrovane. diff --git a/src/config.ts b/src/config.ts index f1fe905..72cec93 100644 --- a/src/config.ts +++ b/src/config.ts @@ -105,6 +105,14 @@ export const config = { databasePoolMax: positiveNumber(process.env.DATABASE_POOL_MAX, 10), /** Spravovane databaze vyzaduji TLS. */ databaseSsl: process.env.DATABASE_SSL === 'true', + /** + * Slozka pro data mimo databazi. + * + * Bez `DATABASE_URL` se do ni uklada JSON, ktery prezije restart procesu + * i containeru. Redeploy ho nezachova - filesystem containeru je docasny. + * Prazdna hodnota vypne i tohle a jede se v ciste pameti. + */ + dataDir: (process.env.DATA_DIR ?? path.join(process.cwd(), 'data')).trim(), // ------------------------------------------------------- skripty konektoru diff --git a/src/data/connectorStore.ts b/src/data/connectorStore.ts index bce875c..e717815 100644 --- a/src/data/connectorStore.ts +++ b/src/data/connectorStore.ts @@ -1,22 +1,27 @@ /** * Konektory: vyber uloziste a jedine misto, kde se rozhoduje o rezimu. * - * Dve implementace, jedno rozhrani: - * - `postgres` kdyz je `DATABASE_URL` a `SECRETS_KEY`, - * - `memory` jinak, tedy pri mockupu a lokalnim vyvoji bez databaze. + * Tri rezimy, jedno rozhrani: * - * Rozhodnuti je **jen tady**. Nikde jinde se nezjistuje, jestli databaze je - + * | Rezim | Kdy | Prezije | + * | ---------- | --------------------------------------- | ----------------- | + * | `postgres` | je `DATABASE_URL` i klic k sifrovani | vse | + * | `file` | neni databaze, ale je datova slozka | restart, ne redeploy | + * | `memory` | ani jedno, nebo se nepodarilo zapsat | nic | + * + * Rozhodnuti je **jen tady**. Nikde jinde se nezjistuje, ktery rezim jede - * kdyby se to rozlezlo po kodu, jedno misto by se zapomnelo a chovalo by se * pak jinak nez zbytek. * - * Databaze potrebuje **oboji**. Bez klice by se pristupove udaje ukladaly - * v plaintextu a to je horsi nez ztratit je pri restartu, proto se v tom - * pripade jede dal v pameti a rekne se to nahlas. + * Databaze potrebuje klic ze `SECRETS_KEY`. Klic vedle dat by u ni nemel smysl: + * kdo ma zalohu tabulky, ma i klic ze stejneho stroje. */ -import { canSealSecrets } from '../db/secretBox.js'; +import path from 'node:path'; +import { config } from '../config.js'; import { isDatabaseEnabled } from '../db/pool.js'; -import { memoryConnectors, seedMemoryConnectors } from './connectors/memory.js'; +import { canSealSecrets, initSecrets, secretsKeySource } from '../db/secretBox.js'; +import { createLocalConnectors } from './connectors/local.js'; import { postgresConnectors } from './connectors/postgres.js'; import type { Connector, @@ -24,6 +29,7 @@ import type { CreateConnectorInput, UpdateConnectorInput, } from './connectors/types.js'; +import { fileSnapshot, memorySnapshot } from './snapshot.js'; export type { Connector, @@ -34,79 +40,112 @@ export type { } from './connectors/types.js'; export { toPublicConnector, validateConnectorValues } from './connectors/types.js'; -export type StorageMode = 'postgres' | 'memory'; +export type StorageMode = 'postgres' | 'file' | 'memory'; export interface StorageStatus { mode: StorageMode; - /** Proc se jede v pameti. null u databaze. */ - reason: string | null; + /** Kratke vysvetleni pro portal. Vzdy vyplnene, i u databaze. */ + reason: string; /** true = restart procesu data smaze. */ ephemeral: boolean; + /** true = redeploy containeru data smaze, i kdyz restart ne. */ + lostOnRedeploy: boolean; + /** Odkud je klic k sifrovani tajnych hodnot. */ + secrets: 'env' | 'file' | 'none'; + /** Kde data lezi. null u pameti. */ + location: string | null; } -let repository: ConnectorRepository = memoryConnectors; +let repository: ConnectorRepository = createLocalConnectors({ + store: memorySnapshot(), + canEncrypt: false, +}); +let flushRepository: (() => Promise) | null = null; + let status: StorageStatus = { mode: 'memory', - reason: 'Databáze není nastavená.', + reason: 'Úložiště se ještě nevybralo.', ephemeral: true, + lostOnRedeploy: true, + secrets: 'none', + location: null, }; export interface InitOptions { /** * false = databaze je nastavena, ale neni pouzitelna (napr. selhaly migrace). - * Pak se jede v pameti, protoze psat do rozbiteho schematu je horsi. + * Pak se jede bez ni, protoze psat do rozbiteho schematu je horsi. */ databaseReady?: boolean; } -/** - * Vybere uloziste. Vola se jednou pri startu, po migracich. - * Volani je idempotentni, at jde zavolat i z testu. - */ +/** Vybere uloziste. Vola se jednou pri startu, po migracich. */ export async function initConnectorStore(options: InitOptions = {}): Promise { const databaseReady = options.databaseReady ?? true; + const useDatabase = isDatabaseEnabled() && databaseReady; - if (isDatabaseEnabled() && !databaseReady) { - repository = memoryConnectors; - status = { - mode: 'memory', - reason: 'Databáze je nastavená, ale schéma není použitelné. Zkontrolujte log migrací.', - ephemeral: true, - }; - console.error(`[storage] ${status.reason}`); - await seedMemoryConnectors(); - return status; - } + // Klic vedle dat je pripustny jen mimo databazi. U ni ma prijit ze secretu. + initSecrets({ allowKeyFile: !useDatabase, dir: config.dataDir }); + const secrets = secretsKeySource(); - if (isDatabaseEnabled() && canSealSecrets()) { + if (useDatabase && canSealSecrets()) { repository = postgresConnectors; - status = { mode: 'postgres', reason: null, ephemeral: false }; - console.info('[storage] konektory: Postgres, přístupové údaje šifrované'); + flushRepository = null; + status = { + mode: 'postgres', + reason: 'Data jsou v Postgresu, přístupové údaje šifrované.', + ephemeral: false, + lostOnRedeploy: false, + secrets, + location: 'Postgres', + }; + console.info(`[storage] konektory: Postgres, klic ze ${secrets === 'env' ? 'secretu' : 'souboru'}`); return status; } - repository = memoryConnectors; + // ---------------------------------------------------------- mimo databazi - if (isDatabaseEnabled() && !canSealSecrets()) { - status = { - mode: 'memory', - reason: - 'Databáze je nastavená, ale chybí SECRETS_KEY. Přístupové údaje by se ukládaly ' + - 'v plaintextu, takže konektory zůstávají v paměti.', - ephemeral: true, - }; - console.error(`[storage] ${status.reason}`); - } else { - status = { - mode: 'memory', - reason: 'Databáze není nastavená (DATABASE_URL). Restart procesu data smaže.', - ephemeral: true, - }; - console.warn(`[storage] ${status.reason}`); + const reasons: string[] = []; + if (isDatabaseEnabled() && !databaseReady) { + reasons.push('Databáze je nastavená, ale schéma není použitelné, zkontrolujte log migrací.'); + } else if (useDatabase && !canSealSecrets()) { + reasons.push('Databáze je nastavená, ale chybí SECRETS_KEY. Údaje by se ukládaly čitelně.'); + } else if (!isDatabaseEnabled()) { + reasons.push('Databáze není nastavená (DATABASE_URL).'); } - // Ukazkovy konektor jen v pametovem rezimu. V databazi by se delal pri kazdem startu. - await seedMemoryConnectors(); + const useFile = config.dataDir !== ''; + const file = path.join(config.dataDir, 'connectors.json'); + + const local = createLocalConnectors({ + store: useFile ? fileSnapshot(file) : memorySnapshot(), + canEncrypt: canSealSecrets(), + }); + await local.init(); + + repository = local; + flushRepository = () => local.flush(); + + if (useFile) { + reasons.push('Data se ukládají do JSON souboru, takže přežijí restart, ale ne redeploy.'); + if (!canSealSecrets()) { + reasons.push('Přístupové údaje se neukládají, protože není čím je zašifrovat.'); + } + } else { + reasons.push('Data jsou jen v paměti procesu, restart je smaže.'); + } + + status = { + mode: useFile ? 'file' : 'memory', + reason: reasons.join(' '), + // Soubor prezije restart procesu, pamet ne. + ephemeral: !useFile, + lostOnRedeploy: true, + secrets, + location: useFile ? file : null, + }; + + console.warn(`[storage] konektory: ${status.mode} - ${status.reason}`); return status; } @@ -114,6 +153,11 @@ export function storageStatus(): StorageStatus { return status; } +/** Dokonci rozepsany zapis. Vola se pri ukonceni procesu. */ +export async function flushConnectorStore(): Promise { + if (flushRepository) await flushRepository(); +} + // ------------------------------------------------- pruchod do zvoleneho uloziste /** diff --git a/src/data/connectors/memory.ts b/src/data/connectors/memory.ts deleted file mode 100644 index ce7008e..0000000 --- a/src/data/connectors/memory.ts +++ /dev/null @@ -1,176 +0,0 @@ -/** - * Konektory v pameti procesu. - * - * Rezim pro mockup a pro lokalni vyvoj bez databaze. **Restart procesu data - * smaze**, vcetne pristupovych udaju. Pri vyvoji s `tsx watch` to znamena - * po kazde uprave kodu, takze je to potreba mit na pameti. - * - * Podle stejneho rozhrani jede i `postgres.ts`. Rozdil se resi jen pri vyberu - * uloziste v `../connectorStore.ts`. - */ - -import { randomUUID } from 'node:crypto'; -import { - missingFields, - nowIso, - type Connector, - type ConnectorRepository, - type CreateConnectorInput, - type UpdateConnectorInput, -} from './types.js'; -import { findService } from '../services.js'; - -const rows: Connector[] = []; - -function byTenant(tenantIds: string[]): Connector[] { - return rows.filter((row) => tenantIds.includes(row.tenantId)); -} - -function clearDefaults(tenantId: string, serviceId: string): void { - for (const row of rows) { - if (row.tenantId === tenantId && row.serviceId === serviceId) row.isDefault = false; - } -} - -export const memoryConnectors: ConnectorRepository = { - kind: 'memory', - - async list(tenantIds, options = {}) { - return byTenant(tenantIds) - .filter((row) => !options.serviceId || row.serviceId === options.serviceId) - .sort((a, b) => a.name.localeCompare(b.name, 'cs')) - .map((row) => ({ ...row, values: { ...row.values } })); - }, - - async get(id, tenantIds) { - const row = rows.find((item) => item.id === id); - // Cizi konektor se chova jako neexistujici, ne jako chyba prava. - if (!row || !tenantIds.includes(row.tenantId)) return undefined; - return { ...row, values: { ...row.values } }; - }, - - async defaultFor(tenantId, serviceId) { - const forService = rows.filter( - (row) => row.tenantId === tenantId && row.serviceId === serviceId && row.enabled, - ); - const chosen = forService.find((row) => row.isDefault) ?? forService[0]; - return chosen ? { ...chosen, values: { ...chosen.values } } : undefined; - }, - - async countsByService(tenantIds) { - const counts = new Map(); - for (const row of byTenant(tenantIds)) { - counts.set(row.serviceId, (counts.get(row.serviceId) ?? 0) + 1); - } - return counts; - }, - - async create(input: CreateConnectorInput) { - const timestamp = nowIso(); - const existing = rows.filter( - (row) => row.tenantId === input.tenantId && row.serviceId === input.serviceId, - ); - - const connector: Connector = { - id: `con_${randomUUID().slice(0, 8)}`, - tenantId: input.tenantId, - serviceId: input.serviceId, - name: input.name, - baseUrl: input.baseUrl ?? null, - values: { ...(input.values ?? {}) }, - enabled: true, - status: 'untested', - lastCheckAt: null, - lastError: null, - // Prvni konektor na sluzbu je vychozi, jinak by krok bez vyberu nemel co vzit. - isDefault: input.isDefault ?? existing.length === 0, - createdAt: timestamp, - updatedAt: timestamp, - }; - - if (connector.isDefault) clearDefaults(input.tenantId, input.serviceId); - rows.push(connector); - return { ...connector, values: { ...connector.values } }; - }, - - async update(id, patch: UpdateConnectorInput, tenantIds) { - const row = rows.find((item) => item.id === id); - if (!row || !tenantIds.includes(row.tenantId)) return undefined; - - if (patch.name !== undefined) row.name = patch.name; - if (patch.baseUrl !== undefined) row.baseUrl = patch.baseUrl; - if (patch.enabled !== undefined) row.enabled = patch.enabled; - - if (patch.values) { - for (const [key, value] of Object.entries(patch.values)) { - if (value === '') delete row.values[key]; - else row.values[key] = value; - } - // Zmena udaju znamena, ze predchozi overeni uz nic nerika. - row.status = 'untested'; - row.lastError = null; - } - - if (patch.isDefault === true) { - clearDefaults(row.tenantId, row.serviceId); - row.isDefault = true; - } - - row.updatedAt = nowIso(); - return { ...row, values: { ...row.values } }; - }, - - async remove(id, tenantIds) { - const row = rows.find((item) => item.id === id); - if (!row || !tenantIds.includes(row.tenantId)) return false; - - rows.splice(rows.indexOf(row), 1); - - // Kdyz zmizel vychozi, prevezme to prvni zbyly - jinak by kroky bez vyberu - // prestaly fungovat, aniz by se cokoliv jineho zmenilo. - if (row.isDefault) { - const next = rows.find( - (item) => item.tenantId === row.tenantId && item.serviceId === row.serviceId, - ); - if (next) next.isDefault = true; - } - return true; - }, - - async setStatus(id, status, error, tenantIds) { - const row = rows.find((item) => item.id === id); - if (!row || !tenantIds.includes(row.tenantId)) return undefined; - - row.status = status; - row.lastError = error; - row.lastCheckAt = nowIso(); - row.updatedAt = row.lastCheckAt; - return { ...row, values: { ...row.values } }; - }, -}; - -/** - * Ukazkovy konektor bez vyplnenych udaju. - * - * Zamerne bez nich: ukazuje presne ten stav, ve kterem konektor vznikne, tedy - * "sluzba je napojena, ale chybi pristupove udaje". Zaklada se jen v pametovem - * rezimu - v databazi by se pri kazdem startu delal znovu. - */ -export async function seedMemoryConnectors(): Promise { - if (rows.length > 0) return; - if (!findService('idoklad')) return; - - await memoryConnectors.create({ - tenantId: 'tnt_automia', - serviceId: 'idoklad', - name: 'iDoklad Automia', - }); -} - -/** Jen pro diagnostiku: kolik povinnych poli chybi napric vsemi konektory. */ -export function memoryMissingCount(): number { - return rows.reduce((total, row) => { - const service = findService(row.serviceId); - return total + (service ? missingFields(service, row.values).length : 0); - }, 0); -} diff --git a/src/db/secretBox.ts b/src/db/secretBox.ts index fb8af38..5975a59 100644 --- a/src/db/secretBox.ts +++ b/src/db/secretBox.ts @@ -12,6 +12,8 @@ */ import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; import { config } from '../config.js'; /** Obalka, ktera se uklada do JSONB. */ @@ -30,21 +32,83 @@ const ALGORITHM = 'aes-256-gcm'; const KEY_VERSION = 1; /** - * Klic z konfigurace, srovnany na 32 bajtu. + * Odkud je klic. * - * SHA-256 z hodnoty promenne, aby fungoval jakkoliv dlouhy retezec. Neni to - * derivace hesla (na to by patril scrypt), ale `SECRETS_KEY` ma byt nahodny - * klic, ne heslo - a to je v dokumentaci napsane. + * `env` ze `SECRETS_KEY`. Klic je oddeleny od dat, takze zaloha souboru + * nebo tabulky sama nestaci. Tohle je jediny rezim vhodny do provozu. + * `file` vygenerovany klic v datove slozce vedle dat. Chrani proti nahodnemu + * precteni JSONu, **ne** proti nekomu, kdo ma pristup k disku - klic + * lezi hned vedle. Je to rezim pro mockup. + * `none` neni cim sifrovat, tajne hodnoty se neukladaji. + */ +export type SecretsKeySource = 'env' | 'file' | 'none'; + +let material: string | null = null; +let source: SecretsKeySource = 'none'; + +/** + * Nastavi klic. Vola se jednou pri startu, pred vyberem uloziste. + * + * `allowKeyFile` je zamerne parametr, ne automatika: v databazovem rezimu se + * klic vedle dat generovat nesmi, tam ma prijit ze secretu. + */ +export function initSecrets(options: { allowKeyFile: boolean; dir: string }): SecretsKeySource { + if (config.secretsKey !== '') { + material = config.secretsKey; + source = 'env'; + return source; + } + + if (!options.allowKeyFile) { + material = null; + source = 'none'; + return source; + } + + const file = path.join(options.dir, 'secrets.key'); + try { + if (fs.existsSync(file)) { + material = fs.readFileSync(file, 'utf8').trim(); + } else { + fs.mkdirSync(options.dir, { recursive: true }); + material = randomBytes(32).toString('base64url'); + // 0600: cist smi jen vlastnik procesu. Na Windows se prava ignoruji. + fs.writeFileSync(file, material, { encoding: 'utf8', mode: 0o600 }); + console.info(`[secrets] vygenerovan klic v ${file}`); + } + source = 'file'; + } catch (err) { + console.error( + '[secrets] klic v souboru nelze pouzit, tajne hodnoty se nebudou ukladat:', + err instanceof Error ? err.message : err, + ); + material = null; + source = 'none'; + } + + return source; +} + +export function secretsKeySource(): SecretsKeySource { + return source; +} + +/** + * Klic srovnany na 32 bajtu. + * + * SHA-256 z hodnoty, aby fungoval jakkoliv dlouhy retezec. Neni to derivace + * hesla (na to by patril scrypt), ale klic ma byt nahodny retezec, ne heslo - + * a to je v dokumentaci napsane. */ function key(): Buffer { - if (config.secretsKey === '') { - throw new Error('SECRETS_KEY neni nastavena, pristupove udaje nelze sifrovat.'); + if (material === null) { + throw new Error('Klic pro sifrovani neni nastaveny, pristupove udaje nelze sifrovat.'); } - return createHash('sha256').update(config.secretsKey).digest(); + return createHash('sha256').update(material).digest(); } export function canSealSecrets(): boolean { - return config.secretsKey !== ''; + return material !== null; } export function seal(value: string): SealedValue { diff --git a/src/index.ts b/src/index.ts index a4fb448..7dbc9d7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,7 +16,7 @@ import { contactRouter } from './routes/contact.js'; import { dashboardRouter } from './routes/dashboard.js'; import { simulateRouter } from './routes/simulate.js'; import { webhookRouter } from './routes/webhook.js'; -import { initConnectorStore, storageStatus } from './data/connectorStore.js'; +import { flushConnectorStore, initConnectorStore, storageStatus } from './data/connectorStore.js'; import { runMigrations } from './db/migrate.js'; import { closeDatabase, databaseHealth, isDatabaseEnabled } from './db/pool.js'; import { ensureLoaded, scriptsDir } from './scripts/registry.js'; @@ -227,7 +227,7 @@ const server = app.listen(config.port, '0.0.0.0', () => { console.info(`[start] health: ${config.rootPath}/health, docs: ${config.rootPath}/docs`); console.info(`[start] skripty konektoru: ${scriptsDir()}`); const storage = storageStatus(); - console.info(`[start] uloziste konektoru: ${storage.mode}${storage.reason ? ` (${storage.reason})` : ''}`); + console.info(`[start] uloziste konektoru: ${storage.mode}${storage.location ? ` (${storage.location})` : ''}`); }); // Pool se pri ukonceni zavre, at se spojeni neopousti otevrena. @@ -235,7 +235,12 @@ for (const signal of ['SIGTERM', 'SIGINT'] as const) { process.on(signal, () => { console.info(`[stop] ${signal}, ukoncuji`); server.close(() => { - void closeDatabase().finally(() => process.exit(0)); + // Rozepsany zapis do souboru se musi dokoncit, jinak se posledni zmena + // ztrati - debounce je kratky, ale nenulovy. + void flushConnectorStore() + .catch((err: unknown) => console.error('[stop] zapis dat selhal:', err)) + .then(() => closeDatabase()) + .finally(() => process.exit(0)); }); }); } diff --git a/web/src/pages/dashboard/Connectors.tsx b/web/src/pages/dashboard/Connectors.tsx index b45dfb3..fdf0bf9 100644 --- a/web/src/pages/dashboard/Connectors.tsx +++ b/web/src/pages/dashboard/Connectors.tsx @@ -2,6 +2,7 @@ import { AlertTriangle, CheckCircle2, Eye, + Info, EyeOff, Pencil, Plus, @@ -118,17 +119,9 @@ export default function Connectors() { {/* Kam se uklada. Bez teto hlasky se clovek divi, kam se podely jeho - konektory po restartu, a hleda chybu v aplikaci. + konektory, a hleda chybu v aplikaci - presne to se stalo. */} - {storage?.ephemeral && ( -

- - - Přístupové údaje se ukládají jen do paměti serveru a restart je smaže. - {storage.reason} - -

- )} + {storage && storage.mode !== 'postgres' && } {(connectors?.length ?? 0) === 0 ? ( @@ -165,6 +158,46 @@ export default function Connectors() { ); } +/** + * Hlaska o ulozisti. + * + * Rozlisuje tri veci, protoze pro uzivatele znamenaji uplne jiny nasledek: + * pamet (ztrata pri restartu), soubor (ztrata pri redeployi) a databaze + * (bez ztraty, hlaska se nezobrazuje). + */ +function StorageNotice({ storage }: { storage: StorageStatus }) { + const severe = storage.mode === 'memory'; + + return ( +
+ {severe ? ( + + ) : ( + + )} +
+

+ {severe + ? 'Přístupové údaje se ukládají jen do paměti serveru, restart je smaže.' + : 'Data se ukládají do souboru na serveru. Restart přežijí, nové nasazení ne.'} +

+

{storage.reason}

+ {storage.secrets === 'file' && ( +

+ Údaje jsou zašifrované klíčem, který leží ve stejné složce. Chrání to proti + náhodnému přečtení souboru, ne proti přístupu k disku serveru. +

+ )} +
+
+ ); +} + // ----------------------------------------------------------------- mala karta function ConnectorCard({ diff --git a/web/src/types/dashboard.ts b/web/src/types/dashboard.ts index 0d08b62..eb86e01 100644 --- a/web/src/types/dashboard.ts +++ b/web/src/types/dashboard.ts @@ -292,13 +292,19 @@ export interface Connector { ready: boolean; } -/** Kam se uklada a jestli to prezije restart. */ +/** Kam se uklada a co to prezije. */ export interface StorageStatus { - mode: 'postgres' | 'memory'; - /** Proc se jede v pameti. null u databaze. */ - reason: string | null; + mode: 'postgres' | 'file' | 'memory'; + /** Kratke vysvetleni pro uzivatele. */ + reason: string; /** true = restart procesu data smaze. */ ephemeral: boolean; + /** true = redeploy containeru data smaze, i kdyz restart ne. */ + lostOnRedeploy: boolean; + /** Odkud je klic k sifrovani tajnych hodnot. */ + secrets: 'env' | 'file' | 'none'; + /** Kde data lezi. null u pameti. */ + location: string | null; } export interface ConnectorTestResult {