Files
edgeguard-native/internal/services/ntp/ntp.go
Debian e4d83d226e 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>
2026-05-11 06:58:54 +02:00

144 lines
4.5 KiB
Go

// 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
}