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>
250 lines
7.7 KiB
Go
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, "/")
|
|
}
|