365 lines
14 KiB
Go
365 lines
14 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)
|
|
}
|
|
|
|
func TestDiagnosticChecksCoverSystemdSchemaAndAuthorityWithoutMutation(t *testing.T) {
|
|
a, record, daemonPath, recoveryPath, _ := fixture(t)
|
|
checks := a.DiagnosticChecks(context.Background(), activeProfile())
|
|
for _, name := range []string{"required command and service", "configuration schema", "write 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 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
|
|
}
|