The e-ink firmware carries a certificate-authority bundle fixed when it was built, so it cannot validate a chain rooted in an authority created afterwards. Let's Encrypt's ISRG Root YR was issued in May 2026 and is not even in an up-to-date Ubuntu CA bundle yet; the kit's firmware predates it. The handshake fails before a request is ever sent, which is why neither Traefik nor the application saw anything at all while the device reported "API connection cannot be established". Ruled out first, with evidence: TLS 1.2 and the ECDHE-RSA-AES-GCM suites an ESP32 needs are both offered, and the intermediate is not cross-signed by an older root, so no alternate path exists in what is served. A Traefik router now serves four device paths over :80, ahead of the entrypoint-wide redirect. The administration stays on TLS. The device token travels in clear; it is used for nothing else and is revocable from the settings page, and the image URL is an unguessable content hash. DEVICE_ALLOW_HTTP existed but was never read — a setting that does nothing misrepresents what it protects. The device routes now refuse an unencrypted request unless it is set, so opening this door is a written decision rather than the silent consequence of a proxy change. DEPLOY.md records the whole diagnosis, including the commands that distinguish a TLS failure from an application one, and what to do the day the firmware learns the new roots. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
108 lines
4.5 KiB
YAML
108 lines
4.5 KiB
YAML
# Production overlay: the application is published by an existing Traefik
|
|
# rather than on a host port.
|
|
#
|
|
# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build --wait
|
|
#
|
|
# Traefik must already be running and own the external network named by
|
|
# TRAEFIK_NETWORK. Nothing here holds a secret: every value comes from .env,
|
|
# which is never committed.
|
|
#
|
|
# Using Nginx Proxy Manager instead? Delete the labels and the `edge` network,
|
|
# keep the published port from docker-compose.yml bound to 127.0.0.1, and point
|
|
# a proxy host at it. The forwarded headers matter either way: the application
|
|
# builds the image URL handed to the panel from them, so X-Forwarded-Proto and
|
|
# X-Forwarded-Host must both reach it or the device will be sent to the wrong
|
|
# scheme.
|
|
|
|
services:
|
|
db:
|
|
# The database is reached only over the compose network.
|
|
ports: !override []
|
|
|
|
app:
|
|
ports: !override []
|
|
networks:
|
|
- default
|
|
- edge
|
|
# The database is the only thing worth persisting; the application writes
|
|
# nothing to its own filesystem.
|
|
read_only: true
|
|
tmpfs:
|
|
- /tmp
|
|
labels:
|
|
traefik.enable: "true"
|
|
traefik.docker.network: ${TRAEFIK_NETWORK:-web}
|
|
traefik.http.routers.horaires.rule: Host(`${APP_DOMAIN:?Set APP_DOMAIN in .env}`)
|
|
traefik.http.routers.horaires.entrypoints: ${TRAEFIK_ENTRYPOINT:-websecure}
|
|
traefik.http.routers.horaires.tls: "true"
|
|
traefik.http.routers.horaires.tls.certresolver: ${TRAEFIK_CERTRESOLVER:-myresolver}
|
|
traefik.http.routers.horaires.middlewares: horaires-hsts
|
|
traefik.http.services.horaires.loadbalancer.server.port: "3010"
|
|
# The admin is only ever served over TLS; say so to the browsers.
|
|
traefik.http.middlewares.horaires-hsts.headers.stsSeconds: "31536000"
|
|
traefik.http.middlewares.horaires-hsts.headers.stsIncludeSubdomains: "true"
|
|
|
|
# --- The panel, in clear, on four paths only ---
|
|
#
|
|
# The e-ink firmware carries a certificate-authority bundle fixed when it
|
|
# was built, so it cannot validate a chain rooted in an authority created
|
|
# afterwards — which is exactly the case with Let's Encrypt's ISRG Root YR
|
|
# (May 2026). The handshake fails before a request is ever sent, which is
|
|
# why neither Traefik nor the application sees anything at all.
|
|
#
|
|
# This router therefore serves the four device paths over plain HTTP. The
|
|
# administration stays on TLS. The trade-off is real and bounded: the
|
|
# device token travels in clear, it is used for nothing else, and it can
|
|
# be revoked from Paramètres → Appareils. The image URL is an unguessable
|
|
# content hash.
|
|
#
|
|
# The priority beats the entrypoint-wide HTTP→HTTPS redirection, which is
|
|
# otherwise applied to everything on :80. Remove this block the day the
|
|
# firmware learns the new roots, and set DEVICE_ALLOW_HTTP=false — the
|
|
# application refuses plain requests without it.
|
|
traefik.http.routers.horaires-device.rule: >-
|
|
Host(`${APP_DOMAIN}`) && (PathPrefix(`/api/setup`) || PathPrefix(`/api/display`)
|
|
|| PathPrefix(`/api/log`) || PathPrefix(`/api/device/`))
|
|
traefik.http.routers.horaires-device.entrypoints: web
|
|
traefik.http.routers.horaires-device.priority: "2147483647"
|
|
traefik.http.routers.horaires-device.service: horaires
|
|
|
|
backup:
|
|
# A nightly dump kept for two weeks. Small, boring, and the only thing
|
|
# standing between a bad migration and retyping a year of opening hours.
|
|
image: postgres:16-alpine
|
|
restart: unless-stopped
|
|
security_opt:
|
|
- no-new-privileges:true
|
|
depends_on:
|
|
db:
|
|
condition: service_healthy
|
|
environment:
|
|
PGPASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
|
|
POSTGRES_USER: ${POSTGRES_USER:-horaires}
|
|
POSTGRES_DB: ${POSTGRES_DB:-horaires}
|
|
BACKUP_KEEP_DAYS: ${BACKUP_KEEP_DAYS:-14}
|
|
volumes:
|
|
- ./backups:/backups
|
|
entrypoint:
|
|
- /bin/sh
|
|
- -c
|
|
- |
|
|
while true; do
|
|
stamp="$$(date +%Y%m%d-%H%M%S)"
|
|
if pg_dump -h db -U "$$POSTGRES_USER" -d "$$POSTGRES_DB" \
|
|
| gzip > "/backups/horaires-$$stamp.sql.gz"; then
|
|
echo "[backup] /backups/horaires-$$stamp.sql.gz"
|
|
else
|
|
echo "[backup] échec du dump $$stamp" >&2
|
|
rm -f "/backups/horaires-$$stamp.sql.gz"
|
|
fi
|
|
find /backups -name 'horaires-*.sql.gz' -mtime "+$$BACKUP_KEEP_DAYS" -delete
|
|
sleep 86400
|
|
done
|
|
|
|
networks:
|
|
edge:
|
|
external: true
|
|
name: ${TRAEFIK_NETWORK:-web}
|