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:
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
|
||||
}
|
||||
Reference in New Issue
Block a user