diff --git a/Docker-AIO/Dockerfile b/Docker-AIO/Dockerfile index 5ae9f06..b46b6c9 100644 --- a/Docker-AIO/Dockerfile +++ b/Docker-AIO/Dockerfile @@ -94,7 +94,7 @@ map $http_upgrade $connection_upgrade { } NGINXMAP -# nginx: serve the SPA and proxy /api/ to the API Server on localhost. +# nginx: serve the SPA and proxy /api/ + /ocpp/ to the API Server on localhost. RUN cat > /etc/nginx/http.d/default.conf <<'NGINX' server { listen 80; @@ -126,6 +126,24 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } + # The chargers' door, not the browser's. The API Server tells a charger to + # dial the host it was itself asked on — this one — so without this location + # the SPA fallback would answer the WebSocket handshake with index.html. + location /ocpp/ { + proxy_pass http://127.0.0.1:8080; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + # A charging session is idle between heartbeats; the default 60s would + # close it under the charger. + proxy_read_timeout 1h; + proxy_send_timeout 1h; + } + location /assets/ { expires 1y; add_header Cache-Control "public, immutable"; diff --git a/Docker/.env.example b/Docker/.env.example index 2c948a7..8ace10f 100644 --- a/Docker/.env.example +++ b/Docker/.env.example @@ -34,6 +34,15 @@ AUTH_USERS_COLLECTION=users # public wss:// base, or set OCPP_REQUIRE_TLS=false on a trusted network. OCPP_REQUIRE_TLS=true OCPP_PUBLIC_URL= +# The charger can dial either door: the API Server port directly, or the Web +# App port, whose BFF now proxies /ocpp/ through to it. The endpoint the panel +# shows is the API Server port only when OCPP_PUBLIC_URL says so — left blank it +# is whichever host the panel itself was reached on, which is the Web App. +# TRUST_FORWARDED_PROTO lets the BFF pass an inbound X-Forwarded-Proto to the +# API Server: needed when TLS ends at a proxy in front of the stack and +# OCPP_REQUIRE_TLS stays on, and unsafe otherwise, since the header is then +# whatever the client said it was. +TRUST_FORWARDED_PROTO=false # --- Host port mappings (optional; defaults shown) -------------------------- PB_PORT=8070 diff --git a/Docker/.env.prod.example b/Docker/.env.prod.example index 52250e2..c73cb4f 100644 --- a/Docker/.env.prod.example +++ b/Docker/.env.prod.example @@ -47,6 +47,15 @@ AUTH_USERS_COLLECTION=users # networks only. OCPP_REQUIRE_TLS=true OCPP_PUBLIC_URL= +# The charger can dial either door: the API Server port directly, or the Web +# App port, whose BFF now proxies /ocpp/ through to it. The endpoint the panel +# shows is the API Server port only when OCPP_PUBLIC_URL says so — left blank it +# is whichever host the panel itself was reached on, which is the Web App. +# TRUST_FORWARDED_PROTO lets the BFF pass an inbound X-Forwarded-Proto to the +# API Server: needed when TLS ends at a proxy in front of the stack and +# OCPP_REQUIRE_TLS stays on, and unsafe otherwise, since the header is then +# whatever the client said it was. +TRUST_FORWARDED_PROTO=false # --- Ports ------------------------------------------------------------------- # WEB_PORT is the public front door (bound on all interfaces). diff --git a/Docker/docker-compose.prod.yml b/Docker/docker-compose.prod.yml index c9e89ce..33f7d2e 100644 --- a/Docker/docker-compose.prod.yml +++ b/Docker/docker-compose.prod.yml @@ -107,8 +107,15 @@ services: api-server: condition: service_healthy environment: - # The BFF reverse-proxies /api/* to the API Server over the internal network. + # The BFF reverse-proxies /api/* — and /ocpp/*, the address chargers are + # told to dial — to the API Server over the internal network. API_BASE: "http://api-server:8080" + # Believe an inbound X-Forwarded-Proto. The API Server reads it to decide a + # charger arrived over TLS, so leave this off unless a TLS-terminating + # proxy in front of the stack is the only way in: otherwise a charger could + # claim wss over a plaintext connection. Set it to true when TLS ends at + # that proxy and OCPP_PUBLIC_URL names a wss:// base through it. + TRUST_FORWARDED_PROTO: "${TRUST_FORWARDED_PROTO:-false}" ports: # The public front door. Bound on all interfaces so browsers can reach it. - "${WEB_PORT:-8090}:8090" diff --git a/Docker/docker-compose.yml b/Docker/docker-compose.yml index e041f05..d64fd9c 100644 --- a/Docker/docker-compose.yml +++ b/Docker/docker-compose.yml @@ -105,8 +105,15 @@ services: api-server: condition: service_healthy environment: - # The BFF reverse-proxies /api/* to the API Server over the internal network. + # The BFF reverse-proxies /api/* — and /ocpp/*, the address chargers are + # told to dial — to the API Server over the internal network. API_BASE: "http://api-server:8080" + # Believe an inbound X-Forwarded-Proto. The API Server reads it to decide a + # charger arrived over TLS, so leave this off unless a TLS-terminating + # proxy in front of the stack is the only way in: otherwise a charger could + # claim wss over a plaintext connection. Set it to true when TLS ends at + # that proxy and OCPP_PUBLIC_URL names a wss:// base through it. + TRUST_FORWARDED_PROTO: "${TRUST_FORWARDED_PROTO:-false}" ports: - "${WEB_PORT:-8090}:8090" healthcheck: diff --git a/Web App/server/main.go b/Web App/server/main.go index 71dc75d..d31fd60 100644 --- a/Web App/server/main.go +++ b/Web App/server/main.go @@ -2,6 +2,13 @@ // Vue single-page app and reverse-proxies /api/* to the API Server, so the // browser only ever talks to this server (same-origin) and all data access // still flows through the API Server. +// +// /ocpp/* is proxied too, for the chargers rather than the browser. The API +// Server hands a charger the endpoint to dial back on, and derives it from the +// Host of the request that asked — which, since the panel asks through this +// proxy, is this server. Without the route that address answered a WebSocket +// handshake with index.html, so the charger could never connect to the address +// it had been given. package main import ( @@ -33,16 +40,37 @@ func main() { loadDotEnv(".env") addr := getenv("WEB_ADDR", ":8090") apiBase := strings.TrimRight(getenv("API_BASE", "http://localhost:8080"), "/") + // Whether an inbound X-Forwarded-Proto is believed. The API Server reads + // that header to decide a charger reached it over TLS, so a client that can + // set it freely could talk plaintext OCPP into a server configured to demand + // wss. It is therefore overwritten with this server's own scheme unless the + // operator says there is a TLS-terminating proxy in front worth trusting. + trustForwardedProto := getenv("TRUST_FORWARDED_PROTO", "") == "true" apiURL, err := url.Parse(apiBase) if err != nil { log.Fatalf("invalid API_BASE %q: %v", apiBase, err) } - // Reverse proxy: /api/* -> API Server (path preserved). + // Reverse proxy: /api/* and /ocpp/* -> API Server (path preserved). proxy := httputil.NewSingleHostReverseProxy(apiURL) + director := proxy.Director + proxy.Director = func(r *http.Request) { + director(r) + // Say how the client reached *this* server. ReverseProxy relays a 101 by + // hijacking the connection, so the OCPP upgrade survives the hop; what it + // cannot tell the API Server on its own is the scheme. + if !trustForwardedProto || r.Header.Get("X-Forwarded-Proto") == "" { + r.Header.Set("X-Forwarded-Proto", forwardedProto(r)) + } + } proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, e error) { log.Printf("proxy error for %s: %v", r.URL.Path, e) + if strings.HasPrefix(r.URL.Path, "/ocpp/") { + // A charger is not reading JSON. Say it plainly and briefly. + http.Error(w, "api server unavailable", http.StatusBadGateway) + return + } http.Error(w, `{"error":"api server unavailable"}`, http.StatusBadGateway) } @@ -55,6 +83,9 @@ func main() { mux := http.NewServeMux() mux.Handle("/api/", proxy) + // The chargers' door. Registered explicitly so the SPA catch-all below never + // answers a WebSocket handshake with a web page. + mux.Handle("/ocpp/", proxy) // Liveness probe. The API Server polls this for the panel status page (see // WEBAPP_URL), and container healthchecks use it. It must be a real route: @@ -85,12 +116,20 @@ func main() { Handler: logRequests(mux), ReadHeaderTimeout: 10 * time.Second, } - log.Printf("listening on %s (proxying /api -> %s)", addr, apiBase) + log.Printf("listening on %s (proxying /api and /ocpp -> %s)", addr, apiBase) if err := srv.ListenAndServe(); err != nil { log.Fatalf("server error: %v", err) } } +// forwardedProto reports the scheme this server was reached on. +func forwardedProto(r *http.Request) string { + if r.TLS != nil { + return "https" + } + return "http" +} + func logRequests(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now()