morz-infoboard/docs/PROVISION-KONZEPT.md
Jesko Anschütz dd3ec070f7 Security-Review + Phase 6: CSRF, Rate-Limiting, Tenant-Isolation, Screenshot, Ansible
### Security-Fixes (K1–K6, W1–W4, W7, N1, N5–N6, V1, V5–V7)
- K1: CSRF-Schutz via Double-Submit-Cookie (httpapi/csrf.go + csrf_helpers.go)
- K2: requireScreenAccess() in allen manage-Handlern (Tenant-Isolation)
- K3: Tenant-Check bei DELETE /api/v1/media/{id}
- K4: requirePlaylistAccess() + GetByItemID() für JSON-API Playlist-Routen
- K5: Admin-Passwort nur noch als [gesetzt] geloggt
- K6: POST /api/v1/screens/register mit Pre-Shared-Secret (MORZ_INFOBOARD_REGISTER_SECRET)
- W1: Race Condition bei order_index behoben (atomare Subquery in AddItem)
- W2: Graceful Shutdown mit 15s Timeout auf SIGTERM/SIGINT
- W3: http.MaxBytesReader (512 MB) in allen Upload-Handlern
- W4: err.Error() nicht mehr an den Client
- W7: Template-Execution via bytes.Buffer (kein partial write bei Fehler)
- N1: Rate-Limiting auf /login (5 Versuche/Minute pro IP, httpapi/ratelimit.go)
- N5: Directory-Listing auf /uploads/ deaktiviert (neuteredFileSystem)
- N6: Uploads nach Tenant getrennt (uploads/{tenantSlug}/)
- V1: Upload-Logik konsolidiert in internal/fileutil/fileutil.go
- V5: Cookie-Name als Konstante reqcontext.SessionCookieName
- V6: Strukturiertes Logging mit log/slog + JSON-Handler
- V7: DB-Pool wird im Graceful-Shutdown geschlossen

### Phase 6: Screenshot-Erzeugung
- player/agent/internal/screenshot/screenshot.go erstellt
- Integration in app.go mit MORZ_INFOBOARD_SCREENSHOT_EVERY Config

### UX: PDF.js Integration
- pdf.min.js + pdf.worker.min.js als lokale Assets eingebettet
- Automatisches Seitendurchblättern im Player

### Ansible: Neue Rollen
- signage_base, signage_server, signage_provision erstellt
- inventory.yml und site.yml erweitert

### Konzept-Docs
- GRUPPEN-KONZEPT.md, KAMPAGNEN-AKTIVIERUNG.md, MONITORING-KONZEPT.md
- PROVISION-KONZEPT.md, TEMPLATE-EDITOR.md, WATCHDOG-KONZEPT.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 21:06:35 +01:00

610 lines
20 KiB
Markdown

# Info-Board Neu - Jobrunner-Konzept fuer Ansible-gestuetzte Erstinstallation
## Ziel
Der Jobrunner fuehrt aus dem Admin-Backend heraus Provisionierungsjobs aus, die ein neues Display technisch in Betrieb nehmen.
Dieses Dokument beschreibt:
- wie ein Admin einen neuen Screen aus dem Web-UI provisioniert
- wie der Server Ansible-Playbooeke orchestriert
- wie der Fortschritt angezeigt wird
- Sicherheits- und Fehlerbehandlung
Grundlagen zur Provisionierungs-Strategie finden sich in `docs/PROVISIONIERUNGSKONZEPT.md`.
## 1. Provisionierungs-Workflow im Admin-UI
### Seite: Admin → Screens → Neu
```
┌──────────────────────────────────────────┐
│ Neuen Screen provisionieren │
├──────────────────────────────────────────┤
│ │
│ Schritt 1 — Grunddaten │
│ │
│ Screen-ID / Slug * │
│ [ info10 ] │
│ (muss eindeutig sein, alphanumerisch) │
│ │
│ Anzeigename * │
│ [ Infowand Bottom-Left ________________ ] │
│ │
│ Beschreibung │
│ [ Neue Infowand Display, pos. 7______ ] │
│ │
│ Device Type * │
│ ⦿ Raspberry Pi 4 │
│ ○ Raspberry Pi 5 │
│ ○ x86 Linux Kiosk │
│ │
│ Aufloesung * │
│ [1920 x 1080 ] Standard fuer RPi │
│ │
│ Orientierung * │
│ ⦿ portrait (hochkant) │
│ ○ landscape (quer) │
│ │
│ Tenant-Zuordnung │
│ [ Dropdown: alle Tenants + "admin" ] │
│ │
│ [Weiter >] [Abbrechen] │
└──────────────────────────────────────────┘
```
### Schritt 2 — Netzwerk- und SSH-Einstellung
```
┌──────────────────────────────────────────┐
│ Schritt 2 — Zugang zur Hardware │
│ │
│ Ziel-IP-Adresse * │
│ [ 192.168.1.50 ] │
│ │
│ SSH-Port │
│ [ 22 ] Standard │
│ │
│ Bootstrap-Benutzer * │
│ ⦿ root │
│ ○ pi │
│ ○ custom: [ ________________ ] │
│ │
│ Bootstrap-Authentifizierung * │
│ ⦿ Passwort (initial, wird durch Key │
│ ersetzt): │
│ [ Passwort ____________ ] │
│ ○ SSH-Key (nur wenn vorvorhanden): │
│ [ Datei auswaehlen ] oder │
│ [ PEM-Key einfuegen ] │
│ │
│ Test-Verbindung │
│ [SSH Test] [PING Test] │
│ │
│ [Weiter >] [Zurueck] [Abbrechen] │
└──────────────────────────────────────────┘
```
### Schritt 3 — Konfiguration und Optionen
```
┌──────────────────────────────────────────┐
│ Schritt 3 — Konfiguration │
│ │
│ Fallback-Verzeichnis (lokal auf Player) │
│ [ /var/lib/signage/fallback ] │
│ │
│ Snapshot-Intervall (Sekunden) │
│ [ 300 ] 0 = deaktiviert │
│ │
│ MQTT-Broker-Adresse (Zielserver) │
│ [ mqtt.example.com ] auto-gefuellt │
│ │
│ Server-API-Adresse │
│ [ https://signage.example.com/api ] │
│ auto-gefuellt │
│ │
│ Gruppen-Zuordnung (optional) │
│ [ Checkboxen: wall-all, wall-row-1 ] │
│ │
│ Tags / Labels (optional) │
│ [ mainfloor, hightrafficarea ] │
│ │
│ [Weiter >] [Zurueck] [Abbrechen] │
└──────────────────────────────────────────┘
```
### Schritt 4 — Review und Start
```
┌──────────────────────────────────────────┐
│ Schritt 4 — Uebersicht & Start │
│ │
│ Zusammenfassung: │
│ │
│ Screen: info10 │
│ Name: Infowand Bottom-Left │
│ Typ: Raspberry Pi 4 │
│ IP: 192.168.1.50 │
│ Aufloesung: 1920 x 1080 │
│ Orientierung: portrait │
│ Tenant: admin │
│ │
│ SSH-Verbindung wird hergestellt... │
│ [✓] SSH-Zugang verifiziert │
│ [✓] Pfadberechtigungen ok │
│ [✓] Speicherplatz ausreichend (15GB) │
│ │
│ Provisioning-Playbook: │
│ [ ] site.yml │
│ ├─ signage_base (Packages, Kernel) │
│ ├─ signage_display (X11, Chromium) │
│ ├─ signage_player (Agent, Config) │
│ └─ signage_provision (Setup-Jobs) │
│ │
│ Warnung: │
│ ! Diesen Prozess kann nicht unterbrochen│
│ werden. Typische Dauer: 10-15 Min. │
│ │
│ [Provisioning starten] [Abbrechen] │
└──────────────────────────────────────────┘
```
## 2. Provisioning-Job: Serverseitige Orchestrierung
### Architektur
```
┌─────────────────────────────────────────┐
│ Admin-UI HTTP Request │
│ POST /api/v1/admin/provision │
└────────────┬────────────────────────────┘
┌─────────────────────────────────────────┐
│ Backend API (Go) │
│ - validiert Eingaben │
│ - erstellt ProvisioningJob in DB │
│ - queued Job in Job-Broker (Redis etc) │
└────────────┬────────────────────────────┘
┌─────────────────────────────────────────┐
│ Jobrunner Worker (Goroutine oder │
│ separater Go-Service) │
│ - laeuft im Server-Container │
│ - zeigt Fortschritt via Websocket │
└────────────┬────────────────────────────┘
┌─────────────────────────────────────────┐
│ Ansible Executor │
│ ansible-playbook site.yml │
│ -i inventory.ini │
│ -e vars.yml │
└────────────┬────────────────────────────┘
┌─────────────────────────────────────────┐
│ Target Device (Raspberry Pi) │
│ SSH: root@192.168.1.50 │
│ - installiert Packages │
│ - startet Services │
│ - synchonisiert Config │
└─────────────────────────────────────────┘
```
### Provisioning-Job-Modell
```sql
CREATE TABLE provisioning_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
screen_id UUID NOT NULL REFERENCES screens(id),
status TEXT NOT NULL CHECK (status IN (
'pending', 'running', 'completed', 'failed'
)),
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
-- SSH/Ansible-Details
target_ip TEXT NOT NULL,
target_port INT NOT NULL DEFAULT 22,
target_user TEXT NOT NULL,
-- Verbrauch von Ressourcen
ansible_job_id TEXT, -- Job-ID aus Ansible-Executor
-- Fehlerbehandlung
error_log TEXT, -- bei failure
created_by_user_id TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```
### Provisioning-Log-Modell
```sql
CREATE TABLE provisioning_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
job_id UUID NOT NULL REFERENCES provisioning_jobs(id) ON DELETE CASCADE,
line_number INT NOT NULL,
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Quelle des Logs
source TEXT NOT NULL CHECK (source IN ('ansible', 'agent', 'system')),
level TEXT NOT NULL CHECK (level IN ('info', 'warn', 'error')),
-- Nachricht
message TEXT NOT NULL,
UNIQUE(job_id, line_number)
);
```
## 3. Jobrunner-Implementierung
### Job-Verarbeitung (Pseudocode)
```go
type ProvisioningJobRunner struct {
db *sql.DB
ansibleBinPath string
logChannel chan ProvisioningLogMessage
}
func (r *ProvisioningJobRunner) ProcessJob(ctx context.Context, jobID uuid.UUID) error {
// 1. Lade Job aus DB
job := r.db.GetProvisioningJob(jobID)
// 2. Setze Status auf "running"
r.db.UpdateProvisioningJob(job.ID, map[string]interface{}{
"status": "running",
"started_at": time.Now(),
})
// 3. Generiere Ansible-Inventar
inventory := r.generateInventory(job)
// [192.168.1.50]
// ansible_user=root
// ansible_password=***
// screen_id=info10
// ansible_become=yes
// 4. Generiere vars.yml
vars := r.generateVars(job)
// screen_id: info10
// display_name: "Infowand Bottom-Left"
// orientation: portrait
// mqtt_broker: mqtt.example.com
// etc.
// 5. Fuehre Ansible aus
cmd := exec.CommandContext(ctx,
r.ansibleBinPath,
"site.yml",
"-i", inventoryPath,
"-e", varsPath,
"-v", // verbose
)
// 6. Piping: Ansible-Ausgabe → Log-Dateien + Websocket
stdout, _ := cmd.StdoutPipe()
stderr, _ := cmd.StderrPipe()
go r.streamLogs(job.ID, stdout, "ansible")
go r.streamLogs(job.ID, stderr, "ansible")
// 7. Warte auf Completion
err := cmd.Run()
// 8. Aktualisiere Job-Status
if err != nil {
r.db.UpdateProvisioningJob(job.ID, map[string]interface{}{
"status": "failed",
"completed_at": time.Now(),
"error_log": err.Error(),
})
return err
}
r.db.UpdateProvisioningJob(job.ID, map[string]interface{}{
"status": "completed",
"completed_at": time.Now(),
})
return nil
}
func (r *ProvisioningJobRunner) streamLogs(jobID uuid.UUID, reader io.Reader, source string) {
scanner := bufio.NewScanner(reader)
lineNum := 1
for scanner.Scan() {
line := scanner.Text()
// Persistiere in DB
r.db.InsertProvisioningLog(ProvisioningLog{
JobID: jobID,
LineNumber: lineNum,
Source: source,
Level: parseLogLevel(line), // heuristic
Message: line,
})
// Schreibe ins Websocket (siehe Abschnitt "Fortschritt")
r.logChannel <- ProvisioningLogMessage{
JobID: jobID,
Line: line,
}
lineNum++
}
}
```
### Ansible-Ausfuehrung mit Jumphost (optional)
Falls der Server nicht direkt die Zielgeraete erreicht, kann ein Jumphost verwendet werden:
```yaml
# ansible.cfg
[defaults]
inventory = inventory.ini
host_key_checking = False
retries = 3
[privilege_escalation]
become = True
become_method = sudo
```
```ini
# inventory.ini fuer Jumphost-Szenario
[targets]
192.168.1.50 ansible_user=root ansible_password=*** \
ansible_ssh_common_args='-o ProxyCommand="ssh -W %h:%p jumphost@example.com"'
```
## 4. Fortschritt und Live-Updates
### Websocket-Kanal fuer Echtzeit-Logs
**HTTP-Upgrade zu Websocket:**
```
GET /api/v1/admin/provision/{jobID}/logs
Upgrade: websocket
Connection: Upgrade
```
**Server sendet kontinuierlich:**
```json
{
"type": "log_line",
"timestamp": "2025-03-25T14:22:00Z",
"line": "TASK [signage_base : Update package cache] **",
"source": "ansible",
"level": "info"
}
```
```json
{
"type": "progress",
"timestamp": "2025-03-25T14:22:15Z",
"current_task": "signage_base : Update package cache",
"task_number": 3,
"total_tasks": 12,
"percent": 25
}
```
```json
{
"type": "status_change",
"timestamp": "2025-03-25T14:35:00Z",
"status": "completed",
"duration_seconds": 780
}
```
### UI-Anzeige waehrend Provisioning
```
┌──────────────────────────────────────────┐
│ Provisioning laeuft: info10 │
│ Gestartet: vor 5 Min. │
│ Geschaetzte verbleibende Zeit: 8 Min. │
├──────────────────────────────────────────┤
│ │
│ [████████████░░░░░░░░░░░░░░] 33% │
│ │
│ Aktuelle Aufgabe: │
│ ⊙ signage_base : Update package cache │
│ │
│ Letzte Logs: │
│ ├─ [14:22:00] TASK [signage_base ...] │
│ ├─ [14:22:05] ok: [192.168.1.50] │
│ ├─ [14:22:10] TASK [signage_display] │
│ ├─ [14:22:15] Chromium wird installiert│
│ └─ [14:22:20] ... │
│ │
│ [Auto-Refresh] [Pause] [Abbrechen] │
│ (Abbrechen: SSH-Verbindung wird nicht │
│ sofort getrennt, aber Job gestoppt) │
└──────────────────────────────────────────┘
```
## 5. Fehlerbehandlung und Recovery
### Fehlerszenarien
| Fehler | Grund | Recovery |
|---|---|---|
| SSH-Verbindung fehlgeschlagen | IP falsch, Passwort falsch, Firewall | Logs zeigen SSH-Error, Admin kann Credentials korrigieren und neu starten |
| Ansible-Playbook fehlgeschlagen | Paket-Versionskonflikt, Platz voll | Logs zeigen welcher Task fehlgeschlagen, Admin kann manuell SSH-en oder Job wiederholen |
| Timeout nach 30 Min. | Sehr langsame Netzwerk oder Device haengt | Job wird abgebrochen, Admin kann Verbindung checken und neu starten |
| Package-Download fehlgeschlagen | Mirror offline, Netzwerk unterbrochen | Ansible retry automatisch 3x, Logs zeigen wget-Error |
### Retry-Logik
```
Strategie: Exponentieller Backoff fuer Playbook-Fehler
Fehler 1: Sofort wiederholen
Fehler 2: Warte 5s, wiederhole
Fehler 3: Warte 15s, wiederhole
Fehler 4+: Gib auf, zeige Fehler
```
### Admin-Recovery
Falls ein Job fehlgeschlagen ist:
```
┌──────────────────────────────────────────┐
│ Provisioning fehlgeschlagen: info10 │
│ │
│ Fehler: │
│ ssh: Could not resolve hostname │
│ (DNS-Fehler oder Geraet nicht erreichbar)│
│ │
│ Empfehlung: │
│ 1. IP-Adresse pruefen │
│ 2. Geraet von Hand SSH-en und testen │
│ 3. Job neu starten: [Neuer Versuch] │
│ │
│ Komplette Logs herunterladen: │
│ [logs-info10-20250325.txt] │
│ │
│ [Neuer Versuch] [Logs zeigen] [Zurueck]│
└──────────────────────────────────────────┘
```
## 6. Sicherheitsaspekte
### SSH-Key-Verwaltung
**Phase 1 — Bootstrap mit Passwort:**
```
Admin gibt Passwort ein
Server speichert Passwort NICHT
Server uebergibt an Ansible nur waehrend dieser Session
Ansible loggt sich ein, generiert SSH-Key
SSH-Key wird auf dem Geraet als authorized_key eingetragen
Passwort wird auf dem Geraet gelöscht oder deaktiviert
```
**Phase 2 — Dauerhaft mit SSH-Key:**
```
Server speichert SSH-Key in Secrets-Backend (z.B. HashiCorp Vault)
Zukuenftige Ansible-Lauefe verwenden den Key
```
### Ansible-Vault fuer sensitive Daten
```yaml
# roles/signage_player/defaults/main.yml
server_api_key: !vault |
$ANSIBLE_VAULT;1.1;AES256
abcd1234...
```
Die Vault-Passphrase wird:
- nie im Klartext gelagert
- vom Server nur zur Laufzeit an Ansible uebergeben
- in Logs nicht ausgegeben
### Sudo ohne Passwort
Ansible erhoeht die Rechte per `sudo` ohne Passwort-Eingabe:
```sudoers
# /etc/sudoers.d/ansible-signage
ansible ALL=(ALL) NOPASSWD: ALL
```
(Alternativ: mit Passwort, das Ansible am Anfang einmal abfragt)
## 7. Verbindung zum bestehenden System
### Provisioning-Trigger aus Admin-UI
```
Admin-Seite: Screens → "+ Neuer Screen"
Formular sammelt Grunddaten
POST /api/v1/admin/provision
Backend:
1. Screen in `screens` Tabelle eintragen
2. ProvisioningJob in `provisioning_jobs` anlegen
3. Job in Broker queuen
Jobrunner:
1. Holt Job aus Broker
2. Startet Ansible
3. Streamt Logs via Websocket
4. Aktualisiert Job-Status bei Completion
Admin sieht Live-Updates im UI
```
### Nach erfolgreichem Provisioning
```
Job-Status: "completed"
Agent auf dem Display startet
Agent registriert sich beim Server
Server setzt Screen-Status auf "online"
Admin sieht Screen in Tabelle mit Status "online"
Admin kann sofort Kampagnen/Playlists zuweisen
```
## 8. Konfigurierbare Parameter
In `/etc/signage/provision.yml`:
```yaml
jobrunner:
max_concurrent_jobs: 3
ansible_timeout_sec: 1800
playbook_path: "/srv/ansible/site.yml"
inventory_template_path: "/srv/ansible/inventory.ini.tpl"
vars_template_path: "/srv/ansible/vars.yml.tpl"
ssh:
known_hosts_file: "/etc/signage/.ssh/known_hosts"
key_storage: "vault" # oder "filesystem"
ansible:
verbosity: "-vv" # oder "-v", "-vvv"
extra_args: ""
```
## 9. Zusammenfassung
Der Jobrunner:
- **ist web-gesteuert** — Provisioning-UI mit Multi-Step-Wizard
- **ist automatisiert** — Ansible Playbooks, nicht manuelle SSH-Kommandos
- **ist transparent** — Live-Logs und Fortschritt-Anzeige
- **ist sicher** — SSH-Keys, Ansible-Vault, keine Plaintext-Credentials in Logs
- **ist resilient** — Retry-Logik und Error-Recovery
- **ist erweiterbar** — neue Rollen und Tasks koennen hinzugefuegt werden ohne UI-Aenderung