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>
275 lines
9.3 KiB
Go
275 lines
9.3 KiB
Go
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package dhcp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"git.beugsiud.de/beug.k/DDI/agent/core"
|
|
)
|
|
|
|
// haKeaConfig is an HA member's configuration as the server's keagen writes
|
|
// it, with the user file at user and the password file at pw.
|
|
func haKeaConfig(socket, user, pw string) string {
|
|
return fmt.Sprintf(`{
|
|
"Dhcp4": {
|
|
"control-sockets": [
|
|
{"socket-type": "unix", "socket-name": %[1]q},
|
|
{"socket-type": "http", "socket-address": "192.0.2.2", "socket-port": 8001,
|
|
"authentication": {"type": "basic", "realm": "ddi-suite-ha",
|
|
"clients": [{"user-file": %[2]q, "password-file": %[3]q}]}}
|
|
],
|
|
"hooks-libraries": [
|
|
{"library": "libdhcp_lease_cmds.so"},
|
|
{"library": "libdhcp_ha.so", "parameters": {"high-availability": [{
|
|
"this-server-name": "primary", "mode": "hot-standby",
|
|
"multi-threading": {"enable-multi-threading": true, "http-dedicated-listener": false},
|
|
"peers": [
|
|
{"name": "primary", "url": "http://192.0.2.2:8001/", "role": "primary", "auto-failover": true,
|
|
"basic-auth-user-file": %[2]q, "basic-auth-password-file": %[3]q},
|
|
{"name": "secondary", "url": "http://192.0.2.3:8001/", "role": "standby", "auto-failover": true,
|
|
"basic-auth-user-file": %[2]q, "basic-auth-password-file": %[3]q}
|
|
]}]}}
|
|
],
|
|
"valid-lifetime": 4294967295,
|
|
"subnet4": []
|
|
}
|
|
}
|
|
`, socket, user, pw)
|
|
}
|
|
|
|
// haFiles is an HA revision's files.
|
|
func haFiles(conf, user, pw string) map[string]string {
|
|
return map[string]string{FileName: conf, UserFileName: user, PasswordFileName: pw}
|
|
}
|
|
|
|
func readFile(t *testing.T, p string) string {
|
|
t.Helper()
|
|
b, err := os.ReadFile(p)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
func exists(p string) bool {
|
|
_, err := os.Lstat(p)
|
|
return err == nil
|
|
}
|
|
|
|
func TestValidateHA(t *testing.T) {
|
|
e := newEnv(t)
|
|
ctx := context.Background()
|
|
dir := filepath.Dir(e.conf)
|
|
pw := filepath.Join(dir, PasswordFileName)
|
|
user := filepath.Join(dir, UserFileName)
|
|
var checked, checkedPath string
|
|
e.run.fn = func(name string, args []string) ([]byte, error) {
|
|
checkedPath = args[len(args)-1]
|
|
checked = readFile(t, checkedPath)
|
|
return nil, nil
|
|
}
|
|
good := revision(t, 1, haFiles(haKeaConfig(e.kea.path, user, pw), "ha\n", "secret\n"))
|
|
if err := e.role.Validate(ctx, good); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// kea-dhcp4 -t reads the revision's credential files, not the live
|
|
// ones, and sees the configuration otherwise unchanged.
|
|
var doc map[string]any
|
|
if err := json.Unmarshal([]byte(checked), &doc); err != nil {
|
|
t.Fatalf("checked configuration: %v\n%s", err, checked)
|
|
}
|
|
for _, f := range []struct{ live, rev string }{
|
|
{pw, filepath.Join(good.Dir, PasswordFileName)},
|
|
{user, filepath.Join(good.Dir, UserFileName)},
|
|
} {
|
|
if strings.Contains(checked, `"`+f.live+`"`) || strings.Count(checked, `"`+f.rev+`"`) != 3 {
|
|
t.Errorf("checked configuration does not use %s:\n%s", f.rev, checked)
|
|
}
|
|
}
|
|
if !strings.Contains(checked, "4294967295") || !strings.Contains(checked, e.kea.path) {
|
|
t.Errorf("checked configuration changed:\n%s", checked)
|
|
}
|
|
if exists(pw) || exists(user) || exists(checkedPath) {
|
|
t.Error("Validate wrote live credential files or left the temporary configuration behind")
|
|
}
|
|
|
|
cases := map[string]struct {
|
|
files map[string]string
|
|
want string
|
|
}{
|
|
"credentials without reference": {
|
|
files: haFiles(keaConfig(e.kea.path), "ha\n", "secret\n"),
|
|
want: "does not reference it",
|
|
},
|
|
"reference without password": {
|
|
files: map[string]string{FileName: haKeaConfig(e.kea.path, user, pw), UserFileName: "ha\n"},
|
|
want: "the revision has none",
|
|
},
|
|
"reference without user": {
|
|
files: map[string]string{FileName: haKeaConfig(e.kea.path, user, pw), PasswordFileName: "x\n"},
|
|
want: "the revision has none",
|
|
},
|
|
"other password file name": {
|
|
files: haFiles(haKeaConfig(e.kea.path, user, filepath.Join(dir, "shadow")), "ha\n", "x\n"),
|
|
want: "file name must be " + PasswordFileName,
|
|
},
|
|
"other user file name": {
|
|
files: haFiles(haKeaConfig(e.kea.path, filepath.Join(dir, "passwd"), pw), "ha\n", "x\n"),
|
|
want: "file name must be " + UserFileName,
|
|
},
|
|
"relative path": {
|
|
files: haFiles(haKeaConfig(e.kea.path, user, "etc/"+PasswordFileName), "ha\n", "x\n"),
|
|
want: "HA credential file",
|
|
},
|
|
"different directories": {
|
|
files: haFiles(haKeaConfig(e.kea.path, filepath.Join(dir, "a", UserFileName), pw), "ha\n", "x\n"),
|
|
want: "same directory",
|
|
},
|
|
"missing directory": {
|
|
files: haFiles(haKeaConfig(e.kea.path, filepath.Join(dir, "nope", UserFileName), filepath.Join(dir, "nope", PasswordFileName)), "ha\n", "x\n"),
|
|
want: "HA credential file directory",
|
|
},
|
|
"different references": {
|
|
files: haFiles(strings.Replace(haKeaConfig(e.kea.path, user, pw), pw, filepath.Join(dir, "a", PasswordFileName), 1), "ha\n", "x\n"),
|
|
want: "different HA credential files",
|
|
},
|
|
"unexpected file": {
|
|
files: map[string]string{FileName: haKeaConfig(e.kea.path, user, pw), PasswordFileName: "x\n", UserFileName: "ha\n", "other": ""},
|
|
want: "exactly",
|
|
},
|
|
}
|
|
for name, c := range cases {
|
|
t.Run(name, func(t *testing.T) {
|
|
err := e.role.Validate(ctx, revision(t, 1, c.files))
|
|
if err == nil || !strings.Contains(err.Error(), c.want) {
|
|
t.Fatalf("err = %v, want %q", err, c.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestActivateHA(t *testing.T) {
|
|
e := newEnv(t)
|
|
ctx := context.Background()
|
|
dir := filepath.Dir(e.conf)
|
|
pw := filepath.Join(dir, PasswordFileName)
|
|
user := filepath.Join(dir, UserFileName)
|
|
conf := haKeaConfig(e.kea.path, user, pw)
|
|
e.kea.setHandler(func(req request) any { return ok(nil) })
|
|
|
|
rev1 := revision(t, 1, haFiles(conf, "ha-1\n", "secret-1\n"))
|
|
if err := e.role.Activate(ctx, rev1, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for p, want := range map[string]string{pw: "secret-1\n", user: "ha-1\n", e.conf: conf} {
|
|
if got := readFile(t, p); got != want {
|
|
t.Errorf("%s = %q", p, got)
|
|
}
|
|
}
|
|
for _, p := range []string{pw, user} {
|
|
if fi, _ := os.Stat(p); fi.Mode().Perm() != 0o640 {
|
|
t.Errorf("%s: mode %o", p, fi.Mode().Perm())
|
|
}
|
|
}
|
|
if c := e.role.checkInstalled(rev1); c.State != core.HealthOK {
|
|
t.Errorf("installed check: %+v", c)
|
|
}
|
|
|
|
// Changed credentials on disk are noticed, without showing them.
|
|
for _, p := range []string{user, pw} {
|
|
old := readFile(t, p)
|
|
if err := os.WriteFile(p, []byte("edited\n"), 0o640); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c := e.role.checkInstalled(rev1)
|
|
if c.State != core.HealthDegraded || !strings.Contains(c.Message, p) || strings.Contains(c.Message, "edited") {
|
|
t.Errorf("modified %s: %+v", p, c)
|
|
}
|
|
if err := os.WriteFile(p, []byte(old), 0o640); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// A failed reload restores configuration and credential files.
|
|
if err := os.WriteFile(pw, []byte("edited\n"), 0o640); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rev2 := revision(t, 2, haFiles(conf, "ha-2\n", "secret-2\n"))
|
|
n := 0
|
|
var mu sync.Mutex
|
|
e.kea.setHandler(func(req request) any {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
n++
|
|
if n == 1 {
|
|
return map[string]any{"result": 1, "text": "HA configuration error"}
|
|
}
|
|
return ok(nil)
|
|
})
|
|
if err := e.role.Activate(ctx, rev2, &rev1); err == nil {
|
|
t.Fatal("reload failure not reported")
|
|
}
|
|
if got := readFile(t, pw); got != "edited\n" {
|
|
t.Errorf("password file not restored: %q", got)
|
|
}
|
|
if got := readFile(t, user); got != "ha-1\n" {
|
|
t.Errorf("user file not restored: %q", got)
|
|
}
|
|
|
|
// Without HA the credential files of the previous revision go.
|
|
e.kea.setHandler(func(req request) any { return ok(nil) })
|
|
rev3 := revision(t, 3, map[string]string{FileName: keaConfig(e.kea.path)})
|
|
if err := e.role.Activate(ctx, rev3, &rev1); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if exists(pw) || exists(user) {
|
|
t.Error("credential files of the previous revision still exist")
|
|
}
|
|
}
|
|
|
|
func TestKeaCredentialPath(t *testing.T) {
|
|
for _, c := range []struct{ dir, file, want string }{
|
|
{"", "/etc/kea/pw", "/etc/kea/pw"},
|
|
{"", "pw", "/pw"},
|
|
{"/etc/kea", "pw", "/etc/kea/pw"},
|
|
{"/etc/kea/", "/pw", "/etc/kea/pw"},
|
|
} {
|
|
if got := keaCredentialPath(c.dir, c.file); got != c.want {
|
|
t.Errorf("keaCredentialPath(%q, %q) = %q, want %q", c.dir, c.file, got, c.want)
|
|
}
|
|
}
|
|
got, err := haCredentialPaths([]byte(`{"Dhcp4": {"control-socket": {"socket-type": "http",
|
|
"authentication": {"directory": "/etc/kea", "clients": [{"user-file": "` + UserFileName + `", "password-file": "` + PasswordFileName + `"}]}}}}`))
|
|
if err != nil || got[PasswordFileName] != "/etc/kea/"+PasswordFileName || got[UserFileName] != "/etc/kea/"+UserFileName {
|
|
t.Errorf("directory and file: %v, %v", got, err)
|
|
}
|
|
if got, err := haCredentialPaths([]byte(`{"Dhcp4": {"hooks-libraries": [{"library": "x", "parameters": {}}]}}`)); len(got) != 0 || err != nil {
|
|
t.Errorf("no references: %v, %v", got, err)
|
|
}
|
|
if _, err := haCredentialPaths([]byte(`{"Dhcp4": `)); err == nil || !strings.Contains(err.Error(), "not valid JSON") {
|
|
t.Errorf("invalid JSON: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestReplaceStrings(t *testing.T) {
|
|
out, err := replaceStrings([]byte(`{"a": "x", "b": ["x", "y", {"c": "x"}], "n": 18446744073709551615, "d": "xx"}`),
|
|
map[string]string{"x": "z", "y": "w"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if want := `{"a":"z","b":["z","w",{"c":"z"}],"d":"xx","n":18446744073709551615}`; string(out) != want {
|
|
t.Errorf("got %s, want %s", out, want)
|
|
}
|
|
if _, err := replaceStrings([]byte("{"), nil); err == nil {
|
|
t.Error("invalid JSON accepted")
|
|
}
|
|
}
|