diff options
| author | Fuwn <[email protected]> | 2026-01-20 17:30:15 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-01-20 17:30:15 -0800 |
| commit | 8b0f24948b3bfb68bf0eb284a5a984f55c9693d4 (patch) | |
| tree | 23d60be9cb58f41f2c3c1f794567617818576328 | |
| parent | feat: Add dockerx command for multi-platform builds (diff) | |
| download | kaze-8b0f24948b3bfb68bf0eb284a5a984f55c9693d4.tar.xz kaze-8b0f24948b3bfb68bf0eb284a5a984f55c9693d4.zip | |
feat: Add POST /api/reload endpoint for config reload
- Add /api/reload endpoint that triggers configuration reload
- Always requires API key authentication (even if api.access is public)
- Returns error if no API keys are configured
- Update config.example.yaml with new endpoint and {group}/{name} format docs
| -rw-r--r-- | cmd/kaze/main.go | 21 | ||||
| -rw-r--r-- | config.example.yaml | 19 | ||||
| -rw-r--r-- | internal/server/server.go | 105 |
3 files changed, 107 insertions, 38 deletions
diff --git a/cmd/kaze/main.go b/cmd/kaze/main.go index a71c03e..f4f61ac 100644 --- a/cmd/kaze/main.go +++ b/cmd/kaze/main.go @@ -131,20 +131,21 @@ func main() { logger.Info("kaze is running", "address", fmt.Sprintf("http://%s:%d", cfg.Server.Host, cfg.Server.Port)) - // Reload function - reloadConfig := func() { + // Reload function (returns error for API endpoint) + var reloadConfig func() error + reloadConfig = func() error { logger.Info("reloading configuration...") newCfg, err := config.Load(*configPath) if err != nil { logger.Error("failed to reload configuration", "error", err) - return + return fmt.Errorf("failed to load configuration: %w", err) } // Validate the new configuration if len(newCfg.Groups) == 0 { logger.Error("invalid configuration: no monitor groups defined") - return + return fmt.Errorf("invalid configuration: no monitor groups defined") } // Stop current scheduler @@ -160,7 +161,7 @@ func main() { logger.Error("failed to create new scheduler", "error", err) // Restart old scheduler sched.Start() - return + return fmt.Errorf("failed to create new scheduler: %w", err) } // Replace scheduler @@ -184,11 +185,12 @@ func main() { logger.Error("server error", "error", err) } }() - return + return fmt.Errorf("failed to create new server: %w", err) } - // Replace server + // Replace server and set reload func on new server srv = newSrv + srv.SetReloadFunc(reloadConfig) // Start new server go func() { @@ -201,8 +203,13 @@ func main() { logger.Info("configuration reloaded successfully", "groups", len(cfg.Groups), "incidents", len(cfg.Incidents)) + + return nil } + // Set reload function on server for API endpoint + srv.SetReloadFunc(reloadConfig) + // Wait for shutdown signal or config changes for { select { diff --git a/config.example.yaml b/config.example.yaml index 91d17e9..1aa725a 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -370,18 +370,27 @@ incidents: # GET /api/health - Simple health check (always public) # Returns: {"status": "ok"} # GET /api/status - All monitors status JSON -# GET /api/monitor/{name} - Single monitor status JSON -# GET /api/history/{name} - Monitor history (supports ?mode=ping|minute|hour|day&count=N) +# GET /api/monitor/{group}/{name} +# - Single monitor status JSON +# GET /api/history/{group}/{name} +# - Monitor history (supports ?mode=ping|minute|hour|day&count=N) # GET /api/page - Full page data (monitors + history + status) in one request # Note: Always public when refresh_mode is "api" # GET /api/summary - Lightweight status overview (counts + overall status, no history) -# GET /api/uptime/{name} - Historical uptime stats for a monitor +# GET /api/uptime/{group}/{name} +# - Historical uptime stats for a monitor # Supports ?period=1h|24h|7d|30d|90d (default: 24h) # GET /api/incidents - List incidents from config # Supports ?filter=all|active|resolved|scheduled (default: all) -# GET /api/badge/{name}.svg - SVG status badge (always public, shields.io style) +# GET /api/badge/{group}/{name}.svg +# - SVG status badge (always public, shields.io style) # Supports: ?label=custom&style=flat|plastic&type=status|uptime -# Example:  +# Example:  +# POST /api/reload - Reload configuration (always requires API key) +# Returns: {"status": "ok", "message": "Configuration reloaded successfully"} +# +# Note: Monitor endpoints use {group}/{name} path format. URL-encode slashes in names. +# Example: /api/monitor/Services/API or /api/monitor/A%2FB%20Tests/Variant%20A # # Authentication (when access is "authenticated"): # - Header: X-API-Key: your-secret-key diff --git a/internal/server/server.go b/internal/server/server.go index b47bf79..ab9ff24 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -28,14 +28,18 @@ var templatesFS embed.FS //go:embed static/* var staticFS embed.FS +// ReloadFunc is a callback function for reloading configuration +type ReloadFunc func() error + // Server handles HTTP requests for the status page type Server struct { - config *config.Config - storage *storage.Storage - scheduler *monitor.Scheduler - logger *slog.Logger - server *http.Server - templates *template.Template + config *config.Config + storage *storage.Storage + scheduler *monitor.Scheduler + logger *slog.Logger + server *http.Server + templates *template.Template + reloadConfig ReloadFunc } // New creates a new HTTP server @@ -89,6 +93,9 @@ func New(cfg *config.Config, store *storage.Storage, sched *monitor.Scheduler, l mux.HandleFunc("GET /api/page", s.withAPIAuth(s.handleAPIPage)) } + // Config reload endpoint - always requires authentication + mux.HandleFunc("POST /api/reload", s.withStrictAuth(s.handleAPIReload)) + // Create HTTP server s.server = &http.Server{ Addr: fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port), @@ -145,39 +152,59 @@ func (s *Server) withAPIAuth(handler http.HandlerFunc) http.HandlerFunc { return case "authenticated": - // Check for API key in header or query param - apiKey := r.Header.Get("X-API-Key") - if apiKey == "" { - apiKey = r.URL.Query().Get("api_key") - } - - if apiKey == "" { + if !s.checkAPIKey(r) { w.Header().Set("WWW-Authenticate", "API-Key") s.jsonError(w, "API key required", http.StatusUnauthorized) return } - // Check if key is valid - valid := false - for _, key := range s.config.API.Keys { - if key == apiKey { - valid = true - break - } - } + // case "public" or default: allow access + } - if !valid { - s.jsonError(w, "Invalid API key", http.StatusUnauthorized) - return - } + handler(w, r) + } +} - // case "public" or default: allow access +// withStrictAuth wraps an API handler that always requires authentication, +// regardless of the api.access setting. Used for sensitive operations like config reload. +func (s *Server) withStrictAuth(handler http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Always require API key, even if api.access is "public" + if len(s.config.API.Keys) == 0 { + s.jsonError(w, "No API keys configured. Add keys to api.keys in config to use this endpoint.", http.StatusForbidden) + return + } + + if !s.checkAPIKey(r) { + w.Header().Set("WWW-Authenticate", "API-Key") + s.jsonError(w, "API key required", http.StatusUnauthorized) + return } handler(w, r) } } +// checkAPIKey validates the API key from request header or query parameter +func (s *Server) checkAPIKey(r *http.Request) bool { + apiKey := r.Header.Get("X-API-Key") + if apiKey == "" { + apiKey = r.URL.Query().Get("api_key") + } + + if apiKey == "" { + return false + } + + for _, key := range s.config.API.Keys { + if key == apiKey { + return true + } + } + + return false +} + // PageData contains data for rendering the status page type PageData struct { Site config.SiteConfig @@ -991,6 +1018,32 @@ func (s *Server) handleAPIIncidents(w http.ResponseWriter, r *http.Request) { }) } +// SetReloadFunc sets the callback function for reloading configuration +func (s *Server) SetReloadFunc(fn ReloadFunc) { + s.reloadConfig = fn +} + +// handleAPIReload triggers a configuration reload (always requires authentication) +func (s *Server) handleAPIReload(w http.ResponseWriter, r *http.Request) { + if s.reloadConfig == nil { + s.jsonError(w, "Reload function not configured", http.StatusServiceUnavailable) + return + } + + s.logger.Info("config reload triggered via API", "remote_addr", r.RemoteAddr) + + if err := s.reloadConfig(); err != nil { + s.logger.Error("config reload failed", "error", err) + s.jsonError(w, fmt.Sprintf("Reload failed: %v", err), http.StatusInternalServerError) + return + } + + s.jsonResponse(w, map[string]string{ + "status": "ok", + "message": "Configuration reloaded successfully", + }) +} + // jsonResponse writes a JSON response func (s *Server) jsonResponse(w http.ResponseWriter, data interface{}) { w.Header().Set("Content-Type", "application/json") |