The address chargers are given is now an address that answers

The API Server tells a charger to dial the host it was itself asked on.
The panel asks through the Web App, so the address handed out is the Web
App's — which proxied /api/ and nothing else, and answered the WebSocket
handshake at /ocpp/ with index.html. A charger pointed at the endpoint the
screen showed could never connect to it, and the screen went on saying
"Not connected" without a hint as to why.

Both front doors now carry /ocpp/ through to the API Server: the BFF via
the same reverse proxy, which relays the 101 by hijacking, and the
all-in-one image's nginx via a location of its own, with timeouts long
enough for a session that is idle between heartbeats.

The proxied hop also has to say how the client arrived, since the API
Server reads X-Forwarded-Proto to decide a charger reached it over TLS.
That header is set from this server's own connection and overwrites
whatever came in: believing a client on that point would let a plaintext
charger claim wss and walk past OCPP_REQUIRE_TLS. TRUST_FORWARDED_PROTO
opts into the inbound value for the one deployment where it is true — TLS
ending at a proxy in front of the stack.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 5 committed 2026-09-01 14:31:24 +02:00
1 parent 019c28db85
commit 9f5c8dc49a
6 files changed
+94 -5

No files matched your search

+41 -2
View File
@@ -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()