// 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" "time" "github.com/gin-gonic/gin" "github.com/jackc/pgx/v5/pgxpool" "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}) }) 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 := nodeIDOrHostname() 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) } 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) } } // 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 }