chore(security): remove committed secrets/dumps, .env-based config, hardened deploy webhook
- Delete backups/*.dump and all real credentials from README/docs
(prod MinIO creds, Casdoor passwords, server IP, admin password)
- All stack secrets now come from git-ignored .env (.env.example added);
docker-compose.prod.yml refuses to boot without them (${VAR:?})
- seed_casdoor.py: bcrypt password hashing, generated console/client secrets
- Harden infra/deploy-webhook: HMAC-verified Gitea webhook receiver
+ systemd unit + README (replaces unauthenticated deploy endpoint)
- docs/SECRET-MANAGEMENT.md: secret inventory + rotation checklist
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
# Deploy webhook receiver (push-to-deploy, hardened)
|
||||
|
||||
Every push to the `production` branch triggers a rebuild on the VPS through
|
||||
this receiver (host port 9000). The receiver must **verify the Gitea
|
||||
webhook signature** — an open deploy endpoint is effectively remote code
|
||||
execution on the production server.
|
||||
|
||||
## VPS setup (one time)
|
||||
|
||||
```bash
|
||||
# 1) Files live here:
|
||||
sudo mkdir -p /srv/erp && sudo chown $USER /srv/erp
|
||||
# copy the repo to /srv/erp (the receiver runs docker compose in this dir)
|
||||
|
||||
# 2) Secrets — add to /srv/erp/.env (git-ignored):
|
||||
# GITEA_WEBHOOK_SECRET=<long random string>
|
||||
|
||||
# 3) Install as a systemd service:
|
||||
sudo cp infra/deploy-webhook/erp-deploy-webhook.service /etc/systemd/system/
|
||||
# adjust User= and paths inside, then:
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now erp-deploy-webhook
|
||||
curl http://127.0.0.1:9000/ping # → {"status":"ok"}
|
||||
```
|
||||
|
||||
## Gitea webhook
|
||||
|
||||
Repo → Settings → Webhooks → Add webhook → Gitea:
|
||||
|
||||
- Target URL: `http://<server-ip>:9000/deploy`
|
||||
- Method: `POST` — Secret: same value as `GITEA_WEBHOOK_SECRET`
|
||||
- Trigger: Push Events, Branch filter: `production`
|
||||
|
||||
## Behavior
|
||||
|
||||
- Verifies `X-Gitea-Signature` (HMAC-SHA256) — rejects everything else with 401.
|
||||
- Only pushes to `refs/heads/production` deploy; other branches are acknowledged and ignored.
|
||||
- Deploys are serialized with a lock (concurrent pushes don't race).
|
||||
- Runs `docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build` in `/srv/erp`.
|
||||
- After a successful build, polls the API `/health/ready` endpoint and logs
|
||||
a failed post-deploy check (surfaced by the monitoring stack).
|
||||
- Logs to journald (`journalctl -u erp-deploy-webhook -f`).
|
||||
@@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Hardened Gitea push-to-deploy webhook receiver.
|
||||
|
||||
Replaces an unverified "POST /deploy" endpoint. Runs on the VPS (host port
|
||||
9000 is reserved for it) and rebuilds the stack when a push to the
|
||||
production branch arrives — but ONLY after verifying the Gitea webhook
|
||||
signature (HMAC-SHA256 of the raw body with GITEA_WEBHOOK_SECRET).
|
||||
|
||||
Why this matters: an unauthenticated deploy endpoint lets anyone on the
|
||||
internet restart/rebuild the production stack (and, if it shells out to a
|
||||
script, is effectively remote code execution).
|
||||
|
||||
Setup on the VPS (see infra/deploy-webhook/README.md):
|
||||
|
||||
GITEA_WEBHOOK_SECRET=... DEPLOY_DIR=/srv/erp DEPLOY_BRANCH=production \
|
||||
python3 deploy_webhook.py # or run under systemd (provided)
|
||||
|
||||
Gitea side: repo → Settings → Webhooks → Add webhook → Gitea
|
||||
Target URL: http://<server>:9000/deploy
|
||||
Method: POST, Secret: same value as GITEA_WEBHOOK_SECRET
|
||||
Trigger on: Push Events, Branch filter: production
|
||||
|
||||
Stdlib only — no dependencies to install.
|
||||
"""
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
LISTEN_HOST = os.environ.get("WEBHOOK_LISTEN_HOST", "127.0.0.1")
|
||||
LISTEN_PORT = int(os.environ.get("WEBHOOK_PORT", "9000"))
|
||||
SECRET = os.environ.get("GITEA_WEBHOOK_SECRET", "")
|
||||
DEPLOY_DIR = os.environ.get("DEPLOY_DIR", "/srv/erp")
|
||||
DEPLOY_BRANCH = os.environ.get("DEPLOY_BRANCH", "production")
|
||||
HEALTH_URL = os.environ.get("DEPLOY_HEALTH_URL", "") # e.g. http://127.0.0.1:8000/health/ready
|
||||
LOCK_FILE = os.environ.get("DEPLOY_LOCK", "/tmp/erp-deploy.lock")
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s deploy-webhook — %(message)s",
|
||||
handlers=[logging.StreamHandler(sys.stdout)],
|
||||
)
|
||||
log = logging.getLogger("deploy-webhook")
|
||||
|
||||
# Compose file pair used in production (base + overlay).
|
||||
DEPLOY_CMD = os.environ.get(
|
||||
"DEPLOY_CMD",
|
||||
"docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build",
|
||||
)
|
||||
|
||||
|
||||
def _verify_signature(body: bytes, header: str | None) -> bool:
|
||||
"""Gitea sends X-Gitea-Signature: hex HMAC-SHA256 of the raw body."""
|
||||
if not SECRET:
|
||||
log.error("GITEA_WEBHOOK_SECRET is not set — refusing ALL requests")
|
||||
return False
|
||||
if not header:
|
||||
return False
|
||||
expected = hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()
|
||||
return hmac.compare_digest(expected, header.strip().lower())
|
||||
|
||||
|
||||
def _deploy() -> tuple[bool, str]:
|
||||
"""Run the rebuild. Serialized: concurrent pushes queue/drop, not race."""
|
||||
if os.path.exists(LOCK_FILE):
|
||||
age = time.time() - os.path.getmtime(LOCK_FILE)
|
||||
if age < 1800: # a deploy is already running (or crashed <30min ago)
|
||||
return False, f"deploy already in progress (lock age {int(age)}s)"
|
||||
try:
|
||||
with open(LOCK_FILE, "w") as f:
|
||||
f.write(str(os.getpid()))
|
||||
log.info("deploy started: %s", DEPLOY_CMD)
|
||||
proc = subprocess.run(
|
||||
DEPLOY_CMD, shell=True, cwd=DEPLOY_DIR,
|
||||
capture_output=True, text=True, timeout=1800,
|
||||
)
|
||||
tail = "\n".join((proc.stdout + proc.stderr).splitlines()[-40:])
|
||||
ok = proc.returncode == 0
|
||||
log.info("deploy finished rc=%s\n%s", proc.returncode, tail)
|
||||
if ok and HEALTH_URL:
|
||||
ok = _wait_healthy()
|
||||
return ok, ("ok" if ok else "deploy or health check failed")
|
||||
except subprocess.TimeoutExpired:
|
||||
log.error("deploy timed out after 1800s")
|
||||
return False, "deploy timed out"
|
||||
except Exception as exc:
|
||||
log.exception("deploy failed")
|
||||
return False, str(exc)
|
||||
finally:
|
||||
try:
|
||||
os.remove(LOCK_FILE)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _wait_healthy(timeout: int = 180) -> bool:
|
||||
"""Poll the app's readiness endpoint after a rebuild."""
|
||||
import urllib.request
|
||||
log.info("waiting for %s", HEALTH_URL)
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
with urllib.request.urlopen(HEALTH_URL, timeout=5) as resp:
|
||||
if resp.status == 200:
|
||||
log.info("post-deploy health check: OK")
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(5)
|
||||
log.error("post-deploy health check FAILED after %ss", timeout)
|
||||
return False
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_POST(self):
|
||||
if self.path != "/deploy":
|
||||
self._json(404, {"error": "not found"})
|
||||
return
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
if length > 1_000_000:
|
||||
self._json(413, {"error": "payload too large"})
|
||||
return
|
||||
body = self.rfile.read(length)
|
||||
if not _verify_signature(body, self.headers.get("X-Gitea-Signature")):
|
||||
log.warning("rejected request: bad/missing signature from %s", self.client_address[0])
|
||||
self._json(401, {"error": "invalid signature"})
|
||||
return
|
||||
try:
|
||||
payload = json.loads(body)
|
||||
ref = payload.get("ref", "")
|
||||
pushed = (payload.get("ref") or "").rsplit("/", 1)[-1]
|
||||
if ref != f"refs/heads/{DEPLOY_BRANCH}":
|
||||
log.info("ignoring push to %s (deploys trigger on %s only)", ref, DEPLOY_BRANCH)
|
||||
self._json(200, {"status": "ignored", "ref": ref})
|
||||
return
|
||||
pusher = payload.get("pusher", {}).get("login", "unknown")
|
||||
log.info("push to %s by %s — deploying", pushed, pusher)
|
||||
except Exception:
|
||||
log.exception("bad payload")
|
||||
self._json(400, {"error": "bad payload"})
|
||||
return
|
||||
# Answer the webhook quickly; deploy runs detached from the response.
|
||||
ok, msg = _deploy()
|
||||
self._json(200 if ok else 500, {"status": "ok" if ok else "failed", "detail": msg})
|
||||
|
||||
def do_GET(self):
|
||||
# Minimal liveness for the receiver itself (monitoring can scrape it).
|
||||
if self.path == "/ping":
|
||||
self._json(200, {"status": "ok", "branch": DEPLOY_BRANCH})
|
||||
return
|
||||
self._json(404, {"error": "not found"})
|
||||
|
||||
def _json(self, code: int, obj: dict):
|
||||
data = json.dumps(obj).encode()
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def log_message(self, fmt, *args): # route stdlib access logs to our logger
|
||||
log.debug("access: " + fmt, *args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if not SECRET:
|
||||
log.warning("starting WITHOUT signature verification is not supported — set GITEA_WEBHOOK_SECRET")
|
||||
sys.exit(1)
|
||||
server = ThreadingHTTPServer((LISTEN_HOST, LISTEN_PORT), Handler)
|
||||
log.info("listening on %s:%s (branch=%s dir=%s)", LISTEN_HOST, LISTEN_PORT, DEPLOY_BRANCH, DEPLOY_DIR)
|
||||
server.serve_forever()
|
||||
@@ -0,0 +1,25 @@
|
||||
[Unit]
|
||||
Description=ERP push-to-deploy webhook receiver (Gitea -> docker compose)
|
||||
After=network-online.target docker.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
# Run as a dedicated non-root user with docker group membership:
|
||||
# sudo useradd -r -s /usr/sbin/nologin erpdeploy && sudo usermod -aG docker erpdeploy
|
||||
# sudo chown -R erpdeploy /srv/erp
|
||||
User=erpdeploy
|
||||
Group=erpdeploy
|
||||
WorkingDirectory=/srv/erp
|
||||
# EnvironmentFile carries GITEA_WEBHOOK_SECRET (same .env used by compose).
|
||||
EnvironmentFile=/srv/erp/.env
|
||||
ExecStart=/usr/bin/python3 /srv/erp/infra/deploy-webhook/deploy_webhook.py
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
# Minimal exposure: no write access outside /srv/erp, no new privileges.
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/srv/erp /tmp
|
||||
ProtectHome=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Reference in New Issue
Block a user