Files
DDI/agent/role/dhcp/dhcp.go
T
sir_melonchenandClaude Opus 5.5 9a6c3f5159 fix(kea): read the HA user from a file as Kea 3.x requires
Kea 3.x rejects clear-text users and passwords in HTTP authentication
("use of clear text 'user' is NOT SECURE"). Control socket clients now
use user-file and password-file, HA peers basic-auth-user-file and
basic-auth-password-file. The new secret artifact ddi-suite-ha-user
sits next to the password file (HAConfig.UserFile, set by derive);
keagen requires the file names ddi-suite-ha-password and
ddi-suite-ha-user. The DHCP agent handles both credential files alike:
consistent references in one directory, validation copy pointing at
the revision's files, written before the configuration, restored on a
failed reload, removed when HA goes away, compared by the health check.
Integration test and e2e read the user from the file; ADR 0023 updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:28:18 +02:00

370 lines
11 KiB
Go

// SPDX-License-Identifier: Apache-2.0
// Package dhcp is the agent role for the Kea DHCPv4 server (ADR 0005,
// ADR 0021). A DHCP revision consists of kea-dhcp4.conf and, for a member
// of a Kea HA pair (ADR 0023), the secret credential files
// ddi-suite-ha-password and ddi-suite-ha-user (Kea 3.x accepts basic-auth
// credentials only from files). The role installs the configuration as
// Config.ConfigFile, writes the credential files to the paths the
// configuration references (file names as above, one existing directory)
// and makes kea-dhcp4 re-read the configuration with config-reload over its
// UNIX control socket. The Kea Control Agent is not used (it is deprecated
// since Kea 2.7.2/3.0).
package dhcp
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"path"
"strings"
"time"
"git.beugsiud.de/beug.k/DDI/agent/core"
"git.beugsiud.de/beug.k/DDI/agent/role/internal/command"
"git.beugsiud.de/beug.k/DDI/agent/role/internal/fsutil"
)
// FileName is the configuration file of a DHCP revision.
const FileName = "kea-dhcp4.conf"
// PasswordFileName is the HA password file of a DHCP revision; it is also
// the only file name the configuration may reference as password file.
const PasswordFileName = "ddi-suite-ha-password" //nolint:gosec // a file name, not a credential
// Role manages kea-dhcp4.
type Role struct {
cfg Config
run command.Runner
now func() time.Time
}
var (
_ core.Role = (*Role)(nil)
_ core.LeaseSource = (*Role)(nil)
)
// New creates the role from the "dhcp:" section of the agent configuration;
// it matches core.RoleFactory. decode may be nil (no section).
func New(decode func(v any) error) (core.Role, error) {
cfg := DefaultConfig()
if decode != nil {
if err := decode(&cfg); err != nil {
return nil, fmt.Errorf("dhcp configuration: %w", err)
}
}
return newRole(cfg, command.Exec{Timeout: cfg.CommandTimeout})
}
func newRole(cfg Config, run command.Runner) (*Role, error) {
if err := cfg.Validate(); err != nil {
return nil, err
}
return &Role{cfg: cfg, run: run, now: time.Now}, nil
}
// Kind implements core.Role.
func (r *Role) Kind() core.Kind { return core.KindDHCP }
// ServiceVersion runs "kea-dhcp4 -v", which prints the bare version
// (e.g. "3.2.1"), and returns "Kea 3.2.1".
func (r *Role) ServiceVersion(ctx context.Context) (string, error) {
out, err := r.run.Run(ctx, r.cfg.KeaDHCP4, "-v")
if err != nil {
return "", err
}
line, _, _ := strings.Cut(strings.TrimSpace(string(out)), "\n")
f := strings.Fields(line)
if len(f) == 0 {
return "", errors.New("kea-dhcp4 -v printed nothing")
}
v := f[0]
if strings.EqualFold(v, "kea") && len(f) > 1 {
v = f[1]
}
if v[0] < '0' || v[0] > '9' {
return "", fmt.Errorf("unexpected output of kea-dhcp4 -v: %q", command.Excerpt([]byte(line)))
}
return "Kea " + v, nil
}
// replaceStrings returns the JSON document data with every string value
// that is a key of replace replaced by its value (numbers are kept as
// written).
func replaceStrings(data []byte, replace map[string]string) ([]byte, error) {
dec := json.NewDecoder(bytes.NewReader(data))
dec.UseNumber()
var doc any
if err := dec.Decode(&doc); err != nil {
return nil, fmt.Errorf("%s: %w", FileName, err)
}
var walk func(v any) any
walk = func(v any) any {
switch x := v.(type) {
case string:
if to, ok := replace[x]; ok {
return to
}
case map[string]any:
for k, e := range x {
x[k] = walk(e)
}
case []any:
for i, e := range x {
x[i] = walk(e)
}
}
return v
}
return json.Marshal(walk(doc))
}
type socketConfig struct {
SocketType string `json:"socket-type"`
SocketName string `json:"socket-name"`
}
// checkControlSocket requires a UNIX control socket equal to the configured
// one: the agent could not reach Kea after a reload otherwise. Kea 2.x uses
// "control-socket"; Kea 3.x additionally accepts a "control-sockets" list.
func (r *Role) checkControlSocket(data []byte) error {
var doc struct {
Dhcp4 *struct {
ControlSocket *socketConfig `json:"control-socket"`
ControlSockets []socketConfig `json:"control-sockets"`
} `json:"Dhcp4"`
}
if err := json.Unmarshal(data, &doc); err != nil {
return fmt.Errorf("%s is not valid JSON: %w", FileName, err)
}
if doc.Dhcp4 == nil {
return fmt.Errorf("%s has no Dhcp4 object", FileName)
}
socks := doc.Dhcp4.ControlSockets
if doc.Dhcp4.ControlSocket != nil {
socks = append(socks, *doc.Dhcp4.ControlSocket)
}
var names []string
for _, s := range socks {
if s.SocketType != "unix" {
continue
}
if path.Clean(s.SocketName) == r.cfg.ControlSocket {
return nil
}
names = append(names, s.SocketName)
}
if len(names) == 0 {
return fmt.Errorf("%s has no UNIX control socket; this agent needs %q", FileName, r.cfg.ControlSocket)
}
return fmt.Errorf("%s uses control socket %q, but this agent's dhcp.control_socket is %q: "+
"set the same control socket in the server's generator settings and the agent configuration",
FileName, strings.Join(names, ", "), r.cfg.ControlSocket)
}
// Activate installs the HA credential files and the configuration and
// sends config-reload. If the reload fails, the previous files are restored
// and reloaded again. Credential files of prev that rev no longer uses are
// removed.
func (r *Role) Activate(ctx context.Context, rev core.Revision, prev *core.Revision) error {
files, data, live, err := r.plan(rev)
if err != nil {
return err
}
used := map[string]bool{}
for _, p := range live {
used[p] = true
}
var stale []string
if prev != nil {
if _, _, old, err := r.plan(*prev); err == nil {
for _, name := range credentialNames {
if p := old[name]; p != "" && !used[p] {
stale = append(stale, p)
}
}
}
}
var backup fsutil.Backup
toSave := append([]string(nil), stale...)
for _, name := range credentialNames {
if p := live[name]; p != "" {
toSave = append(toSave, p)
}
}
for _, p := range append(toSave, r.cfg.ConfigFile) {
if err := backup.Save(p); err != nil {
return err
}
}
restore := func(cause error) error {
errs := []error{cause}
if err := backup.Restore(); err != nil {
errs = append(errs, fmt.Errorf("restoring the previous configuration failed: %w", err))
}
return errors.Join(errs...)
}
// The credentials first: Kea reads them while loading the configuration.
for _, name := range credentialNames {
p := live[name]
if p == "" {
continue
}
f := files.creds[name]
content, err := fsutil.ReadFile(f.src)
if err != nil {
return restore(err)
}
if err := fsutil.WriteFile(p, content, f.mode, fsutil.OwnerFor(p)); err != nil {
return restore(err)
}
}
if err := fsutil.WriteFile(r.cfg.ConfigFile, data, files.confMode, fsutil.OwnerFor(r.cfg.ConfigFile)); err != nil {
return restore(err)
}
for _, p := range stale {
if err := fsutil.Remove(p); err != nil {
return restore(fmt.Errorf("remove %s: %w", p, err))
}
}
reloadErr := r.reload(ctx)
if reloadErr == nil {
return nil
}
errs := []error{reloadErr}
if err := backup.Restore(); err != nil {
errs = append(errs, fmt.Errorf("restoring the previous configuration failed: %w", err))
} else if err := r.reload(context.WithoutCancel(ctx)); err != nil {
errs = append(errs, fmt.Errorf("reload of the restored configuration failed: %w", err))
}
return errors.Join(errs...)
}
// reload sends config-reload. On failure Kea keeps its previous
// configuration (Kea source, loadConfigFile rolls back the staging
// configuration), but the file on disk must be restored as well.
func (r *Role) reload(ctx context.Context) error {
resp, err := r.Command(ctx, "config-reload", nil)
if err != nil {
return err
}
return resp.Err("config-reload")
}
// statusArgs are the arguments of status-get (Kea ARM, "status-get"; the
// "sockets" map exists since Kea 2.3).
type statusArgs struct {
PID *int `json:"pid"`
Uptime *int64 `json:"uptime"`
Reload *int64 `json:"reload"`
Sockets *struct {
Status string `json:"status"`
Errors []string `json:"errors"`
} `json:"sockets"`
}
// Health sends status-get. Failed if Kea does not answer; Degraded if Kea
// reports that it is still retrying to open its DHCP sockets, Failed if it
// gave up. If expected is given, the installed configuration file must
// equal the revision's file (Degraded otherwise, e.g. after a manual edit).
func (r *Role) Health(ctx context.Context, expected *core.Revision) core.Health {
var h core.Health
st := core.HealthCheck{Name: "status-get", State: core.HealthOK}
resp, err := r.Command(ctx, "status-get", nil)
if err == nil {
err = resp.Err("status-get")
}
var args statusArgs
if err == nil && len(resp.Arguments) > 0 {
err = json.Unmarshal(resp.Arguments, &args)
}
if err != nil {
st.State = core.HealthFailed
st.Message = err.Error()
h.Checks = append(h.Checks, st)
h.State = core.HealthFailed
h.Message = "kea-dhcp4 does not answer status-get"
return h
}
st.Message = describeStatus(args)
h.Checks = append(h.Checks, st)
if s := args.Sockets; s != nil && s.Status != "" && s.Status != "ready" {
c := core.HealthCheck{Name: "sockets", State: core.HealthDegraded,
Message: s.Status + ": " + strings.Join(s.Errors, "; ")}
if s.Status == "failed" {
c.State = core.HealthFailed
}
h.Checks = append(h.Checks, c)
}
if expected != nil {
h.Checks = append(h.Checks, r.checkInstalled(*expected))
}
h.State = core.HealthOK
for _, c := range h.Checks {
if c.State > h.State {
h.State = c.State
}
}
h.Message = "kea-dhcp4 " + st.Message
return h
}
func describeStatus(a statusArgs) string {
var parts []string
if a.PID != nil {
parts = append(parts, fmt.Sprintf("pid %d", *a.PID))
}
if a.Uptime != nil {
parts = append(parts, "up "+(time.Duration(*a.Uptime)*time.Second).String())
}
if a.Reload != nil {
parts = append(parts, "configured "+(time.Duration(*a.Reload)*time.Second).String()+" ago")
}
if len(parts) == 0 {
return "is running"
}
return strings.Join(parts, ", ")
}
func (r *Role) checkInstalled(rev core.Revision) core.HealthCheck {
c := core.HealthCheck{Name: "configuration file", State: core.HealthOK}
files, want, live, err := r.plan(rev)
var have []byte
if err == nil {
have, err = fsutil.ReadFile(r.cfg.ConfigFile)
}
// The first credential file that differs; messages never show content.
var differs string
for _, name := range credentialNames {
p := live[name]
if err != nil || p == "" || differs != "" {
continue
}
var w, h []byte
if w, err = fsutil.ReadFile(files.creds[name].src); err == nil {
h, err = fsutil.ReadFile(p)
}
if err == nil && !bytes.Equal(w, h) {
differs = p
}
}
switch {
case err != nil:
c.State = core.HealthDegraded
c.Message = err.Error()
case !bytes.Equal(want, have):
c.State = core.HealthDegraded
c.Message = fmt.Sprintf("%s differs from revision %d", r.cfg.ConfigFile, rev.Number)
case differs != "":
c.State = core.HealthDegraded
c.Message = fmt.Sprintf("HA credential file %s differs from revision %d", differs, rev.Number)
default:
c.Message = fmt.Sprintf("%s is revision %d", r.cfg.ConfigFile, rev.Number)
}
return c
}