From e9a82a1ccac9c909db13a23052c86cbd0820deb4 Mon Sep 17 00:00:00 2001 From: tajniak81 <13187254+tajniak81@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:36:56 +0200 Subject: [PATCH] One hostname a charger can be told about and actually reach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Charger control needs TLS, and the stack speaks plain HTTP, so the README said "terminate TLS in a reverse proxy" and left the operator to work out which four settings have to agree. This adds the proxy: a Caddy overlay that fronts the Web App BFF — which already carries /api/ and /ocpp/ — so browsers and chargers arrive at the same name and the certificate is issued on first boot. The four settings are derived from DV_DOMAIN, since one value getting typed right is better odds than four: OCPP_PUBLIC_URL, OCPP_REQUIRE_TLS back on, CORS, and TRUST_FORWARDED_PROTO on the Web App. That last one is the non-obvious one — without it the BFF overwrites Caddy's X-Forwarded-Proto with its own plaintext hop and the API Server rejects the charger it just told to connect over wss. The README's OCPP section was stale besides: it still sent chargers to the API Server port alone, from before the BFF carried that path. Co-Authored-By: Claude Opus 5 --- Docker/.env.example | 8 +++++ Docker/.env.prod.example | 8 +++++ Docker/Caddyfile | 24 ++++++++++++++ Docker/README.md | 49 ++++++++++++++++++++++------ Docker/docker-compose.tls.yml | 61 +++++++++++++++++++++++++++++++++++ 5 files changed, 141 insertions(+), 9 deletions(-) create mode 100644 Docker/Caddyfile create mode 100644 Docker/docker-compose.tls.yml diff --git a/Docker/.env.example b/Docker/.env.example index 8ace10f..99fecd8 100644 --- a/Docker/.env.example +++ b/Docker/.env.example @@ -66,3 +66,11 @@ VITE_API_BASE= # CORS_ALLOW_ORIGINS browser origins allowed to call the API Server directly. # POCKETBASE_URL=http://pocketbase:8070 # WEBAPP_URL=http://web-app:8090 + +# --- Public hostname + TLS (docker-compose.tls.yml) -------------------------- +# Only read when the TLS overlay is layered on. DV_DOMAIN must resolve to this +# host from the internet, with ports 80 and 443 reaching it; the certificate is +# issued automatically on first boot. Setting it also points chargers at +# wss://DV_DOMAIN, which is what lets OCPP_REQUIRE_TLS stay on. +DV_DOMAIN= +DV_ACME_EMAIL= diff --git a/Docker/.env.prod.example b/Docker/.env.prod.example index c73cb4f..1fb4a60 100644 --- a/Docker/.env.prod.example +++ b/Docker/.env.prod.example @@ -88,3 +88,11 @@ API_BIND=127.0.0.1 # stack: the API Server keeps no state on disk, so everything it owns (plugin # settings included) is backed up by backing up this one path. PB_DATA=pb_data + +# --- Public hostname + TLS (docker-compose.tls.yml) -------------------------- +# Only read when the TLS overlay is layered on. DV_DOMAIN must resolve to this +# host from the internet, with ports 80 and 443 reaching it; the certificate is +# issued automatically on first boot. Setting it also points chargers at +# wss://DV_DOMAIN, which is what lets OCPP_REQUIRE_TLS stay on. +DV_DOMAIN= +DV_ACME_EMAIL= diff --git a/Docker/Caddyfile b/Docker/Caddyfile new file mode 100644 index 0000000..27d9421 --- /dev/null +++ b/Docker/Caddyfile @@ -0,0 +1,24 @@ +# Caddy in front of the stack: one hostname, TLS from Let's Encrypt, everything +# behind it spoken to over the compose network in plain HTTP. +# +# Both doors are the same door here. Browsers get the Web App; chargers dial +# /ocpp/{serial} on the same hostname, and the BFF carries that through to the +# API Server. Caddy proxies WebSocket upgrades without being told to, so there +# is nothing to configure for the charger case. +# +# DV_DOMAIN and DV_ACME_EMAIL come from .env via docker-compose.tls.yml. + +{ + email {$DV_ACME_EMAIL} +} + +{$DV_DOMAIN} { + encode zstd gzip + + # X-Forwarded-Proto is set by Caddy to the scheme the client used. The API + # Server reads it to decide a charger arrived over TLS, which is why the + # web-app service is given TRUST_FORWARDED_PROTO=true — without that the BFF + # would overwrite this header with its own plaintext hop and a charger would + # be rejected under OCPP_REQUIRE_TLS. + reverse_proxy web-app:8090 +} diff --git a/Docker/README.md b/Docker/README.md index ca75f2f..e6b220a 100644 --- a/Docker/README.md +++ b/Docker/README.md @@ -80,16 +80,47 @@ instead. PocketBase runs as root, so a root-owned host directory is fine. ## Charger control (OCPP) -Chargers in own/proxy mode dial in to `/ocpp/{serial}` **on the API Server -port**, authenticating with a per-charger control token in an OCPP Basic-auth -header. A plaintext `ws://` would put that token on the wire in the clear, so -`OCPP_REQUIRE_TLS` defaults to `true` and non-TLS connections are rejected. +Chargers in own/proxy mode dial in to `/ocpp/{serial}`, authenticating with a +per-charger control token in an OCPP Basic-auth header. Two ports answer that +path: the API Server's own, and the Web App's, whose BFF proxies `/ocpp/` +through. The second one matters because it is the address the panel hands out — +the endpoint is derived from the host the panel itself was reached on, which is +the Web App, unless `OCPP_PUBLIC_URL` says otherwise. -This stack serves plain HTTP, so to actually use charger control you need to -terminate TLS in a reverse proxy in front of it and set `OCPP_PUBLIC_URL` to the -public `wss://` base (behind a proxy, deriving it from request headers is -unreliable). `OCPP_REQUIRE_TLS=false` is for trusted networks only. You will -also need `API_BIND` set so the proxy can reach the port. +A plaintext `ws://` puts the control token on the wire in the clear, so +`OCPP_REQUIRE_TLS` defaults to `true` and non-TLS connections are rejected. +`OCPP_REQUIRE_TLS=false` is for a trusted network you own end to end. + +### With a public hostname and TLS + +`docker-compose.tls.yml` adds Caddy in front of the stack: one hostname, a +certificate issued on first boot, and everything behind it spoken to over the +compose network. Browsers and chargers arrive at the same name. + +```sh +# in .env +DV_DOMAIN=drivervault.example.com +DV_ACME_EMAIL=you@example.com + +docker compose -f docker-compose.prod.yml -f docker-compose.tls.yml up -d +``` + +The overlay sets the rest for you: `OCPP_PUBLIC_URL=wss://$DV_DOMAIN`, +`OCPP_REQUIRE_TLS=true`, `CORS_ALLOW_ORIGINS=https://$DV_DOMAIN`, and +`TRUST_FORWARDED_PROTO=true` on the Web App so the BFF passes Caddy's +`X-Forwarded-Proto` to the API Server instead of overwriting it with its own +plaintext hop. Point the charger's OCPP backend at the endpoint the panel then +shows, with the control token as its authorization key. + +Two things the overlay cannot arrange: `DV_DOMAIN` must resolve to the host from +the internet with ports 80 and 443 reaching it (Caddy needs `:80` for the ACME +challenge), and the charger must be able to resolve that name too — behind NAT +that usually means hairpin NAT or a split-DNS entry pointing it at the LAN +address. + +Using a proxy you already run instead? Terminate TLS there, forward to the Web +App port, and set the same four variables by hand — the `X-Forwarded-Proto` one +included, or chargers will be rejected as insecure. ## Notes diff --git a/Docker/docker-compose.tls.yml b/Docker/docker-compose.tls.yml new file mode 100644 index 0000000..6eb01f9 --- /dev/null +++ b/Docker/docker-compose.tls.yml @@ -0,0 +1,61 @@ +name: drivervault + +# TLS overlay — put a public hostname and a real certificate in front of the +# stack. Layer it on top of either base file: +# +# docker compose -f docker-compose.prod.yml -f docker-compose.tls.yml up -d +# +# What it changes: +# • Caddy terminates TLS on :443 and proxies everything to the Web App BFF, +# which already carries /api/ and /ocpp/ through to the API Server. One +# hostname serves browsers and chargers alike. +# • The charger endpoint the panel hands out becomes wss://$DV_DOMAIN/ocpp/…, +# so OCPP_REQUIRE_TLS goes back on and the control token stops crossing the +# network in the clear. +# +# Requirements, none of which this file can arrange for you: +# • DV_DOMAIN resolves to this host from the public internet, and ports 80 and +# 443 reach it (Caddy needs :80 for the ACME challenge, and keeps it for the +# redirect afterwards). +# • The charger can resolve DV_DOMAIN too. On a LAN behind NAT that usually +# means hairpin NAT or a split-DNS entry pointing the name at 10.2.1.10 — +# otherwise the charger looks up a public address it cannot route to. + +services: + caddy: + image: "${CADDY_IMAGE:-caddy:2-alpine}" + container_name: drivervault-caddy + restart: unless-stopped + depends_on: + web-app: + condition: service_healthy + environment: + DV_DOMAIN: "${DV_DOMAIN:?set DV_DOMAIN in .env}" + # Let's Encrypt sends expiry warnings here if renewal ever stops working. + DV_ACME_EMAIL: "${DV_ACME_EMAIL:?set DV_ACME_EMAIL in .env}" + ports: + - "80:80" + - "443:443" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + # Certificates live here. Keep the volume: wiping it re-issues on next + # boot and Let's Encrypt rate-limits that. + - caddy_data:/data + - caddy_config:/config + + api-server: + environment: + # Derived from the one hostname, so there is a single thing to set. + OCPP_REQUIRE_TLS: "true" + OCPP_PUBLIC_URL: "wss://${DV_DOMAIN}" + CORS_ALLOW_ORIGINS: "https://${DV_DOMAIN}" + + web-app: + environment: + # Caddy terminates TLS and says so in X-Forwarded-Proto. Believe it — + # that header is now set by the proxy in front, not by whoever dialed in. + TRUST_FORWARDED_PROTO: "true" + +volumes: + caddy_data: + caddy_config: