This commit is contained in:
h z
2026-02-15 08:49:27 +00:00
commit 72843442ac
21 changed files with 1450 additions and 0 deletions

265
server/handlers.go Normal file
View File

@@ -0,0 +1,265 @@
package server
import (
"encoding/json"
"io"
"net/http"
"os"
"AbstractWizard/config"
)
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request) {
relPath := r.PathValue("path")
fullPath, err := config.FullPath(s.cfg.ConfigDir, relPath)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
data, err := os.ReadFile(fullPath)
if os.IsNotExist(err) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "file not found"})
return
}
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to read file"})
return
}
format, err := config.DetectFormat(fullPath)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
parsed, err := config.Parse(data, format)
if err != nil {
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()})
return
}
s.audit.Log(r.RemoteAddr, "read", relPath, "config read")
writeJSON(w, http.StatusOK, parsed)
}
func (s *Server) handlePutConfig(w http.ResponseWriter, r *http.Request) {
if s.GetMode() == ModeReadOnly {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "server is in readonly mode"})
return
}
relPath := r.PathValue("path")
fullPath, err := config.FullPath(s.cfg.ConfigDir, relPath)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, 10<<20)) // 10 MB limit
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "failed to read request body"})
return
}
format, err := config.DetectFormat(fullPath)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
// Validate that the body is valid config
if _, err := config.Parse(body, format); err != nil {
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()})
return
}
version, err := config.CreateBackup(s.cfg.ConfigDir, relPath, s.cfg.MaxBackups)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "backup failed: " + err.Error()})
return
}
if err := config.AtomicWrite(fullPath, body, 0o644); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "write failed: " + err.Error()})
return
}
s.audit.Log(r.RemoteAddr, "replace", relPath, "full replace")
resp := map[string]string{"status": "ok"}
if version != "" {
resp["backup_version"] = version
}
writeJSON(w, http.StatusOK, resp)
}
func (s *Server) handlePatchConfig(w http.ResponseWriter, r *http.Request) {
if s.GetMode() == ModeReadOnly {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "server is in readonly mode"})
return
}
relPath := r.PathValue("path")
fullPath, err := config.FullPath(s.cfg.ConfigDir, relPath)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
format, err := config.DetectFormat(fullPath)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
// Read existing config
existing := make(map[string]any)
data, err := os.ReadFile(fullPath)
if err != nil && !os.IsNotExist(err) {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to read existing file"})
return
}
if err == nil {
existing, err = config.Parse(data, format)
if err != nil {
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": "existing file is invalid: " + err.Error()})
return
}
}
// Parse patch body
body, err := io.ReadAll(io.LimitReader(r.Body, 10<<20))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "failed to read request body"})
return
}
patch, err := config.Parse(body, format)
if err != nil {
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()})
return
}
merged := config.DeepMerge(existing, patch)
out, err := config.Serialize(merged, format)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "serialization failed"})
return
}
version, err := config.CreateBackup(s.cfg.ConfigDir, relPath, s.cfg.MaxBackups)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "backup failed: " + err.Error()})
return
}
if err := config.AtomicWrite(fullPath, out, 0o644); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "write failed"})
return
}
s.audit.Log(r.RemoteAddr, "patch", relPath, "deep merge update")
resp := map[string]string{"status": "ok"}
if version != "" {
resp["backup_version"] = version
}
writeJSON(w, http.StatusOK, resp)
}
func (s *Server) handleListBackups(w http.ResponseWriter, r *http.Request) {
relPath := r.PathValue("path")
backups, err := config.ListBackups(s.cfg.ConfigDir, relPath)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
type backupResponse struct {
Version string `json:"version"`
Timestamp string `json:"timestamp"`
}
var resp []backupResponse
for _, b := range backups {
resp = append(resp, backupResponse{Version: b.Version, Timestamp: b.Timestamp})
}
if resp == nil {
resp = []backupResponse{}
}
s.audit.Log(r.RemoteAddr, "list_backups", relPath, "listed backups")
writeJSON(w, http.StatusOK, resp)
}
func (s *Server) handleRollback(w http.ResponseWriter, r *http.Request) {
if s.GetMode() == ModeReadOnly {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "server is in readonly mode"})
return
}
relPath := r.PathValue("path")
var body struct {
Version string `json:"version"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
if body.Version == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "version is required"})
return
}
// Backup current state before rollback
if _, err := config.CreateBackup(s.cfg.ConfigDir, relPath, s.cfg.MaxBackups); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "pre-rollback backup failed: " + err.Error()})
return
}
if err := config.Rollback(s.cfg.ConfigDir, relPath, body.Version); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "rollback failed: " + err.Error()})
return
}
s.audit.Log(r.RemoteAddr, "rollback", relPath, "rolled back to "+body.Version)
writeJSON(w, http.StatusOK, map[string]string{"status": "ok", "restored_version": body.Version})
}
func (s *Server) handleGetMode(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"mode": s.GetMode().String()})
}
func (s *Server) handleSetMode(w http.ResponseWriter, r *http.Request) {
var body struct {
Mode string `json:"mode"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
mode, ok := ParseMode(body.Mode)
if !ok {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid mode; must be 'init' or 'readonly'"})
return
}
s.SetMode(mode)
s.audit.Log(r.RemoteAddr, "set_mode", "", "mode changed to "+body.Mode)
writeJSON(w, http.StatusOK, map[string]string{"status": "ok", "mode": mode.String()})
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}

27
server/middleware.go Normal file
View File

@@ -0,0 +1,27 @@
package server
import (
"log"
"net/http"
"time"
)
type responseRecorder struct {
http.ResponseWriter
statusCode int
}
func (r *responseRecorder) WriteHeader(code int) {
r.statusCode = code
r.ResponseWriter.WriteHeader(code)
}
// LoggingMiddleware logs each incoming request with method, path, status, and duration.
func LoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &responseRecorder{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(rec, r)
log.Printf("%s %s %d %s", r.Method, r.URL.Path, rec.statusCode, time.Since(start).Round(time.Millisecond))
})
}

137
server/server.go Normal file
View File

@@ -0,0 +1,137 @@
package server
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"sync"
"syscall"
"time"
"AbstractWizard/audit"
)
// Mode represents the server operating mode.
type Mode int
const (
ModeInit Mode = iota // init: read/write allowed
ModeReadOnly // readonly: only reads allowed
)
func (m Mode) String() string {
switch m {
case ModeInit:
return "init"
case ModeReadOnly:
return "readonly"
default:
return "unknown"
}
}
// ParseMode converts a string to a Mode.
func ParseMode(s string) (Mode, bool) {
switch s {
case "init":
return ModeInit, true
case "readonly":
return ModeReadOnly, true
default:
return 0, false
}
}
// AppConfig holds environment-based configuration.
type AppConfig struct {
ConfigDir string
ListenAddr string
MaxBackups int
}
// Server is the main HTTP server.
type Server struct {
cfg AppConfig
audit *audit.Logger
mode Mode
modeMu sync.RWMutex
srv *http.Server
}
// New creates a new Server.
func New(cfg AppConfig, auditLog *audit.Logger) *Server {
s := &Server{
cfg: cfg,
audit: auditLog,
mode: ModeInit,
}
mux := http.NewServeMux()
s.registerRoutes(mux)
s.srv = &http.Server{
Addr: cfg.ListenAddr,
Handler: LoggingMiddleware(mux),
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
return s
}
// registerRoutes sets up all API routes.
func (s *Server) registerRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /health", s.handleHealth)
mux.HandleFunc("GET /api/v1/config/{path...}", s.handleGetConfig)
mux.HandleFunc("PUT /api/v1/config/{path...}", s.handlePutConfig)
mux.HandleFunc("PATCH /api/v1/config/{path...}", s.handlePatchConfig)
mux.HandleFunc("GET /api/v1/backups/{path...}", s.handleListBackups)
mux.HandleFunc("POST /api/v1/rollback/{path...}", s.handleRollback)
mux.HandleFunc("GET /api/v1/mode", s.handleGetMode)
mux.HandleFunc("PUT /api/v1/mode", s.handleSetMode)
}
// GetMode returns the current server mode.
func (s *Server) GetMode() Mode {
s.modeMu.RLock()
defer s.modeMu.RUnlock()
return s.mode
}
// SetMode changes the server mode.
func (s *Server) SetMode(m Mode) {
s.modeMu.Lock()
defer s.modeMu.Unlock()
s.mode = m
}
// ListenAndServe starts the HTTP server and blocks until shutdown.
func (s *Server) ListenAndServe() error {
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
errCh := make(chan error, 1)
go func() {
log.Printf("listening on %s", s.cfg.ListenAddr)
if err := s.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
errCh <- err
}
close(errCh)
}()
select {
case sig := <-stop:
log.Printf("received signal %v, shutting down", sig)
case err := <-errCh:
if err != nil {
return err
}
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return s.srv.Shutdown(ctx)
}