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

@@ -1 +1 @@
1.0.38
1.0.40

View File

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

View File

@@ -9,7 +9,7 @@ import (
"os"
)
var version = "1.0.38"
var version = "1.0.40"
const usage = `edgeguard-ctl — EdgeGuard CLI

View File

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

View File

@@ -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.

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
}

View File

@@ -1,7 +1,7 @@
{
"name": "edgeguard-management-ui",
"private": true,
"version": "1.0.38",
"version": "1.0.40",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -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() {
<Route path="/vpn/wireguard" element={<WireguardPage />} />
<Route path="/forward-proxy" element={<ForwardProxyPage />} />
<Route path="/dns" element={<DNSPage />} />
<Route path="/ntp" element={<NTPPage />} />
<Route path="/cluster" element={<ClusterPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Route>

View File

@@ -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: <NodeIndexOutlined /> },
{ path: '/ssl', labelKey: 'nav.ssl', icon: <SafetyCertificateOutlined /> },
{ path: '/dns', labelKey: 'nav.dns', icon: <GlobalOutlined /> },
{ path: '/ntp', labelKey: 'nav.ntp', icon: <ClockCircleOutlined /> },
],
},
{
@@ -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()

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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<Settings, 'listen_addresses'> {
listen_addresses: string[]
}
interface SystemIface {
ifname: string
addr_info?: Array<{ family: 'inet' | 'inet6'; local: string; prefixlen: number }>
}
async function listPools(): Promise<Pool[]> {
const r = await apiClient.get('/ntp/pools')
if (!isEnvelope(r.data)) return []
return (r.data.data as { pools?: Pool[] }).pools ?? []
}
async function getSettings(): Promise<Settings | null> {
const r = await apiClient.get('/ntp/settings')
return isEnvelope(r.data) ? (r.data.data as Settings) : null
}
async function listSystemInterfaces(): Promise<SystemIface[]> {
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 (
<div>
<PageHeader
icon={<ClockCircleOutlined />}
title={t('ntp.title')}
subtitle={t('ntp.intro')}
/>
<Tabs
defaultActiveKey="pools"
items={[
{ key: 'pools', label: <span><DatabaseOutlined /> {t('ntp.tabs.pools')}</span>, children: <PoolsTab /> },
{ key: 'settings', label: <span><SettingOutlined /> {t('ntp.tabs.settings')}</span>, children: <SettingsTab /> },
]}
/>
</div>
)
}
function PoolsTab() {
const { t } = useTranslation()
const qc = useQueryClient()
const { data, isLoading } = useQuery({ queryKey: ['ntp', 'pools'], queryFn: listPools })
const [editing, setEditing] = useState<Pool | null>(null)
const [creating, setCreating] = useState(false)
const [form] = Form.useForm<Pool>()
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<Pool> = [
{ title: t('ntp.pool.kind'), dataIndex: 'kind', key: 'kind',
render: (s: string) => <Tag color={s === 'pool' ? 'blue' : 'purple'}>{s}</Tag> },
{ title: t('ntp.pool.address'), dataIndex: 'address', key: 'address',
render: (s: string) => <code>{s}</code> },
{ title: t('ntp.pool.options'), key: 'options',
render: (_, row) => (
<Space size={4}>
{row.iburst && <Tag>iburst</Tag>}
{row.prefer && <Tag color="gold">prefer</Tag>}
{row.minpoll != null && <Text type="secondary">minpoll {row.minpoll}</Text>}
{row.maxpoll != null && <Text type="secondary">maxpoll {row.maxpoll}</Text>}
</Space>
) },
{ 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) => <StatusDot active={v} /> },
{
title: t('common.actions'), key: 'actions',
render: (_, row) => (
<ActionButtons
onEdit={() => {
setEditing(row)
form.setFieldsValue(row)
}}
onDelete={() => del.mutate(row.id)}
deleteConfirm={t('ntp.pool.deleteConfirm', { addr: row.address })}
/>
),
},
]
return (
<>
<DataTable
rowKey="id"
loading={isLoading}
dataSource={data ?? []}
columns={cols}
extraActions={
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
setCreating(true); form.resetFields()
form.setFieldsValue({ kind: 'pool', iburst: true, prefer: false, active: true } as Pool)
}}>
{t('ntp.pool.add')}
</Button>
}
/>
<Modal
title={editing ? t('ntp.pool.edit') : t('ntp.pool.add')}
open={editing !== null || creating}
onCancel={() => { setEditing(null); setCreating(false); form.resetFields() }}
onOk={() => { void form.submit() }}
confirmLoading={upsert.isPending}
width={580}
destroyOnClose
>
<Form form={form} layout="vertical" onFinish={(v) => upsert.mutate(v)}>
<Form.Item label={t('ntp.pool.kind')} name="kind" rules={[{ required: true }]}>
<Select options={[
{ value: 'pool', label: t('ntp.pool.kindPool') },
{ value: 'server', label: t('ntp.pool.kindServer') },
]} />
</Form.Item>
<Form.Item label={t('ntp.pool.address')} name="address" rules={[{ required: true }]}
extra={t('ntp.pool.addressExtra')}>
<Input placeholder="0.de.pool.ntp.org oder 10.0.0.10" />
</Form.Item>
<Space>
<Form.Item label={t('ntp.pool.iburst')} name="iburst" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item label={t('ntp.pool.prefer')} name="prefer" valuePropName="checked">
<Switch />
</Form.Item>
</Space>
<Space>
<Form.Item label={t('ntp.pool.minpoll')} name="minpoll">
<InputNumber min={0} max={17} style={{ width: 100 }} />
</Form.Item>
<Form.Item label={t('ntp.pool.maxpoll')} name="maxpoll">
<InputNumber min={0} max={17} style={{ width: 100 }} />
</Form.Item>
</Space>
<Form.Item label={t('ntp.pool.description')} name="description">
<Input.TextArea rows={2} />
</Form.Item>
<Form.Item label={t('common.active')} name="active" valuePropName="checked">
<Switch />
</Form.Item>
</Form>
</Modal>
</>
)
}
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<SettingsForm>()
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 (
<Form
form={form}
layout="vertical"
initialValues={initial}
onFinish={(v) => save.mutate(v)}
style={{ maxWidth: 720 }}
>
<Alert type="info" showIcon className="mb-12" message={t('ntp.settings.intro')} />
<Form.Item label={t('ntp.settings.serveClients')} name="serve_clients" valuePropName="checked"
extra={t('ntp.settings.serveClientsExtra')}>
<Switch />
</Form.Item>
<Form.Item label={t('ntp.settings.listenAddresses')} name="listen_addresses"
rules={[{ required: true, type: 'array', min: 1 }]}
extra={t('ntp.settings.listenAddressesExtra')}>
<Select mode="tags" options={ipOptions} showSearch optionFilterProp="value"
placeholder={t('ntp.settings.listenAddressesPlaceholder')} />
</Form.Item>
<Form.Item label={t('ntp.settings.allowACL')} name="allow_acl" rules={[{ required: true }]}
extra={t('ntp.settings.allowACLExtra')}>
<Input placeholder="127.0.0.0/8, 10.0.0.0/8" />
</Form.Item>
<Space>
<Form.Item label={t('ntp.settings.makestepSecs')} name="makestep_secs"
extra={t('ntp.settings.makestepSecsExtra')}>
<InputNumber min={0} step={0.1} style={{ width: 120 }} />
</Form.Item>
<Form.Item label={t('ntp.settings.makestepLimit')} name="makestep_limit">
<InputNumber min={-1} max={100} style={{ width: 120 }} />
</Form.Item>
</Space>
<Form.Item label={t('ntp.settings.rtcsync')} name="rtcsync" valuePropName="checked"
extra={t('ntp.settings.rtcsyncExtra')}>
<Switch />
</Form.Item>
<Form.Item label={t('ntp.settings.leapsectz')} name="leapsectz"
extra={t('ntp.settings.leapsectzExtra')}>
<Input placeholder="right/UTC" allowClear />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={save.isPending}>
{t('common.save')}
</Button>
</Form.Item>
</Form>
)
}

View File

@@ -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

View File

@@ -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) ──