CI / Python lint (flake8) (push) Has been cancelled
CI / Python syntax check (push) Has been cancelled
CI / Alembic migration chain (push) Has been cancelled
CI / JavaScript syntax check (push) Has been cancelled
CI / Build extension zip (push) Has been cancelled
149 lines
7.9 KiB
Python
149 lines
7.9 KiB
Python
"""
|
|
gunicorn.conf.py — Gunicorn runtime configuration for PassKeeper.
|
|
|
|
Loaded explicitly by the systemd unit:
|
|
|
|
ExecStart=/home/spuser/.venv/bin/gunicorn -c /home/spuser/PassKeeper/gunicorn.conf.py wsgi:app
|
|
|
|
Every value can be overridden from the environment (systemd reads
|
|
EnvironmentFile=/home/spuser/PassKeeper/.env), so tuning a production box does
|
|
not require editing this file or the unit.
|
|
|
|
── Timeout ordering (this is what causes 502s) ───────────────────────────────
|
|
Nginx must give up BEFORE Gunicorn kills the worker:
|
|
|
|
nginx proxy_read_timeout < gunicorn timeout
|
|
|
|
If Gunicorn kills first, the connection is severed mid-response and Nginx
|
|
reports 502 Bad Gateway. If Nginx gives up first, the client gets a clean
|
|
504 Gateway Timeout instead. `timeout` below is therefore set comfortably
|
|
above the proxy_read_timeout in scripts/passkeeper-nginx.conf — raise this
|
|
value first if you ever raise that one.
|
|
"""
|
|
import multiprocessing
|
|
import os
|
|
|
|
|
|
def _env_int(name: str, default: int) -> int:
|
|
"""Read an int from the environment, falling back on unset/garbage values."""
|
|
try:
|
|
return int(os.environ.get(name, default))
|
|
except (TypeError, ValueError):
|
|
return default
|
|
|
|
|
|
# ── Socket ───────────────────────────────────────────────────────────────────
|
|
# Loopback only — Nginx is the sole public entry point and terminates TLS.
|
|
bind = os.environ.get('GUNICORN_BIND', '127.0.0.1:5000')
|
|
backlog = 2048
|
|
|
|
# ── Worker processes ─────────────────────────────────────────────────────────
|
|
# gthread rather than the default sync worker.
|
|
#
|
|
# Login is the expensive path: Argon2id at ARGON2_MEMORY_COST=65536 (64 MB) with
|
|
# parallelism=4 blocks a sync worker completely for the duration of the hash, and
|
|
# a sync worker serves exactly one request at a time. A handful of concurrent
|
|
# logins was enough to saturate all workers, queue everything behind them, and
|
|
# push requests past the worker timeout — which surfaces as 502.
|
|
#
|
|
# Threads let a worker keep serving while another request is blocked, and make
|
|
# the `keepalive` setting below meaningful (sync workers ignore keep-alive
|
|
# entirely, so Nginx's `proxy_http_version 1.1` was a no-op against them).
|
|
worker_class = 'gthread'
|
|
|
|
# Memory, not CPU, is the binding constraint: each in-flight Argon2id hash claims
|
|
# 64 MB. Keep workers modest and add concurrency with threads instead.
|
|
workers = _env_int('GUNICORN_WORKERS', min(4, multiprocessing.cpu_count() + 1))
|
|
threads = _env_int('GUNICORN_THREADS', 4)
|
|
|
|
# ── Timeouts ─────────────────────────────────────────────────────────────────
|
|
# Must exceed nginx proxy_read_timeout — see the module docstring.
|
|
# Long enough to cover the genuinely slow flows: bulk vault import/export and
|
|
# the atomic re-encryption of every item during a password change or recovery.
|
|
timeout = _env_int('GUNICORN_TIMEOUT', 90)
|
|
|
|
# Let in-flight requests finish on reload/restart rather than cutting them off.
|
|
graceful_timeout = _env_int('GUNICORN_GRACEFUL_TIMEOUT', 30)
|
|
|
|
# Idle keep-alive window for connections from Nginx. Slightly above Nginx's
|
|
# keepalive_timeout (15s) so Gunicorn is never the side that closes first —
|
|
# a connection closed underneath Nginx as it reuses it is a classic 502.
|
|
keepalive = _env_int('GUNICORN_KEEPALIVE', 20)
|
|
|
|
# ── Worker recycling ─────────────────────────────────────────────────────────
|
|
# Recycle workers periodically to bound the impact of any slow memory growth.
|
|
# The jitter staggers restarts so workers never all recycle at once (which would
|
|
# briefly leave nothing to serve — another source of intermittent 502s).
|
|
max_requests = _env_int('GUNICORN_MAX_REQUESTS', 1000)
|
|
max_requests_jitter = _env_int('GUNICORN_MAX_REQUESTS_JITTER', 100)
|
|
|
|
# Heartbeat file location. The default (/tmp) is disk-backed on many systems,
|
|
# and a slow disk makes the arbiter believe healthy workers have died and kill
|
|
# them. /dev/shm is always tmpfs. The unit sets PrivateTmp=true, which does not
|
|
# cover /dev/shm, so this stays writable.
|
|
if os.path.isdir('/dev/shm'):
|
|
worker_tmp_dir = '/dev/shm'
|
|
|
|
# ── Application loading ──────────────────────────────────────────────────────
|
|
# preload_app MUST stay False.
|
|
#
|
|
# create_app() starts an APScheduler BackgroundScheduler for the hourly cleanup
|
|
# of token_blacklist / recovery_challenges / totp_used_codes / expired shares.
|
|
# With preload_app=True the app is built once in the arbiter and then forked —
|
|
# and threads do not survive fork(), so the scheduler thread would exist only in
|
|
# the arbiter, which serves no requests. The cleanup job would silently never
|
|
# run and those tables would grow without bound.
|
|
#
|
|
# Note: Deloy.md's "test Gunicorn manually" step suggests --preload. That is fine
|
|
# for a one-off smoke test, but must never reach the service definition.
|
|
#
|
|
# The cost of preload_app=False is one scheduler per worker, so the cleanup runs
|
|
# `workers` times an hour instead of once. The job only DELETEs already-expired
|
|
# rows, so it is idempotent and the redundancy is harmless.
|
|
preload_app = False
|
|
|
|
# ── Proxy ────────────────────────────────────────────────────────────────────
|
|
# Only trust X-Forwarded-* from the local Nginx. ProxyFix(x_for=1) in the app
|
|
# factory does the actual header parsing; this stops Gunicorn honouring
|
|
# forwarded headers from anything else.
|
|
forwarded_allow_ips = os.environ.get('GUNICORN_FORWARDED_ALLOW_IPS', '127.0.0.1')
|
|
|
|
# ── Request limits ───────────────────────────────────────────────────────────
|
|
# Defence in depth behind Nginx's client_max_body_size 1m.
|
|
limit_request_line = 8190
|
|
limit_request_fields = 100
|
|
limit_request_field_size = 8190
|
|
|
|
# ── Logging ──────────────────────────────────────────────────────────────────
|
|
# Paths must be inside the unit's ReadWritePaths= or Gunicorn cannot start.
|
|
accesslog = os.environ.get('GUNICORN_ACCESS_LOG', '/home/spuser/logs/access.log')
|
|
errorlog = os.environ.get('GUNICORN_ERROR_LOG', '/home/spuser/logs/error.log')
|
|
loglevel = os.environ.get('GUNICORN_LOG_LEVEL', 'warning')
|
|
|
|
# %({X-Forwarded-For}i)s rather than %(h)s — %(h)s would log Nginx's loopback
|
|
# address for every request. Never log Authorization headers or request bodies:
|
|
# they carry bearer tokens and auth_hash values.
|
|
access_log_format = (
|
|
'%({X-Forwarded-For}i)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" %(D)sus'
|
|
)
|
|
|
|
proc_name = 'passkeeper'
|
|
|
|
|
|
# ── Hooks ────────────────────────────────────────────────────────────────────
|
|
|
|
def on_starting(server):
|
|
server.log.info(
|
|
'[PassKeeper] starting: %s worker(s) x %s thread(s), class=%s, timeout=%ss',
|
|
workers, threads, worker_class, timeout,
|
|
)
|
|
|
|
|
|
def worker_abort(worker):
|
|
"""Fires when a worker is killed for exceeding `timeout`."""
|
|
worker.log.error(
|
|
'[PassKeeper] worker %s aborted after %ss — a request exceeded the '
|
|
'timeout. Nginx will have reported 502 to the client.',
|
|
worker.pid, timeout,
|
|
)
|