feat(18-01): implement chi server, auth middleware, overview handler

- Server struct with chi router, embedded template parsing, static file serving
- AuthMiddleware supports Basic and Bearer token with constant-time comparison
- Overview handler renders stats from providers/recon/storage when available
- Nil-safe: works with zero config (shows zeroes, no DB required)
- All 7 tests pass
This commit is contained in:
salvacybersec
2026-04-06 18:02:41 +03:00
parent 3541c82448
commit 268a769efb
3 changed files with 194 additions and 0 deletions

52
pkg/web/auth.go Normal file
View File

@@ -0,0 +1,52 @@
package web
import (
"crypto/subtle"
"net/http"
"strings"
)
// AuthMiddleware returns HTTP middleware that enforces authentication when
// at least one of user/pass or token is configured. If all auth fields are
// empty, the middleware is a no-op passthrough.
//
// Supported schemes:
// - Bearer <token> — matches the configured token
// - Basic <base64> — matches the configured user:pass
func AuthMiddleware(user, pass, token string) func(http.Handler) http.Handler {
authEnabled := user != "" || token != ""
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !authEnabled {
next.ServeHTTP(w, r)
return
}
auth := r.Header.Get("Authorization")
// Check bearer token first.
if token != "" && strings.HasPrefix(auth, "Bearer ") {
provided := strings.TrimPrefix(auth, "Bearer ")
if subtle.ConstantTimeCompare([]byte(provided), []byte(token)) == 1 {
next.ServeHTTP(w, r)
return
}
}
// Check basic auth.
if user != "" {
u, p, ok := r.BasicAuth()
if ok &&
subtle.ConstantTimeCompare([]byte(u), []byte(user)) == 1 &&
subtle.ConstantTimeCompare([]byte(p), []byte(pass)) == 1 {
next.ServeHTTP(w, r)
return
}
}
w.Header().Set("WWW-Authenticate", `Basic realm="keyhunter"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
})
}
}

58
pkg/web/handlers.go Normal file
View File

@@ -0,0 +1,58 @@
package web
import (
"log"
"net/http"
"github.com/salvacybersec/keyhunter/pkg/storage"
)
// OverviewData holds template data for the overview dashboard page.
type OverviewData struct {
TotalKeys int
TotalProviders int
ReconSources int
LastScan string
RecentFindings []storage.Finding
PageTitle string
}
// handleOverview renders the overview dashboard page with aggregated stats.
func (s *Server) handleOverview(w http.ResponseWriter, r *http.Request) {
data := OverviewData{
PageTitle: "Overview",
}
// Provider count.
if s.cfg.Providers != nil {
data.TotalProviders = s.cfg.Providers.Stats().Total
}
// Recon source count.
if s.cfg.ReconEngine != nil {
data.ReconSources = len(s.cfg.ReconEngine.List())
}
// Recent findings + total count from DB.
if s.cfg.DB != nil && s.cfg.EncKey != nil {
recent, err := s.cfg.DB.ListFindingsFiltered(s.cfg.EncKey, storage.Filters{Limit: 10})
if err != nil {
log.Printf("web: listing recent findings: %v", err)
} else {
data.RecentFindings = recent
}
// Total count via a broader query.
all, err := s.cfg.DB.ListFindingsFiltered(s.cfg.EncKey, storage.Filters{})
if err != nil {
log.Printf("web: counting findings: %v", err)
} else {
data.TotalKeys = len(all)
}
}
if err := s.tmpl.ExecuteTemplate(w, "layout", data); err != nil {
log.Printf("web: rendering overview: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}

84
pkg/web/server.go Normal file
View File

@@ -0,0 +1,84 @@
package web
import (
"fmt"
"html/template"
"io/fs"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/salvacybersec/keyhunter/pkg/dorks"
"github.com/salvacybersec/keyhunter/pkg/providers"
"github.com/salvacybersec/keyhunter/pkg/recon"
"github.com/salvacybersec/keyhunter/pkg/storage"
)
// Config holds all dependencies and settings needed by the web server.
type Config struct {
DB *storage.DB
EncKey []byte
Providers *providers.Registry
Dorks *dorks.Registry
ReconEngine *recon.Engine
Port int
AuthUser string
AuthPass string
AuthToken string
}
// Server is the KeyHunter web dashboard backed by chi.
type Server struct {
router chi.Router
cfg Config
tmpl *template.Template
}
// NewServer creates a Server, parsing embedded templates and building routes.
func NewServer(cfg Config) (*Server, error) {
// Parse all templates from the embedded filesystem.
tmpl, err := template.ParseFS(templateFiles, "templates/*.html")
if err != nil {
return nil, fmt.Errorf("parsing templates: %w", err)
}
s := &Server{
cfg: cfg,
tmpl: tmpl,
}
r := chi.NewRouter()
// Middleware stack.
r.Use(middleware.RealIP)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
// Auth middleware (no-op when auth fields are empty).
r.Use(AuthMiddleware(cfg.AuthUser, cfg.AuthPass, cfg.AuthToken))
// Static file serving from embedded FS.
staticSub, err := fs.Sub(staticFiles, "static")
if err != nil {
return nil, fmt.Errorf("creating static sub-filesystem: %w", err)
}
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))))
// Page routes.
r.Get("/", s.handleOverview)
s.router = r
return s, nil
}
// Router returns the chi router for testing.
func (s *Server) Router() chi.Router {
return s.router
}
// ListenAndServe starts the HTTP server on the configured port.
func (s *Server) ListenAndServe() error {
addr := fmt.Sprintf(":%d", s.cfg.Port)
return http.ListenAndServe(addr, s.router)
}