Compare commits

...

6 Commits

Author SHA1 Message Date
fad417697a Merge branch 'fix/proxy-forwarded-ips' into dev: trust X-Forwarded-* from off-host Caddy 2026-04-22 13:08:01 -05:00
5aad7fd48f fix: trust X-Forwarded-* when Caddy is on another host
Site was loading under https but Starlette built logo/static URLs as
http, tripping CSP `img-src 'self'`. Root cause: Dockerfile hardcoded
`--forwarded-allow-ips 127.0.0.1`, but with Caddy on a separate host
(10.10.99.10) reverse-proxying to this VM, the container sees the
request's source IP as the Docker bridge gateway (~172.17.0.1), not
Caddy's real IP — uvicorn discarded X-Forwarded-Proto and fell back
to scheme=http.

Fixes both now and later:

- Dockerfile CMD switched to `sh -c exec uvicorn ... --forwarded-allow-ips
  "${FORWARDED_ALLOW_IPS:-127.0.0.1}"`. PID-1 / signal behaviour preserved
  via exec. Default stays 127.0.0.1 so host-uvicorn dev is unchanged;
  prod sets FORWARDED_ALLOW_IPS=* via .env or compose `command:`.
- docker-compose.prod.yml gets an explicit `command:` override so the
  currently-deployed image (with the hardcoded 127.0.0.1 flag) can be
  fixed without waiting for a rebuild. Also corrected the port binding
  comment + default: Caddy is off-host, so the port must be reachable
  over the LAN — restrict with host firewall / OPNsense instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 13:07:54 -05:00
81cd4eb803 docker for prod 2026-04-22 12:58:23 -05:00
fbd822e8dd edits to .env.example for new port 2026-04-22 08:01:05 -05:00
07d36fe73c Merge branch 'chore/prod-port-8080' into dev: container port 8080 2026-04-22 07:59:22 -05:00
62de2685d7 chore: move prod container port from 8000 to 8080
- Dockerfile: EXPOSE + HEALTHCHECK + uvicorn --port all shift to 8080.
- docker-compose.yml: host:container mapping shifts to 8080:8080.
- run_dev.sh: new host-dev launcher; default PORT=8080 to stay in
  sync with the prod shape.

Caddy upstream config on the VM needs to follow to 127.0.0.1:8080.
PUBLIC_BASE_URL in the production .env should reflect the public
hostname (unaffected by the internal port change).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 07:59:17 -05:00
5 changed files with 152 additions and 14 deletions

View File

@@ -51,7 +51,7 @@ MAGIC_LINK_TTL_MIN=15
# --- Public URL for link construction -------------------------------------- # --- Public URL for link construction --------------------------------------
# Absolute base URL (scheme+host+port) used to build outbound links such as # Absolute base URL (scheme+host+port) used to build outbound links such as
# the magic-link auth email. Override for production. # the magic-link auth email. Override for production.
PUBLIC_BASE_URL=http://127.0.0.1:8000 PUBLIC_BASE_URL=http://127.0.0.1:8080
# --- Build metadata --------------------------------------------------------- # --- Build metadata ---------------------------------------------------------
# Injected at Docker build time. Surfaced by /healthz. Optional in dev. # Injected at Docker build time. Surfaced by /healthz. Optional in dev.

View File

@@ -87,21 +87,25 @@ RUN groupadd -g 10001 app \
USER app USER app
EXPOSE 8000 EXPOSE 8080
# Phase 6 healthcheck: hits /healthz over the loopback with stdlib # Phase 6 healthcheck: hits /healthz over the loopback with stdlib
# urllib so we don't have to install curl/wget in the runtime image. # urllib so we don't have to install curl/wget in the runtime image.
# Intervals tuned for a small VM: probe every 30s, give a fresh # Intervals tuned for a small VM: probe every 30s, give a fresh
# container 10s to finish startup before the first probe counts. # container 10s to finish startup before the first probe counts.
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD python -c "import urllib.request,sys; urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=2); sys.exit(0)" || exit 1 CMD python -c "import urllib.request,sys; urllib.request.urlopen('http://127.0.0.1:8080/healthz', timeout=2); sys.exit(0)" || exit 1
# Run Uvicorn directly. --proxy-headers + --forwarded-allow-ips make # Run Uvicorn directly. --proxy-headers + --forwarded-allow-ips make
# Starlette's ProxyHeadersMiddleware trust X-Forwarded-* only from the # Starlette's ProxyHeadersMiddleware trust X-Forwarded-* only from the
# listed peer IPs (Caddy on the host). No --reload: this is a prod-shape # listed peer IPs. The trusted-IP value is env-driven so the image
# image; local hot-reload is a dev concern and runs outside Docker. # can be reused across topologies:
CMD ["uvicorn", "app.main:app", \ # - local: defaults to 127.0.0.1 (when running uvicorn on the host)
"--host", "0.0.0.0", \ # - docker/compose behind Caddy: set FORWARDED_ALLOW_IPS="*" in .env
"--port", "8000", \ # because the container's source IP is the bridge gateway, not
"--proxy-headers", \ # 127.0.0.1. Safe because the host only binds 127.0.0.1:8080 so
"--forwarded-allow-ips", "127.0.0.1"] # nothing off-host can reach uvicorn directly.
# `sh -c exec` keeps uvicorn as PID 1 so SIGTERM still triggers a
# graceful shutdown (exec form was fine before, but we need shell
# expansion for ${FORWARDED_ALLOW_IPS}).
CMD ["sh", "-c", "exec uvicorn app.main:app --host 0.0.0.0 --port 8080 --proxy-headers --forwarded-allow-ips \"${FORWARDED_ALLOW_IPS:-127.0.0.1}\""]

69
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,69 @@
# ---------------------------------------------------------------------------
# Chicken Babies R Us — production compose file.
#
# Meant for the Debian 12 VM behind Caddy. Unlike docker-compose.yml (which
# builds the image from source for local dev), this file pulls the pre-built
# image from the Gitea container registry so the VM stays a thin runner.
#
# Ansible responsibilities (not this file):
# - render /opt/chicken-babies/.env with real secrets
# - ensure data/ exists and is owned by uid:gid 10001:10001
# sudo install -d -o 10001 -g 10001 /opt/chicken-babies/data/media
# sudo install -d -o 10001 -g 10001 /opt/chicken-babies/data/backups
# - `docker login git.sneakygeek.net -u <user> -p <REGISTRY_TOKEN>`
# - copy this file as /opt/chicken-babies/docker-compose.yml
# - run `docker compose pull && docker compose up -d`
#
# Update flow on the VM (after a CI build publishes a new :latest):
# docker compose pull
# docker compose up -d # restarts only if the image SHA changed
# docker image prune -f # reclaim space from the old layers
# ---------------------------------------------------------------------------
services:
web:
image: git.sneakygeek.net/ptarrant/chicken_babies_site:latest
# Docker pulls :latest on `docker compose pull`; no build context needed.
pull_policy: always
env_file:
- .env
# Override the Dockerfile CMD so uvicorn trusts X-Forwarded-* headers.
# Caddy lives on another server (10.10.99.10) and speaks HTTP to this
# VM on port 8080. Even so, the source IP *inside* the container is
# the Docker bridge gateway (typically 172.17.0.1), NOT 10.10.99.10,
# because Docker NATs the inbound connection. That means allowlisting
# 10.10.99.10 would never match — uvicorn would still drop X-Forwarded-*
# and Starlette would build http:// URLs under an https:// page,
# tripping `img-src 'self'` CSP on the logo, fonts, etc.
#
# "*" is acceptable here because access to port 8080 is controlled
# at the network layer (host firewall / VLAN) — only the Caddy box
# can reach it. If you later move Caddy onto this same host, change
# this back to a specific gateway IP.
command: >
uvicorn app.main:app
--host 0.0.0.0
--port 8080
--proxy-headers
--forwarded-allow-ips *
ports:
# Caddy on 10.10.99.10 reverse-proxies to <this-vm>:8080. Binding
# on all interfaces keeps the compose portable; lock down access
# with the VM's host firewall (nftables / ufw) or upstream
# (OPNsense) to only permit 10.10.99.10 → :8080.
- "8080:8080"
volumes:
# SQLite DB + media uploads live on the host so container rebuilds /
# image rolls don't wipe content. The container runs as uid 10001;
# the host dir must be chown'd to match (see Ansible notes above).
- ./data:/app/data
restart: unless-stopped
# Docker picks up the HEALTHCHECK baked into the image. `docker compose
# ps` surfaces health status; systemd / Ansible tasks can gate on it.
# Container-level logging caps so a chatty bot run doesn't fill the
# VM disk before the host-side logrotate catches it.
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"

View File

@@ -21,10 +21,10 @@ services:
env_file: env_file:
- .env - .env
ports: ports:
# Uvicorn listens on 8000 inside the container. In the production # Uvicorn listens on 8080 inside the container (prod default). In
# topology Caddy fronts this; for local runs it's directly on the # the production topology Caddy fronts this; for local runs it's
# host loopback via the mapped port. # directly on the host loopback via the mapped port.
- "8000:8000" - "8080:8080"
volumes: volumes:
# SQLite DB + media uploads live under data/. Mounting it keeps # SQLite DB + media uploads live under data/. Mounting it keeps
# state on the host so container rebuilds don't wipe content. # state on the host so container rebuilds don't wipe content.

65
run_dev.sh Executable file
View File

@@ -0,0 +1,65 @@
#!/usr/bin/env bash
# ---------------------------------------------------------------------------
# Chicken Babies R Us — local dev launcher.
#
# Runs uvicorn directly on the host (no docker). Used for fast iteration on
# routes, templates, CSS, and middleware. Set APP_ENV=production in front
# of this script if you want to smoke-test the prod-only code paths (HSTS,
# JSON logs, prod-config guardrails).
#
# Usage:
# ./run_dev.sh # dev mode, reload on
# ./run_dev.sh --no-reload # dev mode, reload off
# PORT=8001 ./run_dev.sh # override listen port (default 8000)
# ---------------------------------------------------------------------------
set -euo pipefail
# Anchor paths to the repo root so the script works from any cwd.
cd "$(dirname "$0")"
VENV_DIR="venv"
PYTHON_BIN="${PYTHON_BIN:-python3.12}"
PORT="${PORT:-8080}"
HOST="${HOST:-127.0.0.1}"
# 1. Virtualenv -------------------------------------------------------------
if [[ ! -x "${VENV_DIR}/bin/python" ]]; then
echo "==> Creating venv at ${VENV_DIR}/"
"${PYTHON_BIN}" -m venv "${VENV_DIR}"
fi
# shellcheck disable=SC1091
source "${VENV_DIR}/bin/activate"
# 2. Requirements (reinstall if requirements.txt is newer than the stamp) ---
STAMP="${VENV_DIR}/.requirements.stamp"
if [[ requirements.txt -nt "${STAMP}" ]]; then
echo "==> Installing / updating dependencies"
pip install --quiet --upgrade pip
pip install --quiet -r requirements.txt
touch "${STAMP}"
fi
# 3. .env ------------------------------------------------------------------
if [[ ! -f .env ]]; then
echo "==> No .env found; copying from .env.example"
cp .env.example .env
echo " Edit .env to set SECRET_KEY / RESEND_API_KEY / HCAPTCHA_* as needed."
fi
# 4. Data dir --------------------------------------------------------------
mkdir -p data/media data/backups
# 5. Launch ----------------------------------------------------------------
RELOAD_FLAG="--reload"
if [[ "${1:-}" == "--no-reload" ]]; then
RELOAD_FLAG=""
fi
echo "==> uvicorn on http://${HOST}:${PORT} (APP_ENV=${APP_ENV:-development})"
# shellcheck disable=SC2086
exec uvicorn app.main:app \
--host "${HOST}" \
--port "${PORT}" \
${RELOAD_FLAG} \
--proxy-headers \
--forwarded-allow-ips 127.0.0.1