171 lines
5.5 KiB
Go
171 lines
5.5 KiB
Go
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)
|
|
}
|