From e4d83d226ed6b730c1e7ca82659652ff2c6619a4 Mon Sep 17 00:00:00 2001 From: Debian Date: Mon, 11 May 2026 06:58:54 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20NTP-Server=20(Chrony)=20=E2=80=94=20vol?= =?UTF-8?q?lst=C3=A4ndig?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- VERSION | 2 +- cmd/edgeguard-api/main.go | 11 +- cmd/edgeguard-ctl/main.go | 2 +- cmd/edgeguard-ctl/render.go | 4 +- cmd/edgeguard-scheduler/main.go | 2 +- internal/chrony/chrony.cfg.tpl | 49 +++ internal/chrony/chrony.go | 110 +++++++ internal/database/migrations/0015_ntp.sql | 60 ++++ internal/firewall/firewall.go | 15 + internal/handlers/ntp.go | 175 +++++++++++ internal/models/ntp.go | 38 +++ internal/services/ntp/ntp.go | 143 +++++++++ management-ui/package.json | 2 +- management-ui/src/App.tsx | 2 + .../src/components/Layout/Sidebar.tsx | 4 +- management-ui/src/i18n/locales/de/common.json | 39 +++ management-ui/src/i18n/locales/en/common.json | 39 +++ management-ui/src/pages/NTP/index.tsx | 297 ++++++++++++++++++ packaging/debian/edgeguard-api/DEBIAN/control | 2 +- .../debian/edgeguard-api/DEBIAN/postinst | 17 + 20 files changed, 1005 insertions(+), 8 deletions(-) create mode 100644 internal/chrony/chrony.cfg.tpl create mode 100644 internal/chrony/chrony.go create mode 100644 internal/database/migrations/0015_ntp.sql create mode 100644 internal/handlers/ntp.go create mode 100644 internal/models/ntp.go create mode 100644 internal/services/ntp/ntp.go create mode 100644 management-ui/src/pages/NTP/index.tsx diff --git a/VERSION b/VERSION index 9600efd..42de374 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.38 +1.0.40 diff --git a/cmd/edgeguard-api/main.go b/cmd/edgeguard-api/main.go index afb03ca..4196888 100644 --- a/cmd/edgeguard-api/main.go +++ b/cmd/edgeguard-api/main.go @@ -22,6 +22,7 @@ import ( firewallrender "git.netcell-it.de/projekte/edgeguard-native/internal/firewall" "git.netcell-it.de/projekte/edgeguard-native/internal/haproxy" "git.netcell-it.de/projekte/edgeguard-native/internal/handlers" + chronyrender "git.netcell-it.de/projekte/edgeguard-native/internal/chrony" squidrender "git.netcell-it.de/projekte/edgeguard-native/internal/squid" unboundrender "git.netcell-it.de/projekte/edgeguard-native/internal/unbound" wgrender "git.netcell-it.de/projekte/edgeguard-native/internal/wireguard" @@ -35,6 +36,7 @@ import ( "git.netcell-it.de/projekte/edgeguard-native/internal/services/forwardproxy" "git.netcell-it.de/projekte/edgeguard-native/internal/services/ipaddresses" "git.netcell-it.de/projekte/edgeguard-native/internal/services/networkifs" + ntpsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/ntp" "git.netcell-it.de/projekte/edgeguard-native/internal/services/routingrules" "git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets" "git.netcell-it.de/projekte/edgeguard-native/internal/services/session" @@ -43,7 +45,7 @@ import ( wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard" ) -var version = "1.0.38" +var version = "1.0.40" func main() { addr := os.Getenv("EDGEGUARD_API_ADDR") @@ -147,6 +149,7 @@ func main() { wgPeers := wgsvc.NewPeersRepo(pool) fwdProxyRepo := forwardproxy.New(pool) dnsRepo := dnssvc.New(pool) + ntpRepo := ntpsvc.New(pool) // ACME (Let's Encrypt). Email comes from setup.json — the // wizard collects acme_email and the issuer registers an @@ -202,6 +205,12 @@ func main() { return unboundrender.New(pool).Render(ctx) } handlers.NewDNSHandler(dnsRepo, auditRepo, nodeID, unboundReloader).Register(authed) + + // Chrony NTP reload — re-render edgeguard.conf + reload chrony. + chronyReloader := func(ctx context.Context) error { + return chronyrender.New(pool).Render(ctx) + } + handlers.NewNTPHandler(ntpRepo, auditRepo, nodeID, chronyReloader).Register(authed) } mountUI(r) diff --git a/cmd/edgeguard-ctl/main.go b/cmd/edgeguard-ctl/main.go index 134246c..c4d3abf 100644 --- a/cmd/edgeguard-ctl/main.go +++ b/cmd/edgeguard-ctl/main.go @@ -9,7 +9,7 @@ import ( "os" ) -var version = "1.0.38" +var version = "1.0.40" const usage = `edgeguard-ctl — EdgeGuard CLI diff --git a/cmd/edgeguard-ctl/render.go b/cmd/edgeguard-ctl/render.go index ef5f7bb..7eb7b0c 100644 --- a/cmd/edgeguard-ctl/render.go +++ b/cmd/edgeguard-ctl/render.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "git.netcell-it.de/projekte/edgeguard-native/internal/chrony" "git.netcell-it.de/projekte/edgeguard-native/internal/configgen" "git.netcell-it.de/projekte/edgeguard-native/internal/database" "git.netcell-it.de/projekte/edgeguard-native/internal/firewall" @@ -57,12 +58,13 @@ func cmdRenderConfig(args []string) int { sq := squid.New(pool) wg := wireguard.New(pool, secrets.New("")) ub := unbound.New(pool) + cn := chrony.New(pool) if skipReload { hap.SkipReload = true fw.SkipReload = true } - gens := []configgen.Generator{hap, fw, sq, wg, ub} + gens := []configgen.Generator{hap, fw, sq, wg, ub, cn} results, runErr := configorch.Run(ctx, gens, only) fmt.Print(configorch.Summarise(results)) diff --git a/cmd/edgeguard-scheduler/main.go b/cmd/edgeguard-scheduler/main.go index d3390df..209317c 100644 --- a/cmd/edgeguard-scheduler/main.go +++ b/cmd/edgeguard-scheduler/main.go @@ -21,7 +21,7 @@ import ( "git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts" ) -var version = "1.0.38" +var version = "1.0.40" const ( // renewTickInterval — how often we re-evaluate expiring certs. diff --git a/internal/chrony/chrony.cfg.tpl b/internal/chrony/chrony.cfg.tpl new file mode 100644 index 0000000..0446b72 --- /dev/null +++ b/internal/chrony/chrony.cfg.tpl @@ -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 diff --git a/internal/chrony/chrony.go b/internal/chrony/chrony.go new file mode 100644 index 0000000..14b5eca --- /dev/null +++ b/internal/chrony/chrony.go @@ -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 +} diff --git a/internal/database/migrations/0015_ntp.sql b/internal/database/migrations/0015_ntp.sql new file mode 100644 index 0000000..6dfec6e --- /dev/null +++ b/internal/database/migrations/0015_ntp.sql @@ -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 diff --git a/internal/firewall/firewall.go b/internal/firewall/firewall.go index 0bc3dce..b3005b8 100644 --- a/internal/firewall/firewall.go +++ b/internal/firewall/firewall.go @@ -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 } diff --git a/internal/handlers/ntp.go b/internal/handlers/ntp.go new file mode 100644 index 0000000..e673f86 --- /dev/null +++ b/internal/handlers/ntp.go @@ -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 +} diff --git a/internal/models/ntp.go b/internal/models/ntp.go new file mode 100644 index 0000000..f645342 --- /dev/null +++ b/internal/models/ntp.go @@ -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" } diff --git a/internal/services/ntp/ntp.go b/internal/services/ntp/ntp.go new file mode 100644 index 0000000..6d9b217 --- /dev/null +++ b/internal/services/ntp/ntp.go @@ -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 +} diff --git a/management-ui/package.json b/management-ui/package.json index 0f98405..f814ef4 100644 --- a/management-ui/package.json +++ b/management-ui/package.json @@ -1,7 +1,7 @@ { "name": "edgeguard-management-ui", "private": true, - "version": "1.0.38", + "version": "1.0.40", "type": "module", "scripts": { "dev": "vite", diff --git a/management-ui/src/App.tsx b/management-ui/src/App.tsx index 7726111..c437818 100644 --- a/management-ui/src/App.tsx +++ b/management-ui/src/App.tsx @@ -23,6 +23,7 @@ const FirewallPage = lazy(() => import('./pages/Firewall')) const WireguardPage = lazy(() => import('./pages/Wireguard')) const ForwardProxyPage = lazy(() => import('./pages/ForwardProxy')) const DNSPage = lazy(() => import('./pages/DNS')) +const NTPPage = lazy(() => import('./pages/NTP')) const ClusterPage = lazy(() => import('./pages/Cluster')) const SettingsPage = lazy(() => import('./pages/Settings')) @@ -105,6 +106,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> diff --git a/management-ui/src/components/Layout/Sidebar.tsx b/management-ui/src/components/Layout/Sidebar.tsx index abaaa58..bf5b468 100644 --- a/management-ui/src/components/Layout/Sidebar.tsx +++ b/management-ui/src/components/Layout/Sidebar.tsx @@ -2,6 +2,7 @@ import { NavLink } from 'react-router-dom' import type { ReactNode } from 'react' import { ApartmentOutlined, + ClockCircleOutlined, CloudServerOutlined, ClusterOutlined, DashboardOutlined, @@ -54,6 +55,7 @@ const NAV: NavSection[] = [ { path: '/ip-addresses', labelKey: 'nav.ipAddresses', icon: }, { path: '/ssl', labelKey: 'nav.ssl', icon: }, { path: '/dns', labelKey: 'nav.dns', icon: }, + { path: '/ntp', labelKey: 'nav.ntp', icon: }, ], }, { @@ -73,7 +75,7 @@ const NAV: NavSection[] = [ }, ] -const VERSION = '1.0.38' +const VERSION = '1.0.40' export default function Sidebar({ isOpen, onClose }: SidebarProps) { const { t } = useTranslation() diff --git a/management-ui/src/i18n/locales/de/common.json b/management-ui/src/i18n/locales/de/common.json index 056933d..7f2dabd 100644 --- a/management-ui/src/i18n/locales/de/common.json +++ b/management-ui/src/i18n/locales/de/common.json @@ -15,6 +15,7 @@ "wireguard": "WireGuard", "forwardProxy": "Forward-Proxy", "dns": "DNS", + "ntp": "Zeit (NTP)", "firewall": "Firewall", "cluster": "Cluster", "settings": "Einstellungen", @@ -400,6 +401,44 @@ "wg": "WireGuard" } }, + "ntp": { + "title": "Zeitserver (Chrony)", + "intro": "Chrony als Time-Sync-Daemon (NTP). Quellen oben, Listen-/Serve-Konfig im Settings-Tab. Wenn 'serve_clients' aktiv und LAN-IPs gebound sind, wird die Box selbst zum NTP-Server für das LAN.", + "tabs": { "pools": "Quellen", "settings": "Settings" }, + "pool": { + "kind": "Typ", + "kindPool": "pool — DNS-Round-Robin (mehrere Server aus A-Records)", + "kindServer": "server — einzelner Host", + "address": "Adresse / Host", + "addressExtra": "FQDN (für pool: 0.de.pool.ntp.org) oder IP.", + "iburst": "iburst", + "prefer": "prefer", + "minpoll": "min-poll", + "maxpoll": "max-poll", + "options": "Optionen", + "description": "Beschreibung", + "add": "Quelle hinzufügen", + "edit": "Quelle bearbeiten", + "deleteConfirm": "NTP-Quelle {{addr}} wirklich löschen?" + }, + "settings": { + "intro": "Globale Chrony-Settings. Save reloaded chrony automatisch.", + "serveClients": "Als NTP-Server für Clients arbeiten", + "serveClientsExtra": "Wenn aus: chrony agiert nur als Client (port 0). Wenn an + Listen-IP: bindet UDP/123.", + "listenAddresses": "Listen-Adressen", + "listenAddressesPlaceholder": "IPs wählen (oder eintippen)", + "listenAddressesExtra": "Auf welchen IPs chrony :123/UDP bindet. 127.0.0.1+::1 = nur lokal; LAN-IPs öffnen für LAN-Clients (FW-Rule wird automatisch generiert).", + "allowACL": "Allow-ACL (CIDRs)", + "allowACLExtra": "Wer darf NTP-Time anfragen.", + "makestepSecs": "makestep secs", + "makestepSecsExtra": "Erlaube step (statt slew) wenn offset > N sec.", + "makestepLimit": "makestep limit", + "rtcsync": "RTC mit System-Time syncen", + "rtcsyncExtra": "Hardware-Clock alle 11 min synchron halten — nach Reboot ist die Zeit grob korrekt.", + "leapsectz": "Leap-Sec TZ", + "leapsectzExtra": "Optional, z.B. 'right/UTC' für leap-sec über tzdata." + } + }, "dns": { "title": "DNS (Unbound)", "intro": "Unbound-Resolver auf :53. Lokale Zonen (authoritativ aus DNS-Records) und Forward-Zonen (per stub-zone weiter zu fremden Resolvern). Default-Forwarder für alles andere.", diff --git a/management-ui/src/i18n/locales/en/common.json b/management-ui/src/i18n/locales/en/common.json index 4f2b175..0797c29 100644 --- a/management-ui/src/i18n/locales/en/common.json +++ b/management-ui/src/i18n/locales/en/common.json @@ -15,6 +15,7 @@ "wireguard": "WireGuard", "forwardProxy": "Forward proxy", "dns": "DNS", + "ntp": "Time (NTP)", "firewall": "Firewall", "cluster": "Cluster", "settings": "Settings", @@ -400,6 +401,44 @@ "wg": "WireGuard" } }, + "ntp": { + "title": "Time server (Chrony)", + "intro": "Chrony as time-sync daemon (NTP). Sources on top, listen/serve config on the settings tab. With 'serve_clients' on and LAN-IPs bound, the box itself becomes an NTP server for the LAN.", + "tabs": { "pools": "Sources", "settings": "Settings" }, + "pool": { + "kind": "Type", + "kindPool": "pool — DNS round-robin (multiple servers from A records)", + "kindServer": "server — single host", + "address": "Address / host", + "addressExtra": "FQDN (for pool: 0.de.pool.ntp.org) or IP.", + "iburst": "iburst", + "prefer": "prefer", + "minpoll": "min-poll", + "maxpoll": "max-poll", + "options": "Options", + "description": "Description", + "add": "Add source", + "edit": "Edit source", + "deleteConfirm": "Really delete NTP source {{addr}}?" + }, + "settings": { + "intro": "Global chrony settings. Saves reload chrony automatically.", + "serveClients": "Act as NTP server for clients", + "serveClientsExtra": "If off: chrony acts as client only (port 0). If on + listen IP: binds UDP/123.", + "listenAddresses": "Listen addresses", + "listenAddressesPlaceholder": "Pick IPs (or type)", + "listenAddressesExtra": "Which IPs chrony binds :123/UDP on. 127.0.0.1+::1 = local only; LAN IPs open for LAN clients (FW rule auto-generated).", + "allowACL": "Allow ACL (CIDRs)", + "allowACLExtra": "Who is allowed to ask for NTP time.", + "makestepSecs": "makestep secs", + "makestepSecsExtra": "Allow step (vs. slew) when offset > N seconds.", + "makestepLimit": "makestep limit", + "rtcsync": "Sync RTC with system time", + "rtcsyncExtra": "Keep hardware clock in sync every 11 min — after reboot time is roughly correct.", + "leapsectz": "Leap-sec TZ", + "leapsectzExtra": "Optional, e.g. 'right/UTC' for leap-sec via tzdata." + } + }, "dns": { "title": "DNS (Unbound)", "intro": "Unbound resolver on :53. Local zones (authoritative from DNS records) and forward zones (stub-zone to remote resolvers). Default forwarders catch everything else.", diff --git a/management-ui/src/pages/NTP/index.tsx b/management-ui/src/pages/NTP/index.tsx new file mode 100644 index 0000000..6e3bb73 --- /dev/null +++ b/management-ui/src/pages/NTP/index.tsx @@ -0,0 +1,297 @@ +import { useState } from 'react' +import { + Alert, Button, Form, Input, InputNumber, Modal, Select, Space, Switch, Tabs, Tag, Typography, message, +} from 'antd' +import type { ColumnsType } from 'antd/es/table' +import { ClockCircleOutlined, DatabaseOutlined, PlusOutlined, SettingOutlined } from '@ant-design/icons' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' + +import apiClient, { isEnvelope } from '../../api/client' +import DataTable from '../../components/DataTable' +import PageHeader from '../../components/PageHeader' +import ActionButtons from '../../components/ActionButtons' +import StatusDot from '../../components/StatusDot' + +const { Text } = Typography + +interface Pool { + id: number + kind: 'pool' | 'server' + address: string + iburst: boolean + prefer: boolean + minpoll?: number | null + maxpoll?: number | null + active: boolean + description?: string | null +} + +interface Settings { + listen_addresses: string + allow_acl: string + serve_clients: boolean + makestep_secs: number + makestep_limit: number + rtcsync: boolean + leapsectz?: string | null +} + +interface SettingsForm extends Omit { + listen_addresses: string[] +} + +interface SystemIface { + ifname: string + addr_info?: Array<{ family: 'inet' | 'inet6'; local: string; prefixlen: number }> +} + +async function listPools(): Promise { + const r = await apiClient.get('/ntp/pools') + if (!isEnvelope(r.data)) return [] + return (r.data.data as { pools?: Pool[] }).pools ?? [] +} +async function getSettings(): Promise { + const r = await apiClient.get('/ntp/settings') + return isEnvelope(r.data) ? (r.data.data as Settings) : null +} +async function listSystemInterfaces(): Promise { + const r = await apiClient.get('/system/interfaces') + if (!isEnvelope(r.data)) return [] + return (r.data.data as { interfaces?: SystemIface[] }).interfaces ?? [] +} + +export default function NTPPage() { + const { t } = useTranslation() + return ( +
+ } + title={t('ntp.title')} + subtitle={t('ntp.intro')} + /> + {t('ntp.tabs.pools')}, children: }, + { key: 'settings', label: {t('ntp.tabs.settings')}, children: }, + ]} + /> +
+ ) +} + +function PoolsTab() { + const { t } = useTranslation() + const qc = useQueryClient() + const { data, isLoading } = useQuery({ queryKey: ['ntp', 'pools'], queryFn: listPools }) + + const [editing, setEditing] = useState(null) + const [creating, setCreating] = useState(false) + const [form] = Form.useForm() + + const upsert = useMutation({ + mutationFn: async (v: Pool) => { + if (editing) return (await apiClient.put(`/ntp/pools/${editing.id}`, v)).data + return (await apiClient.post('/ntp/pools', v)).data + }, + onSuccess: () => { + message.success(t('common.save')) + setEditing(null); setCreating(false); form.resetFields() + void qc.invalidateQueries({ queryKey: ['ntp', 'pools'] }) + }, + onError: (e: Error) => message.error(e.message), + }) + const del = useMutation({ + mutationFn: async (id: number) => { await apiClient.delete(`/ntp/pools/${id}`) }, + onSuccess: () => { void qc.invalidateQueries({ queryKey: ['ntp', 'pools'] }) }, + onError: (e: Error) => message.error(e.message), + }) + + const cols: ColumnsType = [ + { title: t('ntp.pool.kind'), dataIndex: 'kind', key: 'kind', + render: (s: string) => {s} }, + { title: t('ntp.pool.address'), dataIndex: 'address', key: 'address', + render: (s: string) => {s} }, + { title: t('ntp.pool.options'), key: 'options', + render: (_, row) => ( + + {row.iburst && iburst} + {row.prefer && prefer} + {row.minpoll != null && minpoll {row.minpoll}} + {row.maxpoll != null && maxpoll {row.maxpoll}} + + ) }, + { title: t('ntp.pool.description'), dataIndex: 'description', key: 'description', + render: (v?: string | null) => v ?? '—' }, + { title: t('common.active'), dataIndex: 'active', key: 'active', + render: (v: boolean) => }, + { + title: t('common.actions'), key: 'actions', + render: (_, row) => ( + { + setEditing(row) + form.setFieldsValue(row) + }} + onDelete={() => del.mutate(row.id)} + deleteConfirm={t('ntp.pool.deleteConfirm', { addr: row.address })} + /> + ), + }, + ] + + return ( + <> + } onClick={() => { + setCreating(true); form.resetFields() + form.setFieldsValue({ kind: 'pool', iburst: true, prefer: false, active: true } as Pool) + }}> + {t('ntp.pool.add')} + + } + /> + + { setEditing(null); setCreating(false); form.resetFields() }} + onOk={() => { void form.submit() }} + confirmLoading={upsert.isPending} + width={580} + destroyOnClose + > +
upsert.mutate(v)}> + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + ) +} + +function SettingsTab() { + const { t } = useTranslation() + const qc = useQueryClient() + const { data, isLoading } = useQuery({ queryKey: ['ntp', 'settings'], queryFn: getSettings }) + const { data: sys } = useQuery({ queryKey: ['system', 'interfaces'], queryFn: listSystemInterfaces }) + const [form] = Form.useForm() + + const ipOptions: { value: string; label: string }[] = [ + { value: '0.0.0.0', label: '0.0.0.0 — alle IPv4-Interfaces' }, + { value: '::', label: ':: — alle IPv6-Interfaces' }, + { value: '127.0.0.1', label: '127.0.0.1 — Loopback IPv4' }, + { value: '::1', label: '::1 — Loopback IPv6' }, + ] + for (const i of sys ?? []) { + if (i.ifname === 'lo') continue + for (const a of i.addr_info ?? []) { + ipOptions.push({ + value: a.local, + label: `${a.local} — ${i.ifname} (${a.family === 'inet' ? 'IPv4' : 'IPv6'})`, + }) + } + } + + const initial: SettingsForm | undefined = data ? { + ...data, + listen_addresses: data.listen_addresses.split(',').map(s => s.trim()).filter(Boolean), + } : undefined + + const save = useMutation({ + mutationFn: async (v: SettingsForm) => { + const body: Settings = { ...v, listen_addresses: v.listen_addresses.join(', ') } + return (await apiClient.put('/ntp/settings', body)).data + }, + onSuccess: () => { + message.success(t('common.save')) + void qc.invalidateQueries({ queryKey: ['ntp', 'settings'] }) + }, + onError: (e: Error) => message.error(e.message), + }) + + if (isLoading) return null + return ( +
save.mutate(v)} + style={{ maxWidth: 720 }} + > + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/packaging/debian/edgeguard-api/DEBIAN/control b/packaging/debian/edgeguard-api/DEBIAN/control index a82d45d..950be5f 100644 --- a/packaging/debian/edgeguard-api/DEBIAN/control +++ b/packaging/debian/edgeguard-api/DEBIAN/control @@ -12,7 +12,7 @@ Description: EdgeGuard — native Reverse-Proxy / LB / Forward-Proxy / VPN / Fir PG Streaming Replication + provider Floating-IP for HTTP ingress). . This package ships the management API, scheduler and CLI. -Depends: postgresql-16 | postgresql-17, haproxy (>= 2.8), squid, wireguard-tools, unbound, nftables, certbot, openssl, sudo, adduser, systemd, ca-certificates +Depends: postgresql-16 | postgresql-17, haproxy (>= 2.8), squid, wireguard-tools, unbound, chrony, nftables, certbot, openssl, sudo, adduser, systemd, ca-certificates Recommends: edgeguard-keydb (>= 6.3.4-edgeguard1), apparmor, fail2ban Section: admin Priority: optional diff --git a/packaging/debian/edgeguard-api/DEBIAN/postinst b/packaging/debian/edgeguard-api/DEBIAN/postinst index 7830777..327c83a 100755 --- a/packaging/debian/edgeguard-api/DEBIAN/postinst +++ b/packaging/debian/edgeguard-api/DEBIAN/postinst @@ -60,6 +60,10 @@ edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl reload unbound.service edgeguard ALL=(root) NOPASSWD: /bin/systemctl reload unbound.service edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl restart unbound.service edgeguard ALL=(root) NOPASSWD: /bin/systemctl restart unbound.service +edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl reload chrony.service +edgeguard ALL=(root) NOPASSWD: /bin/systemctl reload chrony.service +edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl restart chrony.service +edgeguard ALL=(root) NOPASSWD: /bin/systemctl restart chrony.service SUDOERS # ── Distro-Conf-Includes für die per-Service Renderer ───────── @@ -90,6 +94,19 @@ SUDOERS fi chown "$EG_USER":"$EG_USER" /etc/unbound/unbound.conf.d/edgeguard.conf chmod 0644 /etc/unbound/unbound.conf.d/edgeguard.conf + + # Chrony: gleicher Pattern wie Unbound — Drop-in im conf.d, der + # vom distro-default chrony.conf included wird. Datei gehört + # edgeguard damit der Renderer sie überschreiben kann. + install -d /etc/chrony/conf.d + if [ -L /etc/chrony/conf.d/edgeguard.conf ]; then + rm /etc/chrony/conf.d/edgeguard.conf + fi + if [ ! -f /etc/chrony/conf.d/edgeguard.conf ]; then + : > /etc/chrony/conf.d/edgeguard.conf + fi + chown "$EG_USER":"$EG_USER" /etc/chrony/conf.d/edgeguard.conf + chmod 0644 /etc/chrony/conf.d/edgeguard.conf chmod 0440 /etc/sudoers.d/edgeguard # ── Sysctl-Profil für Edge-Gateway (NAT + HAProxy + Forwarding) ──