// 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" firewallrender "git.netcell-it.de/projekte/edgeguard-native/internal/firewall" "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/acme" "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/firewall" "git.netcell-it.de/projekte/edgeguard-native/internal/services/ipaddresses" "git.netcell-it.de/projekte/edgeguard-native/internal/services/networkifs" "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" "git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts" ) 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) ifsRepo := networkifs.New(pool) ipsRepo := ipaddresses.New(pool) tlsRepo := tlscerts.New(pool) fwAddrObj := firewall.NewAddressObjectsRepo(pool) fwAddrGrp := firewall.NewAddressGroupsRepo(pool) fwSvc := firewall.NewServicesRepo(pool) fwSvcGrp := firewall.NewServiceGroupsRepo(pool) fwRules := firewall.NewRulesRepo(pool) fwNAT := firewall.NewNATRulesRepo(pool) // ACME (Let's Encrypt). Email comes from setup.json — the // wizard collects acme_email and the issuer registers an // account on first /tls-certs/issue call. var acmeService handlers.LetsEncryptIssuer if st != nil && st.ACMEEmail != "" { acmeService = acme.New(st.ACMEEmail) } 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.NewNetworksHandler(ifsRepo, ipsRepo, auditRepo, nodeID).Register(authed) handlers.NewIPAddressesHandler(ipsRepo, auditRepo, nodeID).Register(authed) handlers.NewClusterHandler(clusterStore, nodeID).Register(authed) handlers.NewTLSCertsHandler(tlsRepo, auditRepo, nodeID, acmeService).Register(authed) // Firewall reload: nach jeder Mutation den Renderer neu fahren // (writes ruleset.nft + sudo nft -f). Errors loggen, nicht failen. fwReloader := func(ctx context.Context) error { return firewallrender.New(pool).Render(ctx) } handlers.NewFirewallHandler(fwAddrObj, fwAddrGrp, fwSvc, fwSvcGrp, fwRules, fwNAT, auditRepo, nodeID, fwReloader).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 = ` EdgeGuard

EdgeGuard

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.

` // openDBBestEffort opens the pool with a 3s timeout. Returns the // non-nil error so callers can decide whether to register CRUD or // degrade gracefully. func openDBBestEffort() (*pgxpoolPool, error) { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() dsn := database.ConnStringFromEnv() return database.Open(ctx, dsn) } // pgxpoolPool aliases the concrete pool type so we don't import it in // main.go on every platform — keeps the import block lean. type pgxpoolPool = pgxpool.Pool // nodeIDOrHostname returns the node identifier audit_log entries are // stamped with. v1 just uses /etc/machine-id (or the hostname on dev // machines without one). Phase 3's cluster store will replace this. func nodeIDOrHostname() string { if b, err := os.ReadFile("/etc/machine-id"); err == nil { s := string(b) s = stripTrailingNewline(s) if s != "" { return s } } if h, err := os.Hostname(); err == nil { return h } return "unknown" } func stripTrailingNewline(s string) string { for len(s) > 0 && (s[len(s)-1] == '\n' || s[len(s)-1] == '\r') { s = s[:len(s)-1] } return s } // randomEphemeralSecret is the fallback for dev environments where // /var/lib/edgeguard isn't writable. Tokens issued with this secret // die on restart — production reads/writes the persistent file via // session.NewSignerFromPath. func randomEphemeralSecret() []byte { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { // Should never happen on a sane Linux box; fall back to a // time-based filler so the process can at least start. log.Printf("WARN: crypto/rand read failed: %v", err) } return b }