// 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) } } }