// Command edgeguard-api serves the management REST API on // 127.0.0.1:9443. HAProxy (or a dev curl) terminates TLS in front of // it; this process is plain HTTP behind that. package main import ( "context" "crypto/rand" "log" "log/slog" "net/http" "os" "path/filepath" "strings" "time" "github.com/gin-gonic/gin" "github.com/jackc/pgx/v5/pgxpool" "git.netcell-it.de/projekte/edgeguard-native/internal/cluster" "git.netcell-it.de/projekte/edgeguard-native/internal/database" "git.netcell-it.de/projekte/edgeguard-native/internal/handlers" "git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response" "git.netcell-it.de/projekte/edgeguard-native/internal/services/audit" "git.netcell-it.de/projekte/edgeguard-native/internal/services/backends" "git.netcell-it.de/projekte/edgeguard-native/internal/services/domains" "git.netcell-it.de/projekte/edgeguard-native/internal/services/routingrules" "git.netcell-it.de/projekte/edgeguard-native/internal/services/session" "git.netcell-it.de/projekte/edgeguard-native/internal/services/setup" ) var version = "0.0.1-dev" func main() { addr := os.Getenv("EDGEGUARD_API_ADDR") if addr == "" { addr = "127.0.0.1:9443" } dataDir := os.Getenv("EDGEGUARD_DATA_DIR") if dataDir == "" { dataDir = setup.DefaultDir } setupStore := setup.NewStore(dataDir) signer, err := session.NewSignerFromPath("") if err != nil { // /var/lib/edgeguard not writable in dev → fall back to a // process-local secret so `go run` works without sudo. Tokens // won't survive a restart, which is fine for an unprivileged // developer machine. slog.Warn("session signer: persisted secret unavailable, using ephemeral", "error", err) signer = session.NewSigner(randomEphemeralSecret(), nil, 0) } gin.SetMode(gin.ReleaseMode) r := gin.New() r.Use(handlers.Recover()) // Health endpoints are mounted *before* SetupGate so they answer // 200 even on a virgin box. UI uses /api/v1/system/health for the // post-upgrade version-flip poll. r.GET("/healthz", func(c *gin.Context) { response.OK(c, gin.H{"status": "ok", "version": version}) }) r.GET("/api/health", func(c *gin.Context) { response.OK(c, gin.H{"status": "ok", "version": version}) }) // ACME HTTP-01 webroot — HAProxy proxies these through pre-setup // so certbot can issue the first cert. Webroot location matches // certbot's default; override via EDGEGUARD_ACME_WEBROOT for // dev/tests. acmeWebroot := os.Getenv("EDGEGUARD_ACME_WEBROOT") handlers.NewACMEHandler(acmeWebroot).Register(r) v1 := r.Group("/api/v1") v1.Use(handlers.SetupGate(setupStore)) requireAuth := handlers.RequireAuth(signer) handlers.NewSetupHandler(setupStore).Register(v1) handlers.NewSystemHandler(version).Register(v1) handlers.NewAuthHandler(setupStore, signer).Register(v1, requireAuth) // Open the DB pool best-effort. Without a reachable PG, CRUD // handlers stay unregistered and only Auth/Setup/System answer — // good enough for `go run` on a developer machine that has no // postgres-16 yet. pool, err := openDBBestEffort() if err != nil { slog.Warn("DB pool unavailable, CRUD endpoints disabled", "error", err) } else { slog.Info("DB pool open, registering CRUD handlers") nodeID, nodeErr := cluster.EnsureNodeID("") if nodeErr != nil { slog.Warn("node-id not persisted, using ephemeral", "id", nodeID, "error", nodeErr) } clusterStore := cluster.NewStore(pool) // Self-register in ha_nodes — only if setup is complete // (we want the operator-defined FQDN, not the OS hostname, // to land in api_url). Failures are logged but non-fatal. ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) st, _ := setupStore.Load() if st != nil && st.Completed { if _, err := cluster.EnsureSelfRegistered(ctx, clusterStore, st.FQDN, "primary"); err != nil { slog.Warn("self-register in ha_nodes failed", "error", err) } } cancel() auditRepo := audit.New(pool) domainsRepo := domains.New(pool) backendsRepo := backends.New(pool) routingRepo := routingrules.New(pool) authed := v1.Group("") authed.Use(requireAuth) handlers.NewDomainsHandler(domainsRepo, routingRepo, auditRepo, nodeID).Register(authed) handlers.NewBackendsHandler(backendsRepo, auditRepo, nodeID).Register(authed) handlers.NewRoutingRulesHandler(routingRepo, auditRepo, nodeID).Register(authed) handlers.NewClusterHandler(clusterStore, nodeID).Register(authed) } mountUI(r) log.Printf("edgeguard-api %s listening on %s", version, addr) srv := &http.Server{Addr: addr, Handler: r} if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("edgeguard-api: %v", err) } } // mountUI serves the management UI — Vite-built static assets under // /usr/share/edgeguard/ui/ — with SPA fallback (any path that isn't // /api/* or /healthz and isn't a real file → index.html). When the // dist directory is missing (dev box without `bun run build`), a // placeholder HTML page is served at /. func mountUI(r *gin.Engine) { uiDir := os.Getenv("EDGEGUARD_UI_DIR") if uiDir == "" { uiDir = "/usr/share/edgeguard/ui" } indexPath := filepath.Join(uiDir, "index.html") if _, err := os.Stat(indexPath); err != nil { slog.Warn("UI dist not found, serving placeholder", "ui_dir", uiDir, "error", err) r.NoRoute(func(c *gin.Context) { path := c.Request.URL.Path if isAPIPath(path) { c.Status(http.StatusNotFound) return } c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(uiPlaceholder)) }) return } r.NoRoute(func(c *gin.Context) { path := c.Request.URL.Path if isAPIPath(path) { c.Status(http.StatusNotFound) return } // Serve real file when one exists for the requested path. // filepath.Clean blocks `..` traversal; the join still pins // the result inside uiDir even with shenanigans. clean := filepath.Clean(path) if !strings.HasPrefix(clean, "/") { clean = "/" + clean } full := filepath.Join(uiDir, clean) if !strings.HasPrefix(full, uiDir) { c.Status(http.StatusForbidden) return } if info, err := os.Stat(full); err == nil && !info.IsDir() { c.File(full) return } // SPA fallback — React Router renders the right page. c.File(indexPath) }) } // isAPIPath returns true for paths the API owns; UI serves // everything else. /healthz and /api/health are technically API // surfaces but don't need to fall through to index.html either. func isAPIPath(p string) bool { return strings.HasPrefix(p, "/api/") || p == "/healthz" || p == "/api/health" } const uiPlaceholder = `
The management UI has not been built yet. From the project root, run:
cd management-ui && bun install && bun run build
Then the same URL will serve the React SPA. The REST API is fully functional at
/api/v1/* regardless.