docs/03-DATA-MODEL.md
Rama main · réplica de lectura del repositorio original.
# 03 — Kern-Datenmodell
SQLAlchemy 2.0, dialektneutral (SQLite-Default, Postgres vorgesehen, aber ungetestet — siehe
D-02 in [00-DECISIONS](00-DECISIONS.md)).
Konventionen:
- PK: `id` als UUIDv7 (RFC 9562) in `String(36)` (zeitlich sortierbar, dialektneutral, in Logs lesbar).
IDs aus Versionen bis 0.9.0 haben 40 Zeichen (12 Zeichen Zeit vorne) und bleiben in SQLite gültig.
- Zeitstempel: `DateTime(timezone=True)`, UTC, in Python gesetzt.
- Strukturierte Felder: `sa.JSON` (nicht `JSONB`).
- Listen: Assoziationstabelle, nie Array-Spalte.
- Soft-Delete nur da, wo es fachlich nötig ist (`revoked_at`, `disabled_at`).
- Extension-Tabellen: Präfix `ext_<id>_`, eigener Alembic-Branch, hier nicht beschrieben.
---
## 1. Identität und Zugriff
### `users`
| Spalte | Typ | Anmerkung |
|---|---|---|
| id | str(36) PK | |
| username | str(64) unique | Login |
| email | str(255) null | optional, eine Angabe zum Konto; Nodvard Deck verschickt keine E-Mails |
| display_name | str(128) | |
| password_hash | str(255) | Argon2id |
| totp_secret_id | FK secrets.id null | 2FA-Secret liegt im Vault, nicht hier |
| is_active | bool | |
| is_owner | bool | genau einer; umgeht RBAC, kann nicht entzogen werden |
| locale | str(8) | `de` default — UI-Sprache ist Nutzereinstellung |
| created_at, last_login_at | ts | |
### `roles`, `user_roles`, `role_permissions`
`roles(id, name unique, description, is_builtin)`.
Eingebaut: `owner`, `admin`, `operator`, `viewer`.
Berechtigungen sind **Strings**, keine Tabelle — Extensions bringen eigene mit, ohne
Migration:
```
<domäne>.<aktion>[:<scope>]
hosts.read hosts.write hosts.execute
secrets.read:ssh-* secrets.write
audit.read settings.write extensions.manage
system.read apps.write
actions.approve actions.approve:high
soc.read soc.manage (Nodvard Shield)
inventory.read inventory.write (Inventar)
```
`*` als Suffix-Wildcard im Scope ist erlaubt (`secrets.read:ssh-*`), sonst exakter
Vergleich. Auswertung in `core/rbac.py`, reine Funktionen, ohne DB testbar.
### `refresh_tokens`
| Spalte | Anmerkung |
|---|---|
| id, user_id | |
| token_hash | SHA-256 des opaken Werts; der Wert selbst wird nie gespeichert |
| client_type | `web` \| `android` \| `cli` — steuert Cookie vs. Bearer |
| device_name, user_agent, ip | für „Aktive Sitzungen" in den Einstellungen |
| expires_at, revoked_at, last_used_at | |
| replaced_by_id | Nachfolger-Sitzung, nur bei Rotation gesetzt (nicht bei Logout/Passwortwechsel), ohne Fremdschlüssel. Grundlage der 30-s-Gnadenfrist beim Erneuern: ein eben rotierter Token bekommt noch einen Access-Token der Nachfolger-Sitzung, aber keinen neuen Refresh-Token (zwei Tabs, App + Tab). Trägt außerdem die Prüfung der `sid` im Zugangs-Token (gilt, solange am Ende der Kette eine gültige Zeile steht) und das Abmelden der ganzen Kette (`logout` widerruft die Zeile am Ende, auch mit einem schon erneuerten Token, solange dessen Zeile noch steht, also bis eine Refresh-Laufzeit nach dem Erneuern, siehe „Aufräumen“). Gelesen wird die Kette in einer einzigen rekursiven Abfrage (`WITH RECURSIVE`, `services.auth.session_chain_end`), Grenzen in [04 §2](04-API.md#2-ohne-authentifizierung) „Access-Token und Anmeldung“ |
**Aufräumen** (`services.auth.purge_old_logins`, Kern-Job `logins-retention`, nachts 03:50): Zeilen, deren `expires_at`
oder `revoked_at` länger als `refresh_token_ttl_days` (Vorgabe 30) zurückliegt, werden gelöscht. Bis dahin bleiben auch
erneuerte und beendete Zeilen stehen, denn die Kette über `replaced_by_id` braucht sie (Gnadenfrist, `sid` offener
Terminal- und Live-Verbindungen). Ein Zugangs-Token zeigt nur während seiner Laufzeit (Vorgabe 15 Minuten) auf eine
Zeile, eine Zeile geht erst eine Refresh-Laufzeit nach ihrem Ende. Weil die Erneuerungen einer Kette der Reihe nach
enden, geht immer nur ihr ältester Teil; von jeder übrigen Zeile führt die Kette weiter bis zur laufenden Anmeldung
(Abmelden, Prüfung offener Anmeldungen). Ohne den Job bliebe jede Erneuerung samt Adresse und Browser für immer liegen.
### `api_tokens`
Für Automatisierung und die Android-App im Langzeitbetrieb.
`id, user_id, name, token_hash, scopes (JSON), expires_at, revoked_at, last_used_at`.
Auch Aufrufe per Cron brauchen damit ein Token; ungeschützte Endpunkte für Automatisierung gibt es nicht.
---
## 2. Hosts
Der Kern besitzt die Host-Identität — siehe
[01 §5](01-ARCHITECTURE.md#5-hosts-gehören-dem-kern-nicht-der-extension).
### `hosts`
| Spalte | Anmerkung |
|---|---|
| id | |
| name | str(64) unique, technisch (`pve1`, `bastel-pi`) |
| display_name | str(128) |
| address | str(255) — IP oder DNS |
| os_family | `linux` \| `windows` \| `other` |
| kind | frei, von der Extension gesetzt (`vm`, `container`, `bare-metal`) |
| provider_ext_id | null bei manuell angelegten. Das ist auch das Merkmal „kein Modul pflegt den Zustand“: nur solche Server prüft der Kern-Job `host-reachability` |
| provider_ref | Fremdschlüssel beim Anbieter (z. B. `node/qemu/110`) |
| is_managed | ob Aktionen erlaubt sind |
| enabled | |
| last_seen_at | letzte erfolgreiche Prüfung (Modul, „Verbindung prüfen“ oder `host-reachability`); leer = noch nie gesehen, dann gibt es beim ersten Ausfall keine Meldung |
| status | `up` \| `down` \| `unknown` \| `maintenance`. Von Hand angelegte Server führt `host-reachability` nach (TCP zum SSH-Port, „down“ erst nach 2 Fehlschlägen hintereinander); `maintenance` fasst der Job nicht an. Eingelesene Server führt ihr Modul nach, über `ctx.hosts.set_status` (Proxmox: der einzige Knoten einer Verbindung, die mindestens 4 Minuten am Stück bei keinem Abgleich antwortet, `down`; Knoten eines Clusters oder einer Verbindung, die nur ablehnt, Gäste, deren Liste nicht lesbar war, und Server deaktivierter Verbindungen `unknown`) |
| metadata | JSON, anbieterspezifisch. Ausnahme ist der Schlüssel `login_ok`, er gehört dem Kern: der Beleg einer erfolgreichen SSH-Anmeldung mit dem Standard-Zugang (`credential_id`, `address`, `port`, `at`). Daraus entsteht `login_ok_at` in der API, solange Zugang, Adresse und Port noch passen. Der Abgleich eines Anbieters ersetzt den Rest und behält ihn |
| created_at, updated_at | |
`host_tags(host_id, tag)` — Tags sind das generische Ersatzkonstrukt für fest verdrahtete
Sonderlisten (etwa „alle Docker-Hosts“ oder „kritische Container“). Eine Extension fragt
„alle Hosts mit Tag `docker`" statt eine eigene Liste zu pflegen.
`host_groups(id, name, description)` + `host_group_members(group_id, host_id)` —
Zielgruppen für Skripte (Script-Repository, siehe [02 §8](02-EXTENSION-API.md#8-wie-drei-module-gegen-die-schnittstelle-implementiert-werden)).
### `host_credentials`
| Spalte | Anmerkung |
|---|---|
| id, host_id | |
| kind | `ssh_key` \| `ssh_password` \| `api_token` |
| username | |
| port | |
| secret_id | FK `secrets.id` — der Wert liegt ausschließlich im Vault |
| is_default | genau eine pro Host |
### `known_hosts`
`(host_id, key_type, fingerprint, first_seen_at, accepted_by_user_id)` —
ersetzt `StrictHostKeyChecking=no`. Abweichung ⇒ Verbindung scheitert sichtbar. Ein unbekannter Schlüssel
wird beim ersten Verbinden nur dann automatisch gemerkt, wenn keine Bestätigung verlangt ist (§9
`ssh.confirm_new_host_keys`, `ssh.host_key_confirm_required`); sonst erst über `POST /hosts/{id}/known-hosts`.
---
## 3. Secrets-Vault
### `secrets`
| Spalte | Anmerkung |
|---|---|
| id | |
| label | str(128) unique — der Name, den Extensions referenzieren |
| kind | `password` \| `ssh_private_key` \| `api_token` \| `totp` \| `generic` |
| ciphertext | LargeBinary, Fernet |
| key_version | int — ermöglicht Rotation ohne Big-Bang |
| owner_ext_id | null = gehört dem Kern/Admin |
| description | |
| created_by_user_id, created_at, rotated_at, last_used_at | |
| expires_at | Ablaufdatum, nur als Angabe: danach wird nichts gesperrt. Daran erinnert der Kern-Job `secret-expiry-reminder` |
| metadata | JSON, **nie sensibel** (z. B. Public-Key-Fingerprint). Unter `expiry_notice` `{for, stage}` steht, welche Erinnerung für welches Ablaufdatum schon verschickt ist |
`secret_grants(secret_id, grantee_type ext|role, grantee_id)` — wer darf lesen.
`secret_usages(secret_id, consumer_type, consumer_id, first_used_at, last_used_at)` — je Verwender eine Zeile, geschrieben
von `vault_use`. Feste Bindungen (Besitzer-Modul, Server-Zugang, Zwei-Faktor) stehen nicht dort, sie werden beim Lesen
abgeleitet (`used_by` in `GET /secrets`, nur die festen Bindungen zusätzlich in `bound`).
**Invarianten, die im Code erzwungen werden:**
1. Kein API-Endpunkt gibt `ciphertext` oder Klartext zurück. Nie. Auch nicht für `owner`.
Schreiben und Ersetzen ja, Lesen nein.
2. Extensions bekommen ein `SecretHandle`; der Klartext existiert nur innerhalb eines
`async with ctx.vault_use(handle) as value:`-Blocks.
3. Jede Materialisierung wird festgehalten (Label und Verwender, ohne Wert), gebündelt: höchstens eine Audit-Zeile
`secret.used` je Stunde, Geheimnis und Verwender. Wann ein Verwender ein Geheimnis zuletzt benutzt hat, steht
zusätzlich in `secret_usages` und in `secrets.last_used_at`.
4. Der Master-Key liegt nie in der DB.
5. `PUT /secrets/{id}/value` und `DELETE /secrets/{id}` lehnen das Secret hinter `users.totp_secret_id`
(Zwei-Faktor-Schlüssel eines Kontos) mit `409` ab: Ersetzen oder Löschen geht dort nicht. Abschalten geht nur über
Mein Konto, „Zwei-Faktor zurücksetzen“ oder den Notfall-Befehl.
---
## 4. Audit-Log
### `audit_log` — append-only
| Spalte | Anmerkung |
|---|---|
| id | |
| ts | indiziert |
| actor_type | `user` \| `extension` \| `ai` \| `scheduler` \| `system` \| `anonymous` (nicht angemeldet) |
| actor_id | User-ID, Extension-ID oder Modellname; bei `login.failed`/`login.locked` mit unbekanntem Benutzernamen (eine zu lange Eingabe zählt genauso) `unbekannt` mit `actor_type` `anonymous`, nie der getippte Text |
| action | `host.restart`, `secret.used`, `action.denied`, `login.failed` … |
| target_type, target_id | |
| outcome | `success` \| `failure` \| `denied` \| `proposed` |
| reason | **Text** — hier landet `BEGRUENDUNG:` |
| detail | JSON — Befehl, Exit-Code, Gate-Entscheidung; bei `action.executed` von Ausgabe und Fehlertext nur die Länge, nie Text vom Server (ältere Einträge können noch Ausgabe enthalten); bei `login.failed`/`login.locked` mit unbekanntem Benutzernamen statt des Texts nur `username_ref` (12 Hex-Zeichen, HMAC-SHA256 der Eingabe mit dem JWT-Geheimnis der Installation; gleiche Eingabe, gleiche Kennung) und `username_length` |
| correlation_id | verbindet Incident → Vorschlag → Ausführung → Benachrichtigung |
| ip, user_agent | |
Kein `UPDATE`, kein `DELETE` aus der Anwendung; nur ein Retention-Job löscht jenseits von
`audit.retention_days`. Die Trennung `outcome=proposed` von `success` ist der Unterschied
zwischen „die KI wollte" und „die KI tat" — ohne sie lässt sich im Nachhinein nicht
sagen, was wirklich ausgeführt wurde.
Zu lange Werte in `actor_type`, `actor_id`, `action`, `target_type`, `target_id`, `ip` und
`user_agent` werden beim Schreiben auf die Spaltenlänge gekürzt (letztes Zeichen „…“);
`reason` und `detail` werden nicht gekürzt.
Ältere Versionen schrieben bei einer Anmeldung mit unbekanntem Namen den eingetippten Text oder
eine Kennung `unbekannt:…` als `actor_id` ins Protokoll, bei `login.locked` auch in `detail.username`.
Die Migration `f3a9c6d18e24` bereinigt diese Einträge einmalig: Akteur `anonymous`/`unbekannt`,
`detail.username` fällt weg (unter SQLite mit `PRAGMA secure_delete`). Ein `login.locked` eines
inzwischen gelöschten Kontos wird genauso behandelt. Das lässt sich nicht rückgängig machen.
Die Kopien unter `backups/vor-update/` und ältere Sicherungen enthalten die Namen noch, bis sie
ersetzt oder gelöscht sind.
---
## 5. Aktionen — das Gate-Journal
### `actions`
| Spalte | Anmerkung |
|---|---|
| id | |
| ext_id | wer vorschlägt |
| action_type | `shell.exec`, `docker.restart`, `vm.start`, `script.run`, `file.delete` |
| host_id | null bei nicht host-gebundenen Aktionen |
| payload | JSON |
| risk | `low` \| `medium` \| `high` \| `critical` |
| status | `proposed` → `approved` → `executing` → `succeeded`/`failed`, oder `denied`/`expired`/`dismissed` |
| proposed_by_type / _id | `ai` + Modell, `user`, `scheduler`, `event` |
| reason | Pflichtfeld, NOT NULL |
| gate_decision | JSON: welche Regel griff, welche Muster trafen |
| approved_by_user_id, approved_at | |
| executed_at, finished_at | |
| result | JSON: exit_code, stdout/stderr (gekürzt), Dauer |
| correlation_id | |
| idempotency_key | verhindert Doppelausführung bei Retry/Doppelklick |
| expires_at | unbestätigte Vorschläge verfallen (Default 24 h) |
Diese Tabelle trennt Vorschlagen und Ausführen (siehe
[01 §4](01-ARCHITECTURE.md#4-das-aktions-gate)). Die Bestätigungs-Karte in der
UI ist eine Zeile mit `status=proposed`; „volle Autonomie" heißt, dass das Gate direkt auf
`approved` setzt — derselbe Datensatz, dieselbe Nachvollziehbarkeit, nur ohne Klick.
Ebenso bei einer gültigen Dauerfreigabe ([01 §4](01-ARCHITECTURE.md#4-das-aktions-gate)): `gate_decision` ist dann
`{"rule": "standing_approval", "standing_approval": {granted_by, granted_at, label}}`, und `approved_by_user_id`
ist die Person, die sie erteilt hat. Erkennt das Gate eine Dauerfreigabe nicht an, steht sie mit `problem` (und ggf.
der fehlenden `permission`) unter `gate_decision.standing_approval_rejected`.
`action_flap_history(host_id, fingerprint, ts, blocked)` — generalisiertes Anti-Flapping
über einen Hash aus (host, action_type, normalisiertem Payload), nicht nur über
Docker-Restarts. Statt fester Grenzwerte in einem einzelnen Watcher gilt es
für **alle** Extensions, weil es im Kern sitzt.
---
## 6. Extension-Registry
### `extensions`
| Spalte | Anmerkung |
|---|---|
| id | = Manifest-ID, PK. Nach einer Umbenennung (`legacy_ids`, [02 §1](02-EXTENSION-API.md#erweiterung-umbenennen-legacy_ids)) bleibt die Zeile unter der alten Kennung (**Speicher-Kennung**), z. B. `nexus-soc` für Nodvard Shield auf Installationen von vor 0.7; neue Installationen legen die Zeile mit der neuen Kennung an |
| version, api_version | |
| state | `enabled` \| `disabled` \| `error` \| `incompatible` \| `uninstalling` |
| source | `bundled` \| `local` \| `pip` \| `marketplace` |
| manifest | JSON, wie eingelesen |
| granted_permissions | JSON — was der Admin bestätigt hat, kann weniger sein als beantragt |
| settings | JSON, gegen das deklarierte Schema validiert |
| last_error, last_error_at | |
| installed_at, updated_at | |
### `connector_instances`
`id, ext_id, type_id, name, config (JSON, Secrets als `{"$secret": "<label>"}`-Referenz),
enabled, health_status, health_message, last_check_at`.
---
## 7. Zeitplan und Läufe
### `jobs`
`id, ext_id (null = Kern, sonst die Speicher-Kennung der Extension), name, kind, schedule (Cron), timezone, target (JSON: host/group),
params (JSON), enabled, next_run_at, created_by_user_id`
`schedule` ist normales 5-Felder-Crontab (Minute Stunde Tag Monat Wochentag) in der Zeitzone des Jobs.
Im Wochentagsfeld sind **0 und 7 beide Sonntag**, 1 = Montag … 6 = Samstag – wie im Zeitplan-Wähler.
Die Übersetzung auf APSchedulers eigene Zählung (0 = Montag) macht `nodvard_deck.core.cron.cron_trigger`;
dasselbe gilt für die Cron-Ausdrücke der Wartungsfenster.
**Eine** Tabelle für alle geplanten Arbeiten — Audits von Nodvard Shield und Skripte gleichermaßen.
Die „Fleet-weiter Zeitplan"-Ansicht ist ein `SELECT`. Ein versehentlich doppelt angelegter
Job fällt darin als Doppelzeile auf.
### `job_runs`
`id, job_id (null bei Ad-hoc), ext_id, trigger (manual|schedule|ai|event), status
(running|succeeded|failed|interrupted|skipped), started_at, finished_at, exit_code,
host_id, output_ref, error, actor, correlation_id`
`output_ref` zeigt auf eine Datei unter `<Datenordner>/runs/<id>.log`, nicht in die DB —
Skript-Ausgaben sind unbegrenzt groß, DB-Zeilen sollten es nicht sein.
`interrupted` existiert, damit ein Neustart mitten im Lauf sichtbar wird statt zu
verschwinden; ebenso endet ein Lauf, den das Herunterfahren abbricht oder gar nicht mehr startet.
**Aufräumen** (`services/job_retention.py`, Kern-Job `job-runs-retention`, nachts 03:20): beendete Läufe
(`status` ≠ `running`), die vor mehr als `jobs.run_retention_days` Tagen (Vorgabe 30, 1–3650) begannen, werden samt
Protokolldatei gelöscht. Pro `job_id` bleiben immer die 20 neuesten beendeten Läufe stehen, Läufe mit `job_id` leer
(Job gelöscht, Ad-hoc) gehen nur nach Alter, laufende nie. Gelöscht wird in Häppchen zu 5000 Zeilen (Commit dazwischen,
damit SQLite nicht lange sperrt); Dateien erst nach dem Commit und nur innerhalb von `<Datenordner>/runs`.
Zusätzlich verschwinden `*.log`-Dateien dort, zu denen keine Zeile gehört und die älter als die Aufbewahrung sind.
`host-reachability` räumt seine Läufe weiterhin selbst nach 24 Stunden auf (`services.jobs.prune_runs`).
Die Datenbank läuft im WAL-Modus mit `auto_vacuum=NONE`; freie Seiten füllt SQLite mit neuen Zeilen wieder auf, die
Dateigröße pendelt sich ein. Ein `VACUUM` (schreibt alles neu, hält die Schreibsperre, braucht bis zum Doppelten an
Platz) und `incremental_vacuum` (wirkt nur nach einem solchen Umstellen) laufen bewusst nicht im Betrieb.
---
## 8. Benachrichtigungen
`notifications(id, ts, severity (info|warning|critical), title, body, source_ext_id,
correlation_id, read_at, payload)` — die Historie im Dashboard
(„Notification-Center").
Versandkanäle sind **Connector-Instanzen** einer Extension, die `NotificationChannel`
erfüllt, keine eigene Tabelle. `notification_deliveries(notification_id, channel_id,
status, error, sent_at)` protokolliert, ob der Versand geklappt hat — ohne diese
Rückmeldung fällt ein Kanal, der ins Leere meldet, unter Umständen wochenlang nicht auf.
**Aufräumen** (`services/notification_retention.py`, Kern-Job `notifications-retention`, nachts 03:30): Meldungen, deren
`ts` älter ist als `notifications.retention_days` Tage (Vorgabe 90, 1–3650), werden samt ihren Zustellprotokollen
gelöscht – gelesen oder ungelesen, nur eine **ungelesene kritische** Meldung (`severity` `critical`, `read_at` leer)
bleibt stehen, bis jemand sie liest –, in Häppchen zu 5000 Zeilen mit Commit dazwischen. Die Zustellprotokolle gehen
ausdrücklich mit (nicht nur über `ON DELETE CASCADE`, denn `PRAGMA foreign_keys` hängt an der Verbindung). Von Hand
löschen geht über `DELETE /notifications/{id}` und `POST /notifications/delete-read` (docs/04-API.md); eine Zustellung,
die gerade läuft, schreibt ihr Protokoll nur, solange die Meldung noch da ist (`services.notifications._log_deliveries`).
---
## 9. Einstellungen und Branding
`settings(key, scope (global|user|host), user_id, value JSON, updated_at, updated_by_user_id)`
Reservierte Schlüssel:
```
branding.product_name branding.short_name branding.logo_url
branding.favicon_url branding.colors branding.login_subtitle
branding.support_url
autonomy.mode autonomy.max_risk
audit.retention_days maintenance.windows security.deny_patterns
jobs.run_retention_days notifications.retention_days
locale.default notifications.defaults system.timezone
system.update_check.enabled system.update_check.channel
system.trusted_proxies
backup.config backup.key backup.last_run
system.instance_id hosts.reachability.enabled
hosts.reachability.interval_minutes hosts.reachability.state
ssh.confirm_new_host_keys ssh.host_key_confirm_required
demo.seeded_ids
ui.custom_css ui.theme ui.first_steps_dismissed
ui.design_notice_dismissed
```
`ui.theme`, `ui.first_steps_dismissed` und `ui.design_notice_dismissed` gelten je Benutzer (`scope = 'user'`), `ui.custom_css`
(das eigene CSS der Admins) global. `demo.seeded_ids` merkt die Zeilen der Beispieldaten (siehe docs/00-DECISIONS.md D-10).
`system.update_check.*` und `system.trusted_proxies` sind über `PUT /settings/{key}` setzbar, siehe docs/04-API.md
§3 „Einstellungen“.
Sicherungen (`services/backups.py`, nur über `/system/backups/...`, nicht über `/settings`; Ändern nur der Owner):
| Schlüssel | Inhalt |
|---|---|
| `backup.config` | `{enabled, schedule, keep, dir, include_runs}`; `dir = null` heißt `<Datenordner>/backups` |
| `backup.key` | `{recipient ("age1…"), key_id, kdf {alg: "argon2id", t, m_kib, p, salt}, created_at}` – **kein** Passwort, **kein** privater Schlüssel. Aus Passwort + `kdf` entsteht der Schlüssel bei Bedarf neu |
| `backup.last_run` | `{at, ok, trigger, name, size, error, deleted[]}` |
| `system.instance_id` | zufällige Kennung dieser Installation (steht im Manifest jeder Sicherung) |
`system.read` (Einstellungen → System ansehen) hat nur `admin` über `*`; Kritisches dort verlangt den Owner
selbst, siehe docs/04-API.md „System und Sicherungen“.
`system.timezone` (IANA-Name, Vorgabe aus `NODVARD_DECK_TIMEZONE`/`TZ`, sonst Europe/Berlin) und
`audit.retention_days` (7–3650, Rückfall die Umgebungsvariable) und `jobs.run_retention_days` (1–3650, Vorgabe 30;
Aufbewahrung der Job-Läufe, siehe §7 `job_runs`) und `notifications.retention_days` (1–3650, Vorgabe 90; Aufbewahrung der
Meldungen, siehe §8) sind über `PUT /settings/{key}` setzbar, siehe
docs/04-API.md §3 „Einstellungen“.
Erreichbarkeitsprüfung (`services/reachability.py`): `hosts.reachability.enabled` (Vorgabe `true`) und
`hosts.reachability.interval_minutes` (1–60, Vorgabe 2) sind über `PUT /settings/{key}` setzbar. `hosts.reachability.state`
ist ein interner Merker `{host_id: {"muted": bool}}` für Server, deren Ausfall schon gemeldet wurde (`muted` = im
Wartungsfenster nur im Verlauf, wird danach einmal hörbar nachgeholt); er steht nicht in `GET /settings`.
Der Kern-Job `host-reachability` hängt wie die anderen Kern-Jobs in `jobs` (`ext_id` null); seine Läufe in `job_runs`
bleiben nur 24 Stunden, weil er bis zu 1440 mal am Tag läuft.
Server-Schlüssel (`services/hosts.py`): `ssh.confirm_new_host_keys` (global, Vorgabe `true`) legt fest, ob neue
Server-Schlüssel erst nach Bestätigung gemerkt werden; das Anlegen des ersten Kontos speichert `true`, ohne gespeicherten
Wert gilt ebenfalls `true`, nur ein gespeichertes `false` schaltet es ab. `NODVARD_DECK_SSH_CONFIRM_NEW_HOST_KEYS` übersteuert ihn (siehe docs/04-API.md §3
„Einstellungen“). `ssh.host_key_confirm_required` ist ein interner Merker je Server (`scope = 'host'`, `user_id` =
Server-ID): gesetzt beim Vergessen eines gemerkten Schlüssels (`DELETE /hosts/{id}/known-hosts/{key_type}`, im selben
Commit), entfernt nur beim Bestätigen (`POST /hosts/{id}/known-hosts`) und mit dem Server. Solange er da ist, merkt
keine Verbindung für diesen Server einen Schlüssel still, auch nicht gegen die Umgebungsvariable; er steht nicht in
`GET /settings`.
`maintenance.windows` ist eine Liste aus (Cron-Ausdruck, Dauer, betroffene Hosts, was
unterdrückt wird) statt eines fest eingebauten Fensters. Wichtig: die Unterdrückung gilt
**symmetrisch** für Offline- *und* Online-Meldungen — sonst kommt nach einem still
gemeldeten Ausfall eine hörbare Entwarnung ohne Vorgeschichte.
Welche Meldung ein Fenster erfasst, steht im Payload der Meldung: `host_id` (ein Server)
oder `host_ids` (Sammelmeldung über mehrere Server, nur still, wenn **alle** in einem
laufenden Fenster liegen). Eine Meldung ohne beides wird nie unterdrückt.
`security.deny_patterns` ist die Sperrliste des Aktions-Gates
([01 §4](01-ARCHITECTURE.md#4-das-aktions-gate)) als Konfiguration statt als
Konstante: der Kern liefert einen Satz Defaults mit, Extensions dürfen ergänzen,
niemand darf sie leeren.
### `dashboard_layouts`
`id, user_id, name, is_default, items (JSON: [{widget_id, ext_id, x, y, w, h, config}]), groups (JSON, null)` —
identisch für Web und App, damit die Anordnung auf dem Telefon nicht neu erfunden wird. `config.group` legt ein Widget
in eine andere Gruppe als die seines Moduls. `config.size` (`s`, `m` oder `l`, in der Oberfläche klein, mittel, groß:
2, 3 oder 5 Rasterzeilen hoch) legt eine feste Höhe fest; ohne Angabe richtet sich die Höhe nach dem Inhalt. Die
Oberfläche speichert `x + w` höchstens 12. `groups` hält Reihenfolge, eigene Namen und Zuklappen der Gruppen (`[{key, title, collapsed}]`);
leer heißt Vorgabe.
### `custom_apps`
Eigene App-Kacheln im Cockpit („+ App hinzufügen“) – von Hand angelegte Links für alles, was der
Container-Erkennung nicht auffällt (Router, NAS-Oberfläche, Pi-hole …).
| Spalte | Anmerkung |
|---|---|
| id | |
| name | str(60), getrimmt; ohne Steuerzeichen, Zeilen-/Absatztrenner und Sonderleerzeichen, mit mindestens einem sichtbaren Zeichen |
| url | str(1000), nur `http://`/`https://`, ohne Benutzer/Passwort und ohne Leer-/Steuerzeichen |
| icon | str(32) null: Name aus der festen lucide-Auswahl (`services/custom_apps.APP_ICONS`) oder ein einzelnes Emoji – nie eine Bild-Adresse |
| color | str(7) null: `#rrggbb`; leer = Farbe nach dem Namen |
| group_name | str(40) null: Gruppe für den Filter im Cockpit (in der API `group`), gleiche Zeichenregeln wie `name` |
| sort_order | int, Index: Position (neue Apps kommen ans Ende; `PUT /apps/order` zählt neu durch) |
| open_in_new_tab | bool, Standard `true` |
| host_id | FK hosts.id null, `ON DELETE SET NULL`: optionaler Server-Bezug, nur zur Anzeige |
| created_by_user_id | str(36) null, ohne Fremdschlüssel (wie `jobs`) |
| created_at, updated_at | ts |
Der Server ruft die Adresse **nie selbst** ab (keine Statusabfrage → kein SSRF); sie ist nur ein Link im Browser.
Höchstens 200 Apps (das Anlegen gibt sonst `409`; die Beispieldaten halten die Grenze ein, und beim Verschieben darf die Liste etwas länger sein, falls zwei Anlegen gleichzeitig durchkamen). Alembic-Revision `d4b7a2c91e63` im Kern-Zweig (Downgrade löscht Tabelle und Indizes).
Die Berechtigung `apps.write` ist additiv (rein ein neuer String, keine Migration): Administrator und Owner
haben sie, Bediener und Betrachter nicht von selbst; Lesen braucht wie die erkannten Apps `hosts.read`.