package handlers import ( "context" "errors" "fmt" "log/slog" "os/exec" "strconv" "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) g.GET("/sources", h.Sources) g.POST("/force-sync", h.ForceSync) 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 } _ = h.Audit.Log(c.Request.Context(), actorOf(c), "ntp.pool.delete", strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID) response.NoContent(c) h.reload(c.Request.Context(), "pool.delete") } // ForceSync runs `chronyc makestep` which immediately adjusts the // system clock to the current NTP reference. Useful after a long // outage or VM migration where the clock has drifted by more than // the 1ms default slew threshold. func (h *NTPHandler) ForceSync(c *gin.Context) { out, err := exec.Command("chronyc", "makestep").CombinedOutput() if err != nil { slog.Error("ntp force-sync failed", "err", err, "out", string(out)) response.Internal(c, err) return } _ = h.Audit.Log(c.Request.Context(), actorOf(c), "ntp.force-sync", "chrony", nil, h.NodeID) response.OK(c, gin.H{"message": "clock stepped", "output": string(out)}) } // Sources liefert die aktuellen NTP-Quellen via `chronyc sources`. // Jede Zeile wird in ein NTPSource-Objekt geparst und als Array zurückgegeben. func (h *NTPHandler) Sources(c *gin.Context) { out, err := exec.Command("chronyc", "sources").Output() if err != nil { response.OK(c, gin.H{ "sources": []any{}, "error": "chronyc nicht verfügbar: " + err.Error(), }) return } response.OK(c, gin.H{"sources": parseChronymSources(string(out))}) } type ntpSource struct { Mode string `json:"mode"` State string `json:"state"` Active bool `json:"active"` Name string `json:"name"` Stratum int `json:"stratum"` Poll int `json:"poll"` Reach string `json:"reach"` LastRx string `json:"last_rx"` Sample string `json:"sample"` } func parseChronymSources(out string) []ntpSource { modeMap := map[byte]string{'^': "server", '=': "peer", '#': "local"} stateMap := map[byte]string{ '*': "synced", '+': "combined", '-': "not_combined", '?': "unreachable", 'x': "error", '~': "variable", } var srcs []ntpSource for _, line := range strings.Split(out, "\n") { if len(line) < 2 { continue } mode, ok := modeMap[line[0]] if !ok { continue } stateChar := line[1] stateStr, ok := stateMap[stateChar] if !ok { stateStr = string(stateChar) } fields := strings.Fields(strings.TrimSpace(line[2:])) if len(fields) < 5 { continue } src := ntpSource{ Mode: mode, State: stateStr, Active: stateChar == '*' || stateChar == '+', Name: fields[0], Reach: fields[3], LastRx: fields[4], } _, _ = fmt.Sscanf(fields[1], "%d", &src.Stratum) _, _ = fmt.Sscanf(fields[2], "%d", &src.Poll) if len(fields) >= 6 { src.Sample = strings.Join(fields[5:], " ") } srcs = append(srcs, src) } return srcs } 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 }