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:
co-authored by
Claude Opus 5
parent
019c28db85
commit
9f5c8dc49a
+19
-1
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
+41
-2
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user