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