From 906bdf1033907f0fa956746527acba097fcd708f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 08:22:46 +0000 Subject: [PATCH] feat(deploy): Webhook, Build-Skript, nginx, Traefik Co-Authored-By: Claude Opus 5.5 --- deploy/README.md | 98 +++++++ deploy/compose-snippet.yml | 81 ++++++ deploy/deploy.sh | 133 +++++++++ deploy/nginx/aircard-ios.org.conf | 66 +++++ deploy/traefik/aircard.yml | 22 ++ deploy/webhook-server.js | 233 +++++++++++++++ deploy/webhook-server.test.js | 451 ++++++++++++++++++++++++++++++ 7 files changed, 1084 insertions(+) create mode 100644 deploy/README.md create mode 100644 deploy/compose-snippet.yml create mode 100755 deploy/deploy.sh create mode 100644 deploy/nginx/aircard-ios.org.conf create mode 100644 deploy/traefik/aircard.yml create mode 100644 deploy/webhook-server.js create mode 100644 deploy/webhook-server.test.js diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..3eb75d7 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,98 @@ +# Deployment aircard-ios.org + +Die Seite wird auf dem Webklar-Server aus dem Gitea-Repo `knso/AirCardIOS` gebaut und von nginx ausgeliefert. Traefik stellt TLS und Weiterleitungen. Dieser Ordner enthält alles, was dafür im Repo liegt; die Dateien auf dem Server sind Kopien davon. + +## Bestandteile + +| Datei im Repo | Ziel auf dem Server (`/home/webklar/apps/`) | Aufgabe | +|---|---|---| +| `deploy/deploy.sh` | `aircard-ios.org/deploy.sh` (ausführbar) | klont/aktualisiert das Repo, baut `web/` und kopiert `dist/` nach `out/` | +| `deploy/webhook-server.js` | `aircard-ios.org/webhook-server.js` | nimmt den Gitea-Webhook an, startet `deploy.sh` nacheinander, pollt alle 15 Minuten | +| `deploy/nginx/aircard-ios.org.conf` | `aircard-ios.org/nginx/aircard-ios.org.conf` | nginx-Konfiguration der Seite | +| `deploy/traefik/aircard.yml` | `traefik/dynamic/aircard.yml` | Route `/v1` mit Header `X-Appwrite-Project: aircard` an Appwrite | +| `deploy/compose-snippet.yml` | `docker-compose.yml` (nach dem eship-Block) | Dienste `aircard-build` und `aircard` | + +Weitere Ordner unter `aircard-ios.org/` (nicht im Repo): `build/` (Klon des Repos, Arbeitsordner des Build-Containers), `out/` (gebaute Seite, von nginx ausgeliefert), `dev/` (Arbeitskopie für die Entwicklung, nicht gemountet). + +## Einrichten (einmalig) + +```sh +APP=/home/webklar/apps +SRC=$APP/aircard-ios.org/dev/deploy + +mkdir -p $APP/aircard-ios.org/build $APP/aircard-ios.org/out $APP/aircard-ios.org/nginx +cp $SRC/deploy.sh $APP/aircard-ios.org/deploy.sh && chmod +x $APP/aircard-ios.org/deploy.sh +cp $SRC/webhook-server.js $APP/aircard-ios.org/webhook-server.js +cp $SRC/nginx/aircard-ios.org.conf $APP/aircard-ios.org/nginx/aircard-ios.org.conf + +# Compose: Backup, dann den Inhalt von compose-snippet.yml unter `services:` direkt nach dem eship-Block einfügen +cp $APP/docker-compose.yml $APP/docker-compose.yml.bak-2026-10-04-aircard +cd $APP && docker compose config -q; echo $? # 0; einzige erlaubte Ausgabe: Warnung "attribute `version` is obsolete" + +# Traefik-Route atomar ablegen (Datei-Provider lädt sie ohne Neustart) +cp $SRC/traefik/aircard.yml $APP/traefik/dynamic/aircard.yml.tmp && mv $APP/traefik/dynamic/aircard.yml.tmp $APP/traefik/dynamic/aircard.yml +``` + +Die `.env` neben der `docker-compose.yml` muss `GITEA_DEPLOY_TOKEN` (Lesezugriff auf `knso/AirCardIOS`) und `GITEA_DEPLOY_WEBHOOK_SECRET` enthalten. Beide Werte gehören nie in Logs, Chats oder Commits. + +## Starten + +```sh +cd /home/webklar/apps && docker compose up -d aircard-build aircard +``` + +Nur diese beiden Dienste starten, nie ein nacktes `docker compose up -d` (das würde nicht laufende Dienste wie eship-build wieder anlegen). Der Build-Container installiert beim Start `git`, startet den Webhook-Server und baut sofort einmal (`initial`). Den Fortschritt zeigt `docker logs -f webklar-aircard-build`; am Ende steht `Deployment abgeschlossen` (oder eine Zeile mit `ERROR:`). + +Ohne `AIRCARD_WEBHOOK_TOKEN` (also ohne `GITEA_DEPLOY_WEBHOOK_SECRET` in der `.env`) beendet sich der Webhook-Server mit Exit-Code 1, statt Anfragen ungeprüft durchzulassen. + +## Manuell deployen + +```sh +docker exec webklar-aircard-build sh /deploy.sh +``` + +Ohne `DEPLOY_TRIGGER` baut das Skript immer neu, auch wenn sich der Commit nicht geändert hat. + +## Gitea-Webhook + +Repo `knso/AirCardIOS` → Einstellungen → Webhooks → Gitea: + +| Feld | Wert | +|---|---| +| Ziel-URL | `https://aircard-ios.org/webhook/deploy` | +| HTTP-Methode | POST | +| Content-Type | `application/json` | +| Secret | Wert von `GITEA_DEPLOY_WEBHOOK_SECRET` aus `/home/webklar/apps/.env` (volle Länge, nur dort nachsehen) | +| Auslöser | nur Push-Ereignisse | +| Branch-Filter | `main` | +| Aktiv | ja | + +„Test delivery“ muss `202` ergeben. `401` heißt: Secret stimmt nicht. `200` mit `{"ignored":true}` heißt: Push auf einen anderen Branch als `main`. + +## Verhalten + +- **Webhook** (`POST /webhook/deploy`): prüft die Signatur im Header `x-gitea-signature` (HMAC-SHA256 über den Rohbody). `401` bei falscher oder fehlender Signatur, `413` bei Body über 1 MB, `200` bei Push auf einen anderen Branch, `202` bei Push auf `main`; der Build läuft danach im Hintergrund. Alles andere antwortet `404`. +- **Warteschlange**: Builds laufen nacheinander, höchstens einer läuft und einer wartet. Weitere Pushes während ein Lauf wartet, werden mit `202` beantwortet und fügen keinen Lauf hinzu (der wartende Lauf holt ohnehin den neuesten Stand). +- **Trigger**: `webhook` und manuelle Aufrufe bauen immer. `initial` (Container-Start) und `poll` (alle 15 Minuten) bauen nur, wenn es einen neuen Commit gibt oder `out/index.html` fehlt. +- **Gemerkter Commit**: `build/.git/aircard-last-deployed-commit` enthält den zuletzt **versuchten** Commit, geschrieben vor dem Build. Ein kaputter Build wird deshalb nicht alle 15 Minuten wiederholt; die alte Seite bleibt online, bis ein neuer Commit oder ein Webhook-/manueller Lauf kommt. +- **Fehler**: Ein fehlgeschlagener Lauf wird mit `[webhook] … deployment failed` protokolliert und beendet den Server nicht. Fehlerzeilen des Skripts beginnen mit `ERROR:`. +- **Build-Variablen**: `VITE_APPWRITE_ENDPOINT` und `VITE_APPWRITE_PROJECT_ID` kommen aus der Container-Umgebung (compose); Vite liest sie direkt. + +## Änderungen später + +- `deploy.sh` ändern: Datei auf dem Server mit `cp` überschreiben (nicht `mv`, sonst zeigt der Datei-Mount auf die alte Datei). Gilt beim nächsten Lauf. +- `webhook-server.js` ändern: ebenso mit `cp` ersetzen, dann `cd /home/webklar/apps && docker compose restart aircard-build`. +- nginx-Konfiguration ändern: mit `cp` ersetzen, dann `docker exec webklar-aircard nginx -t && docker exec webklar-aircard nginx -s reload`. +- Traefik-Route ändern: Datei atomar ersetzen wie oben; Löschen der Datei entfernt die Route wieder. + +## www.aircard-ios.org + +Solange `dig +short www.aircard-ios.org @8.8.8.8` nichts liefert, gibt es nur die Hauptdomain. Zeigt der A-Record auf `91.99.156.85`, werden die auskommentierten www-Labels am Dienst `aircard` in `docker-compose.yml` aktiviert (Backup vorher, dann `docker compose config -q` und `docker compose up -d aircard`). Sie leiten per 301 auf `https://aircard-ios.org` weiter und haben eigene Router, damit ein www-Fehler das Zertifikat der Hauptdomain nicht blockiert. + +## Tests + +```sh +node --test deploy/webhook-server.test.js +``` + +Die Tests brauchen nur Node und starten die Server auf Port 0. diff --git a/deploy/compose-snippet.yml b/deploy/compose-snippet.yml new file mode 100644 index 0000000..635efb9 --- /dev/null +++ b/deploy/compose-snippet.yml @@ -0,0 +1,81 @@ + # ============================================== + # AIRCARD-IOS.ORG - Automatisches Deployment aus Gitea + # URL: aircard-ios.org + # Repository: https://git.webklar.com/knso/AirCardIOS + # Einfügen unter `services:` direkt nach dem eship-Block (siehe deploy/README.md). + # Starten NUR mit: docker compose up -d aircard-build aircard + # (ein nacktes `up -d` würde andere Dienste wieder anlegen) + # Die Appwrite-Route liegt als Datei in traefik/dynamic/aircard.yml. + # ============================================== + aircard-build: + image: node:22-alpine + container_name: webklar-aircard-build + restart: unless-stopped + init: true + mem_limit: 768m + working_dir: /app + environment: + - GITEA_URL=https://git.webklar.com + - GITEA_USER=knso + - GITEA_REPO=AirCardIOS + - GITEA_TOKEN=${GITEA_DEPLOY_TOKEN} + - APP_SUBDIR=web + - DEPLOY_BRANCH=main + - AIRCARD_DEPLOY_BRANCH=main + - AIRCARD_WEBHOOK_TOKEN=${GITEA_DEPLOY_WEBHOOK_SECRET} + - AIRCARD_WEBHOOK_PORT=8087 + - AIRCARD_DEPLOY_SCRIPT=/deploy.sh + - POLL_INTERVAL_MINUTES=15 + - NODE_OPTIONS=--max-old-space-size=512 + - VITE_APPWRITE_ENDPOINT=https://aircard-ios.org/v1 + - VITE_APPWRITE_PROJECT_ID=aircard + volumes: + - ./aircard-ios.org/build:/app + - ./aircard-ios.org/out:/out + - ./aircard-ios.org/deploy.sh:/deploy.sh:ro + - ./aircard-ios.org/webhook-server.js:/webhook/server.js:ro + networks: + - webklar-network + command: /bin/sh -c "apk add --no-cache git && node /webhook/server.js" + labels: + - "traefik.enable=true" + - "traefik.http.routers.aircard-webhook.rule=Host(`aircard-ios.org`) && Path(`/webhook/deploy`)" + - "traefik.http.routers.aircard-webhook.entrypoints=websecure" + - "traefik.http.routers.aircard-webhook.tls.certresolver=letsencrypt" + - "traefik.http.routers.aircard-webhook.priority=200" + - "traefik.http.services.aircard-webhook.loadbalancer.server.port=8087" + + aircard: + image: nginx:alpine + container_name: webklar-aircard + restart: unless-stopped + mem_limit: 64m + volumes: + - ./aircard-ios.org/out:/usr/share/nginx/html:ro + - ./aircard-ios.org/nginx/aircard-ios.org.conf:/etc/nginx/conf.d/default.conf:ro + networks: + - webklar-network + labels: + - "traefik.enable=true" + - "traefik.http.routers.aircard.rule=Host(`aircard-ios.org`)" + - "traefik.http.routers.aircard.entrypoints=websecure" + - "traefik.http.routers.aircard.tls.certresolver=letsencrypt" + - "traefik.http.services.aircard.loadbalancer.server.port=80" + - "traefik.http.middlewares.aircard-https.redirectscheme.scheme=https" + - "traefik.http.middlewares.aircard-https.redirectscheme.permanent=true" + - "traefik.http.routers.aircard-http.rule=Host(`aircard-ios.org`)" + - "traefik.http.routers.aircard-http.entrypoints=web" + - "traefik.http.routers.aircard-http.middlewares=aircard-https" + # --- www (Step 10): erst einkommentieren, wenn www.aircard-ios.org per DNS auf 91.99.156.85 zeigt. + # Eigene Router, damit ein www-Fehler (Zertifikat) die Hauptdomain nicht blockiert. + # `$$` ist das Escape für ein einzelnes `$` in docker compose; `\\.` ergibt im YAML ein `\.`. + # - "traefik.http.routers.aircard-www.rule=Host(`www.aircard-ios.org`)" + # - "traefik.http.routers.aircard-www.entrypoints=websecure" + # - "traefik.http.routers.aircard-www.tls.certresolver=letsencrypt" + # - "traefik.http.routers.aircard-www.middlewares=aircard-www" + # - "traefik.http.routers.aircard-www-http.rule=Host(`www.aircard-ios.org`)" + # - "traefik.http.routers.aircard-www-http.entrypoints=web" + # - "traefik.http.routers.aircard-www-http.middlewares=aircard-www" + # - "traefik.http.middlewares.aircard-www.redirectregex.regex=^https?://www\\.aircard-ios\\.org(.*)" + # - "traefik.http.middlewares.aircard-www.redirectregex.replacement=https://aircard-ios.org$${1}" + # - "traefik.http.middlewares.aircard-www.redirectregex.permanent=true" diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100755 index 0000000..1bae102 --- /dev/null +++ b/deploy/deploy.sh @@ -0,0 +1,133 @@ +#!/bin/sh +# AirCard-Deployment (läuft im Container webklar-aircard-build, node:22-alpine, POSIX sh). +# +# Ablauf: Repo klonen bzw. aktualisieren -> (bei poll/initial) überspringen, wenn dieser Commit +# schon versucht wurde -> npm ci -> Build -> web/dist nach $OUT_DIR kopieren. +# +# Aufruf: durch webhook-server.js (DEPLOY_TRIGGER=initial|poll|webhook) oder von Hand: +# docker exec webklar-aircard-build sh /deploy.sh +# +# Variablen (Standardwerte in Klammern): +# REPO_DIR (/app) OUT_DIR (/out) GITEA_URL (https://git.webklar.com) GITEA_USER (knso) +# GITEA_REPO (AirCardIOS) GITEA_TOKEN (leer = anonym) APP_SUBDIR (web) DEPLOY_BRANCH (main) +# DEPLOY_TRIGGER (leer = manual) +# +# Fehlerzeilen beginnen mit "ERROR:"; der letzte Satz eines erfolgreichen Builds ist +# "Deployment abgeschlossen". +set -e + +# Jeder Abbruch soll eine ERROR-Zeile hinterlassen, auch wenn set -e ihn auslöst. +ERROR_PRINTED="" +fail() { + echo "ERROR: $*" + ERROR_PRINTED=1 + exit 1 +} +trap 'status=$?; if [ "${status}" -ne 0 ] && [ -z "${ERROR_PRINTED}" ]; then echo "ERROR: Deployment unerwartet abgebrochen (Exit-Code ${status})"; fi' EXIT + +REPO_DIR="${REPO_DIR:-/app}" +OUT_DIR="${OUT_DIR:-/out}" +GITEA_URL="${GITEA_URL:-https://git.webklar.com}" +GITEA_URL="${GITEA_URL%/}" +GITEA_USER="${GITEA_USER:-knso}" +GITEA_REPO="${GITEA_REPO:-AirCardIOS}" +GITEA_TOKEN="${GITEA_TOKEN:-}" +APP_SUBDIR="${APP_SUBDIR:-web}" +DEPLOY_BRANCH="${DEPLOY_BRANCH:-main}" + +# Clone-URL: mit Token nur, wenn GITEA_TOKEN gesetzt ist. Die URL mit Token wird nie ausgegeben. +CLEAN_URL="${GITEA_URL}/${GITEA_USER}/${GITEA_REPO}.git" +if [ -n "${GITEA_TOKEN}" ]; then + case "${GITEA_URL}" in + https://*) REPO_URL="https://${GITEA_TOKEN}@${GITEA_URL#https://}/${GITEA_USER}/${GITEA_REPO}.git" ;; + http://*) REPO_URL="http://${GITEA_TOKEN}@${GITEA_URL#http://}/${GITEA_USER}/${GITEA_REPO}.git" ;; + *) fail "GITEA_URL muss mit https:// oder http:// beginnen" ;; + esac +else + REPO_URL="${CLEAN_URL}" +fi + +# git-Aufruf, dessen Ausgabe nie eine URL mit Zugangsdaten verrät (git zeigt sie in manchen Fehlermeldungen). +run_git() { + _git_log="$(mktemp)" + if "$@" >"${_git_log}" 2>&1; then + _git_rc=0 + else + _git_rc=$? + fi + sed 's#//[^/@ ]*@#//***@#g' "${_git_log}" + rm -f "${_git_log}" + return "${_git_rc}" +} + +echo "==========================================" +echo "AirCard Deployment (Trigger: ${DEPLOY_TRIGGER:-manual})" +echo "==========================================" +echo "Repository: ${GITEA_URL}/${GITEA_USER}/${GITEA_REPO} (Branch ${DEPLOY_BRANCH})" +echo "" + +mkdir -p "${REPO_DIR}" "${OUT_DIR}" || fail "Verzeichnisse ${REPO_DIR} und ${OUT_DIR} nicht anlegbar" + +if [ ! -d "${REPO_DIR}/.git" ]; then + echo "Klone Repository..." + # Reste eines abgebrochenen Klons entfernen (inkl. versteckter Dateien); das Verzeichnis selbst ist ein Mount. + rm -rf "${REPO_DIR}"/* "${REPO_DIR}"/.[!.]* "${REPO_DIR}"/..?* + run_git git clone --quiet --branch "${DEPLOY_BRANCH}" "${REPO_URL}" "${REPO_DIR}" || + fail "Repository konnte nicht geklont werden" +fi + +cd "${REPO_DIR}" || fail "Verzeichnis ${REPO_DIR} nicht erreichbar" + +echo "Aktualisiere Repository..." +# Remote-URL bei jedem Lauf neu setzen, damit ein getauschtes Token greift. +git remote set-url origin "${REPO_URL}" || fail "git remote set-url fehlgeschlagen" +run_git git fetch --quiet origin "+refs/heads/${DEPLOY_BRANCH}:refs/remotes/origin/${DEPLOY_BRANCH}" || + fail "git fetch für Branch ${DEPLOY_BRANCH} fehlgeschlagen" +# Token nur für clone/fetch: danach aus der Remote-URL entfernen, damit er nicht in .git/config liegen +# bleibt (dort könnten ihn die Build-Skripte lesen). +git remote set-url origin "${CLEAN_URL}" || fail "git remote set-url fehlgeschlagen" +git reset --hard "origin/${DEPLOY_BRANCH}" >/dev/null || fail "git reset auf origin/${DEPLOY_BRANCH} fehlgeschlagen" +git clean -fd >/dev/null || fail "git clean fehlgeschlagen" + +CURRENT_COMMIT="$(git rev-parse HEAD)" || fail "git rev-parse HEAD fehlgeschlagen" +echo "Commit: ${CURRENT_COMMIT}" + +# Beim Neustart (initial) und beim Polling nur bauen, wenn es einen neuen Commit gibt. +# Webhook-, manuelle und Aufrufe ohne Trigger bauen immer. +MARKER="${REPO_DIR}/.git/aircard-last-deployed-commit" +case "${DEPLOY_TRIGGER:-manual}" in + poll | initial) + if [ -f "${MARKER}" ] && [ "$(cat "${MARKER}")" = "${CURRENT_COMMIT}" ] && [ -f "${OUT_DIR}/index.html" ]; then + echo "Commit ${CURRENT_COMMIT} unverändert, Build übersprungen." + exit 0 + fi + ;; +esac + +# Gemerkt wird der zuletzt VERSUCHTE Commit (vor dem Build): ein kaputter Build wird nicht alle +# 15 Minuten wiederholt, und die alte Seite bleibt online. +echo "${CURRENT_COMMIT}" >"${MARKER}" || fail "Marker ${MARKER} nicht schreibbar" + +[ -d "${REPO_DIR}/${APP_SUBDIR}" ] || fail "Ordner ${APP_SUBDIR} fehlt im Repository" +cd "${REPO_DIR}/${APP_SUBDIR}" + +# Zugangsdaten nicht an npm und die Build-Skripte weiterreichen. +unset GITEA_TOKEN REPO_URL + +echo "" +echo "Installiere Abhängigkeiten..." +npm ci --no-audit --no-fund || fail "npm ci fehlgeschlagen" + +echo "" +echo "Baue die Seite..." +# VITE_*-Variablen kommen direkt aus der Container-Umgebung (compose). +rm -rf dist +npm run build || fail "Build fehlgeschlagen" +[ -f dist/index.html ] || fail "dist/index.html fehlt nach dem Build" + +echo "" +echo "Kopiere nach ${OUT_DIR}..." +rm -rf "${OUT_DIR}"/* "${OUT_DIR}"/.[!.]* "${OUT_DIR}"/..?* +cp -r dist/. "${OUT_DIR}/" || fail "Kopieren nach ${OUT_DIR} fehlgeschlagen" + +echo "Deployment abgeschlossen" diff --git a/deploy/nginx/aircard-ios.org.conf b/deploy/nginx/aircard-ios.org.conf new file mode 100644 index 0000000..f47a473 --- /dev/null +++ b/deploy/nginx/aircard-ios.org.conf @@ -0,0 +1,66 @@ +# nginx für aircard-ios.org (Container webklar-aircard, nginx:alpine; wird nach +# /etc/nginx/conf.d/default.conf gemountet). Ausgeliefert wird die statische Seite aus +# /usr/share/nginx/html (= out/ des Build-Containers). TLS macht Traefik davor. +# +# Wichtig: In den location-Blöcken kein add_header verwenden - sonst entfallen die +# Sicherheits-Header vom server-Block. "expires" ist davon nicht betroffen. +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # Sicherheits-Header (alle Antworten, auch Fehler) + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_comp_level 6; + gzip_types + text/plain + text/css + text/xml + text/javascript + application/javascript + application/json + application/manifest+json + application/xml + image/svg+xml; + + # Gehashte Build-Dateien: ein Jahr, nie auf index.html zurückfallen. + location ^~ /assets/ { + try_files $uri =404; + expires 1y; + access_log off; + } + + # Die Appwrite-API liegt nur über Traefik (Header X-Appwrite-Project) auf /v1. + # Ohne diesen Header darf nginx hier nie die App (index.html) ausliefern. + location ^~ /v1/ { + return 404; + } + + location = /index.html { + expires -1; + } + + # Versteckte Dateien sperren (außer /.well-known). Steht vor den Regex-Blöcken + # darunter, damit z. B. /.git/x.png nicht als Bild durchgeht. + location ~ /\.(?!well-known) { + deny all; + } + + location ~* \.(?:png|jpe?g|gif|webp|avif|svg|ico|woff2?|ttf|otf)$ { + expires 7d; + } + + # Single-Page-App: alle anderen Pfade (/karten, /editor ...) liefern index.html. + location / { + try_files $uri $uri/ /index.html; + expires -1; + } +} diff --git a/deploy/traefik/aircard.yml b/deploy/traefik/aircard.yml new file mode 100644 index 0000000..d1bc0fb --- /dev/null +++ b/deploy/traefik/aircard.yml @@ -0,0 +1,22 @@ +# Appwrite-Route für aircard-ios.org (Projekt "aircard" auf der bestehenden Appwrite-Instanz). +# +# Das Frontend ruft https://aircard-ios.org/v1/... auf und sendet den Header +# X-Appwrite-Project: aircard. Nur Anfragen MIT diesem Header gehen an Appwrite; alles andere +# unter /v1 (z. B. ein oder ein Aufruf ohne Header) landet beim nginx der Seite und +# bekommt dort 404. +# +# Priorität 150 liegt über dem Seiten-Router (Host-Regel, Label-Router `aircard`) und unter +# dem Webhook-Router (200). Der Service `appwrite@docker` stammt aus dem Appwrite-Container; +# an diesem Container wird nichts geändert. +# +# Wird per Datei-Provider geladen (Hot-Reload). Additiv + reversibel: Datei löschen entfernt die Route. +http: + routers: + aircard-api: + rule: "Host(`aircard-ios.org`) && PathPrefix(`/v1`) && Header(`X-Appwrite-Project`, `aircard`)" + entryPoints: + - websecure + service: appwrite@docker + priority: 150 + tls: + certResolver: letsencrypt diff --git a/deploy/webhook-server.js b/deploy/webhook-server.js new file mode 100644 index 0000000..d6512c2 --- /dev/null +++ b/deploy/webhook-server.js @@ -0,0 +1,233 @@ +'use strict'; + +/** + * Deploy-Webhook für aircard-ios.org (Node 22, nur Bordmittel). + * + * POST /webhook/deploy Gitea-Push-Webhook; Signatur im Header x-gitea-signature + * (HMAC-SHA256 über den Rohbody, Hex ohne Präfix). + * 401 Signatur fehlt oder stimmt nicht + * 413 Body größer als 1 MB + * 200 {"ignored":true} Push auf einen anderen Branch (oder ohne Branch) + * 202 {"queued":true} Push auf den Deploy-Branch; der Build läuft im Hintergrund + * alles andere 404 + * + * Builds laufen nacheinander: höchstens ein laufender und ein wartender Lauf. Weitere + * Anfragen während ein Lauf wartet, werden mit 202 beantwortet, fügen aber keinen + * Lauf hinzu (der wartende Lauf holt ohnehin den neuesten Stand). + * + * Umgebung (nur beim Start als Programm): AIRCARD_WEBHOOK_TOKEN (Pflicht, sonst Exit 1), + * AIRCARD_WEBHOOK_PORT (8087), AIRCARD_DEPLOY_BRANCH (main), AIRCARD_DEPLOY_SCRIPT + * (/deploy.sh), POLL_INTERVAL_MINUTES (15; 0 schaltet das Polling ab). + */ + +const http = require('node:http'); +const crypto = require('node:crypto'); +const { spawn } = require('node:child_process'); + +const MAX_BODY_BYTES = 1024 * 1024; +const WEBHOOK_PATH = '/webhook/deploy'; +const BRANCH_PREFIX = 'refs/heads/'; + +/** + * Prüft die Gitea-Signatur: kleingeschriebenes Hex von HMAC-SHA256 über den Rohbody. + * false bei leerem Secret, fehlender oder unpassend langer Signatur. + */ +function verifySignature(raw, signatureHex, secret) { + if (typeof secret !== 'string' || secret === '') return false; + if (typeof signatureHex !== 'string' || signatureHex === '') return false; + if (!Buffer.isBuffer(raw)) return false; + + const expected = Buffer.from(crypto.createHmac('sha256', secret).update(raw).digest('hex')); + const given = Buffer.from(signatureHex); + // timingSafeEqual wirft bei ungleicher Länge, deshalb vorher prüfen. + if (given.length !== expected.length) return false; + return crypto.timingSafeEqual(given, expected); +} + +/** `refs/heads/` -> ``, alles andere -> null. */ +function branchFromPayload(body) { + if (body === null || typeof body !== 'object') return null; + const ref = body.ref; + if (typeof ref !== 'string' || !ref.startsWith(BRANCH_PREFIX)) return null; + const branch = ref.slice(BRANCH_PREFIX.length); + return branch === '' ? null : branch; +} + +/** + * Baut den HTTP-Server (ohne ihn zu starten). + * + * @param {object} options + * @param {string} options.secret Webhook-Secret; leer -> jede Anfrage bekommt 401 + * @param {string} [options.branch] Deploy-Branch (Standard: main) + * @param {(trigger: string) => Promise} options.runDeploy führt einen Lauf aus + * @param {(line: string) => void} [options.log] bekommt fertige Zeilen, die mit [webhook] beginnen + * @returns {http.Server} mit zusätzlicher Methode `enqueueDeploy(trigger)` für initial/poll-Läufe + * über dieselbe Warteschlange + */ +function createServer({ secret, branch = 'main', runDeploy, log = console.log }) { + const say = (message) => log(`[webhook] ${new Date().toISOString()} ${message}`); + + // Warteschlange: ein laufender Lauf + ein Platz für einen wartenden Lauf. + let running = false; + let pending = null; // Trigger des wartenden Laufs oder null + + async function drain() { + if (running) return; + running = true; + try { + while (pending !== null) { + const trigger = pending; + pending = null; + say(`starting deployment (trigger=${trigger})`); + try { + await runDeploy(trigger); + say(`deployment finished (trigger=${trigger})`); + } catch (err) { + say(`deployment failed (trigger=${trigger}): ${err && err.message ? err.message : err}`); + } + } + } finally { + running = false; + } + } + + /** @returns {boolean} true, wenn ein neuer Lauf vorgemerkt wurde; false, wenn schon einer wartet */ + function enqueueDeploy(trigger) { + if (pending !== null) { + // Der Webhook baut immer, ein Poll darf überspringen: der wartende Lauf behält den stärkeren Trigger. + if (trigger === 'webhook') pending = 'webhook'; + return false; + } + pending = trigger; + drain(); + return true; + } + + function sendJson(res, status, payload) { + const body = JSON.stringify(payload); + res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }); + res.end(body); + } + + function handleWebhook(req, res) { + const chunks = []; + let size = 0; + let tooLarge = false; + + req.on('data', (chunk) => { + if (tooLarge) return; // Rest verwerfen, die Verbindung bleibt sauber + size += chunk.length; + if (size > MAX_BODY_BYTES) { + tooLarge = true; + chunks.length = 0; + say('rejected request: body too large'); + sendJson(res, 413, { error: 'payload too large' }); + return; + } + chunks.push(chunk); + }); + + req.on('error', (err) => say(`request error: ${err.message}`)); + + req.on('end', () => { + if (tooLarge) return; + const raw = Buffer.concat(chunks); + + if (!verifySignature(raw, req.headers['x-gitea-signature'], secret)) { + say('rejected request: invalid signature'); + sendJson(res, 401, { error: 'invalid signature' }); + return; + } + + let pushedBranch = null; + try { + pushedBranch = branchFromPayload(JSON.parse(raw.toString('utf8'))); + } catch { + // kein JSON: wie ein Push ohne Branch behandeln + } + + if (pushedBranch !== branch) { + say(`ignoring push to ${pushedBranch === null ? 'unknown ref' : pushedBranch} (only ${branch} deploys)`); + sendJson(res, 200, { ignored: true }); + return; + } + + // Sofort antworten: Gitea wartet nur wenige Sekunden, der Build dauert Minuten. + const queued = enqueueDeploy('webhook'); + say(queued ? `push to ${branch} queued` : `push to ${branch} accepted, a deployment is already waiting`); + sendJson(res, 202, { queued: true }); + }); + } + + const server = http.createServer((req, res) => { + const pathname = (req.url || '').split('?')[0]; + if (req.method === 'POST' && pathname === WEBHOOK_PATH) { + handleWebhook(req, res); + return; + } + sendJson(res, 404, { error: 'not found' }); + }); + + server.enqueueDeploy = enqueueDeploy; + return server; +} + +// ---- Start als Programm ----------------------------------------------------- + +/** + * Startet `sh