feat(deploy): Webhook, Build-Skript, nginx, Traefik
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
30f4ddc0f4
commit
906bdf1033
7 files changed
+1084
No files matched your search
@@ -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.
|
||||||
@@ -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"
|
||||||
Executable
+133
@@ -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"
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <img src> 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
|
||||||
@@ -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/<x>` -> `<x>`, 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<void>} 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 <script>` mit DEPLOY_TRIGGER; erfolgreich bei Exit-Code 0, sonst Ablehnung.
|
||||||
|
* Das Webhook-Secret bekommt das Skript nicht (npm und die Build-Skripte laufen darunter).
|
||||||
|
*/
|
||||||
|
function spawnDeploy(script, trigger) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const env = { ...process.env, DEPLOY_TRIGGER: trigger };
|
||||||
|
delete env.AIRCARD_WEBHOOK_TOKEN;
|
||||||
|
const child = spawn('sh', [script], { stdio: 'inherit', env });
|
||||||
|
child.once('error', reject);
|
||||||
|
child.once('exit', (code, signal) => {
|
||||||
|
if (code === 0) resolve();
|
||||||
|
else reject(new Error(`deploy exited with ${code === null ? `signal ${signal}` : `code ${code}`}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function integerFromEnv(name, fallback, { min }) {
|
||||||
|
const raw = process.env[name];
|
||||||
|
if (raw === undefined || raw.trim() === '') return fallback;
|
||||||
|
const value = Number.parseInt(raw, 10);
|
||||||
|
return Number.isInteger(value) && value >= min ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const secret = process.env.AIRCARD_WEBHOOK_TOKEN || '';
|
||||||
|
if (secret.trim() === '') {
|
||||||
|
console.error('[webhook] AIRCARD_WEBHOOK_TOKEN ist nicht gesetzt - Abbruch (ohne Secret wird kein Webhook angeboten)');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const port = integerFromEnv('AIRCARD_WEBHOOK_PORT', 8087, { min: 1 });
|
||||||
|
const branch = process.env.AIRCARD_DEPLOY_BRANCH || 'main';
|
||||||
|
const script = process.env.AIRCARD_DEPLOY_SCRIPT || '/deploy.sh';
|
||||||
|
const pollMinutes = integerFromEnv('POLL_INTERVAL_MINUTES', 15, { min: 0 });
|
||||||
|
|
||||||
|
const server = createServer({
|
||||||
|
secret,
|
||||||
|
branch,
|
||||||
|
runDeploy: (trigger) => spawnDeploy(script, trigger),
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(port, () => {
|
||||||
|
console.log(`[webhook] ${new Date().toISOString()} listening on :${port} (branch ${branch})`);
|
||||||
|
server.enqueueDeploy('initial');
|
||||||
|
if (pollMinutes > 0) {
|
||||||
|
console.log(`[webhook] ${new Date().toISOString()} periodic check enabled: every ${pollMinutes} minutes`);
|
||||||
|
setInterval(() => server.enqueueDeploy('poll'), pollMinutes * 60 * 1000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { verifySignature, branchFromPayload, createServer };
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
main();
|
||||||
|
}
|
||||||
@@ -0,0 +1,451 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Tests für deploy/webhook-server.js. Aufruf: node --test deploy/webhook-server.test.js
|
||||||
|
// Nur Node-Bordmittel; die Server laufen auf Port 0 und werden nach jedem Test geschlossen.
|
||||||
|
|
||||||
|
const { test, afterEach } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const crypto = require('node:crypto');
|
||||||
|
const http = require('node:http');
|
||||||
|
const net = require('node:net');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const os = require('node:os');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { spawn } = require('node:child_process');
|
||||||
|
|
||||||
|
const { verifySignature, branchFromPayload, createServer } = require('./webhook-server.js');
|
||||||
|
|
||||||
|
const SECRET = 'test-secret-0123456789abcdef-test-secret-0123456789abcdef';
|
||||||
|
|
||||||
|
function sign(raw, secret = SECRET) {
|
||||||
|
return crypto.createHmac('sha256', secret).update(raw).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushBody(ref) {
|
||||||
|
return Buffer.from(JSON.stringify({ ref, after: '0123456789abcdef0123456789abcdef01234567' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitFor(condition, what, timeoutMs = 3000) {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
while (!condition()) {
|
||||||
|
if (Date.now() > deadline) throw new Error(`Zeitüberschreitung beim Warten auf: ${what}`);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
// ---- Aufräumen -------------------------------------------------------------
|
||||||
|
|
||||||
|
const servers = [];
|
||||||
|
const children = [];
|
||||||
|
const tmpDirs = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
for (const child of children.splice(0)) {
|
||||||
|
if (child.exitCode === null && child.signalCode === null) {
|
||||||
|
child.kill('SIGKILL');
|
||||||
|
await new Promise((resolve) => child.once('exit', resolve));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const server of servers.splice(0)) {
|
||||||
|
server.closeAllConnections();
|
||||||
|
await new Promise((resolve) => server.close(resolve));
|
||||||
|
}
|
||||||
|
for (const dir of tmpDirs.splice(0)) {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Hilfen für Server-Tests -----------------------------------------------
|
||||||
|
|
||||||
|
/** Startet createServer() auf Port 0. runDeploy-Aufrufe werden in `calls` gesammelt. */
|
||||||
|
async function startServer(overrides = {}) {
|
||||||
|
const calls = [];
|
||||||
|
const logs = [];
|
||||||
|
const options = {
|
||||||
|
secret: SECRET,
|
||||||
|
branch: 'main',
|
||||||
|
runDeploy: async (trigger) => {
|
||||||
|
calls.push(trigger);
|
||||||
|
},
|
||||||
|
log: (line) => logs.push(line),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
const server = createServer(options);
|
||||||
|
servers.push(server);
|
||||||
|
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
return { server, port: server.address().port, calls, logs };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein runDeploy, das so lange blockiert, bis der Test den Lauf freigibt. */
|
||||||
|
function blockedDeploy() {
|
||||||
|
const calls = [];
|
||||||
|
const releases = [];
|
||||||
|
const runDeploy = (trigger) => {
|
||||||
|
calls.push(trigger);
|
||||||
|
return new Promise((resolve) => releases.push(resolve));
|
||||||
|
};
|
||||||
|
return { calls, releases, runDeploy };
|
||||||
|
}
|
||||||
|
|
||||||
|
function request(port, { method = 'POST', path: urlPath = '/webhook/deploy', headers = {}, body } = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = http.request({ host: '127.0.0.1', port, method, path: urlPath, headers }, (res) => {
|
||||||
|
const chunks = [];
|
||||||
|
res.on('data', (chunk) => chunks.push(chunk));
|
||||||
|
res.on('end', () => {
|
||||||
|
const text = Buffer.concat(chunks).toString('utf8');
|
||||||
|
let json = null;
|
||||||
|
try {
|
||||||
|
json = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
// kein JSON
|
||||||
|
}
|
||||||
|
resolve({ status: res.statusCode, headers: res.headers, text, json });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
if (body) req.write(body);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function signedPush(port, ref, secret = SECRET) {
|
||||||
|
const body = pushBody(ref);
|
||||||
|
return request(port, {
|
||||||
|
headers: { 'content-type': 'application/json', 'x-gitea-signature': sign(body, secret) },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- verifySignature --------------------------------------------------------
|
||||||
|
|
||||||
|
test('verifySignature: gültige Signatur wird akzeptiert', () => {
|
||||||
|
const raw = Buffer.from('{"ref":"refs/heads/main"}');
|
||||||
|
assert.equal(verifySignature(raw, sign(raw), SECRET), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verifySignature: falscher Rohbody oder falsches Secret wird abgelehnt', () => {
|
||||||
|
const raw = Buffer.from('{"ref":"refs/heads/main"}');
|
||||||
|
assert.equal(verifySignature(Buffer.from('{"ref":"refs/heads/dev"}'), sign(raw), SECRET), false);
|
||||||
|
assert.equal(verifySignature(raw, sign(raw, 'anderes-secret'), SECRET), false);
|
||||||
|
assert.equal(verifySignature(raw, sign(raw), 'anderes-secret'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verifySignature: ungültige oder unpassend lange Signaturen liefern false ohne Exception', () => {
|
||||||
|
const raw = Buffer.from('{}');
|
||||||
|
const valid = sign(raw);
|
||||||
|
assert.equal(verifySignature(raw, 'deadbeef', SECRET), false);
|
||||||
|
assert.equal(verifySignature(raw, valid.slice(0, -1), SECRET), false);
|
||||||
|
assert.equal(verifySignature(raw, `${valid}00`, SECRET), false);
|
||||||
|
assert.equal(verifySignature(raw, 'z'.repeat(valid.length), SECRET), false);
|
||||||
|
assert.equal(verifySignature(raw, `sha256=${valid}`, SECRET), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verifySignature: leeres Secret ist immer false', () => {
|
||||||
|
const raw = Buffer.from('{}');
|
||||||
|
assert.equal(verifySignature(raw, sign(raw, ''), ''), false);
|
||||||
|
assert.equal(verifySignature(raw, sign(raw), ''), false);
|
||||||
|
assert.equal(verifySignature(raw, sign(raw), undefined), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verifySignature: fehlender Header ist false', () => {
|
||||||
|
const raw = Buffer.from('{}');
|
||||||
|
assert.equal(verifySignature(raw, undefined, SECRET), false);
|
||||||
|
assert.equal(verifySignature(raw, '', SECRET), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- branchFromPayload ------------------------------------------------------
|
||||||
|
|
||||||
|
test('branchFromPayload: refs/heads/<x> ergibt <x>', () => {
|
||||||
|
assert.equal(branchFromPayload({ ref: 'refs/heads/main' }), 'main');
|
||||||
|
assert.equal(branchFromPayload({ ref: 'refs/heads/feature/neu' }), 'feature/neu');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('branchFromPayload: alles andere ergibt null', () => {
|
||||||
|
assert.equal(branchFromPayload({}), null);
|
||||||
|
assert.equal(branchFromPayload(null), null);
|
||||||
|
assert.equal(branchFromPayload('refs/heads/main'), null);
|
||||||
|
assert.equal(branchFromPayload({ ref: 42 }), null);
|
||||||
|
assert.equal(branchFromPayload({ ref: 'refs/tags/v1' }), null);
|
||||||
|
assert.equal(branchFromPayload({ ref: 'refs/heads/' }), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Server -----------------------------------------------------------------
|
||||||
|
|
||||||
|
test('Server: unsignierter POST ergibt 401 und startet keinen Lauf', async () => {
|
||||||
|
const { port, calls } = await startServer();
|
||||||
|
const res = await request(port, {
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: pushBody('refs/heads/main'),
|
||||||
|
});
|
||||||
|
assert.equal(res.status, 401);
|
||||||
|
await pause(20);
|
||||||
|
assert.deepEqual(calls, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Server: POST mit falscher Signatur ergibt 401', async () => {
|
||||||
|
const { port, calls } = await startServer();
|
||||||
|
const res = await signedPush(port, 'refs/heads/main', 'falsches-secret');
|
||||||
|
assert.equal(res.status, 401);
|
||||||
|
assert.deepEqual(calls, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Server: Signatur nur im Header x-gitea-signature, keine Fallbacks', async () => {
|
||||||
|
const { port, calls } = await startServer();
|
||||||
|
const body = pushBody('refs/heads/main');
|
||||||
|
const res = await request(port, {
|
||||||
|
headers: {
|
||||||
|
'x-hub-signature-256': `sha256=${sign(body)}`,
|
||||||
|
'x-hub-signature': `sha256=${sign(body)}`,
|
||||||
|
'x-gitea-token': SECRET,
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
assert.equal(res.status, 401);
|
||||||
|
assert.deepEqual(calls, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Server: leeres Secret lehnt jeden Push ab', async () => {
|
||||||
|
const { port, calls } = await startServer({ secret: '' });
|
||||||
|
const body = pushBody('refs/heads/main');
|
||||||
|
const res = await request(port, { headers: { 'x-gitea-signature': sign(body, '') }, body });
|
||||||
|
assert.equal(res.status, 401);
|
||||||
|
assert.deepEqual(calls, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Server: signierter Push auf main ergibt 202 und runDeploy("webhook")', async () => {
|
||||||
|
const { port, calls } = await startServer();
|
||||||
|
const res = await signedPush(port, 'refs/heads/main');
|
||||||
|
assert.equal(res.status, 202);
|
||||||
|
assert.deepEqual(res.json, { queued: true });
|
||||||
|
await waitFor(() => calls.length === 1, 'runDeploy');
|
||||||
|
assert.deepEqual(calls, ['webhook']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Server: Push auf einen anderen Branch wird mit 200 ignoriert', async () => {
|
||||||
|
const { port, calls } = await startServer();
|
||||||
|
const res = await signedPush(port, 'refs/heads/dev');
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.deepEqual(res.json, { ignored: true });
|
||||||
|
await pause(20);
|
||||||
|
assert.deepEqual(calls, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Server: Tag-Push und Body ohne Branch werden mit 200 ignoriert', async () => {
|
||||||
|
const { port, calls } = await startServer();
|
||||||
|
assert.deepEqual((await signedPush(port, 'refs/tags/v1')).json, { ignored: true });
|
||||||
|
|
||||||
|
const notJson = Buffer.from('das ist kein JSON');
|
||||||
|
const res = await request(port, { headers: { 'x-gitea-signature': sign(notJson) }, body: notJson });
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.deepEqual(res.json, { ignored: true });
|
||||||
|
assert.deepEqual(calls, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Server: alles andere ergibt 404', async () => {
|
||||||
|
const { port, calls } = await startServer();
|
||||||
|
assert.equal((await request(port, { method: 'GET', path: '/' })).status, 404);
|
||||||
|
assert.equal((await request(port, { method: 'GET', path: '/webhook/deploy' })).status, 404);
|
||||||
|
assert.equal((await request(port, { method: 'POST', path: '/' })).status, 404);
|
||||||
|
assert.equal((await request(port, { method: 'POST', path: '/webhook/deploy/x' })).status, 404);
|
||||||
|
assert.equal((await request(port, { method: 'GET', path: '/healthz' })).status, 404);
|
||||||
|
assert.deepEqual(calls, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Server: zu großer Body ergibt 413 und startet keinen Lauf', async () => {
|
||||||
|
const { port, calls } = await startServer();
|
||||||
|
const body = Buffer.alloc(1024 * 1024 + 1, 'a');
|
||||||
|
const res = await request(port, { headers: { 'x-gitea-signature': sign(body) }, body });
|
||||||
|
assert.equal(res.status, 413);
|
||||||
|
await pause(20);
|
||||||
|
assert.deepEqual(calls, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Server: drei schnelle Pushes bei blockiertem runDeploy -> alle 202, danach genau 2 Läufe', async () => {
|
||||||
|
const deploy = blockedDeploy();
|
||||||
|
const { port } = await startServer({ runDeploy: deploy.runDeploy });
|
||||||
|
|
||||||
|
const first = await signedPush(port, 'refs/heads/main');
|
||||||
|
const second = await signedPush(port, 'refs/heads/main');
|
||||||
|
const third = await signedPush(port, 'refs/heads/main');
|
||||||
|
assert.deepEqual([first.status, second.status, third.status], [202, 202, 202]);
|
||||||
|
assert.deepEqual([first.json, second.json, third.json], [{ queued: true }, { queued: true }, { queued: true }]);
|
||||||
|
|
||||||
|
// Solange der erste Lauf hängt, startet kein zweiter (nacheinander, nie parallel).
|
||||||
|
await pause(50);
|
||||||
|
assert.equal(deploy.calls.length, 1);
|
||||||
|
|
||||||
|
deploy.releases[0]();
|
||||||
|
await waitFor(() => deploy.calls.length === 2, 'zweiter Lauf');
|
||||||
|
deploy.releases[1]();
|
||||||
|
await pause(50);
|
||||||
|
assert.deepEqual(deploy.calls, ['webhook', 'webhook']);
|
||||||
|
assert.equal(deploy.releases.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Server: ein Push nach Ende aller Läufe startet wieder einen Lauf', async () => {
|
||||||
|
const deploy = blockedDeploy();
|
||||||
|
const { port } = await startServer({ runDeploy: deploy.runDeploy });
|
||||||
|
|
||||||
|
await signedPush(port, 'refs/heads/main');
|
||||||
|
deploy.releases[0]();
|
||||||
|
await pause(30);
|
||||||
|
await signedPush(port, 'refs/heads/main');
|
||||||
|
await waitFor(() => deploy.calls.length === 2, 'zweiter Lauf');
|
||||||
|
deploy.releases[1]();
|
||||||
|
await pause(30);
|
||||||
|
assert.deepEqual(deploy.calls, ['webhook', 'webhook']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Server: ein fehlgeschlagener Lauf wird protokolliert, der Server läuft weiter', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const { port, logs } = await startServer({
|
||||||
|
runDeploy: async (trigger) => {
|
||||||
|
calls.push(trigger);
|
||||||
|
throw new Error('deploy exited with code 1');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal((await signedPush(port, 'refs/heads/main')).status, 202);
|
||||||
|
await waitFor(() => logs.some((line) => line.includes('deploy exited with code 1')), 'Fehlerzeile im Log');
|
||||||
|
|
||||||
|
assert.equal((await signedPush(port, 'refs/heads/main')).status, 202);
|
||||||
|
await waitFor(() => calls.length === 2, 'zweiter Lauf nach Fehler');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Server: synchron werfendes runDeploy bringt den Server nicht zum Absturz', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const { port } = await startServer({
|
||||||
|
runDeploy: () => {
|
||||||
|
calls.push('x');
|
||||||
|
throw new Error('kaputt');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal((await signedPush(port, 'refs/heads/main')).status, 202);
|
||||||
|
await waitFor(() => calls.length === 1, 'Lauf');
|
||||||
|
assert.equal((await signedPush(port, 'refs/heads/main')).status, 202);
|
||||||
|
await waitFor(() => calls.length === 2, 'zweiter Lauf');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Server: enqueueDeploy teilt die Warteschlange; ein wartender poll wird vom Webhook überholt', async () => {
|
||||||
|
const deploy = blockedDeploy();
|
||||||
|
const { server, port } = await startServer({ runDeploy: deploy.runDeploy });
|
||||||
|
|
||||||
|
server.enqueueDeploy('initial');
|
||||||
|
server.enqueueDeploy('poll'); // wartet
|
||||||
|
const res = await signedPush(port, 'refs/heads/main');
|
||||||
|
assert.equal(res.status, 202);
|
||||||
|
await pause(30);
|
||||||
|
assert.deepEqual(deploy.calls, ['initial']);
|
||||||
|
|
||||||
|
deploy.releases[0]();
|
||||||
|
await waitFor(() => deploy.calls.length === 2, 'zweiter Lauf');
|
||||||
|
deploy.releases[1]();
|
||||||
|
await pause(30);
|
||||||
|
// Der Webhook baut immer; deshalb darf der wartende Lauf kein überspringbarer "poll" bleiben.
|
||||||
|
assert.deepEqual(deploy.calls, ['initial', 'webhook']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Server: Logzeilen beginnen mit [webhook] und enthalten weder Secret noch Signatur', async () => {
|
||||||
|
const { port, logs } = await startServer();
|
||||||
|
const body = pushBody('refs/heads/main');
|
||||||
|
const signature = sign(body);
|
||||||
|
await request(port, { headers: { 'x-gitea-signature': signature }, body });
|
||||||
|
await request(port, { headers: { 'x-gitea-signature': 'deadbeef' }, body });
|
||||||
|
await signedPush(port, 'refs/heads/dev');
|
||||||
|
await pause(30);
|
||||||
|
|
||||||
|
assert.ok(logs.length >= 3, `zu wenige Logzeilen: ${logs.length}`);
|
||||||
|
for (const line of logs) {
|
||||||
|
assert.ok(line.startsWith('[webhook]'), `Zeile beginnt nicht mit [webhook]: ${line}`);
|
||||||
|
assert.ok(!line.includes(SECRET), 'Secret im Log');
|
||||||
|
assert.ok(!line.includes(signature), 'Signatur im Log');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Start als Programm (require.main === module) ---------------------------
|
||||||
|
|
||||||
|
const SERVER_JS = path.join(__dirname, 'webhook-server.js');
|
||||||
|
|
||||||
|
function freePort() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const probe = net.createServer();
|
||||||
|
probe.once('error', reject);
|
||||||
|
probe.listen(0, '127.0.0.1', () => {
|
||||||
|
const { port } = probe.address();
|
||||||
|
probe.close(() => resolve(port));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeTmpDir() {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'aircard-webhook-'));
|
||||||
|
tmpDirs.push(dir);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startProgram(env) {
|
||||||
|
const child = spawn(process.execPath, [SERVER_JS], {
|
||||||
|
env: { PATH: process.env.PATH, ...env },
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
children.push(child);
|
||||||
|
const output = { text: '' };
|
||||||
|
child.stdout.on('data', (chunk) => (output.text += chunk));
|
||||||
|
child.stderr.on('data', (chunk) => (output.text += chunk));
|
||||||
|
return { child, output };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Start ohne AIRCARD_WEBHOOK_TOKEN: Fehlermeldung ohne Wert, Exit-Code 1', async () => {
|
||||||
|
for (const env of [{}, { AIRCARD_WEBHOOK_TOKEN: '' }]) {
|
||||||
|
const { child, output } = startProgram({ ...env, AIRCARD_WEBHOOK_PORT: String(await freePort()) });
|
||||||
|
const code = await new Promise((resolve) => child.once('exit', resolve));
|
||||||
|
assert.equal(code, 1);
|
||||||
|
assert.match(output.text, /AIRCARD_WEBHOOK_TOKEN/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Start: initial-Lauf, Webhook-Lauf mit DEPLOY_TRIGGER und weiterlaufender Server nach Fehlschlag', async () => {
|
||||||
|
const dir = makeTmpDir();
|
||||||
|
const record = path.join(dir, 'triggers.txt');
|
||||||
|
const script = path.join(dir, 'deploy.sh');
|
||||||
|
const secretSeen = path.join(dir, 'secret-seen.txt');
|
||||||
|
// Protokolliert (ob das Webhook-Secret im Env ankommt, dann den Trigger); scheitert, solange "fail" existiert.
|
||||||
|
fs.writeFileSync(
|
||||||
|
script,
|
||||||
|
`#!/bin/sh\necho "\${AIRCARD_WEBHOOK_TOKEN:-leer}" >> "${secretSeen}"\necho "$DEPLOY_TRIGGER" >> "${record}"\n` +
|
||||||
|
`if [ -f "${path.join(dir, 'fail')}" ]; then exit 3; fi\n`,
|
||||||
|
);
|
||||||
|
fs.writeFileSync(path.join(dir, 'fail'), '');
|
||||||
|
|
||||||
|
const port = await freePort();
|
||||||
|
const { child, output } = startProgram({
|
||||||
|
AIRCARD_WEBHOOK_TOKEN: SECRET,
|
||||||
|
AIRCARD_WEBHOOK_PORT: String(port),
|
||||||
|
AIRCARD_DEPLOY_BRANCH: 'main',
|
||||||
|
AIRCARD_DEPLOY_SCRIPT: script,
|
||||||
|
POLL_INTERVAL_MINUTES: '0',
|
||||||
|
});
|
||||||
|
const triggers = () => (fs.existsSync(record) ? fs.readFileSync(record, 'utf8').trim().split('\n') : []);
|
||||||
|
|
||||||
|
await waitFor(() => triggers().length === 1, 'initial-Lauf', 8000);
|
||||||
|
assert.deepEqual(triggers(), ['initial']);
|
||||||
|
await waitFor(() => /deploy exited with code 3/.test(output.text), 'Fehlerzeile', 5000);
|
||||||
|
|
||||||
|
// Der Server lebt trotz fehlgeschlagenem Lauf und nimmt signierte Pushes an.
|
||||||
|
fs.rmSync(path.join(dir, 'fail'));
|
||||||
|
const res = await signedPush(port, 'refs/heads/main');
|
||||||
|
assert.equal(res.status, 202);
|
||||||
|
await waitFor(() => triggers().length === 2, 'webhook-Lauf', 8000);
|
||||||
|
assert.deepEqual(triggers(), ['initial', 'webhook']);
|
||||||
|
assert.equal(child.exitCode, null);
|
||||||
|
// Das Deploy-Skript (und damit npm) bekommt das Webhook-Secret nicht.
|
||||||
|
assert.deepEqual(fs.readFileSync(secretSeen, 'utf8').trim().split('\n'), ['leer', 'leer']);
|
||||||
|
|
||||||
|
for (const line of output.text.split('\n').filter(Boolean)) {
|
||||||
|
assert.ok(line.startsWith('[webhook]'), `Zeile beginnt nicht mit [webhook]: ${line}`);
|
||||||
|
assert.ok(!line.includes(SECRET), 'Secret im Log');
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in new issue
Block a user