Files
DDI/agent/role/dhcp/credentials.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

250 lines
7.7 KiB
Go

// SPDX-License-Identifier: Apache-2.0
package dhcp
import (
"context"
"encoding/json"
"fmt"
"io/fs"
"os"
"path"
"path/filepath"
"slices"
"strings"
"git.beugsiud.de/beug.k/DDI/agent/core"
"git.beugsiud.de/beug.k/DDI/agent/role/internal/fsutil"
)
// UserFileName is the HA user file of a DHCP revision; it is also the only
// file name the configuration may reference as user file.
const UserFileName = "ddi-suite-ha-user"
// credentialNames are the HA credential files (ADR 0023). Kea 3.x refuses
// clear-text users and passwords in HTTP authentication, so both come from
// files, which the revision carries as secret artifacts.
var credentialNames = []string{PasswordFileName, UserFileName}
// revFile is a file inside a revision.
type revFile struct {
src string
mode fs.FileMode
}
// revFiles are the files of a revision.
type revFiles struct {
conf string // path inside the revision
confMode fs.FileMode
// creds are the HA credential files of the revision by file name.
creds map[string]revFile
}
// source returns the files of rev.
func source(rev core.Revision) (revFiles, error) {
out := revFiles{creds: map[string]revFile{}}
bad := false
for _, f := range rev.Files {
_, dup := out.creds[f.Path]
switch {
case f.CreateOnly:
bad = true
case f.Path == FileName && out.conf == "":
out.conf, out.confMode = filepath.Join(rev.Dir, FileName), fs.FileMode(f.Mode)
case slices.Contains(credentialNames, f.Path) && !dup:
out.creds[f.Path] = revFile{src: filepath.Join(rev.Dir, f.Path), mode: fs.FileMode(f.Mode)}
default:
bad = true
}
}
if bad || out.conf == "" {
paths := make([]string, len(rev.Files))
for i, f := range rev.Files {
paths[i] = f.Path
}
return revFiles{}, fmt.Errorf("DHCP revision must contain exactly %s (and %s for HA), got %v",
FileName, strings.Join(credentialNames, ", "), paths)
}
return out, nil
}
// plan reads the configuration of rev and finds the live paths of its HA
// credential files by file name (empty without HA). The revision has a
// credential file exactly if the configuration references one.
func (r *Role) plan(rev core.Revision) (files revFiles, data []byte, live map[string]string, err error) {
if files, err = source(rev); err != nil {
return files, nil, nil, err
}
if data, err = fsutil.ReadFile(files.conf); err != nil {
return files, nil, nil, err
}
if live, err = haCredentialPaths(data); err != nil {
return files, nil, nil, err
}
for _, name := range credentialNames {
_, inRev := files.creds[name]
switch p, ok := live[name]; {
case inRev && !ok:
return files, nil, nil, fmt.Errorf("the revision has %s, but %s does not reference it", name, FileName)
case !inRev && ok:
return files, nil, nil, fmt.Errorf("%s references the HA credential file %q, but the revision has none", FileName, p)
}
}
return files, data, live, nil
}
// Validate checks that the configuration uses this agent's control socket
// and valid HA credential file paths, and runs "kea-dhcp4 -t" on it. With
// HA, Kea reads the credential files while checking, so the check runs on a
// temporary copy of the configuration that refers to the revision's files
// instead of the live ones.
func (r *Role) Validate(ctx context.Context, rev core.Revision) error {
files, data, live, err := r.plan(rev)
if err != nil {
return err
}
if err := r.checkControlSocket(data); err != nil {
return err
}
check := files.conf
if len(live) > 0 {
replace := map[string]string{}
for name, p := range live {
if err := fsutil.RequireDir(filepath.Dir(p)); err != nil {
return fmt.Errorf("HA credential file directory: %w", err)
}
replace[p] = files.creds[name].src
}
tmp, err := os.MkdirTemp("", "ddi-dhcp-validate-")
if err != nil {
return err
}
defer func() { _ = os.RemoveAll(tmp) }()
test, err := replaceStrings(data, replace)
if err != nil {
return err
}
check = filepath.Join(tmp, FileName)
if err := os.WriteFile(check, test, 0o600); err != nil {
return err
}
}
if _, err := r.run.Run(ctx, r.cfg.KeaDHCP4, "-t", check); err != nil {
return fmt.Errorf("configuration check failed: %w", err)
}
return nil
}
// credentialRefs is the part of the configuration that names HA credential
// files: the clients of control socket authentication (Kea ARM 3.x,
// "HTTP/HTTPS Control Socket": user-file, password-file) and the HA peers
// (basic-auth-user-file, basic-auth-password-file).
type credentialRefs struct {
Dhcp4 *struct {
ControlSocket *authSocket `json:"control-socket"`
ControlSockets []authSocket `json:"control-sockets"`
HooksLibraries []struct {
Parameters *struct {
HighAvailability []struct {
Peers []struct {
UserFile string `json:"basic-auth-user-file"`
PasswordFile string `json:"basic-auth-password-file"`
} `json:"peers"`
} `json:"high-availability"`
} `json:"parameters"`
} `json:"hooks-libraries"`
} `json:"Dhcp4"`
}
type authSocket struct {
Authentication *struct {
Directory string `json:"directory"`
Clients []struct {
UserFile string `json:"user-file"`
PasswordFile string `json:"password-file"`
} `json:"clients"`
} `json:"authentication"`
}
// haCredentialPaths returns the HA credential files the configuration
// references, by file name. All references to one kind must name the same
// clean absolute path whose file name is PasswordFileName or UserFileName,
// and both files must share a directory: the agent writes the files there,
// so it must not be able to replace anything else.
func haCredentialPaths(data []byte) (map[string]string, error) {
var doc credentialRefs
if err := json.Unmarshal(data, &doc); err != nil {
return nil, fmt.Errorf("%s is not valid JSON: %w", FileName, err)
}
refs := map[string][]string{}
add := func(name, p string) {
if p != "" {
refs[name] = append(refs[name], p)
}
}
if doc.Dhcp4 != nil {
socks := doc.Dhcp4.ControlSockets
if doc.Dhcp4.ControlSocket != nil {
socks = append(socks, *doc.Dhcp4.ControlSocket)
}
for _, s := range socks {
if s.Authentication == nil {
continue
}
for _, c := range s.Authentication.Clients {
if c.UserFile != "" {
add(UserFileName, keaCredentialPath(s.Authentication.Directory, c.UserFile))
}
if c.PasswordFile != "" {
add(PasswordFileName, keaCredentialPath(s.Authentication.Directory, c.PasswordFile))
}
}
}
for _, h := range doc.Dhcp4.HooksLibraries {
if h.Parameters == nil {
continue
}
for _, rel := range h.Parameters.HighAvailability {
for _, p := range rel.Peers {
add(UserFileName, p.UserFile)
add(PasswordFileName, p.PasswordFile)
}
}
}
}
out := map[string]string{}
for _, name := range credentialNames {
rs := refs[name]
if len(rs) == 0 {
continue
}
p := rs[0]
for _, o := range rs[1:] {
if o != p {
return nil, fmt.Errorf("%s references different HA credential files (%q and %q)", FileName, p, o)
}
}
if err := checkAbsPath(p); err != nil {
return nil, fmt.Errorf("HA credential file: %w", err)
}
if path.Base(p) != name {
return nil, fmt.Errorf("HA credential file %q: the file name must be %s", p, name)
}
out[name] = p
}
if pw, user := out[PasswordFileName], out[UserFileName]; pw != "" && user != "" && path.Dir(pw) != path.Dir(user) {
return nil, fmt.Errorf("HA credential files %q and %q must be in the same directory", pw, user)
}
return out, nil
}
// keaCredentialPath joins the directory and file name of a credential file
// the way Kea does (BasicHttpAuthConfig::getFileContent): a '/' between
// them, a leading '/' of the file name dropped.
func keaCredentialPath(dir, file string) string {
if dir == "" || !strings.HasSuffix(dir, "/") {
dir += "/"
}
return dir + strings.TrimPrefix(file, "/")
}