Files
edgeguard-native/internal/handlers/ntp.go
Debian ac068bc9dd feat(dns+ntp): DNS-Cache-Flush + NTP-Force-Sync — operative Aktionen (1.1.94)
Backend: POST /dns/flush-cache (unbound-control flush_zone .)
         POST /ntp/force-sync  (chronyc makestep)
Beide werden im Audit-Log festgehalten.
UI: Schaltflächen in DNS-Settings und NTP-Settings neben Save,
    mit Tooltip-Beschreibung + i18n (de+en).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 16:52:20 +02:00

264 lines
6.7 KiB
Go

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)
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
}
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)})
}
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
}