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