# EK-DOS-WEB betreiben

Zielsystem: Debian/Ubuntu mit nginx und php8.5-fpm. Postgres und Redis laufen
bereits für n8n und werden mitbenutzt (eigene Datenbank, eigene Redis-Nummer).

## Verzeichnisse auf dem Webserver

```
/var/www/ekdos/
├── current -> releases/<sha>     was nginx liest
├── previous                      Notiz für rollback.sh
├── releases/
│   └── <sha>/
│       ├── public/               das Vite-Bündel        -> nginx root
│       └── backend/              PHP + vendor/          -> fastcgi
│           ├── .env -> ../../../shared/.env
│           └── var/cache/        kompilierter DI-Container, je Veröffentlichung
├── shared/
│   └── .env                      überlebt jedes Ausrollen
└── deploy/
    ├── release.sh
    └── rollback.sh
```

Oberfläche und Backend liegen **in derselben Veröffentlichung** und werden mit
einem einzigen Symlink-Tausch gemeinsam sichtbar. Eine neue Oberfläche kann
daher nie gegen ein altes Backend laufen.

## 1. Pakete

```bash
sudo apt update
sudo apt install -y nginx php8.5-fpm php8.5-cli php8.5-pgsql php8.5-redis \
                    php8.5-mbstring php8.5-curl php8.5-intl php8.5-xml \
                    composer rsync curl
```

`composer` wird auf dem Webserver gebraucht: die PHP-Abhängigkeiten werden dort
aufgelöst, nicht im Gitea-Runner. Nur dort stimmen PHP-Version und Erweiterungen,
sodass die Plattformprüfung von Composer etwas Echtes prüft.

`php8.5-intl` ist optional, verbessert aber die Erkennung der Kundenrücksprache
in Ticketberichten (Unicode-Normalisierung). Ohne die Erweiterung greift ein
Rückfall ohne Normalisierung.

## 2. Datenbank und Redis

```bash
sudo -u postgres createuser ekdos --pwprompt
sudo -u postgres createdb ekdos --owner=ekdos
```

Redis braucht nichts weiter -- EK-DOS legt seine Schlüssel unter dem Präfix
`ekdos:` ab und nutzt eine eigene Datenbanknummer (`REDIS_DB`).

> Redis hält Sitzungen **und** den n8n-Cache. Ist Redis weg, ist niemand mehr
> angemeldet und jede Ansicht fragt wieder direkt bei n8n an. Die Anwendung
> läuft weiter, aber langsamer und mit erneuter Anmeldung.

## 3. Benutzer und Rechte

Zwei Konten: `ekdos` führt PHP aus, `deploy` nimmt die Dateien entgegen. Über
die gemeinsame Gruppe kommt php-fpm an das, was `deploy` hochlädt.

```bash
sudo useradd --system --home /var/www/ekdos --shell /usr/sbin/nologin ekdos
sudo useradd --create-home --shell /bin/bash deploy
sudo usermod -aG ekdos deploy

sudo mkdir -p /var/www/ekdos/{releases,shared,deploy}
sudo chown -R deploy:ekdos /var/www/ekdos
sudo chmod -R g+rX /var/www/ekdos

# setgid: alles neu Angelegte erbt die Gruppe ekdos, sonst käme php-fpm
# nach dem nächsten Ausrollen nicht mehr an die Dateien.
sudo find /var/www/ekdos -type d -exec chmod g+s {} +

sudo mkdir -p /var/log/php && sudo chown ekdos:ekdos /var/log/php
```

`release.sh` lädt php-fpm neu. Dafür genau dieses eine Kommando ohne Passwort:

```bash
echo 'deploy ALL=(root) NOPASSWD: /bin/systemctl reload php8.5-fpm' \
  | sudo tee /etc/sudoers.d/ekdos-deploy
sudo chmod 440 /etc/sudoers.d/ekdos-deploy
sudo visudo -c
```

## 4. Konfiguration anlegen

```bash
sudo -u deploy cp backend/.env.example /var/www/ekdos/shared/.env
sudo -u deploy editor /var/www/ekdos/shared/.env
sudo chown deploy:ekdos /var/www/ekdos/shared/.env
sudo chmod 640 /var/www/ekdos/shared/.env
```

`640` mit Gruppe `ekdos`: php-fpm liest sie, sonst niemand. Sie wird beim
Ausrollen ausdrücklich nie überschrieben.

### n8n-Adressen

| Einstellung         | Wert                             | warum                                                      |
| ------------------- | -------------------------------- | ---------------------------------------------------------- |
| `N8N_PUBLIC_BASE`   | `https://n8n.elektro-krueger.eu` | Der Dienst auf Port 5678 ist nur über den CNAME erreichbar  |
| `N8N_INTERNAL_HOST` | `10.0.11.131`                    | Jeder weitere n8n-Dienst auf einem anderen Port             |

## 5. php-fpm

```bash
sudo cp deploy/php-fpm/ekdos.pool.conf /etc/php/8.5/fpm/pool.d/ekdos.conf
sudo php-fpm8.5 -t
sudo systemctl restart php8.5-fpm
ls -l /run/php/ekdos.sock      # muss www-data:www-data 0660 gehören
```

Der Pool setzt `opcache.validate_timestamps = 0`. Neuer Quelltext wird deshalb
**erst nach einem `systemctl reload php8.5-fpm`** wirksam -- `release.sh` macht
das selbst.

## 6. nginx

Die nginx-Konfiguration wird auf dem Server gepflegt und gehört bewusst nicht
ins Repository. Was sie leisten muss:

| Pfad          | Ziel                                                                     |
| ------------- | ------------------------------------------------------------------------ |
| `/api/`       | `fastcgi_pass unix:/run/php/ekdos.sock`, `SCRIPT_FILENAME` auf `/var/www/ekdos/current/backend/public/index.php` |
| `/assets/`    | `try_files $uri =404` -- kein SPA-Rückfall, sonst wird eine fehlende JS-Datei zu HTML mit Status 200 |
| alles andere  | `root /var/www/ekdos/current/public`, `try_files $uri $uri/ /index.html`  |

Drei Punkte, die erfahrungsgemäss Ärger machen:

- **`HTTP_X_FORWARDED_FOR` auf `$remote_addr` setzen**, nicht auf
  `$proxy_add_x_forwarded_for`. Letzteres hängt die Adresse nur an einen vom
  Client mitgeschickten Header an -- damit liesse sich die Anmeldebremse mit
  einem selbst gesetzten `X-Forwarded-For` umgehen. Steht ein Proxy davor,
  dessen Netze über `set_real_ip_from` eintragen.
- **`add_header` wird nicht vererbt**, sobald eine `location` eine eigene
  Kopfzeile setzt. Ein Block mit eigenem `Cache-Control` verliert damit alle
  Sicherheitskopfzeilen des Serverblocks.
- **`/api/health` von einer HTTPS-Umleitung ausnehmen.** `release.sh` prüft
  lokal über HTTP; eine 301 wäre für `curl -f` ein Erfolg, ohne dass PHP je
  erreicht würde. Alternativ `EKDOS_HEALTH_URL` auf die HTTPS-Adresse setzen.

Läuft der Zugriff nur intern über HTTP, muss `SESSION_COOKIE_SECURE=false`
gesetzt sein -- ein Secure-Cookie wird über reines HTTP nicht gesendet.

## 7. Erstes Ausrollen von Hand

Die Strecke übernimmt das später, aber einmal von Hand zeigt, ob alles steht:

```bash
# auf dem Arbeitsplatz
cd frontend && pnpm install && pnpm build && cd ..

SHA=erste
ssh deploy@10.0.11.131 "mkdir -p /var/www/ekdos/releases/$SHA"
rsync -az --delete frontend/dist/ deploy@10.0.11.131:/var/www/ekdos/releases/$SHA/public/
rsync -az --delete --exclude vendor/ --exclude .env --exclude var/ \
      backend/ deploy@10.0.11.131:/var/www/ekdos/releases/$SHA/backend/
rsync -az --chmod=F755 deploy/release.sh deploy/rollback.sh \
      deploy@10.0.11.131:/var/www/ekdos/deploy/

ssh deploy@10.0.11.131 /var/www/ekdos/deploy/release.sh $SHA
```

`release.sh` installiert die Composer-Pakete, schreibt das Schema fort, schaltet
den Symlink um, lädt php-fpm neu, verwirft den n8n-Cache und prüft
`/api/health`. Bricht etwas vorher ab, bleibt die laufende Fassung unberührt.

Danach den ersten Administrator anlegen:

```bash
ssh deploy@10.0.11.131 'php /var/www/ekdos/current/backend/bin/ekdos user:create'
ssh deploy@10.0.11.131 'php /var/www/ekdos/current/backend/bin/ekdos check'
```

## 8. Gitea-Strecke

`.gitea/workflows/build.yml` läuft bei jedem Push auf `main`:

| Auftrag                | Container          | tut                                                          |
| ---------------------- | ------------------ | ------------------------------------------------------------- |
| `backend-pruefen`      | `php:8.5-cli`      | `php -l` über alle Dateien, `composer validate`                |
| `bauen-und-ausrollen`  | `node:22-bookworm` | `pnpm typecheck` / `build`, dann rsync über SSH und `release.sh` |

Bei einem Pull Request wird nur geprüft und gebaut; ausgerollt wird
ausschliesslich aus `main`.

Bauen und Ausrollen liegen bewusst in **einem** Auftrag: das Bündel bleibt im
Arbeitsverzeichnis und geht von dort per rsync raus. `upload-artifact` /
`download-artifact` würden nur eine Fassungsabhängigkeit einführen (v3 oder v4,
je nach Gitea-Version) und damit einen verlässlichen Fehlschlag beim ersten Lauf.

**Es gibt keinen automatischen Rückschritt.** `release.sh` tauscht den Symlink
erst nach Composer und Migration. Scheitert es davor, läuft die alte Fassung
unverändert weiter -- ein automatischer Rückschritt würde dann auf die
*vorletzte* schalten und aus einem folgenlosen Fehlschlag einen Ausfall machen.
Der Lauf berichtet stattdessen, was gerade aktiv ist und ob `/api/health`
antwortet; zurückgeschaltet wird bei Bedarf von Hand.

### Warum der PHP-Auftrag erst node installiert

`actions/checkout` ist eine JavaScript-Aktion. act_runner führt solche Aktionen
mit dem `node` **aus dem Job-Container** aus -- anders als die gehosteten Runner
von GitHub, die ihr eigenes node mitbringen. `php:8.5-cli` enthält weder node
noch git, deshalb bricht der Checkout dort mit `exit code 127` ab, ohne eine
Zeile Ausgabe zu erzeugen.

Der erste Schritt des Auftrags installiert daher `git` und `nodejs` per apt. Er
ist ein reines Shell-Kommando und läuft deshalb auch im nackten Container. Der
Bau-Auftrag braucht das nicht: `node:22-bookworm` bringt beides mit.

Bookworm liefert node 18, `actions/checkout@v4` deklariert node 20. In der
Praxis läuft das Bündel der Aktion auf node 18 -- sollte es doch einmal klemmen,
node 20 aus dem NodeSource-Paketstand installieren.

### Schlüssel einrichten

Auf dem Arbeitsplatz:

```bash
ssh-keygen -t ed25519 -f gitea-deploy -C 'gitea-runner -> ekdos' -N ''
ssh-copy-id -i gitea-deploy.pub deploy@10.0.11.131
ssh-keyscan -t ed25519 10.0.11.131
```

In Gitea unter *Settings -> Secrets* zwei Einträge:

| Secret               | Inhalt                                                      |
| -------------------- | ----------------------------------------------------------- |
| `DEPLOY_SSH_KEY`     | der Inhalt von `gitea-deploy` (der **private** Schlüssel)    |
| `DEPLOY_KNOWN_HOSTS` | die Ausgabe von `ssh-keyscan -t ed25519 10.0.11.131`         |

`DEPLOY_KNOWN_HOSTS` ist optional -- fehlt es, übernimmt der Lauf den
Fingerabdruck ungeprüft und schreibt einen Hinweis ins Protokoll. Für den
Dauerbetrieb setzen.

Den privaten Schlüssel danach vom Arbeitsplatz löschen; er liegt in Gitea.

### Lockfiles

Beide Aufträge bestehen auf Sperrdateien, sonst löst jeder Lauf neu auf und
bekommt womöglich andere Fassungen als geprüft:

```bash
cd backend  && composer update      # erzeugt composer.lock
cd frontend && pnpm install         # erzeugt pnpm-lock.yaml
```

Beide einchecken.

## Zurückschalten

```bash
ssh deploy@10.0.11.131 /var/www/ekdos/deploy/rollback.sh --list   # was liegt da
ssh deploy@10.0.11.131 /var/www/ekdos/deploy/rollback.sh          # eine zurück
ssh deploy@10.0.11.131 /var/www/ekdos/deploy/rollback.sh 2f9c1ab  # auf eine bestimmte
```

Es wird nur der Symlink getauscht und php-fpm neu geladen -- Sekunden. Die
letzten fünf Veröffentlichungen bleiben liegen.

> **Migrationen werden nicht zurückgenommen.** Hat eine Fassung das Schema
> geändert, muss die alte damit umgehen können. Bei den bisherigen Migrationen
> (nur Tabellen anlegen) ist das der Fall.

## Prüfen, wenn etwas klemmt

```bash
php /var/www/ekdos/current/backend/bin/ekdos check   # Postgres, Redis, n8n
curl -fsS http://127.0.0.1/api/health                # ohne Anmeldung erreichbar
tail -f /var/log/php/ekdos-error.log                 # Ausnahmen aus dem Backend
tail -f /var/log/nginx/ekdos.error.log
```

| Symptom                                         | meistens                                                          |
| ----------------------------------------------- | ------------------------------------------------------------------ |
| 502 auf `/api/`                                 | Socket fehlt oder falsche Rechte; `systemctl status php8.5-fpm`     |
| 403 auf `/api/`, im Fehlerlog „Permission denied" | setgid fehlt, php-fpm kommt nicht an die neue Veröffentlichung    |
| Anmeldung wirkt, danach sofort abgemeldet       | Redis nicht erreichbar, oder `SESSION_COOKIE_SECURE=true` ohne TLS  |
| Listen leer, „n8n nicht erreichbar"             | `N8N_PUBLIC_BASE` falsch, oder der Webhook ist in n8n nicht aktiv   |
| Angezeigte Daten sind veraltet                  | Rückfall-Cache greift, weil n8n gerade nicht antwortet              |
| 503 „noch nicht eingerichtet"                   | `N8N_INVOICE_SYNC_SECRET` bzw. `N8N_CUSTOMER_KEY` fehlt in `.env`   |
| Quelltextänderung wirkt nicht                   | Opcache; `sudo systemctl reload php8.5-fpm`                         |
| Ausrollen bricht bei „composer install" ab      | Erweiterung fehlt; `composer check-platform-reqs` auf dem Server    |

## Alle Benutzer aussperren

```bash
redis-cli --scan --pattern 'ekdos:sess:*' | xargs -r redis-cli del
```

Danach muss sich jeder neu anmelden. Einzelne Konten trifft es gezielt über die
Benutzerverwaltung (Deaktivieren beendet die offenen Sitzungen sofort).
