One hostname a charger can be told about and actually reach
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9f5c8dc49a
commit
e9a82a1cca
@@ -66,3 +66,11 @@ VITE_API_BASE=
|
|||||||
# CORS_ALLOW_ORIGINS browser origins allowed to call the API Server directly.
|
# CORS_ALLOW_ORIGINS browser origins allowed to call the API Server directly.
|
||||||
# POCKETBASE_URL=http://pocketbase:8070
|
# POCKETBASE_URL=http://pocketbase:8070
|
||||||
# WEBAPP_URL=http://web-app:8090
|
# 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=
|
||||||
|
|||||||
@@ -88,3 +88,11 @@ API_BIND=127.0.0.1
|
|||||||
# stack: the API Server keeps no state on disk, so everything it owns (plugin
|
# 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.
|
# settings included) is backed up by backing up this one path.
|
||||||
PB_DATA=pb_data
|
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=
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
+40
-9
@@ -80,16 +80,47 @@ instead. PocketBase runs as root, so a root-owned host directory is fine.
|
|||||||
|
|
||||||
## Charger control (OCPP)
|
## Charger control (OCPP)
|
||||||
|
|
||||||
Chargers in own/proxy mode dial in to `/ocpp/{serial}` **on the API Server
|
Chargers in own/proxy mode dial in to `/ocpp/{serial}`, authenticating with a
|
||||||
port**, authenticating with a per-charger control token in an OCPP Basic-auth
|
per-charger control token in an OCPP Basic-auth header. Two ports answer that
|
||||||
header. A plaintext `ws://` would put that token on the wire in the clear, so
|
path: the API Server's own, and the Web App's, whose BFF proxies `/ocpp/`
|
||||||
`OCPP_REQUIRE_TLS` defaults to `true` and non-TLS connections are rejected.
|
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
|
A plaintext `ws://` puts the control token on the wire in the clear, so
|
||||||
terminate TLS in a reverse proxy in front of it and set `OCPP_PUBLIC_URL` to the
|
`OCPP_REQUIRE_TLS` defaults to `true` and non-TLS connections are rejected.
|
||||||
public `wss://` base (behind a proxy, deriving it from request headers is
|
`OCPP_REQUIRE_TLS=false` is for a trusted network you own end to end.
|
||||||
unreliable). `OCPP_REQUIRE_TLS=false` is for trusted networks only. You will
|
|
||||||
also need `API_BIND` set so the proxy can reach the port.
|
### 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
|
## Notes
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
Reference in New Issue
Block a user