package api import ( "embed" "fmt" "io/fs" "net/http" "os" "time" "github.com/go-chi/chi/v5" "github.com/johanj/clavitor/lib" ) // NewRouter creates the main router with all routes registered. func NewRouter(cfg *lib.Config, webFS embed.FS) *chi.Mux { r := chi.NewRouter() h := NewHandlers(cfg) // Global middleware r.Use(LoggingMiddleware) r.Use(CORSMiddleware) r.Use(SecurityHeadersMiddleware) r.Use(RateLimitMiddleware(120)) // 120 req/min per IP r.Use(L1Middleware(cfg.DataDir)) // stateless: extract L1 from Bearer, open DB, forget // Health check (unauthenticated — no Bearer needed) r.Get("/health", h.Health) // Ping — minimal latency probe for looking glass (no DB, no auth) node, _ := os.Hostname() r.Get("/ping", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Origin", "*") body := fmt.Sprintf(`{"ok":true,"node":"%s","ts":%d}`, node, time.Now().Unix()) w.Write([]byte(body)) }) // Auth endpoints (unauthenticated — no Bearer, DB found by glob) r.Get("/api/auth/status", h.AuthStatus) r.Post("/api/auth/register/begin", h.AuthRegisterBegin) r.Post("/api/auth/register/complete", h.AuthRegisterComplete) r.Post("/api/auth/login/begin", h.AuthLoginBegin) r.Post("/api/auth/login/complete", h.AuthLoginComplete) // Legacy setup (only works when no credentials exist — for tests) r.Post("/api/auth/setup", h.Setup) // API routes (authenticated — L1 in Bearer, already validated by L1Middleware) r.Route("/api", func(r chi.Router) { mountAPIRoutes(r, h) }) // --- Vault App UI at /app/* --- appRoot, err := fs.Sub(webFS, "web") if err == nil { appServer := http.FileServer(http.FS(appRoot)) r.Get("/app", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/app/", http.StatusMovedPermanently) }) r.Handle("/app/*", http.StripPrefix("/app", appServer)) } // --- Root-level: minimal, disclose nothing --- // Legitimate browser/crawler requests get a fast, empty response. // Everything else hits the tarpit (30s slow drain). favicon, _ := fs.ReadFile(webFS, "web/favicon.svg") serveFavicon := func(w http.ResponseWriter, r *http.Request) { if favicon != nil { w.Header().Set("Content-Type", "image/svg+xml") w.Header().Set("Cache-Control", "public, max-age=86400") w.Write(favicon) } else { w.WriteHeader(204) } } nothing := func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(204) } disallow := func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain") w.Write([]byte("User-agent: *\nDisallow: /\n")) } r.Get("/", nothing) r.Get("/favicon.ico", serveFavicon) r.Get("/favicon.svg", serveFavicon) r.Get("/robots.txt", disallow) r.Get("/sitemap.xml", nothing) r.Get("/sitemap.xml.gz", nothing) r.Get("/sitemap-index.xml", nothing) r.Get("/ads.txt", nothing) r.Get("/app-ads.txt", nothing) r.Get("/manifest.json", nothing) r.Get("/browserconfig.xml", nothing) r.Get("/crossdomain.xml", nothing) r.Get("/humans.txt", nothing) r.Get("/security.txt", nothing) r.Get("/apple-touch-icon.png", nothing) r.Get("/apple-touch-icon-precomposed.png", nothing) r.Get("/.well-known/security.txt", nothing) r.Get("/.well-known/acme-challenge/*", nothing) r.Get("/.well-known/change-password", nothing) r.Get("/.well-known/openid-configuration", nothing) r.Get("/.well-known/webfinger", nothing) r.Get("/.well-known/assetlinks.json", nothing) r.Get("/.well-known/apple-app-site-association", nothing) r.Get("/.well-known/mta-sts.txt", nothing) r.Get("/.well-known/nodeinfo", nothing) // Tarpit: everything not registered above. // Hold the connection for 30s, drip slowly, waste scanner resources. r.NotFound(tarpitHandler) r.MethodNotAllowed(tarpitHandler) return r } // mountAPIRoutes registers the authenticated API handlers on the given router. func mountAPIRoutes(r chi.Router, h *Handlers) { // Vault info (for Tokens page config snippets) r.Get("/vault-info", h.VaultInfo) // Entries CRUD r.Get("/entries", h.ListEntries) r.Post("/entries", h.CreateEntry) r.Put("/entries", h.UpsertEntry) r.Get("/entries/{id}", h.GetEntry) r.Put("/entries/{id}", h.UpdateEntry) r.Delete("/entries/{id}", h.DeleteEntry) // Search r.Get("/search", h.SearchEntries) // Password generator r.Get("/generate", h.GeneratePassword) // Audit log r.Get("/audit", h.GetAuditLog) // Extension API r.Get("/ext/totp/{id}", h.GetTOTP) r.Get("/ext/match", h.MatchURL) // Backups r.Get("/backups", h.ListBackups) r.Post("/backups", h.CreateBackup) r.Post("/backups/restore", h.RestoreBackup) // Agent management (owner-only — handlers reject agent tokens) r.Post("/agents", h.HandleCreateAgent) r.Get("/agents", h.HandleListAgents) r.Get("/agents/{id}", h.HandleGetAgent) r.Put("/agents/{id}", h.HandleUpdateAgent) r.Delete("/agents/{id}", h.HandleDeleteAgent) // Entry scope management (owner-only) r.Put("/entries/{id}/scopes", h.HandleUpdateEntryScopes) // Vault lock r.Get("/vault-lock", h.HandleVaultLockStatus) r.Post("/vault-unlock", h.HandleVaultUnlock) // WebAuthn r.Post("/webauthn/register/begin", h.HandleWebAuthnRegisterBegin) r.Post("/webauthn/register/complete", h.HandleWebAuthnRegisterComplete) r.Post("/webauthn/auth/begin", h.HandleWebAuthnAuthBegin) r.Post("/webauthn/auth/complete", h.HandleWebAuthnAuthComplete) r.Get("/webauthn/credentials", h.HandleListWebAuthnCredentials) r.Delete("/webauthn/credentials/{id}", h.HandleDeleteWebAuthnCredential) }