Confirmed on the operator's NAS. The earlier advice (insert an ACCEPT rule ahead of DSM's chain) does not hold: DSM reloads its firewall periodically, flushing and rebuilding the chains. You can watch FORWARD_FIREWALL's DROP counter reset to zero and the inserted rule vanish with it, having matched no packets. That is also why the symptom looked intermittent — 60/40 success on repeated connections, with RELATED,ESTABLISHED keeping existing flows alive. The fix is one level down: net.bridge.bridge-nf-call-iptables=0 takes same-bridge traffic out of iptables entirely, so DSM's reloading cannot affect it. Persisted with a Boot-up Triggered Task. Section 8 now also answers "is that safe", accurately: the flag affects only packets bridged within one network. Traffic between different Docker networks is routed, so DOCKER-ISOLATION still applies; inbound traffic to published ports is still routed and still firewalled; INPUT, which protects the NAS itself, is untouched. The one real caveat is that it is host-wide, so VMs sharing a bridge lose same-bridge filtering too. The migrate failure message points at the sysctl now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nux3LCu4zP9nih4BRhPyHw
122 lines
4.5 KiB
Bash
Executable File
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 ends in DROP), and it reloads periodically, so an added iptables "
|
|
"rule does not survive. Fix:\n"
|
|
" sysctl -w net.bridge.bridge-nf-call-iptables=0\n"
|
|
"See docs/nas-install.md section 8.",
|
|
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
|