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:
MMD
2026-09-18 21:55:24 +03:30
parent 5a9ef408ff
commit 43e317eeba
15 changed files with 558 additions and 117 deletions
+48
View File
@@ -0,0 +1,48 @@
# ============================================================
# ERP stack configuration — copy to .env and fill in real values.
# .env is git-ignored; NEVER commit real credentials.
# Local dev: cp .env.example .env (defaults work as-is)
# Production VPS: /srv/erp/.env with STRONG values for every entry —
# docker-compose.prod.yml refuses to boot without them.
# ============================================================
# --- Public hosts (production; local defaults live in docker-compose.yml) ---
APP_HOST=aramesh.bennu-api-bahrestan.ir
API_HOST=api.bennu-api-bahrestan.ir
AUTH_HOST=casdoor.bennu-api-bahrestan.ir
S3_HOST=s3.bennu-api-bahrestan.ir
STORAGE_HOST=storage.bennu-api-bahrestan.ir
# --- Traefik / Let's Encrypt ---
TRAEFIK_ACME_EMAIL=you@example.com
# --- Environment: "prod" enables the fail-fast startup checks in the API ---
ENVIRONMENT=prod
# --- PostgreSQL (app + Casdoor share the instance) ---
POSTGRES_PASSWORD=CHANGE_ME_strong_db_password
# --- MinIO object storage root credentials ---
MINIO_ROOT_USER=CHANGE_ME_minio_user
MINIO_ROOT_PASSWORD=CHANGE_ME_long_random_minio_password
# --- Casdoor OIDC application (must match the Casdoor application record) ---
CASDOOR_CLIENT_SECRET=CHANGE_ME_casdoor_client_secret
CASDOOR_ADMIN_PASSWORD=CHANGE_ME_casdoor_console_admin_password
# --- CORS: exact browser origins allowed to call the API (comma-separated) ---
CORS_ORIGINS=https://aramesh.bennu-api-bahrestan.ir
# --- Developer Mode (X-User-* header identity + login panel) ---
# MUST stay false in production. Only for local debugging without tokens.
DEV_LOGIN_ENABLED=false
# --- Deploy webhook (Gitea push → rebuild; see infra/deploy-webhook/) ---
GITEA_WEBHOOK_SECRET=CHANGE_ME_long_random_webhook_secret
# --- Monitoring stack (see monitoring/docker-compose.monitoring.yml) ---
OPENPANEL_CLIENT_ID=
OPENPANEL_CLIENT_SECRET=
SENTRY_DSN_BACKEND=
SENTRY_DSN_FRONTEND=
GRAFANA_ADMIN_PASSWORD=CHANGE_ME_grafana_password
+10
View File
@@ -11,9 +11,19 @@ __pycache__/
venv/ venv/
# ---- Env / secrets ---- # ---- Env / secrets ----
# All credentials live in git-ignored .env files (server-side and local).
# NEVER commit .env or database dumps — see docs/SECRET-MANAGEMENT.md.
.env
.env.*
!*.env.example
backend/.env
frontend/.env frontend/.env
*.local *.local
# ---- Database dumps (contain user rows + signing material) ----
*.dump
*.sql.gz
# ---- Local session / backups ---- # ---- Local session / backups ----
.zcode/ .zcode/
v1.2 - Copy/ v1.2 - Copy/
+33 -47
View File
@@ -105,8 +105,8 @@ for production builds).
| What | Where | Credentials | | What | Where | Credentials |
|---|---|---| |---|---|---|
| App | http://localhost (via Traefik) | — | | App | http://localhost (via Traefik) | — |
| Casdoor console | http://localhost:8080 | `admin` / `123` | | Casdoor console | http://localhost:8080 | admin credentials are set when you run the seeder (`CASDOOR_ADMIN_PW`) — never commit them |
| Seeded app users (org `inventory`) | `razaei` (admin), `mousavi` (مدیر تدارکات), `mohaghegh` (مدیر پروژه), `tester` (مدیر پروژه، پروژه «تست») | password `123456` | | Seeded app users (org `inventory`) | `razaei` (admin), `mousavi` (مدیر تدارکات), `mohaghegh` (مدیر پروژه), `tester` (مدیر پروژه، پروژه «تست») | password from `CASDOOR_SEED_PASSWORD` (bcrypt-hashed at rest — see `backend/tools/seed_casdoor.py`) |
Run `backend/tools/seed_casdoor.py` once after the first boot to create the Run `backend/tools/seed_casdoor.py` once after the first boot to create the
Casdoor organization, application and users (idempotent). Full details — Casdoor organization, application and users (idempotent). Full details —
@@ -143,7 +143,7 @@ is an SSH tunnel from your machine:
use the panel): use the panel):
```powershell ```powershell
ssh -L 9001:127.0.0.1:9001 root@171.22.25.75 ssh -L 9001:127.0.0.1:9001 <user>@<server-host>
``` ```
You will also land in a normal SSH session — that is fine; the tunnel You will also land in a normal SSH session — that is fine; the tunnel
@@ -151,18 +151,17 @@ is an SSH tunnel from your machine:
2. In your browser, open: **http://localhost:9001** 2. In your browser, open: **http://localhost:9001**
3. Log in with: 3. Log in with the production MinIO root credentials from the server's
`/srv/erp/.env` (`MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD`) — they are
| Username | Password | **never** written in this repo or its history. See
|---|---| [`docs/SECRET-MANAGEMENT.md`](docs/SECRET-MANAGEMENT.md).
| `aramesh-minio` | `feb10fadb5441019dfc33bf12a9b0f74e7b6c478` |
You will see the **`procurement-files`** bucket — the invoices/proformas the You will see the **`procurement-files`** bucket — the invoices/proformas the
ERP stores, browsable and downloadable from there. ERP stores, browsable and downloadable from there.
⚠️ These credentials are **full admin access to the file storage**. Treat ⚠️ Those credentials are **full admin access to the file storage**. They live
them as a production secret (this README is inside the repo, so anyone with only in the server-side `.env`; if one ever leaks, rotate it immediately
repo access can read them — rotate them on the server if they ever leak). (checklist in `docs/SECRET-MANAGEMENT.md`).
Close the PowerShell window (or `Ctrl+C`) when you are done to drop the Close the PowerShell window (or `Ctrl+C`) when you are done to drop the
tunnel. tunnel.
@@ -262,47 +261,33 @@ docker exec -it procurement-postgres psql -U postgres -d procurement
--- ---
## Restoring the committed seed data ## Recovery / seeding
The databases are **not** in git — Docker volumes hold the data. The repo The databases are **not** in git — Docker volumes hold the data, and
ships recovery dumps in `backups/علی محقق/`: database dumps must never be committed (they contain user rows and signing
material). Two supported ways to bring a fresh stack up:
| File | Contents | 1. **Empty boot** — schema v2.6 auto-applies on the first boot of an empty
|---|---| `postgres_data` volume; then run the seeders:
| `casdoor.dump` | Casdoor schema + console admin (`admin` / `123`), org `inventory`, app `inventory-app`, the JWT signing cert, and the single org user `ali.mohaghegh` |
| `procurement.dump` | Procurement schema + the single `users` row «علی محقق» with role `admin`; all other tables empty (categories/units re-seed at API startup) |
⚠️ This **replaces all data** in the `procurement` and `casdoor` databases.
On a running stack:
```powershell
docker compose stop api casdoor
docker cp "backups/علی محقق/procurement.dump" procurement-postgres:/tmp/
docker cp "backups/علی محقق/casdoor.dump" procurement-postgres:/tmp/
docker exec procurement-postgres pg_restore -U postgres -d procurement --clean --if-exists /tmp/procurement.dump
docker exec procurement-postgres pg_restore -U postgres -d casdoor --clean --if-exists /tmp/casdoor.dump
docker compose start casdoor api
```
Login afterwards: `ali.mohaghegh` / `hulkfuck002` (Casdoor rejects spaces
in usernames, so «ali mohaghegh» is not a valid login name).
Want demo data instead? Two seeders are still available (they create
different users — see the Login table above):
1. **App seeder** (realistic demo dataset; keeps the Casdoor org as-is):
```powershell ```powershell
# Casdoor org + app + users (prints/generates credentials — do not commit them)
docker compose run --rm -e CASDOOR_BASE=http://casdoor:8000 api python tools/seed_casdoor.py
# Demo dataset (optional):
docker exec procurement-api sh -c "cd /srv/backend && python -m tools.seed" docker exec procurement-api sh -c "cd /srv/backend && python -m tools.seed"
``` ```
2. **Casdoor seeder** (creates org `inventory` + app + users razaei /
mousavi / mohaghegh / tester — run once after a fresh volume): 2. **Restore your own dump** — create dumps on the server (kept server-side,
e.g. `/srv/erp/backups/`), never in git:
```powershell ```powershell
python backend/tools/seed_casdoor.py # from the host, or from the api container docker exec procurement-postgres pg_dump -U postgres -d procurement -Fc > /srv/erp/backups/procurement-$(date +%F).dump
docker exec procurement-postgres pg_dump -U postgres -d casdoor -Fc > /srv/erp/backups/casdoor-$(date +%F).dump
``` ```
To create a fresh dump of your own current state: stop `api` + `casdoor`, Restore with `pg_restore --clean --if-exists` (stop `api` + `casdoor`
`docker exec procurement-postgres pg_dump -U postgres -d procurement -Fc` first — this **replaces all data**). The scheduled backup sidecar
(+ the `casdoor` DB) into `backups/`, then start them again. (see `docs/OPERATIONS.md`) automates this.
## Common operations ## Common operations
@@ -317,10 +302,11 @@ docker compose down -v # ⚠️ full reset — wipes DB + MinIO vol
## Known behavior ## Known behavior
- Authentication is Casdoor-based (JWT verified via JWKS): `/auth`, `/users`, - Authentication is Casdoor-based (JWT verified via JWKS) and **required on
`/inventory` and `/pm` are protected (401 without a token, 403 without a role). every API route** except `/health`, `/health/ready` and `/auth/login`
The procurement CRUD endpoints (quotes, suppliers, materials, …) are still (401 without a token, 403 without a role). The former dev-header fallback
open — dev placeholder, do not expose to the public internet. (`X-User-Role` / `X-User-Name-B64`) is disabled unless
`PROCUREMENT_DEV_LOGIN_ENABLED=true` — never enable it in production.
- Roles live in the app's `users` table (Casdoor is identity-only): `pm`, - Roles live in the app's `users` table (Casdoor is identity-only): `pm`,
`procurement_manager`, `admin`. Role changes are **admin-only** (RBAC); `procurement_manager`, `admin`. Role changes are **admin-only** (RBAC);
the procurement manager can only assign projects to project managers. the procurement manager can only assign projects to project managers.
+38 -16
View File
@@ -18,24 +18,39 @@ for the in-page login flow.
import http.cookiejar import http.cookiejar
import json import json
import os import os
import secrets
import urllib.request import urllib.request
import bcrypt
BASE = os.environ.get("CASDOOR_BASE", "http://localhost:8080") BASE = os.environ.get("CASDOOR_BASE", "http://localhost:8080")
ADMIN = os.environ.get("CASDOOR_ADMIN", "admin") ADMIN = os.environ.get("CASDOOR_ADMIN", "admin")
ADMIN_PW = os.environ.get("CASDOOR_ADMIN_PW", "123") # Console admin password — MUST be overridden outside local dev (see
# docs/SECRET-MANAGEMENT.md); the historical default "123" is gone.
ADMIN_PW = os.environ.get("CASDOOR_ADMIN_PW", "")
if not ADMIN_PW:
ADMIN_PW = secrets.token_urlsafe(12)
print(f"[seed] generated Casdoor console admin password: {ADMIN_PW}")
ORG = "inventory" ORG = "inventory"
APP = "inventory-app" APP = "inventory-app"
CLIENT_ID = "inventory-app-client" CLIENT_ID = "inventory-app-client"
CLIENT_SECRET = "inventory-app-secret-2026" CLIENT_SECRET = os.environ.get("CASDOOR_CLIENT_SECRET", "")
if not CLIENT_SECRET:
CLIENT_SECRET = secrets.token_urlsafe(24)
print(f"[seed] generated Casdoor client secret: {CLIENT_SECRET}")
REDIRECT_URI = "http://localhost/login" # Traefik edge (v1.3); unused by the in-page flow REDIRECT_URI = "http://localhost/login" # Traefik edge (v1.3); unused by the in-page flow
# One password for all seeded dev users — override with CASDOOR_SEED_PASSWORD;
# stored as a bcrypt hash (passwordType "bcrypt"), never plaintext.
SEED_PASSWORD = os.environ.get("CASDOOR_SEED_PASSWORD", "123456")
USERS = [ USERS = [
# (name, first_name, last_name, email, password) # (name, first_name, last_name, email)
("razaei", "آقای", "رضایی", "razaei@sample.ir", "123456"), ("razaei", "آقای", "رضایی", "razaei@sample.ir"),
("mousavi", "آقای", "موسوی", "mousavi@sample.ir", "123456"), ("mousavi", "آقای", "موسوی", "mousavi@sample.ir"),
("mohaghegh", "آقای", "محقق", "mohaghegh@sample.ir", "123456"), ("mohaghegh", "آقای", "محقق", "mohaghegh@sample.ir"),
("tester", "کاربر", "تست", "tester@sample.ir", "123456"), ("tester", "کاربر", "تست", "tester@sample.ir"),
] ]
@@ -69,17 +84,21 @@ def _login():
return jar return jar
def _bcrypt(pw: str) -> str:
return bcrypt.hashpw(pw.encode("utf-8"), bcrypt.gensalt()).decode("ascii")
def main(): def main():
cookies = _login() cookies = _login()
if cookies is None: if cookies is None:
return return
# 1) organization (passwordType "plain" = dev: passwords stored as-is) # 1) organization (bcrypt — passwords are never stored plaintext)
status, body = _request("POST", "/api/add-organization", { status, body = _request("POST", "/api/add-organization", {
"owner": "admin", "owner": "admin",
"name": ORG, "name": ORG,
"displayName": "سازمان تدارکات", "displayName": "سازمان تدارکات",
"passwordType": "plain", "passwordType": "bcrypt",
}, cookies) }, cookies)
print("add-organization:", status, body.get("msg", body.get("error", ""))) print("add-organization:", status, body.get("msg", body.get("error", "")))
@@ -102,7 +121,8 @@ def main():
print("add-application:", status, body.get("msg", body.get("error", ""))) print("add-application:", status, body.get("msg", body.get("error", "")))
# 3) users (roles are NOT stored here — they live in the app's users table) # 3) users (roles are NOT stored here — they live in the app's users table)
for name, first, last, email, pw in USERS: pw_hash = _bcrypt(SEED_PASSWORD)
for name, first, last, email in USERS:
status, body = _request("POST", "/api/add-user", { status, body = _request("POST", "/api/add-user", {
"owner": ORG, "owner": ORG,
"name": name, "name": name,
@@ -110,21 +130,23 @@ def main():
"firstName": first, "firstName": first,
"lastName": last, "lastName": last,
"email": email, "email": email,
"password": pw, "password": pw_hash,
"passwordType": "plain", "passwordType": "bcrypt",
"phone": "", "phone": "",
"type": "normal-user", "type": "normal-user",
}, cookies) }, cookies)
print(f"add-user {name}:", status, body.get("msg", body.get("error", ""))) print(f"add-user {name}:", status, body.get("msg", body.get("error", "")))
# Ensure passwordType even for already-existing users (this Casdoor # Ensure passwordType even for already-existing users (this Casdoor
# version refuses login when both user and org passwordType are empty). # version refuses login when both user and org passwordType are empty).
status, body = _request("POST", f"/api/update-user?id={ORG}/{name}&columns=password_type", { status, body = _request("POST", f"/api/update-user?id={ORG}/{name}&columns=password_type,password", {
"passwordType": "plain", "passwordType": "bcrypt",
"password": pw_hash,
}, cookies) }, cookies)
print(f"set-password-type {name}:", status, body.get("msg", body.get("error", ""))) print(f"set-password-type {name}:", status, body.get("msg", body.get("error", "")))
print("\nDone. Casdoor console: http://localhost:8080 (admin/123)") print("\nDone. Casdoor console: http://localhost:8080")
print("Login in the app: http://localhost:3000/login") print(f"Seeded users password: {SEED_PASSWORD} (change via CASDOOR_SEED_PASSWORD)")
print("Login in the app: http://localhost/login")
if __name__ == "__main__": if __name__ == "__main__":
Binary file not shown.
Binary file not shown.
+54 -27
View File
@@ -1,32 +1,72 @@
# ============================================================================ # ============================================================================
# Production overlay — real domains + HTTPS (Let's Encrypt) + HTTP→HTTPS # Production overlay — real domains + HTTPS (Let's Encrypt) + HTTP→HTTPS
# redirect. Usage: # redirect + security middlewares + required secrets.
# #
# APP_HOST=app.example.com API_HOST=api.example.com AUTH_HOST=auth.example.com \ # All credentials come from the git-ignored .env NEXT TO THE COMPOSE FILES
# S3_HOST=s3.example.com STORAGE_HOST=storage.example.com \ # (compose loads it automatically — keep it on the VPS, never in git):
# TRAEFIK_ACME_EMAIL=admin@example.com \ #
# cp .env.example .env # then fill in STRONG values
# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build # docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
# #
# Requirements: DNS records for every host pointing at the server, ports # Requirements: DNS records for every host pointing at the server, ports
# 80 + 443 open (ACME http-challenge uses port 80), and the api's # 80 + 443 open (ACME http-challenge uses port 80). The api's issuer is
# PROCUREMENT_CASDOOR_ISSUER must be updated to the public auth host # derived here from AUTH_HOST (see docs/casdoor-auth.md).
# (see docs/casdoor-auth.md). #
# Any `${VAR:?}` below refuses to boot when the variable is missing/empty —
# that is deliberate: production must not run on defaults.
# ============================================================================ # ============================================================================
services: services:
traefik: traefik:
environment: environment:
# Certificates persist across restarts (acme.json in the traefik_certs volume). # Certificates persist across restarts (acme.json in the traefik_certs volume).
TRAEFIK_ACME_EMAIL: ${TRAEFIK_ACME_EMAIL:?set TRAEFIK_ACME_EMAIL for production} TRAEFIK_ACME_EMAIL: ${TRAEFIK_ACME_EMAIL:?set TRAEFIK_ACME_EMAIL in .env}
postgres:
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
minio:
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:?set MINIO_ROOT_USER in .env}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?set MINIO_ROOT_PASSWORD in .env}
labels:
- traefik.http.routers.minio-api.rule=Host(`${S3_HOST:?set S3_HOST}`)
- traefik.http.routers.minio-api.entrypoints=websecure
- traefik.http.routers.minio-api.tls=true
- traefik.http.routers.minio-api.tls.certresolver=letsencrypt
- traefik.http.routers.minio-api.middlewares=secure-headers@file
- traefik.http.routers.minio-api-redir.rule=Host(`${S3_HOST:?set S3_HOST}`)
- traefik.http.routers.minio-api-redir.entrypoints=web
- traefik.http.routers.minio-api-redir.middlewares=redirect-https@file
- traefik.http.routers.minio-console.rule=Host(`${STORAGE_HOST:?set STORAGE_HOST}`)
- traefik.http.routers.minio-console.entrypoints=websecure
- traefik.http.routers.minio-console.tls=true
- traefik.http.routers.minio-console.tls.certresolver=letsencrypt
- traefik.http.routers.minio-console.middlewares=secure-headers@file
- traefik.http.routers.minio-console-redir.rule=Host(`${STORAGE_HOST:?set STORAGE_HOST}`)
- traefik.http.routers.minio-console-redir.entrypoints=web
- traefik.http.routers.minio-console-redir.middlewares=redirect-https@file
api: api:
ports: [] # production: only reachable through Traefik ports: [] # production: only reachable through Traefik
environment:
PROCUREMENT_DATABASE_URL: postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@postgres:5432/procurement
PROCUREMENT_MINIO_ACCESS_KEY: ${MINIO_ROOT_USER:?set MINIO_ROOT_USER in .env}
PROCUREMENT_MINIO_SECRET_KEY: ${MINIO_ROOT_PASSWORD:?set MINIO_ROOT_PASSWORD in .env}
PROCUREMENT_CASDOOR_CLIENT_SECRET: ${CASDOOR_CLIENT_SECRET:?set CASDOOR_CLIENT_SECRET in .env}
PROCUREMENT_CASDOOR_ADMIN_PASSWORD: ${CASDOOR_ADMIN_PASSWORD:?set CASDOOR_ADMIN_PASSWORD in .env}
# Issuer must equal the browser-facing Casdoor origin (its discovery doc).
PROCUREMENT_CASDOOR_ISSUER: https://${AUTH_HOST:?set AUTH_HOST}
PROCUREMENT_CORS_ORIGINS: ${CORS_ORIGINS:?set CORS_ORIGINS in .env}
PROCUREMENT_ENVIRONMENT: prod
PROCUREMENT_DEV_LOGIN_ENABLED: "false"
labels: labels:
- traefik.http.routers.api.rule=Host(`${API_HOST:?set API_HOST}`) - traefik.http.routers.api.rule=Host(`${API_HOST:?set API_HOST}`)
- traefik.http.routers.api.entrypoints=websecure - traefik.http.routers.api.entrypoints=websecure
- traefik.http.routers.api.tls=true - traefik.http.routers.api.tls=true
- traefik.http.routers.api.tls.certresolver=letsencrypt - traefik.http.routers.api.tls.certresolver=letsencrypt
# HTTP → HTTPS - traefik.http.routers.api.middlewares=secure-headers@file,api-ratelimit@file,api-bodylimit@file
- traefik.http.routers.api-redir.rule=Host(`${API_HOST:?set API_HOST}`) - traefik.http.routers.api-redir.rule=Host(`${API_HOST:?set API_HOST}`)
- traefik.http.routers.api-redir.entrypoints=web - traefik.http.routers.api-redir.entrypoints=web
- traefik.http.routers.api-redir.middlewares=redirect-https@file - traefik.http.routers.api-redir.middlewares=redirect-https@file
@@ -41,34 +81,21 @@ services:
- traefik.http.routers.casdoor.entrypoints=websecure - traefik.http.routers.casdoor.entrypoints=websecure
- traefik.http.routers.casdoor.tls=true - traefik.http.routers.casdoor.tls=true
- traefik.http.routers.casdoor.tls.certresolver=letsencrypt - traefik.http.routers.casdoor.tls.certresolver=letsencrypt
- traefik.http.routers.casdoor.middlewares=secure-headers@file
- traefik.http.routers.casdoor-redir.rule=Host(`${AUTH_HOST:?set AUTH_HOST}`) - traefik.http.routers.casdoor-redir.rule=Host(`${AUTH_HOST:?set AUTH_HOST}`)
- traefik.http.routers.casdoor-redir.entrypoints=web - traefik.http.routers.casdoor-redir.entrypoints=web
- traefik.http.routers.casdoor-redir.middlewares=redirect-https@file - traefik.http.routers.casdoor-redir.middlewares=redirect-https@file
frontend: frontend:
build:
args:
DEV_LOGIN_ENABLED: "false"
labels: labels:
- traefik.http.routers.frontend.rule=Host(`${APP_HOST:?set APP_HOST}`) - traefik.http.routers.frontend.rule=Host(`${APP_HOST:?set APP_HOST}`)
- traefik.http.routers.frontend.entrypoints=websecure - traefik.http.routers.frontend.entrypoints=websecure
- traefik.http.routers.frontend.tls=true - traefik.http.routers.frontend.tls=true
- traefik.http.routers.frontend.tls.certresolver=letsencrypt - traefik.http.routers.frontend.tls.certresolver=letsencrypt
- traefik.http.routers.frontend.middlewares=secure-headers@file
- traefik.http.routers.frontend-redir.rule=Host(`${APP_HOST:?set APP_HOST}`) - traefik.http.routers.frontend-redir.rule=Host(`${APP_HOST:?set APP_HOST}`)
- traefik.http.routers.frontend-redir.entrypoints=web - traefik.http.routers.frontend-redir.entrypoints=web
- traefik.http.routers.frontend-redir.middlewares=redirect-https@file - traefik.http.routers.frontend-redir.middlewares=redirect-https@file
minio:
ports: [] # production: only reachable through Traefik
labels:
- traefik.http.routers.minio-api.rule=Host(`${S3_HOST:?set S3_HOST}`)
- traefik.http.routers.minio-api.entrypoints=websecure
- traefik.http.routers.minio-api.tls=true
- traefik.http.routers.minio-api.tls.certresolver=letsencrypt
- traefik.http.routers.minio-api-redir.rule=Host(`${S3_HOST:?set S3_HOST}`)
- traefik.http.routers.minio-api-redir.entrypoints=web
- traefik.http.routers.minio-api-redir.middlewares=redirect-https@file
- traefik.http.routers.minio-console.rule=Host(`${STORAGE_HOST:?set STORAGE_HOST}`)
- traefik.http.routers.minio-console.entrypoints=websecure
- traefik.http.routers.minio-console.tls=true
- traefik.http.routers.minio-console.tls.certresolver=letsencrypt
- traefik.http.routers.minio-console-redir.rule=Host(`${STORAGE_HOST:?set STORAGE_HOST}`)
- traefik.http.routers.minio-console-redir.entrypoints=web
- traefik.http.routers.minio-console-redir.middlewares=redirect-https@file
+22 -13
View File
@@ -6,7 +6,9 @@ services:
environment: environment:
POSTGRES_DB: procurement POSTGRES_DB: procurement
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres # Local dev default; production overrides via .env (required by the
# prod overlay) — never commit real passwords.
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
ports: ports:
- "5433:5432" - "5433:5432"
@@ -69,8 +71,8 @@ services:
command: server /data --console-address ":9001" command: server /data --console-address ":9001"
environment: environment:
MINIO_ROOT_USER: minioadmin MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
MINIO_ROOT_PASSWORD: minioadmin MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
ports: ports:
- "9000:9000" # S3 API - "9000:9000" # S3 API
@@ -110,8 +112,8 @@ services:
environment: environment:
MINIO_ENDPOINT: minio:9000 MINIO_ENDPOINT: minio:9000
MINIO_ROOT_USER: minioadmin MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
MINIO_ROOT_PASSWORD: minioadmin MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
MINIO_BUCKET: procurement-files MINIO_BUCKET: procurement-files
entrypoint: > entrypoint: >
@@ -170,24 +172,30 @@ services:
container_name: procurement-api container_name: procurement-api
environment: environment:
PROCUREMENT_DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/procurement PROCUREMENT_DATABASE_URL: postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:-postgres}@postgres:5432/procurement
PROCUREMENT_MINIO_ENDPOINT: minio:9000 PROCUREMENT_MINIO_ENDPOINT: minio:9000
PROCUREMENT_MINIO_ACCESS_KEY: minioadmin PROCUREMENT_MINIO_ACCESS_KEY: ${MINIO_ROOT_USER:-minioadmin}
PROCUREMENT_MINIO_SECRET_KEY: minioadmin PROCUREMENT_MINIO_SECRET_KEY: ${MINIO_ROOT_PASSWORD:-minioadmin}
PROCUREMENT_MINIO_BUCKET: procurement-files PROCUREMENT_MINIO_BUCKET: procurement-files
PROCUREMENT_MINIO_SECURE: "false" PROCUREMENT_MINIO_SECURE: "false"
# "dev" enables the local defaults; "prod" turns on the fail-fast
# startup checks (no wildcard CORS, no default credentials).
PROCUREMENT_ENVIRONMENT: ${ENVIRONMENT:-dev}
PROCUREMENT_DEV_LOGIN_ENABLED: ${DEV_LOGIN_ENABLED:-false}
# Comma-separated browser origins (JSON-free .env friendly).
PROCUREMENT_CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost,http://localhost:3000,http://localhost:5173}
# Casdoor: endpoint is the in-network service URL; the issuer must match # Casdoor: endpoint is the in-network service URL; the issuer must match
# the origin Casdoor advertises to browsers (its OIDC discovery doc). # the origin Casdoor advertises to browsers (its OIDC discovery doc).
PROCUREMENT_CASDOOR_ENDPOINT: http://casdoor:8000 PROCUREMENT_CASDOOR_ENDPOINT: http://casdoor:8000
PROCUREMENT_CASDOOR_ISSUER: http://localhost:8080 PROCUREMENT_CASDOOR_ISSUER: ${CASDOOR_ISSUER:-http://localhost:8080}
PROCUREMENT_CASDOOR_CLIENT_ID: inventory-app-client PROCUREMENT_CASDOOR_CLIENT_ID: inventory-app-client
PROCUREMENT_CASDOOR_CLIENT_SECRET: inventory-app-secret-2026 PROCUREMENT_CASDOOR_CLIENT_SECRET: ${CASDOOR_CLIENT_SECRET:-inventory-app-secret-2026}
PROCUREMENT_CASDOOR_ORGANIZATION: inventory PROCUREMENT_CASDOOR_ORGANIZATION: inventory
PROCUREMENT_CASDOOR_APP_NAME: inventory-app PROCUREMENT_CASDOOR_APP_NAME: inventory-app
PROCUREMENT_CASDOOR_AUDIENCE: inventory-app-client PROCUREMENT_CASDOOR_AUDIENCE: inventory-app-client
# Console admin used by POST /users/sync (identity mirroring). # Console admin used by POST /users/sync (identity mirroring).
PROCUREMENT_CASDOOR_ADMIN_USER: admin PROCUREMENT_CASDOOR_ADMIN_USER: admin
PROCUREMENT_CASDOOR_ADMIN_PASSWORD: "123" PROCUREMENT_CASDOOR_ADMIN_PASSWORD: ${CASDOOR_ADMIN_PASSWORD:-123}
ports: ports:
- "8000:8000" - "8000:8000"
@@ -214,8 +222,9 @@ services:
build: build:
context: ./frontend context: ./frontend
args: args:
# Developer Mode panel on the login page — set false for production. # Developer Mode panel on the login page — dev-only aid. Production
DEV_LOGIN_ENABLED: ${DEV_LOGIN_ENABLED:-true} # builds MUST leave this false (identity comes from Casdoor tokens).
DEV_LOGIN_ENABLED: ${DEV_LOGIN_ENABLED:-false}
container_name: procurement-frontend container_name: procurement-frontend
labels: labels:
+67
View File
@@ -0,0 +1,67 @@
# Secret management & rotation
**Policy (short):** real credentials live only in git-ignored `.env` files —
the server-side `/srv/erp/.env` (used by the deploy webhook and compose) and
local dev `.env` copies. Nothing secret goes into git: not in the README,
not in dumps, not in docs. The repo is (or was) **public** — assume anything
ever committed is compromised and rotate.
`docker-compose.prod.yml` declares every production secret as
`${VAR:?set in .env}` so the stack **refuses to boot** on defaults.
## Where each secret lives
| Secret | Used by | Defined in |
|---|---|---|
| `POSTGRES_PASSWORD` | postgres, api | server `.env` |
| `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD` | minio, minio-init, api | server `.env` |
| `CASDOOR_CLIENT_SECRET` | Casdoor application record, api | server `.env` (must match the app in the Casdoor console) |
| `CASDOOR_ADMIN_PASSWORD` | Casdoor console admin, api (`/users/sync`) | server `.env` (rotate in the Casdoor console too) |
| `CORS_ORIGINS`, `*_HOST`, `TRAEFIK_ACME_EMAIL` | compose / Traefik | server `.env` |
| `GITEA_WEBHOOK_SECRET` | deploy-webhook receiver (port 9000) | server `.env` + Gitea webhook config |
| Monitoring DSNs/keys | OpenPanel, GlitchTip, Grafana | `monitoring/.env` (server-side) |
## Rotation checklist — run after any leak suspicion
The production branch history contained real credentials (MinIO admin,
Casdoor passwords, server IP, DB dumps). All of the following must be
rotated once, on the VPS:
```bash
cd /srv/erp
# 1) MinIO root — then update MINIO_ROOT_* in /srv/erp/.env
mc alias set local http://127.0.0.1:9000 <old-user> '<old-pass>'
mc admin user add local <new-user> '<new-pass>' # then:
mc admin policy attach local consoleAdmin --user <new-user>
mc admin user remove local <old-user>
# (simplest equivalent: mc admin user svcacct or change root via env +
# `docker compose up -d minio` — MinIO persists root creds from env)
# 2) PostgreSQL — then update POSTGRES_PASSWORD in .env
docker exec -it procurement-postgres psql -U postgres -c \
"ALTER USER postgres PASSWORD '<new-strong-password>';"
docker compose up -d api # api re-reads the DSN from .env
# 3) Casdoor console admin (console → Identity → users → admin → reset
# password) — then update CASDOOR_ADMIN_PASSWORD in .env
# 4) Casdoor application client secret (console → Applications →
# inventory-app → edit secret) — then update CASDOOR_CLIENT_SECRET in .env
# 5) Seeded human users: reset each password in the Casdoor console.
# 6) Gitea webhook secret (Gitea → repo → Settings → Webhooks) — then update
# GITEA_WEBHOOK_SECRET in .env and restart the receiver (systemctl restart erp-deploy-webhook)
# 7) Apply everything:
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
```
- **SSH**: disable password login (`PasswordAuthentication no` in
`/etc/ssh/sshd_config`), use keys only, and prefer a non-root user with
sudo over `root` direct login.
- **Repo hygiene**: keep the repo private until the git history is either
purged (BFG / `git filter-repo` on the README + `backups/` blobs) or every
leaked value is rotated. Deleting a file in a later commit does **not**
remove it from history.
- **Verify**: after rotation, `docker compose logs api | grep -i error`,
log into the app, upload + download one attachment, and confirm the
webhook deploy still works with a test push.
+11 -11
View File
@@ -12,7 +12,7 @@ scenarios, test results, and how to manage the system yourself.
|---|---|---| |---|---|---|
| App (frontend) | http://localhost (Traefik edge; production: your domain) | — | | App (frontend) | http://localhost (Traefik edge; production: your domain) | — |
| API (backend) | http://localhost:8000 (Swagger: `/docs`) | — | | API (backend) | http://localhost:8000 (Swagger: `/docs`) | — |
| Casdoor console | http://localhost:8080 | **admin / 123** (built-in org) | | Casdoor console | http://localhost:8080 | admin credentials come from `CASDOOR_ADMIN_PW` when running the seeder — never committed |
| Casdoor OIDC discovery | http://localhost:8080/.well-known/openid-configuration | — | | Casdoor OIDC discovery | http://localhost:8080/.well-known/openid-configuration | — |
| Casdoor public JWKS | http://localhost:8080/.well-known/jwks | — | | Casdoor public JWKS | http://localhost:8080/.well-known/jwks | — |
| In-network Casdoor (from the api container) | http://casdoor:8000 | — | | In-network Casdoor (from the api container) | http://casdoor:8000 | — |
@@ -22,13 +22,13 @@ scenarios, test results, and how to manage the system yourself.
| Username | Password | Casdoor org | App role (our `users` table) | | Username | Password | Casdoor org | App role (our `users` table) |
|---|---|---|---| |---|---|---|---|
| `razaei` | `123456` | inventory | `admin` (super user) | | `razaei` | `CASDOOR_SEED_PASSWORD` | inventory | `admin` (super user) |
| `mousavi` | `123456` | inventory | `procurement_manager` (مدیر تدارکات) | | `mousavi` | `CASDOOR_SEED_PASSWORD` | inventory | `procurement_manager` (مدیر تدارکات) |
| `mohaghegh` | `123456` | inventory | `pm` (مدیر پروژه، بدون پروژه) | | `mohaghegh` | `CASDOOR_SEED_PASSWORD` | inventory | `pm` (مدیر پروژه، بدون پروژه) |
| `tester` | `123456` | inventory | `pm` (مدیر پروژه — پروژه «تست») | | `tester` | `CASDOOR_SEED_PASSWORD` | inventory | `pm` (مدیر پروژه — پروژه «تست») |
These users are created by `backend/tools/seed_casdoor.py` (idempotent — safe Passwords are stored bcrypt-hashed (passwordType `bcrypt`). These users are
to re-run). created by `backend/tools/seed_casdoor.py` (idempotent — safe to re-run).
--- ---
@@ -95,7 +95,7 @@ identity only.** No new authorization model was introduced.
the OIDC client. Grant types: `authorization_code`, `refresh_token`, the OIDC client. Grant types: `authorization_code`, `refresh_token`,
`password`. Token format JWT (so the backend can verify via JWKS). `password`. Token format JWT (so the backend can verify via JWKS).
- **Casdoor Organization** (`inventory`): the tenant; every app user belongs - **Casdoor Organization** (`inventory`): the tenant; every app user belongs
to it. (The Casdoor console admin `admin/123` lives in the separate to it. (The Casdoor console admin lives in the separate
`built-in` org.) `built-in` org.)
- **User — two records per person:** - **User — two records per person:**
1. Casdoor user (login credential: name + password). 1. Casdoor user (login credential: name + password).
@@ -158,7 +158,7 @@ After that, log in as the admin and use the Organization panel
| Test | Action | Result | Evidence | | Test | Action | Result | Evidence |
|---|---|---|---| |---|---|---|---|
| 1 | Login: `POST /api/v1/auth/login` (tester/123456) | **PASS** | 200, JWT + profile returned | | 1 | Login: `POST /api/v1/auth/login` (tester / CASDOOR_SEED_PASSWORD) | **PASS** | 200, JWT + profile returned |
| 2 | Authenticated API: `GET /users/me` with token | **PASS** | 200, role `pm` + project «تست» | | 2 | Authenticated API: `GET /users/me` with token | **PASS** | 200, role `pm` + project «تست» |
| 3 | No token: `GET /inventory/warehouses` | **PASS** | 401 `UNAUTHORIZED` | | 3 | No token: `GET /inventory/warehouses` | **PASS** | 401 `UNAUTHORIZED` |
| 4 | Invalid token: corrupted Bearer | **PASS** | 401 `UNAUTHORIZED` «توکن نامعتبر است» | | 4 | Invalid token: corrupted Bearer | **PASS** | 401 `UNAUTHORIZED` «توکن نامعتبر است» |
@@ -179,7 +179,7 @@ After that, log in as the admin and use the Organization panel
| 6 | Organization exists | ✅ `inventory` | | 6 | Organization exists | ✅ `inventory` |
| 7 | Application exists | ✅ `inventory-app` | | 7 | Application exists | ✅ `inventory-app` |
| 8 | Client exists | ✅ `inventory-app-client` (grants incl. `password`) | | 8 | Client exists | ✅ `inventory-app-client` (grants incl. `password`) |
| 9 | Test users exist | ✅ razaei, mousavi, mohaghegh, tester (password 123456) | | 9 | Test users exist | ✅ razaei, mousavi, mohaghegh, tester (password: CASDOOR_SEED_PASSWORD) |
| 10 | Redirect URI matches | ✅ N/A for the in-page flow (redirects unused). The seeded app holds the legacy `http://localhost:3000/login`; the seeder now defaults to `http://localhost/login` | | 10 | Redirect URI matches | ✅ N/A for the in-page flow (redirects unused). The seeded app holds the legacy `http://localhost:3000/login`; the seeder now defaults to `http://localhost/login` |
| 11 | OIDC works | ✅ discovery doc + token endpoint + JWT issuance verified | | 11 | OIDC works | ✅ discovery doc + token endpoint + JWT issuance verified |
| 12 | PKCE works | ➖ **N/A** — the PKCE/redirect flow was replaced by the in-page password-grant login (per the redesign spec); nothing to verify | | 12 | PKCE works | ➖ **N/A** — the PKCE/redirect flow was replaced by the in-page password-grant login (per the redesign spec); nothing to verify |
@@ -270,7 +270,7 @@ After that, log in as the admin and use the Organization panel
## 9. Managing Casdoor yourself ## 9. Managing Casdoor yourself
- **Console:** http://localhost:8080 — log in as **admin / 123**. From there - **Console:** http://localhost:8080 — log in as the admin (credentials from the seeder run). From there
you can manage Organizations, Users, Applications, Providers, Certificates. you can manage Organizations, Users, Applications, Providers, Certificates.
- **Seeding** (org `inventory`, app `inventory-app`, the four users) — - **Seeding** (org `inventory`, app `inventory-app`, the four users) —
re-runnable at any time: re-runnable at any time:
+4 -3
View File
@@ -13,9 +13,10 @@ ARG NPM_REGISTRY=https://registry.npmmirror.com
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm config set registry ${NPM_REGISTRY} && npm ci RUN npm config set registry ${NPM_REGISTRY} && npm ci
# Developer Mode panel on the login page (default enabled; set to # Developer Mode panel on the login page (dev-only aid). Production builds
# "false" to build a production login without the dev panel). # MUST keep this "false" — identity comes from Casdoor tokens, and the
ARG DEV_LOGIN_ENABLED=true # backend rejects the X-User-* header path unless DEV_LOGIN_ENABLED=true.
ARG DEV_LOGIN_ENABLED=false
ENV VITE_ENABLE_DEV_LOGIN=${DEV_LOGIN_ENABLED} ENV VITE_ENABLE_DEV_LOGIN=${DEV_LOGIN_ENABLED}
# Source code + build # Source code + build
+42
View File
@@ -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`).
+176
View File
@@ -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
+28
View File
@@ -7,3 +7,31 @@ http:
redirectScheme: redirectScheme:
scheme: https scheme: https
permanent: true permanent: true
# Baseline security headers for every public router (attached in the
# prod overlay; local http://localhost stays untouched).
secure-headers:
headers:
stsSeconds: 31536000
stsIncludeSubdomains: true
contentTypeNosniff: true
referrerPolicy: strict-origin-when-cross-origin
customFrameOptionsValue: DENY
browserXssFilter: true
customResponseHeaders:
X-Powered-By: ""
Server: ""
# Edge rate limiting — coarse abuse brake for the API host
# (average 50 req/s sustained, burst 150). Fine-grained login limiting
# lives in the app (slowapi).
api-ratelimit:
rateLimit:
average: 50
burst: 150
# Cap request bodies at the edge (attachments are ≤5 MB per file;
# 12 MB leaves room for multipart overhead).
api-bodylimit:
buffering:
maxRequestBodyBytes: 12582912