aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-01-20 17:30:15 -0800
committerFuwn <[email protected]>2026-01-20 17:30:15 -0800
commit8b0f24948b3bfb68bf0eb284a5a984f55c9693d4 (patch)
tree23d60be9cb58f41f2c3c1f794567617818576328
parentfeat: Add dockerx command for multi-platform builds (diff)
downloadkaze-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.go21
-rw-r--r--config.example.yaml19
-rw-r--r--internal/server/server.go105
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: ![Status](https://status.example.com/api/badge/Website.svg)
+# Example: ![Status](https://status.example.com/api/badge/Services/Website.svg)
+# 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")