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>
370 lines
11 KiB
Go
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
|
|
}
|