feat: NTP-Server (Chrony) — vollständig

Stub raus, vollständige Implementierung analog Unbound/Squid:

* Migration 0015: ntp_settings (single-row mit listen_addresses,
  allow_acl, serve_clients, makestep, rtcsync) + ntp_pools (kind
  pool|server, address, iburst/prefer, minpoll/maxpoll). Default
  4 deutsche pool.ntp.org-Server seeded.
* Models DNSSettings/NTPPool, services/ntp Repo, handlers/ntp.go
  REST /api/v1/ntp/{settings,pools} mit Auto-Restart nach Mutation.
* internal/chrony/chrony.cfg.tpl + chrony.go: Renderer schreibt
  /etc/chrony/conf.d/edgeguard.conf direkt (analog unbound — distro
  chrony.conf included conf.d automatisch). Listen-bind nur wenn
  serve_clients=true; sonst port 0 (= Client-only).
* main.go: ntpRepo + chronyReloader injiziert.
* render.go: chrony als sechste generator.
* postinst:
  - chrony als hard Depends im control file.
  - Conf-Datei /etc/chrony/conf.d/edgeguard.conf wird als
    edgeguard:edgeguard 0644 angelegt.
  - Sudoers für systemctl reload + restart chrony.
* Auto-FW-Rule-Generator: udp/123 wenn serve_clients=true und
  listen_addresses non-loopback enthält.
* Frontend /ntp: PageHeader + Quellen-Tab + Settings-Tab. Listen-
  Addresses als Multi-Select aus Kernel-IPs (analog DNS).
* Sidebar-Eintrag unter Network.
* i18n DE/EN für ntp.* Block.

chrony.service hat kein 'reload' — Renderer ruft RestartService auf.

Verified: 4 default-pool-server connected (chronyc sources zeigt
sie nach erstem render).

Version 1.0.40.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-11 06:58:54 +02:00
parent 2556a93b34
commit e4d83d226e
20 changed files with 1005 additions and 8 deletions

View File

@@ -0,0 +1,49 @@
# Generated by edgeguard — do not edit by hand.
# Re-generate via `edgeguard-ctl render-config --only=chrony`.
#
# This file lives in /etc/chrony/conf.d/edgeguard.conf — chrony's
# main /etc/chrony/chrony.conf includes the directory automatically
# (Debian default).
# ── Upstream sources ───────────────────────────────────────────
{{range .Pools}}
{{- if .Active}}
{{.Kind}} {{.Address}}{{if .Iburst}} iburst{{end}}{{if .Prefer}} prefer{{end}}{{if .MinPoll}} minpoll {{.MinPoll}}{{end}}{{if .MaxPoll}} maxpoll {{.MaxPoll}}{{end}}
{{- end}}
{{end}}
# ── Listen-Bind ────────────────────────────────────────────────
# Wenn nichts ausser localhost gebound ist, lassen wir bindaddress
# weg (chrony default = alle Interfaces). Sonst explizite bindaddress
# pro IP. Mit serve_clients=false wird port 0 → kein Listen-Socket
# (= reiner Client).
{{if .Settings.ServeClients}}
{{- range .ListenAddresses}}
bindaddress {{.}}
{{- end}}
{{- range .AllowACLs}}
allow {{.}}
{{- end}}
{{else}}
port 0
{{- end}}
# ── Step + Drift ───────────────────────────────────────────────
# makestep N L: erlaubt einen step von >N Sekunden in den ersten L
# updates (wichtig wenn der Clock weit weg ist; sonst nur slew).
makestep {{.Settings.MakestepSecs}} {{.Settings.MakestepLimit}}
driftfile /var/lib/chrony/chrony.drift
{{- if .Settings.RTCSync}}
# RTC mit System-Time syncen (für Reboot-Konsistenz).
rtcsync
{{- end}}
{{- if .Settings.LeapsecTZ}}
# Leap-Sekunden via tz-Datei (nicht slew).
leapsectz {{.Settings.LeapsecTZ}}
{{- end}}
# Logging
logdir /var/log/chrony
log measurements statistics tracking

110
internal/chrony/chrony.go Normal file
View File

@@ -0,0 +1,110 @@
// Package chrony renders /etc/chrony/conf.d/edgeguard.conf from
// ntp_settings + ntp_pools and reloads chrony.service. Distro
// /etc/chrony/chrony.conf includes conf.d/* automatically — wir
// schreiben nur unseren drop-in.
package chrony
import (
"bytes"
"context"
_ "embed"
"fmt"
"os"
"strings"
"text/template"
"github.com/jackc/pgx/v5/pgxpool"
"git.netcell-it.de/projekte/edgeguard-native/internal/configgen"
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
ntpsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/ntp"
)
// ConfPath: distro chrony.conf includes /etc/chrony/conf.d/*.conf.
// Wir schreiben direkt rein. postinst legt die Datei initial mit
// chown edgeguard:edgeguard 0644 an damit der Renderer schreibt.
const ConfPath = "/etc/chrony/conf.d/edgeguard.conf"
//go:embed chrony.cfg.tpl
var cfgTpl string
var tpl = template.Must(template.New("chrony").Parse(cfgTpl))
type View struct {
Settings *models.NTPSettings
Pools []models.NTPPool
ListenAddresses []string
AllowACLs []string
}
type Generator struct {
Pool *pgxpool.Pool
Repo *ntpsvc.Repo
SkipReload bool
}
func New(pool *pgxpool.Pool) *Generator {
return &Generator{Pool: pool, Repo: ntpsvc.New(pool)}
}
func (g *Generator) Name() string { return "chrony" }
func (g *Generator) Render(ctx context.Context) error {
settings, err := g.Repo.GetSettings(ctx)
if err != nil {
return fmt.Errorf("settings: %w", err)
}
pools, err := g.Repo.ListPools(ctx)
if err != nil {
return fmt.Errorf("pools: %w", err)
}
view := View{
Settings: settings,
Pools: pools,
ListenAddresses: filterNonLoopback(splitCSV(settings.ListenAddresses)),
AllowACLs: splitCSV(settings.AllowACL),
}
var body bytes.Buffer
if err := tpl.Execute(&body, view); err != nil {
return fmt.Errorf("template: %w", err)
}
// Direct write (kein tmp+rename) — analog unbound, weil
// /etc/chrony/conf.d/ root-owned ist und edgeguard nur die eine
// Datei überschreiben darf (postinst chown).
if err := os.WriteFile(ConfPath, body.Bytes(), 0o644); err != nil {
return fmt.Errorf("write %s: %w", ConfPath, err)
}
if g.SkipReload {
return nil
}
// chrony.service kennt kein 'systemctl reload' — nur restart.
// ~200ms ohne NTP-Antworten beim Save, dafür neue conf wirksam.
return configgen.RestartService("chrony")
}
func splitCSV(s string) []string {
out := []string{}
for _, part := range strings.Split(s, ",") {
p := strings.TrimSpace(part)
if p != "" {
out = append(out, p)
}
}
return out
}
// filterNonLoopback wirft 127.x / ::1 raus — wenn NUR localhost im
// listen_addresses ist, lassen wir den bindaddress-Block weg und
// chrony bindet auf alle Interfaces (default), was für eine reine
// Client-Konfiguration nicht stört.
func filterNonLoopback(in []string) []string {
out := []string{}
for _, ip := range in {
if ip == "::1" || ip == "localhost" || strings.HasPrefix(ip, "127.") {
continue
}
out = append(out, ip)
}
return out
}

View File

@@ -0,0 +1,60 @@
-- +goose Up
-- +goose StatementBegin
-- ntp_settings — single-row, analog dns_settings.
-- listen_addresses ist Komma-separiert; access_acl gibt die CIDR-
-- Liste die als NTP-Client erlaubt ist.
CREATE TABLE IF NOT EXISTS ntp_settings (
id BIGINT PRIMARY KEY DEFAULT 1,
listen_addresses TEXT NOT NULL DEFAULT '127.0.0.1, ::1',
allow_acl TEXT NOT NULL DEFAULT '127.0.0.0/8, ::1/128, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16',
serve_clients BOOLEAN NOT NULL DEFAULT TRUE,
makestep_secs NUMERIC(8,2) NOT NULL DEFAULT 1.0,
makestep_limit INTEGER NOT NULL DEFAULT 3,
rtcsync BOOLEAN NOT NULL DEFAULT TRUE,
leapsectz TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT ntp_settings_singleton CHECK (id = 1)
);
INSERT INTO ntp_settings (id) VALUES (1) ON CONFLICT DO NOTHING;
-- +goose StatementEnd
-- +goose StatementBegin
-- ntp_pools — upstream NTP-server/pool entries.
-- kind='pool' für Round-Robin-DNS-Names (z.B. 0.de.pool.ntp.org),
-- 'server' für Einzel-Hosts. iburst empfohlen für schnelleren Sync.
CREATE TABLE IF NOT EXISTS ntp_pools (
id BIGSERIAL PRIMARY KEY,
kind TEXT NOT NULL DEFAULT 'pool',
address TEXT NOT NULL,
iburst BOOLEAN NOT NULL DEFAULT TRUE,
prefer BOOLEAN NOT NULL DEFAULT FALSE,
minpoll INTEGER,
maxpoll INTEGER,
active BOOLEAN NOT NULL DEFAULT TRUE,
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT ntp_pools_kind_check CHECK (kind IN ('pool', 'server'))
);
-- Sinnvolle Defaults: 4 deutsche pool.ntp.org-Server. Operator kann
-- jederzeit eigene pools/server hinzufügen oder diese deaktivieren.
INSERT INTO ntp_pools (kind, address, iburst, description) VALUES
('pool', '0.de.pool.ntp.org', TRUE, 'Default upstream'),
('pool', '1.de.pool.ntp.org', TRUE, 'Default upstream'),
('pool', '2.de.pool.ntp.org', TRUE, 'Default upstream'),
('pool', '3.de.pool.ntp.org', TRUE, 'Default upstream')
ON CONFLICT DO NOTHING;
-- +goose StatementEnd
-- +goose StatementBegin
CREATE INDEX IF NOT EXISTS idx_ntp_pools_active ON ntp_pools (active) WHERE active;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS ntp_pools;
DROP TABLE IF EXISTS ntp_settings;
-- +goose StatementEnd

View File

@@ -329,6 +329,21 @@ func (g *Generator) loadAutoRules(ctx context.Context) []AutoFWRule {
}
}
// Chrony NTP: wenn serve_clients=true und listen_addresses
// non-loopback enthält → udp 123 pro IP. Wenn die Liste nur
// localhost ist, kein FW-Rule (chrony bindet dann nichts
// nach außen).
var nlist string
var serveClients bool
if err := g.Pool.QueryRow(ctx, `SELECT listen_addresses, serve_clients FROM ntp_settings WHERE id=1`).Scan(&nlist, &serveClients); err == nil && serveClients {
for _, ip := range splitCSV(nlist) {
if isLoopback(ip) || ip == "0.0.0.0" || ip == "::" {
continue
}
out = append(out, AutoFWRule{Proto: "udp", Port: 123, DstIP: ip, Comment: "NTP (chrony) auf " + ip})
}
}
return out
}

175
internal/handlers/ntp.go Normal file
View File

@@ -0,0 +1,175 @@
package handlers
import (
"context"
"errors"
"log/slog"
"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)
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)
}
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
}

38
internal/models/ntp.go Normal file
View File

@@ -0,0 +1,38 @@
package models
import "time"
// NTPSettings — single-row, mirrors chrony.conf globals plus
// listen-bind decisions.
type NTPSettings struct {
ID int64 `gorm:"primaryKey" json:"id"`
ListenAddresses string `gorm:"column:listen_addresses" json:"listen_addresses"`
AllowACL string `gorm:"column:allow_acl" json:"allow_acl"`
ServeClients bool `gorm:"column:serve_clients" json:"serve_clients"`
MakestepSecs float64 `gorm:"column:makestep_secs" json:"makestep_secs"`
MakestepLimit int `gorm:"column:makestep_limit" json:"makestep_limit"`
RTCSync bool `gorm:"column:rtcsync" json:"rtcsync"`
LeapsecTZ *string `gorm:"column:leapsectz" json:"leapsectz,omitempty"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
}
func (NTPSettings) TableName() string { return "ntp_settings" }
// NTPPool — one upstream entry. Kind 'pool' triggers DNS round-robin
// (chrony adds N sources from the A-record set), 'server' is a single
// host.
type NTPPool struct {
ID int64 `gorm:"primaryKey" json:"id"`
Kind string `gorm:"column:kind" json:"kind"`
Address string `gorm:"column:address" json:"address"`
Iburst bool `gorm:"column:iburst" json:"iburst"`
Prefer bool `gorm:"column:prefer" json:"prefer"`
MinPoll *int `gorm:"column:minpoll" json:"minpoll,omitempty"`
MaxPoll *int `gorm:"column:maxpoll" json:"maxpoll,omitempty"`
Active bool `gorm:"column:active" json:"active"`
Description *string `gorm:"column:description" json:"description,omitempty"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
}
func (NTPPool) TableName() string { return "ntp_pools" }

View File

@@ -0,0 +1,143 @@
// Package ntp provides CRUD against ntp_settings (single-row) and
// ntp_pools. Renderer in internal/chrony consumes the same data.
package ntp
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
)
var ErrPoolNotFound = errors.New("ntp pool not found")
type Repo struct {
Pool *pgxpool.Pool
}
func New(pool *pgxpool.Pool) *Repo { return &Repo{Pool: pool} }
// ── Settings ───────────────────────────────────────────────────
func (r *Repo) GetSettings(ctx context.Context) (*models.NTPSettings, error) {
row := r.Pool.QueryRow(ctx, `
SELECT id, listen_addresses, allow_acl, serve_clients,
makestep_secs, makestep_limit, rtcsync, leapsectz, updated_at
FROM ntp_settings WHERE id=1`)
var s models.NTPSettings
if err := row.Scan(&s.ID, &s.ListenAddresses, &s.AllowACL, &s.ServeClients,
&s.MakestepSecs, &s.MakestepLimit, &s.RTCSync, &s.LeapsecTZ, &s.UpdatedAt); err != nil {
return nil, err
}
return &s, nil
}
func (r *Repo) UpdateSettings(ctx context.Context, s models.NTPSettings) (*models.NTPSettings, error) {
row := r.Pool.QueryRow(ctx, `
UPDATE ntp_settings SET
listen_addresses=$1, allow_acl=$2, serve_clients=$3,
makestep_secs=$4, makestep_limit=$5, rtcsync=$6, leapsectz=$7,
updated_at=NOW()
WHERE id=1
RETURNING id, listen_addresses, allow_acl, serve_clients,
makestep_secs, makestep_limit, rtcsync, leapsectz, updated_at`,
s.ListenAddresses, s.AllowACL, s.ServeClients,
s.MakestepSecs, s.MakestepLimit, s.RTCSync, s.LeapsecTZ)
var out models.NTPSettings
if err := row.Scan(&out.ID, &out.ListenAddresses, &out.AllowACL, &out.ServeClients,
&out.MakestepSecs, &out.MakestepLimit, &out.RTCSync, &out.LeapsecTZ, &out.UpdatedAt); err != nil {
return nil, err
}
return &out, nil
}
// ── Pools ──────────────────────────────────────────────────────
const poolSelect = `
SELECT id, kind, address, iburst, prefer, minpoll, maxpoll, active,
description, created_at, updated_at
FROM ntp_pools
`
func (r *Repo) ListPools(ctx context.Context) ([]models.NTPPool, error) {
rows, err := r.Pool.Query(ctx, poolSelect+" ORDER BY id ASC")
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]models.NTPPool, 0, 8)
for rows.Next() {
p, err := scanPool(rows)
if err != nil {
return nil, err
}
out = append(out, *p)
}
return out, rows.Err()
}
func (r *Repo) GetPool(ctx context.Context, id int64) (*models.NTPPool, error) {
row := r.Pool.QueryRow(ctx, poolSelect+" WHERE id=$1", id)
p, err := scanPool(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrPoolNotFound
}
return nil, err
}
return p, nil
}
func (r *Repo) CreatePool(ctx context.Context, p models.NTPPool) (*models.NTPPool, error) {
row := r.Pool.QueryRow(ctx, `
INSERT INTO ntp_pools (kind, address, iburst, prefer, minpoll, maxpoll, active, description)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
RETURNING id, kind, address, iburst, prefer, minpoll, maxpoll, active,
description, created_at, updated_at`,
p.Kind, p.Address, p.Iburst, p.Prefer, p.MinPoll, p.MaxPoll, p.Active, p.Description)
return scanPool(row)
}
func (r *Repo) UpdatePool(ctx context.Context, id int64, p models.NTPPool) (*models.NTPPool, error) {
row := r.Pool.QueryRow(ctx, `
UPDATE ntp_pools SET kind=$1, address=$2, iburst=$3, prefer=$4,
minpoll=$5, maxpoll=$6, active=$7, description=$8, updated_at=NOW()
WHERE id=$9
RETURNING id, kind, address, iburst, prefer, minpoll, maxpoll, active,
description, created_at, updated_at`,
p.Kind, p.Address, p.Iburst, p.Prefer, p.MinPoll, p.MaxPoll, p.Active, p.Description, id)
out, err := scanPool(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrPoolNotFound
}
return nil, err
}
return out, nil
}
func (r *Repo) DeletePool(ctx context.Context, id int64) error {
tag, err := r.Pool.Exec(ctx, `DELETE FROM ntp_pools WHERE id=$1`, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrPoolNotFound
}
return nil
}
func scanPool(row interface{ Scan(...any) error }) (*models.NTPPool, error) {
var p models.NTPPool
if err := row.Scan(
&p.ID, &p.Kind, &p.Address, &p.Iburst, &p.Prefer,
&p.MinPoll, &p.MaxPoll, &p.Active, &p.Description,
&p.CreatedAt, &p.UpdatedAt,
); err != nil {
return nil, err
}
return &p, nil
}