Files
webmail-client/docker/entrypoint.sh
T
lukasandClaude Opus 5 d29c46c229 Docs: DSM firewall blocks container-to-container traffic
Diagnosed on a real Synology: with the DSM firewall enabled, iptables reads

    -A FORWARD -j FORWARD_FIREWALL      <- DSM, first, default deny
    -A FORWARD -j DEFAULT_FORWARD       <- Docker's ACCEPT rules, never reached

and bridge-nf-call-iptables=1 sends container-to-container packets through
FORWARD. A new connection to postgres:5432 matches no DSM allow rule and is
dropped before Docker's own "-i docker-xxx -o docker-xxx -j ACCEPT" is
consulted. RELATED,ESTABLISHED is accepted, which is why the port could test
open from a throwaway container while every new connection timed out — the
symptom looked intermittent and pointed nowhere near the firewall.

- docs §8: the diagnosis, the one-line fix
  (iptables -I FORWARD 1 -i docker+ -o docker+ -j ACCEPT), a boot-up Triggered
  Task so it survives reboots and firewall reloads, and a verification command.
- The migrate failure message now names this cause and the fix, since that is
  the error an operator actually sees.

Nothing in the stack is at fault here; this is host firewall configuration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nux3LCu4zP9nih4BRhPyHw
2026-09-14 21:10:37 +02:00

122 lines
4.5 KiB
Bash
Executable File

#!/bin/sh
# One image, several roles. Called as: entrypoint <command> [args...]
#
# api uvicorn, behind the `web` nginx container
# worker-sync lease-owned account sync supervisor (SPEC §6.3)
# worker-jobs arq consumer (interactive + bulk queues)
# worker-all both in one process (small installs; halves RAM use)
# migrate core Alembic upgrade + per-plugin install; exits 0 when done
# reindex rebuild the search index from Postgres
# plugins `python -m app.plugins.cli ...` passthrough
# shell /bin/sh (debugging)
#
# Any other argument is exec'd verbatim, so `docker compose run api python -c ...`
# still works.
set -eu
: "${API_HOST:=0.0.0.0}"
: "${API_PORT:=8000}"
: "${API_WORKERS:=1}"
# Plugins whose migrations are applied on every `migrate` run. Space-separated so
# an operator can add a third-party plugin without editing the image.
: "${ENABLED_PLUGINS:=calendar contacts labels filters}"
cmd="${1:-api}"
[ "$#" -gt 0 ] && shift || true
# Belt and braces on top of the compose healthcheck: a NAS with slow disks can
# take a while over initdb, and `migrate` is a one-shot with no restart policy —
# if it starts a moment early it fails the whole bring-up.
wait_for_db() {
python - <<'PYEOF'
import os, socket, sys, time
from urllib.parse import urlparse
url = os.environ.get("DATABASE_URL", "")
parsed = urlparse(url.replace("+asyncpg", ""))
host = parsed.hostname or "postgres"
port = parsed.port or 5432
deadline = time.time() + float(os.environ.get("DB_WAIT_SECONDS", "180"))
# Report WHY it is not connecting, not just that it isn't: the first failure
# immediately, then every 30s. A silent retry loop that ends in "never accepted"
# tells whoever is installing this precisely nothing.
last_error = None
next_report = 0.0
while time.time() < deadline:
try:
with socket.create_connection((host, port), timeout=3):
print(f"[entrypoint] database accepting connections at {host}:{port}")
sys.exit(0)
except OSError as exc:
last_error = exc
if time.time() >= next_report:
try:
resolved = socket.gethostbyname(host)
except OSError as dns_exc:
resolved = f"UNRESOLVED ({dns_exc})"
print(
f"[entrypoint] waiting for {host}:{port} "
f"(resolves to {resolved}): {type(exc).__name__}: {exc}",
flush=True,
)
next_report = time.time() + 30
time.sleep(2)
print(
f"[entrypoint] ERROR: {host}:{port} never accepted a connection. "
f"Last error: {type(last_error).__name__}: {last_error}",
file=sys.stderr,
)
print(
"[entrypoint] If this is a Synology NAS: DSM's firewall filters traffic "
"between containers (FORWARD -j FORWARD_FIREWALL runs before Docker's own "
"rules and denies by default). Fix:\n"
" iptables -I FORWARD 1 -i docker+ -o docker+ -j ACCEPT\n"
"See docs/nas-install.md, 'DSM firewall blocks container-to-container "
"traffic'.",
file=sys.stderr,
)
sys.exit(1)
PYEOF
}
run_migrations() {
wait_for_db
echo "[entrypoint] core schema: alembic upgrade head"
alembic upgrade head
for plugin in $ENABLED_PLUGINS; do
echo "[entrypoint] plugin schema: $plugin"
python -m app.plugins.cli install "$plugin"
done
echo "[entrypoint] migrations complete"
}
case "$cmd" in
api)
# --proxy-headers + --forwarded-allow-ips are what let the app see the
# real scheme through nginx/DSM. Without them every request looks like
# plain http and the session cookie loses its __Host- prefix and Secure
# flag (app/identity/cookies.py:22). The allow-list is the internal
# docker network only; this port is never published directly.
exec uvicorn app.main:create_app \
--factory \
--host "$API_HOST" \
--port "$API_PORT" \
--workers "$API_WORKERS" \
--proxy-headers \
--forwarded-allow-ips "${FORWARDED_ALLOW_IPS:-*}" \
--no-server-header \
"$@"
;;
worker-sync) WORKER_ROLE=sync exec python -m workers.run "$@" ;;
worker-jobs) WORKER_ROLE=jobs exec python -m workers.run "$@" ;;
worker-all) WORKER_ROLE=all exec python -m workers.run "$@" ;;
migrate) run_migrations ;;
reindex) exec python scripts/reindex_search.py "$@" ;;
plugins) exec python -m app.plugins.cli "$@" ;;
shell) exec /bin/sh "$@" ;;
*) exec "$cmd" "$@" ;;
esac