package handlers import ( "context" "errors" "fmt" "log/slog" "os/exec" "strings" "github.com/gin-gonic/gin" "git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response" "git.netcell-it.de/projekte/edgeguard-native/internal/models" "git.netcell-it.de/projekte/edgeguard-native/internal/services/audit" ntpsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/ntp" ) type NTPHandler struct { Repo *ntpsvc.Repo Audit *audit.Repo NodeID string Reloader func(ctx context.Context) error } func NewNTPHandler(repo *ntpsvc.Repo, a *audit.Repo, nodeID string, reloader func(context.Context) error) *NTPHandler { return &NTPHandler{Repo: repo, Audit: a, NodeID: nodeID, Reloader: reloader} } func (h *NTPHandler) reload(ctx context.Context, op string) { if h.Reloader == nil { return } if err := h.Reloader(ctx); err != nil { slog.Warn("chrony: reload after mutation failed", "op", op, "error", err) } } func (h *NTPHandler) Register(rg *gin.RouterGroup) { g := rg.Group("/ntp") g.GET("/settings", h.GetSettings) g.PUT("/settings", h.UpdateSettings) g.GET("/status", h.Status) p := g.Group("/pools") p.GET("", h.ListPools) p.POST("", h.CreatePool) p.GET("/:id", h.GetPool) p.PUT("/:id", h.UpdatePool) p.DELETE("/:id", h.DeletePool) } // Status liefert den aktuellen chrony-Tracking-Status via `chronyc tracking`. // Output-Felder: synced (bool), reference (Quell-Server), stratum (int), // offset_ms (float), freq_ppm (float), rms_offset_ms (float), error (string // wenn chronyc nicht verfügbar oder System-Takt nicht sync'd). func (h *NTPHandler) Status(c *gin.Context) { out, err := exec.Command("chronyc", "tracking").Output() if err != nil { response.OK(c, gin.H{ "synced": false, "error": "chronyc nicht verfügbar: " + err.Error(), }) return } resp := parseChronyTracking(string(out)) response.OK(c, resp) } type chronyStatus struct { Synced bool `json:"synced"` Reference string `json:"reference"` Stratum int `json:"stratum"` OffsetMs float64 `json:"offset_ms"` FreqPPM float64 `json:"freq_ppm"` RMSOffsetMs float64 `json:"rms_offset_ms"` Error string `json:"error,omitempty"` } func parseChronyTracking(out string) chronyStatus { s := chronyStatus{} for _, line := range strings.Split(out, "\n") { line = strings.TrimSpace(line) key, val, ok := strings.Cut(line, ":") if !ok { continue } key = strings.TrimSpace(key) val = strings.TrimSpace(val) switch key { case "Reference ID": // "Reference ID : A29FC801 (time.cloudflare.com)" if i := strings.Index(val, "("); i >= 0 { s.Reference = strings.Trim(val[i:], "()") } else { s.Reference = val } s.Synced = val != "00000000 ()" case "Stratum": fmt.Sscanf(val, "%d", &s.Stratum) if s.Stratum > 0 && s.Stratum < 16 { s.Synced = true } case "System time": // "0.000012345 seconds fast of NTP time" var v float64 fmt.Sscanf(val, "%f", &v) s.OffsetMs = v * 1000 case "Frequency": // "-12.345 ppm slow" or "+12.345 ppm fast" fmt.Sscanf(val, "%f", &s.FreqPPM) case "RMS offset": var v float64 fmt.Sscanf(val, "%f", &v) s.RMSOffsetMs = v * 1000 } } return s } func (h *NTPHandler) GetSettings(c *gin.Context) { s, err := h.Repo.GetSettings(c.Request.Context()) if err != nil { response.Internal(c, err) return } response.OK(c, s) } func (h *NTPHandler) UpdateSettings(c *gin.Context) { var req models.NTPSettings if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, err) return } out, err := h.Repo.UpdateSettings(c.Request.Context(), req) if err != nil { response.Internal(c, err) return } _ = h.Audit.Log(c.Request.Context(), actorOf(c), "ntp.settings.update", "settings", out, h.NodeID) response.OK(c, out) h.reload(c.Request.Context(), "settings.update") } func (h *NTPHandler) ListPools(c *gin.Context) { out, err := h.Repo.ListPools(c.Request.Context()) if err != nil { response.Internal(c, err) return } response.OK(c, gin.H{"pools": out}) } func (h *NTPHandler) GetPool(c *gin.Context) { id, ok := parseID(c) if !ok { return } p, err := h.Repo.GetPool(c.Request.Context(), id) if err != nil { if errors.Is(err, ntpsvc.ErrPoolNotFound) { response.NotFound(c, err) return } response.Internal(c, err) return } response.OK(c, p) } func (h *NTPHandler) CreatePool(c *gin.Context) { var req models.NTPPool if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, err) return } if err := validateNTPPool(&req); err != nil { response.BadRequest(c, err) return } out, err := h.Repo.CreatePool(c.Request.Context(), req) if err != nil { response.Internal(c, err) return } _ = h.Audit.Log(c.Request.Context(), actorOf(c), "ntp.pool.create", out.Address, out, h.NodeID) response.Created(c, out) h.reload(c.Request.Context(), "pool.create") } func (h *NTPHandler) UpdatePool(c *gin.Context) { id, ok := parseID(c) if !ok { return } var req models.NTPPool if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, err) return } if err := validateNTPPool(&req); err != nil { response.BadRequest(c, err) return } out, err := h.Repo.UpdatePool(c.Request.Context(), id, req) if err != nil { if errors.Is(err, ntpsvc.ErrPoolNotFound) { response.NotFound(c, err) return } response.Internal(c, err) return } _ = h.Audit.Log(c.Request.Context(), actorOf(c), "ntp.pool.update", out.Address, out, h.NodeID) response.OK(c, out) h.reload(c.Request.Context(), "pool.update") } func (h *NTPHandler) DeletePool(c *gin.Context) { id, ok := parseID(c) if !ok { return } if err := h.Repo.DeletePool(c.Request.Context(), id); err != nil { if errors.Is(err, ntpsvc.ErrPoolNotFound) { response.NotFound(c, err) return } response.Internal(c, err) return } response.NoContent(c) h.reload(c.Request.Context(), "pool.delete") } func validateNTPPool(p *models.NTPPool) error { if p.Address == "" { return errors.New("address required") } switch p.Kind { case "pool", "server": default: return errors.New("kind must be 'pool' or 'server'") } return nil }