Files
proxy/internal/dockerdaemon/dockerdaemon_test.go
T

439 lines
17 KiB
Go

package dockerdaemon_test
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"proxyctl/internal/dockerdaemon"
"proxyctl/internal/profile"
"proxyctl/internal/target"
"proxyctl/internal/target/targettest"
)
type recorder struct{ events []string }
type fakeRunner struct {
recorder *recorder
show string
fail map[string]error
}
func (r *fakeRunner) Run(_ context.Context, name string, args ...string) ([]byte, error) {
command := strings.Join(append([]string{name}, args...), " ")
r.recorder.events = append(r.recorder.events, command)
if err := r.fail[command]; err != nil {
return nil, err
}
if strings.Contains(command, " show ") {
return []byte(r.show), nil
}
if strings.Contains(command, " is-active ") {
return []byte("active\n"), nil
}
return nil, nil
}
type recordingFS struct{ recorder *recorder }
func (f recordingFS) ReadFile(path string) ([]byte, error) { return os.ReadFile(path) }
func (f recordingFS) FileMode(path string) (os.FileMode, error) {
info, err := os.Stat(path)
if err != nil {
return 0, err
}
return info.Mode().Perm(), nil
}
func (f recordingFS) AtomicWrite(path string, data []byte, mode os.FileMode) error {
f.recorder.events = append(f.recorder.events, "write "+filepath.Base(path))
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
temporary := path + ".tmp"
if err := os.WriteFile(temporary, data, mode); err != nil {
return err
}
return os.Rename(temporary, path)
}
func (f recordingFS) Remove(path string) error {
f.recorder.events = append(f.recorder.events, "remove "+filepath.Base(path))
return os.Remove(path)
}
func fixture(t *testing.T) (*dockerdaemon.Adapter, *recorder, string, string, *fakeRunner) {
t.Helper()
directory := t.TempDir()
daemonPath := filepath.Join(directory, "etc", "docker", "daemon.json")
recoveryPath := filepath.Join(directory, "state", "docker-daemon.json")
record := &recorder{}
runner := &fakeRunner{recorder: record, show: "LoadState=loaded\nFragmentPath=/lib/systemd/system/docker.service\n", fail: map[string]error{}}
adapter := dockerdaemon.New(dockerdaemon.Configuration{DaemonConfigPath: daemonPath, RecoveryPath: recoveryPath, TrustedRoot: directory}, recordingFS{recorder: record}, runner)
return adapter, record, daemonPath, recoveryPath, runner
}
func activeProfile() *profile.Profile {
return &profile.Profile{Name: "office", Mode: profile.AdvancedMode, HTTPURL: "http://alice:secret@proxy.example:8080", HTTPSURL: "https://proxy.example:8443", Bypass: []string{"localhost", ".example.com", "10.0.0.0/8", "2001:db8::1"}}
}
type faultFS struct {
recordingFS
permission bool
failDaemon bool
failRecoveryOnce bool
}
func (f *faultFS) AtomicWrite(path string, data []byte, mode os.FileMode) error {
if f.permission {
return os.ErrPermission
}
if f.failDaemon && filepath.Base(path) == "daemon.json" {
return errors.New("write failed")
}
if f.failRecoveryOnce && filepath.Base(path) == "docker-daemon.json" {
f.failRecoveryOnce = false
return errors.New("secret recovery failure")
}
return f.recordingFS.AtomicWrite(path, data, mode)
}
type fixedAuthority int
func (a fixedAuthority) EffectiveUID() int { return int(a) }
func TestDiagnosticChecksCoverSystemdSchemaAndAuthorityWithoutMutation(t *testing.T) {
a, record, daemonPath, recoveryPath, runner := fixture(t)
a = dockerdaemon.New(dockerdaemon.Configuration{DaemonConfigPath: daemonPath, RecoveryPath: recoveryPath, TrustedRoot: filepath.Dir(filepath.Dir(daemonPath))}, recordingFS{recorder: record}, runner, fixedAuthority(0))
checks := a.DiagnosticChecks(context.Background(), activeProfile())
for _, name := range []string{"required command and service", "configuration schema", "write authority", "service control authority"} {
found := false
for _, check := range checks {
found = found || check.Name == name
}
if !found {
t.Errorf("diagnostics missing %q: %#v", name, checks)
}
}
for _, event := range record.events {
if strings.HasPrefix(event, "write ") || strings.HasPrefix(event, "remove ") || strings.Contains(event, " restart ") || strings.Contains(event, "daemon-reload") {
t.Fatalf("diagnostics mutated daemon state: %#v", record.events)
}
}
for _, path := range []string{daemonPath, recoveryPath} {
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("diagnostics created %s: %v", path, err)
}
}
}
func TestDiagnosticServiceControlAuthorityUsesInjectedEUIDWithoutMutation(t *testing.T) {
for _, test := range []struct {
name string
uid int
ok bool
}{
{name: "unprivileged", uid: 1000, ok: false},
{name: "root", uid: 0, ok: true},
} {
t.Run(test.name, func(t *testing.T) {
_, record, daemonPath, recoveryPath, runner := fixture(t)
a := dockerdaemon.New(dockerdaemon.Configuration{DaemonConfigPath: daemonPath, RecoveryPath: recoveryPath}, recordingFS{recorder: record}, runner, fixedAuthority(test.uid))
checks := a.DiagnosticChecks(context.Background(), activeProfile())
var found *target.Check
for i := range checks {
if checks[i].Name == "service control authority" {
found = &checks[i]
}
}
if found == nil || found.OK != test.ok {
t.Fatalf("service authority check = %#v", found)
}
for _, event := range record.events {
if strings.Contains(event, "restart") || strings.Contains(event, "daemon-reload") {
t.Fatalf("authority diagnosis mutated service: %#v", record.events)
}
}
})
}
}
func TestDiagnosticsAndEnableRejectMalformedDaemonProxyTypesWithoutMutation(t *testing.T) {
for _, document := range []string{
`[]`,
`{"proxies":[]}`,
`{"proxies":{"http-proxy":42}}`,
`{"proxies":{"https-proxy":false}}`,
`{"proxies":{"no-proxy":[]}}`,
} {
t.Run(document, func(t *testing.T) {
a, record, daemonPath, _, _ := fixture(t)
if err := os.MkdirAll(filepath.Dir(daemonPath), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(daemonPath, []byte(document), 0o600); err != nil {
t.Fatal(err)
}
checks := a.DiagnosticChecks(context.Background(), activeProfile())
if checks[1].OK {
t.Fatalf("malformed schema passed diagnostics: %#v", checks)
}
record.events = nil
result := a.SetDesired(context.Background(), true, activeProfile())
if result.Err == nil {
t.Fatalf("enable accepted malformed schema: %#v", result)
}
for _, event := range record.events {
if strings.HasPrefix(event, "write ") || strings.Contains(event, "restart") || strings.Contains(event, "daemon-reload") {
t.Fatalf("malformed schema caused mutation: %#v", record.events)
}
}
got, err := os.ReadFile(daemonPath)
if err != nil || string(got) != document {
t.Fatalf("daemon config changed: %q, %v", got, err)
}
})
}
}
func TestDockerDaemonRunsReusableTargetContract(t *testing.T) {
targettest.Run(t, func(t *testing.T) targettest.Fixture {
a, _, daemonPath, _, _ := fixture(t)
return targettest.Fixture{
Target: a, Active: activeProfile(), Secret: "secret",
MakeExternal: func(t *testing.T) {
if err := os.MkdirAll(filepath.Dir(daemonPath), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(daemonPath, []byte(`{"proxies":{"http-proxy":"http://external.example:8080"}}`), 0o600); err != nil {
t.Fatal(err)
}
},
MakeDrifted: func(t *testing.T) {
if err := os.WriteFile(daemonPath, []byte(`{"proxies":{"http-proxy":"http://changed.example:8080"}}`), 0o600); err != nil {
t.Fatal(err)
}
},
PermissionFailure: func(t *testing.T) target.OperationResult {
failed, record, _, _, failedRunner := fixture(t)
files := &faultFS{recordingFS: recordingFS{recorder: record}, permission: true}
failed = dockerdaemon.New(dockerdaemon.Configuration{DaemonConfigPath: failed.DaemonConfigPath(), RecoveryPath: failed.RecoveryPath(), TrustedRoot: filepath.Dir(filepath.Dir(failed.DaemonConfigPath()))}, files, failedRunner)
return failed.SetDesired(context.Background(), true, activeProfile())
},
PartialFailure: func(t *testing.T) target.OperationResult {
failed, record, _, _, failedRunner := fixture(t)
files := &faultFS{recordingFS: recordingFS{recorder: record}, failDaemon: true}
failed = dockerdaemon.New(dockerdaemon.Configuration{DaemonConfigPath: failed.DaemonConfigPath(), RecoveryPath: failed.RecoveryPath()}, files, failedRunner)
return failed.SetDesired(context.Background(), true, activeProfile())
},
RedactionFailure: func(t *testing.T) target.OperationResult {
failed, _, _, _, failedRunner := fixture(t)
failedRunner.fail["systemctl daemon-reload"] = errors.New("http://alice:secret@proxy.example:8080")
return failed.SetDesired(context.Background(), true, activeProfile())
},
RecoveryRetry: func(t *testing.T) (target.OperationResult, target.OperationResult, target.OperationResult) {
failed, record, daemon, recovery, failedRunner := fixture(t)
if err := os.MkdirAll(filepath.Dir(daemon), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(daemon, []byte(`{"proxies":{"http-proxy":"http://external.example:8080"}}`), 0o600); err != nil {
t.Fatal(err)
}
files := &faultFS{recordingFS: recordingFS{recorder: record}, failRecoveryOnce: true}
failed = dockerdaemon.New(dockerdaemon.Configuration{DaemonConfigPath: daemon, RecoveryPath: recovery}, files, failedRunner)
first := failed.SetDesired(context.Background(), true, activeProfile())
retry := failed.SetDesired(context.Background(), true, activeProfile())
disabled := failed.SetDesired(context.Background(), false, nil)
return first, retry, disabled
},
}
})
}
func TestDockerDaemonAvailabilityDescriptionAndConfirmation(t *testing.T) {
a, _, _, _, runner := fixture(t)
if got := a.Availability(context.Background()); !got.Available || !strings.Contains(got.Evidence, "systemd") {
t.Fatalf("availability = %#v", got)
}
description := a.Description()
for _, phrase := range []string{"registry", "not container network traffic", "restart", "permission"} {
if !strings.Contains(strings.ToLower(description.Summary+" "+description.Details), phrase) {
t.Errorf("description misses %q: %#v", phrase, description)
}
}
if warning := a.Confirmation(true); !strings.Contains(strings.ToLower(warning), "restart") || !strings.Contains(strings.ToLower(warning), "running") {
t.Fatalf("confirmation = %q", warning)
}
runner.show = "LoadState=not-found\nFragmentPath=\n"
if got := a.Availability(context.Background()); got.Available || !strings.Contains(got.Evidence, "not detected") {
t.Fatalf("unsupported availability = %#v", got)
}
}
func TestDockerDaemonEnableOrdersManagedWriteReloadRestartAndPostInspect(t *testing.T) {
a, record, daemonPath, _, _ := fixture(t)
result := a.SetDesired(context.Background(), true, activeProfile())
if result.Err != nil || result.Inspection.State != target.Enabled {
t.Fatalf("enable = %#v", result)
}
wantOrder := []string{"write docker-daemon.json", "write daemon.json", "systemctl daemon-reload", "systemctl restart docker.service", "systemctl is-active docker.service", "write docker-daemon.json"}
assertSubsequence(t, record.events, wantOrder)
var config map[string]any
data, err := os.ReadFile(daemonPath)
if err != nil || json.Unmarshal(data, &config) != nil {
t.Fatalf("daemon config: %v %s", err, data)
}
proxies := config["proxies"].(map[string]any)
if proxies["http-proxy"] != activeProfile().HTTPURL || proxies["https-proxy"] != activeProfile().HTTPSURL || !strings.Contains(proxies["no-proxy"].(string), "10.0.0.0/8") {
t.Fatalf("managed proxies = %#v", proxies)
}
record.events = nil
second := a.SetDesired(context.Background(), true, activeProfile())
if second.Err != nil || second.Inspection.State != target.Enabled || len(mutationEvents(record.events)) != 0 {
t.Fatalf("idempotent enable = %#v, events=%v", second, record.events)
}
}
func TestDockerDaemonPreservesAndRestoresExistingConfiguration(t *testing.T) {
a, _, daemonPath, recoveryPath, _ := fixture(t)
original := []byte("{\n \"log-level\": \"debug\",\n \"proxies\": {\"http-proxy\": \"http://prior.example:3128\"}\n}\n")
if err := os.MkdirAll(filepath.Dir(daemonPath), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(daemonPath, original, 0o640); err != nil {
t.Fatal(err)
}
if got := a.Inspect(context.Background(), activeProfile()).State; got != target.External {
t.Fatalf("initial = %s", got)
}
if got := a.SetDesired(context.Background(), true, activeProfile()); got.Err != nil {
t.Fatal(got.Err)
}
if got := a.SetDesired(context.Background(), false, nil); got.Err != nil || got.Inspection.State != target.External {
t.Fatalf("disable = %#v", got)
}
restored, _ := os.ReadFile(daemonPath)
if !reflect.DeepEqual(restored, original) {
t.Fatalf("restored:\n%s\nwant:\n%s", restored, original)
}
if info, err := os.Stat(daemonPath); err != nil || info.Mode().Perm() != 0o640 {
t.Fatalf("restored mode = %v, %v", info, err)
}
if _, err := os.Stat(recoveryPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("recovery remains: %v", err)
}
}
func TestDockerDaemonRestartFailureIsRedactedTruthfulAndRetryable(t *testing.T) {
a, record, _, _, runner := fixture(t)
restart := "systemctl restart docker.service"
runner.fail[restart] = fmt.Errorf("restart exposed http://alice:secret@proxy.example:8080")
first := a.SetDesired(context.Background(), true, activeProfile())
if first.Err == nil || strings.Contains(first.Err.Error(), "secret") || first.Inspection.State != target.Enabled {
t.Fatalf("restart failure = %#v", first)
}
delete(runner.fail, restart)
record.events = nil
retry := a.SetDesired(context.Background(), true, activeProfile())
if retry.Err != nil || retry.Inspection.State != target.Enabled {
t.Fatalf("retry = %#v", retry)
}
if contains(record.events, "write daemon.json") || !contains(record.events, restart) {
t.Fatalf("retry events = %v", record.events)
}
}
func TestDockerDaemonRejectsIncompatibleProfileBeforeMutation(t *testing.T) {
for _, p := range []*profile.Profile{
{Name: "socks", Mode: profile.SingleMode, SingleURL: "socks5://proxy.example:1080"},
{Name: "all", Mode: profile.AdvancedMode, HTTPURL: "http://proxy.example:8080", AllURL: "http://proxy.example:8080"},
{Name: "bypass", Mode: profile.AdvancedMode, HTTPURL: "http://proxy.example:8080", Bypass: []string{"*"}},
} {
t.Run(p.Name, func(t *testing.T) {
a, record, _, _, _ := fixture(t)
result := a.SetDesired(context.Background(), true, p)
if result.Err == nil || len(mutationEvents(record.events)) != 0 || result.Inspection.State != target.Disabled {
t.Fatalf("incompatible result = %#v events=%v", result, record.events)
}
})
}
}
func TestDockerDaemonNeverEscalatesAndReportsPermissionNormally(t *testing.T) {
a, record, _, _, runner := fixture(t)
runner.fail["systemctl daemon-reload"] = os.ErrPermission
got := a.SetDesired(context.Background(), true, activeProfile())
if got.Err == nil || !errors.Is(got.Err, os.ErrPermission) || got.Inspection.State != target.Enabled {
t.Fatalf("permission = %#v", got)
}
for _, event := range record.events {
if strings.Contains(event, "sudo") || strings.Contains(event, "pkexec") {
t.Fatalf("privilege escalation: %v", record.events)
}
}
}
func TestDockerDaemonInspectionHasAllFourStates(t *testing.T) {
a, _, daemonPath, _, _ := fixture(t)
ctx := context.Background()
if got := a.Inspect(ctx, activeProfile()).State; got != target.Disabled {
t.Fatalf("disabled = %s", got)
}
if err := os.MkdirAll(filepath.Dir(daemonPath), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(daemonPath, []byte(`{"proxies":{"http-proxy":"http://external.example:8080"}}`), 0o600); err != nil {
t.Fatal(err)
}
if got := a.Inspect(ctx, activeProfile()).State; got != target.External {
t.Fatalf("external = %s", got)
}
if got := a.SetDesired(ctx, true, activeProfile()); got.Err != nil {
t.Fatal(got.Err)
}
if got := a.Inspect(ctx, activeProfile()).State; got != target.Enabled {
t.Fatalf("enabled = %s", got)
}
if err := os.WriteFile(daemonPath, []byte(`{"proxies":{"http-proxy":"http://changed.example:8080"}}`), 0o600); err != nil {
t.Fatal(err)
}
if got := a.Inspect(ctx, activeProfile()).State; got != target.Drifted {
t.Fatalf("drifted = %s", got)
}
}
func assertSubsequence(t *testing.T, got, want []string) {
t.Helper()
index := 0
for _, event := range got {
if index < len(want) && event == want[index] {
index++
}
}
if index != len(want) {
t.Fatalf("events %v do not contain ordered %v", got, want)
}
}
func mutationEvents(events []string) []string {
var result []string
for _, event := range events {
if strings.HasPrefix(event, "write ") || strings.HasPrefix(event, "remove ") || strings.Contains(event, "daemon-reload") || strings.Contains(event, " restart ") {
result = append(result, event)
}
}
return result
}
func contains(values []string, want string) bool {
for _, value := range values {
if value == want {
return true
}
}
return false
}