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>
163 lines
5.1 KiB
Go
163 lines
5.1 KiB
Go
// Command webapp is the Web App backend-for-frontend. It serves the embedded
|
|
// 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 (
|
|
"embed"
|
|
"io/fs"
|
|
"log"
|
|
"net/http"
|
|
"net/http/httputil"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
//go:embed all:dist
|
|
var distFS embed.FS
|
|
|
|
func getenv(key, def string) string {
|
|
if v := os.Getenv(key); v != "" {
|
|
return v
|
|
}
|
|
return def
|
|
}
|
|
|
|
func main() {
|
|
log.SetFlags(log.LstdFlags | log.Lmsgprefix)
|
|
log.SetPrefix("[web] ")
|
|
|
|
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/* 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)
|
|
}
|
|
|
|
// Embedded SPA file server.
|
|
sub, err := fs.Sub(distFS, "dist")
|
|
if err != nil {
|
|
log.Fatalf("embed dist: %v", err)
|
|
}
|
|
spa := http.FileServer(http.FS(sub))
|
|
|
|
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:
|
|
// without one the SPA catch-all below would answer with index.html, which
|
|
// looks healthy even when the proxy target is misconfigured.
|
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("ok"))
|
|
})
|
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
|
// Serve static assets when they exist; otherwise fall back to index.html
|
|
// so client-side routing works on deep links.
|
|
if r.URL.Path != "/" {
|
|
if f, err := sub.Open(strings.TrimPrefix(r.URL.Path, "/")); err == nil {
|
|
f.Close()
|
|
spa.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
}
|
|
r2 := r.Clone(r.Context())
|
|
r2.URL.Path = "/"
|
|
spa.ServeHTTP(w, r2)
|
|
})
|
|
|
|
srv := &http.Server{
|
|
Addr: addr,
|
|
Handler: logRequests(mux),
|
|
ReadHeaderTimeout: 10 * time.Second,
|
|
}
|
|
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()
|
|
next.ServeHTTP(w, r)
|
|
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start).Round(time.Millisecond))
|
|
})
|
|
}
|
|
|
|
// loadDotEnv loads KEY=VALUE pairs from a .env file if present.
|
|
func loadDotEnv(path string) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return
|
|
}
|
|
for _, line := range strings.Split(string(data), "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
k, v, ok := strings.Cut(line, "=")
|
|
if !ok {
|
|
continue
|
|
}
|
|
k = strings.TrimSpace(k)
|
|
v = strings.Trim(strings.TrimSpace(v), `"'`)
|
|
if _, exists := os.LookupEnv(k); !exists {
|
|
_ = os.Setenv(k, v)
|
|
}
|
|
}
|
|
}
|