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:
@@ -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)
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"os"
|
||||
)
|
||||
|
||||
var version = "1.0.38"
|
||||
var version = "1.0.40"
|
||||
|
||||
const usage = `edgeguard-ctl — EdgeGuard CLI
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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.
|
||||
|
||||
49
internal/chrony/chrony.cfg.tpl
Normal file
49
internal/chrony/chrony.cfg.tpl
Normal 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
110
internal/chrony/chrony.go
Normal 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
|
||||
}
|
||||
60
internal/database/migrations/0015_ntp.sql
Normal file
60
internal/database/migrations/0015_ntp.sql
Normal 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
|
||||
@@ -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
175
internal/handlers/ntp.go
Normal 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
38
internal/models/ntp.go
Normal 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" }
|
||||
143
internal/services/ntp/ntp.go
Normal file
143
internal/services/ntp/ntp.go
Normal 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
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "edgeguard-management-ui",
|
||||
"private": true,
|
||||
"version": "1.0.38",
|
||||
"version": "1.0.40",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
297
management-ui/src/pages/NTP/index.tsx
Normal file
297
management-ui/src/pages/NTP/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) ──
|
||||
|
||||
Reference in New Issue
Block a user