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.
|
||||
# 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=
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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