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

20 KiB

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

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

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)

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:

# ansible.cfg
[defaults]
inventory = inventory.ini
host_key_checking = False
retries = 3

[privilege_escalation]
become = True
become_method = sudo
# 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:

{
  "type": "log_line",
  "timestamp": "2025-03-25T14:22:00Z",
  "line": "TASK [signage_base : Update package cache] **",
  "source": "ansible",
  "level": "info"
}
{
  "type": "progress",
  "timestamp": "2025-03-25T14:22:15Z",
  "current_task": "signage_base : Update package cache",
  "task_number": 3,
  "total_tasks": 12,
  "percent": 25
}
{
  "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

# 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:

# /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:

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