diff --git a/cmd/proxyctl/main.go b/cmd/proxyctl/main.go index 8bb4e23..7a9fa16 100644 --- a/cmd/proxyctl/main.go +++ b/cmd/proxyctl/main.go @@ -9,6 +9,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "proxyctl/internal/app" + "proxyctl/internal/dockerdaemon" "proxyctl/internal/profile" "proxyctl/internal/shell" "proxyctl/internal/target" @@ -23,11 +24,17 @@ func main() { } func run(args []string, input io.Reader, output io.Writer) error { + return runWithSystemTargets(args, input, output, nil) +} + +// runWithSystemTargets keeps process smoke tests hermetic. A nil slice selects +// production system Targets; a non-nil slice supplies fakes (or no Targets). +func runWithSystemTargets(args []string, input io.Reader, output io.Writer, systemTargets []target.Target) error { executable, err := os.Executable() if err != nil { return fmt.Errorf("locate proxyctl executable: %w", err) } - return runWithEnvironment(args, input, output, processEnvironment{}, nil, executable) + return runWithEnvironmentAndTargets(args, input, output, processEnvironment{}, nil, executable, systemTargets) } type processEnvironment struct{} @@ -41,6 +48,10 @@ func (processEnvironment) LookupUser(name string) (profile.UserIdentity, error) func (processEnvironment) LookupEnv(name string) (string, bool) { return os.LookupEnv(name) } func runWithEnvironment(args []string, input io.Reader, output io.Writer, environment profile.IdentityEnvironment, files shell.FileSystem, executable string) error { + return runWithEnvironmentAndTargets(args, input, output, environment, files, executable, nil) +} + +func runWithEnvironmentAndTargets(args []string, input io.Reader, output io.Writer, environment profile.IdentityEnvironment, files shell.FileSystem, executable string, systemTargets []target.Target) error { profileConfiguration, err := profile.ResolveConfiguration(environment) if err != nil { return err @@ -74,7 +85,11 @@ func runWithEnvironment(args []string, input io.Reader, output io.Writer, enviro } store := profile.NewOwnedFileStoreAt(profileConfiguration.Path, profileConfiguration.Owner, profileConfiguration.TrustedRoot) - application, err := app.NewWithTargets(store, []target.Target{shellTarget}) + if systemTargets == nil { + systemTargets = []target.Target{dockerdaemon.New(dockerdaemon.DefaultConfiguration(), nil, nil)} + } + targets := append([]target.Target{shellTarget}, systemTargets...) + application, err := app.NewWithTargets(store, targets) if err != nil { return err } diff --git a/cmd/proxyctl/main_test.go b/cmd/proxyctl/main_test.go index ca4adfb..8de42e5 100644 --- a/cmd/proxyctl/main_test.go +++ b/cmd/proxyctl/main_test.go @@ -6,12 +6,14 @@ import ( "path/filepath" "strings" "testing" + + "proxyctl/internal/target" ) func TestDefaultInvocationStartsTUIWithNoProfileState(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) var output bytes.Buffer - if err := run(nil, strings.NewReader("q"), &output); err != nil { + if err := runWithSystemTargets(nil, strings.NewReader("q"), &output, []target.Target{}); err != nil { t.Fatalf("run proxyctl: %v", err) } if !strings.Contains(output.String(), "No Active Profile") { @@ -27,7 +29,7 @@ func TestDefaultInvocationLoadsPersistedActiveProfile(t *testing.T) { "activeProfile": "office" }`) var output bytes.Buffer - if err := run(nil, strings.NewReader("q"), &output); err != nil { + if err := runWithSystemTargets(nil, strings.NewReader("q"), &output, []target.Target{}); err != nil { t.Fatalf("run proxyctl: %v", err) } if !strings.Contains(output.String(), "Active Profile: office") { diff --git a/internal/app/app.go b/internal/app/app.go index 006010e..aeee6dc 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -44,9 +44,10 @@ type Snapshot struct { } type Result struct { - Snapshot Snapshot - Notice string - Err error + Snapshot Snapshot + Notice string + Confirmation string + Err error } type Module interface { @@ -159,6 +160,9 @@ func (a *Application) setTargetDesired(intent Intent) Result { if intent.Enabled && active == nil { return Result{Snapshot: a.Snapshot(), Err: errors.New("no Active Profile; open Profile management before enabling a Target")} } + if warning := selected.Confirmation(intent.Enabled); warning != "" && !intent.Confirm { + return Result{Snapshot: a.Snapshot(), Confirmation: warning} + } result := selected.SetDesired(context.Background(), intent.Enabled, active) a.inspections[selected.ID()] = result.Inspection notice := "" diff --git a/internal/app/target_test.go b/internal/app/target_test.go index d4434e6..eeb235f 100644 --- a/internal/app/target_test.go +++ b/internal/app/target_test.go @@ -13,10 +13,11 @@ import ( ) type fakeTarget struct { - available bool - state target.State - setErr error - calls []bool + available bool + state target.State + setErr error + confirmation string + calls []bool } func (f *fakeTarget) ID() string { return "shell" } @@ -29,6 +30,7 @@ func (f *fakeTarget) Availability(context.Context) target.Availability { func (f *fakeTarget) Inspect(context.Context, *profile.Profile) target.Inspection { return target.Inspection{State: f.state, Evidence: "actual state"} } +func (f *fakeTarget) Confirmation(bool) string { return f.confirmation } func (f *fakeTarget) SetDesired(_ context.Context, enabled bool, _ *profile.Profile) target.OperationResult { f.calls = append(f.calls, enabled) if f.setErr == nil { @@ -71,6 +73,31 @@ func TestApplicationTargetSeamRequiresActiveProfileAndReturnsTruthfulResults(t * } } +func TestApplicationRequiresTargetConfirmationBeforeMutation(t *testing.T) { + adapter := &fakeTarget{available: true, state: target.Disabled, confirmation: "Docker will restart immediately and may disrupt running work."} + application, err := app.NewWithTargets(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json")), []target.Target{targetWithID{fakeTarget: adapter, id: "docker-daemon"}}) + if err != nil { + t.Fatal(err) + } + createAndActivate(t, application) + + warning := application.Dispatch(app.Intent{Kind: app.SetTargetDesired, TargetID: "docker-daemon", Enabled: true}) + if warning.Confirmation == "" || warning.Err != nil || len(adapter.calls) != 0 || warning.Snapshot.Targets[0].State != target.Disabled { + t.Fatalf("unconfirmed result = %#v, calls=%v", warning, adapter.calls) + } + confirmed := application.Dispatch(app.Intent{Kind: app.SetTargetDesired, TargetID: "docker-daemon", Enabled: true, Confirm: true}) + if confirmed.Err != nil || len(adapter.calls) != 1 || confirmed.Snapshot.Targets[0].State != target.Enabled { + t.Fatalf("confirmed result = %#v, calls=%v", confirmed, adapter.calls) + } +} + +type targetWithID struct { + *fakeTarget + id string +} + +func (t targetWithID) ID() string { return t.id } + func TestApplicationHidesUnavailableTargetButKeepsExplanation(t *testing.T) { adapter := &fakeTarget{available: false, state: target.Disabled} application, err := app.NewWithTargets(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json")), []target.Target{adapter}) diff --git a/internal/dockerdaemon/dockerdaemon.go b/internal/dockerdaemon/dockerdaemon.go new file mode 100644 index 0000000..8096c71 --- /dev/null +++ b/internal/dockerdaemon/dockerdaemon.go @@ -0,0 +1,434 @@ +// Package dockerdaemon implements the systemd-managed Docker daemon Target. +package dockerdaemon + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io/fs" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + + "proxyctl/internal/profile" + "proxyctl/internal/securefs" + "proxyctl/internal/target" +) + +const service = "docker.service" + +type Configuration struct { + DaemonConfigPath string + RecoveryPath string + TrustedRoot string +} + +func DefaultConfiguration() Configuration { + return Configuration{DaemonConfigPath: "/etc/docker/daemon.json", RecoveryPath: "/var/lib/proxyctl/docker-daemon.json", TrustedRoot: "/"} +} + +type FileSystem interface { + ReadFile(string) ([]byte, error) + FileMode(string) (os.FileMode, error) + AtomicWrite(string, []byte, os.FileMode) error + Remove(string) error +} + +type OSFileSystem struct{ store securefs.Store } + +func NewOSFileSystem(configuration Configuration) OSFileSystem { + root := configuration.TrustedRoot + if root == "" { + root = "/" + } + return OSFileSystem{store: securefs.Store{TrustedRoot: root}} +} +func (f OSFileSystem) ReadFile(path string) ([]byte, error) { return f.store.ReadFile(path) } +func (f OSFileSystem) FileMode(path string) (os.FileMode, error) { return f.store.FileMode(path) } +func (f OSFileSystem) AtomicWrite(path string, data []byte, mode os.FileMode) error { + return f.store.AtomicWrite(path, data, mode) +} +func (f OSFileSystem) Remove(path string) error { return f.store.Remove(path) } + +type CommandRunner interface { + Run(context.Context, string, ...string) ([]byte, error) +} +type ExecRunner struct{} + +func (ExecRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) { + output, err := exec.CommandContext(ctx, name, args...).CombinedOutput() + if err != nil { + return output, fmt.Errorf("%s: %s: %w", name, strings.TrimSpace(string(output)), err) + } + return output, nil +} + +type Adapter struct { + configuration Configuration + files FileSystem + runner CommandRunner +} + +func New(configuration Configuration, files FileSystem, runner CommandRunner) *Adapter { + if configuration.DaemonConfigPath == "" { + configuration.DaemonConfigPath = "/etc/docker/daemon.json" + } + if configuration.RecoveryPath == "" { + configuration.RecoveryPath = "/var/lib/proxyctl/docker-daemon.json" + } + if files == nil { + value := NewOSFileSystem(configuration) + files = value + } + if runner == nil { + runner = ExecRunner{} + } + return &Adapter{configuration: configuration, files: files, runner: runner} +} + +func (a *Adapter) ID() string { return "docker-daemon" } +func (a *Adapter) Description() target.Description { + return target.Description{ + Name: "Docker daemon", + Summary: "Configures proxying for Docker daemon operations such as registry access, not container network traffic or container/build defaults.", + Details: "Uses managed Docker daemon configuration. Every change reloads systemd and immediately restarts Docker, which may disrupt running work. System-file and service permissions are required; proxyctl reports permission errors and never initiates privilege escalation.", + } +} +func (a *Adapter) Confirmation(bool) string { + return "Docker will restart immediately. This may disrupt running containers and other work. Confirm before proxyctl changes daemon configuration." +} +func (a *Adapter) Availability(ctx context.Context) target.Availability { + availability, _ := a.detect(ctx) + return availability +} +func (a *Adapter) detect(ctx context.Context) (target.Availability, error) { + output, err := a.runner.Run(ctx, "systemctl", "show", service, "--property=LoadState", "--property=FragmentPath") + if err != nil { + return target.Availability{Evidence: "A supported systemd-managed Docker daemon was not detected"}, err + } + values := parseProperties(string(output)) + if values["LoadState"] != "loaded" || strings.TrimSpace(values["FragmentPath"]) == "" { + return target.Availability{Evidence: "A supported systemd-managed Docker daemon was not detected"}, nil + } + return target.Availability{Available: true, Evidence: "Docker daemon service is loaded and managed by systemd at " + values["FragmentPath"]}, nil +} + +func parseProperties(output string) map[string]string { + result := map[string]string{} + for _, line := range strings.Split(output, "\n") { + key, value, ok := strings.Cut(line, "=") + if ok { + result[strings.TrimSpace(key)] = strings.TrimSpace(value) + } + } + return result +} + +type recovery struct { + Original []byte `json:"original,omitempty"` + OriginalExists bool `json:"originalExists"` + OriginalMode uint32 `json:"originalMode,omitempty"` + ConfigFingerprint string `json:"configFingerprint"` + RestartedFingerprint string `json:"restartedFingerprint,omitempty"` +} + +func (a *Adapter) Inspect(_ context.Context, active *profile.Profile) target.Inspection { + metadata, managed, metadataErr := a.readRecovery() + data, configErr := a.files.ReadFile(a.configuration.DaemonConfigPath) + if managed || (metadataErr != nil && !errors.Is(metadataErr, fs.ErrNotExist)) { + if metadataErr != nil { + return target.Inspection{State: target.Drifted, Evidence: "managed Docker daemon recovery metadata cannot be read"} + } + if configErr != nil { + return target.Inspection{State: target.Drifted, Evidence: "managed Docker daemon configuration is missing or unreadable"} + } + if fingerprint(data) == metadata.ConfigFingerprint && active != nil { + normalized, err := compatible(*active) + if err == nil && configMatchesProfile(data, normalized) { + return target.Inspection{State: target.Enabled, Evidence: "managed Docker daemon configuration matches the Active Profile"} + } + } + return target.Inspection{State: target.Drifted, Evidence: "managed Docker daemon configuration does not match the Active Profile"} + } + if configErr == nil && recognizesProxy(data) { + return target.Inspection{State: target.External, Evidence: "recognized proxy settings exist in Docker daemon configuration"} + } + return target.Inspection{State: target.Disabled, Evidence: "no recognized Docker daemon proxy configuration detected"} +} + +func (a *Adapter) SetDesired(ctx context.Context, enabled bool, active *profile.Profile) target.OperationResult { + if enabled && active == nil { + return target.OperationResult{Inspection: a.Inspect(ctx, active), Err: errors.New("no Active Profile; open Profile management to create and activate one")} + } + var normalized profile.Profile + var err error + if enabled { + normalized, err = compatible(*active) + if err != nil { + return target.OperationResult{Inspection: a.Inspect(ctx, active), Err: redact(err, active)} + } + } + availability, detectionErr := a.detect(ctx) + if detectionErr != nil { + return target.OperationResult{Inspection: a.Inspect(ctx, active), Err: redact(fmt.Errorf("detect systemd-managed Docker daemon: %w", detectionErr), active)} + } + if !availability.Available { + return target.OperationResult{Inspection: a.Inspect(ctx, active), Err: errors.New("Docker daemon Target is unavailable: a supported systemd-managed Docker daemon was not detected")} + } + if enabled { + err = a.enable(ctx, normalized) + } else { + err = a.disable(ctx) + } + inspection := a.Inspect(ctx, active) + return target.OperationResult{Inspection: inspection, Err: redact(err, active)} +} + +func compatible(input profile.Profile) (profile.Profile, error) { + p, err := profile.ValidateAndNormalize(input) + if err != nil { + return profile.Profile{}, err + } + if p.Mode == profile.AdvancedMode && p.AllURL != "" { + return profile.Profile{}, errors.New("Docker daemon Target does not support an ALL/SOCKS proxy; remove it before applying") + } + for name, raw := range map[string]string{"HTTP": p.HTTPURL, "HTTPS": p.HTTPSURL} { + if raw == "" { + continue + } + u, _ := url.Parse(raw) + if u.Scheme != "http" && u.Scheme != "https" { + return profile.Profile{}, fmt.Errorf("Docker daemon Target does not support %s proxy scheme %q", name, u.Scheme) + } + } + return p, nil +} + +func (a *Adapter) enable(ctx context.Context, p profile.Profile) error { + metadata, managed, err := a.readRecovery() + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("read Docker daemon recovery metadata: %w", err) + } + current, readErr := a.files.ReadFile(a.configuration.DaemonConfigPath) + if readErr != nil && !errors.Is(readErr, fs.ErrNotExist) { + return fmt.Errorf("read Docker daemon configuration: %w", readErr) + } + if !managed { + metadata.OriginalExists = readErr == nil + if metadata.OriginalExists { + metadata.Original = append([]byte(nil), current...) + if mode, modeErr := a.files.FileMode(a.configuration.DaemonConfigPath); modeErr == nil { + metadata.OriginalMode = uint32(mode.Perm()) + } else { + return fmt.Errorf("inspect Docker daemon configuration permissions: %w", modeErr) + } + } + } + base := current + if errors.Is(readErr, fs.ErrNotExist) { + base = []byte("{}") + } + desired, err := mergeConfig(base, p) + if err != nil { + return fmt.Errorf("parse Docker daemon configuration before changing it: %w", err) + } + desiredFingerprint := fingerprint(desired) + if managed && fingerprint(current) == metadata.ConfigFingerprint && metadata.ConfigFingerprint == desiredFingerprint && metadata.RestartedFingerprint == desiredFingerprint { + return nil + } + metadata.ConfigFingerprint = desiredFingerprint + metadata.RestartedFingerprint = "" + if err := a.writeRecovery(metadata); err != nil { + return fmt.Errorf("persist Docker daemon recovery metadata before changing configuration: %w", err) + } + if fingerprint(current) != desiredFingerprint { + mode := os.FileMode(0o600) + if metadata.OriginalExists { + mode = os.FileMode(metadata.OriginalMode) + } + if err := a.files.AtomicWrite(a.configuration.DaemonConfigPath, desired, mode); err != nil { + return fmt.Errorf("write Docker daemon configuration after recovery metadata persisted: %w", err) + } + } + if err := a.reloadRestartInspect(ctx); err != nil { + return err + } + metadata.RestartedFingerprint = desiredFingerprint + if err := a.writeRecovery(metadata); err != nil { + return fmt.Errorf("record completed Docker daemon restart: %w", err) + } + return nil +} + +func (a *Adapter) disable(ctx context.Context) error { + metadata, managed, err := a.readRecovery() + if errors.Is(err, fs.ErrNotExist) || !managed { + return nil + } + if err != nil { + return fmt.Errorf("read Docker daemon recovery metadata: %w", err) + } + if metadata.OriginalExists { + current, _ := a.files.ReadFile(a.configuration.DaemonConfigPath) + if fingerprint(current) != fingerprint(metadata.Original) { + if err := a.files.AtomicWrite(a.configuration.DaemonConfigPath, metadata.Original, os.FileMode(metadata.OriginalMode)); err != nil { + return fmt.Errorf("restore Docker daemon configuration: %w", err) + } + } + } else if err := a.files.Remove(a.configuration.DaemonConfigPath); err != nil && !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("remove managed Docker daemon configuration: %w", err) + } + if err := a.reloadRestartInspect(ctx); err != nil { + return err + } + if err := a.files.Remove(a.configuration.RecoveryPath); err != nil && !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("remove Docker daemon recovery metadata after restart: %w", err) + } + return nil +} + +func (a *Adapter) reloadRestartInspect(ctx context.Context) error { + if _, err := a.runner.Run(ctx, "systemctl", "daemon-reload"); err != nil { + return fmt.Errorf("reload systemd manager configuration: %w", err) + } + if _, err := a.runner.Run(ctx, "systemctl", "restart", service); err != nil { + return fmt.Errorf("restart Docker daemon: %w", err) + } + output, err := a.runner.Run(ctx, "systemctl", "is-active", service) + if err != nil { + return fmt.Errorf("inspect Docker daemon after restart: %w", err) + } + if strings.TrimSpace(string(output)) != "active" { + return fmt.Errorf("inspect Docker daemon after restart: service state is %q", strings.TrimSpace(string(output))) + } + return nil +} + +func (a *Adapter) readRecovery() (recovery, bool, error) { + data, err := a.files.ReadFile(a.configuration.RecoveryPath) + if err != nil { + return recovery{}, false, err + } + var value recovery + if err := json.Unmarshal(data, &value); err != nil { + return recovery{}, true, err + } + return value, true, nil +} +func (a *Adapter) writeRecovery(value recovery) error { + data, err := json.MarshalIndent(value, "", " ") + if err != nil { + return err + } + return a.files.AtomicWrite(a.configuration.RecoveryPath, append(data, '\n'), 0o600) +} + +func mergeConfig(data []byte, p profile.Profile) ([]byte, error) { + var document map[string]any + if len(strings.TrimSpace(string(data))) == 0 { + data = []byte("{}") + } + if err := json.Unmarshal(data, &document); err != nil { + return nil, err + } + if document == nil { + document = map[string]any{} + } + proxies := map[string]any{} + if existing, ok := document["proxies"].(map[string]any); ok { + for key, value := range existing { + proxies[key] = value + } + } else if _, exists := document["proxies"]; exists { + return nil, errors.New("the existing proxies field is not an object") + } + setOrDelete(proxies, "http-proxy", p.HTTPURL) + setOrDelete(proxies, "https-proxy", p.HTTPSURL) + setOrDelete(proxies, "no-proxy", strings.Join(p.Bypass, ",")) + document["proxies"] = proxies + output, err := json.MarshalIndent(document, "", " ") + if err != nil { + return nil, err + } + return append(output, '\n'), nil +} +func setOrDelete(values map[string]any, key, value string) { + if value == "" { + delete(values, key) + } else { + values[key] = value + } +} +func configMatchesProfile(data []byte, p profile.Profile) bool { + var document map[string]any + if json.Unmarshal(data, &document) != nil { + return false + } + proxies, ok := document["proxies"].(map[string]any) + if !ok { + return false + } + return stringValue(proxies["http-proxy"]) == p.HTTPURL && stringValue(proxies["https-proxy"]) == p.HTTPSURL && stringValue(proxies["no-proxy"]) == strings.Join(p.Bypass, ",") +} +func recognizesProxy(data []byte) bool { + var document map[string]any + if json.Unmarshal(data, &document) != nil { + return false + } + proxies, ok := document["proxies"].(map[string]any) + if !ok { + return false + } + for _, key := range []string{"http-proxy", "https-proxy", "no-proxy"} { + if stringValue(proxies[key]) != "" { + return true + } + } + return false +} +func stringValue(value any) string { result, _ := value.(string); return result } +func fingerprint(data []byte) string { sum := sha256.Sum256(data); return hex.EncodeToString(sum[:]) } + +type redactedError struct { + message string + class error +} + +func (e *redactedError) Error() string { return e.message } +func (e *redactedError) Is(other error) bool { return e.class != nil && errors.Is(e.class, other) } +func redact(err error, active *profile.Profile) error { + if err == nil { + return nil + } + message := profile.RedactText(err.Error()) + if active != nil { + for _, raw := range []string{active.SingleURL, active.HTTPURL, active.HTTPSURL, active.AllURL} { + if raw == "" { + continue + } + message = strings.ReplaceAll(message, raw, profile.ObscureURL(raw)) + if parsed, parseErr := url.Parse(raw); parseErr == nil && parsed.User != nil { + if password, ok := parsed.User.Password(); ok && password != "" { + message = strings.ReplaceAll(message, password, "REDACTED") + } + } + } + } + var class error + for _, safe := range []error{os.ErrPermission, fs.ErrNotExist, fs.ErrExist, context.Canceled, context.DeadlineExceeded} { + if errors.Is(err, safe) { + class = safe + break + } + } + return &redactedError{message: message, class: class} +} + +// Paths expose configured effects for diagnostics and hermetic tests. +func (a *Adapter) DaemonConfigPath() string { return filepath.Clean(a.configuration.DaemonConfigPath) } +func (a *Adapter) RecoveryPath() string { return filepath.Clean(a.configuration.RecoveryPath) } diff --git a/internal/dockerdaemon/dockerdaemon_test.go b/internal/dockerdaemon/dockerdaemon_test.go new file mode 100644 index 0000000..bd8b592 --- /dev/null +++ b/internal/dockerdaemon/dockerdaemon_test.go @@ -0,0 +1,340 @@ +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 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 +} diff --git a/internal/shell/shell.go b/internal/shell/shell.go index 5de23c8..95db2f7 100644 --- a/internal/shell/shell.go +++ b/internal/shell/shell.go @@ -183,9 +183,10 @@ func (a *Adapter) Availability(context.Context) target.Availability { return target.Availability{Evidence: "Shell Target requires Bash or Zsh; neither was detected"} } } -func (a *Adapter) EnvironmentPath() string { return a.configuration.EnvironmentFile } -func (a *Adapter) RecoveryPath() string { return a.configuration.RecoveryFile } -func (a *Adapter) StartupPath() string { return a.configuration.StartupFile } +func (a *Adapter) Confirmation(bool) string { return "" } +func (a *Adapter) EnvironmentPath() string { return a.configuration.EnvironmentFile } +func (a *Adapter) RecoveryPath() string { return a.configuration.RecoveryFile } +func (a *Adapter) StartupPath() string { return a.configuration.StartupFile } func (a *Adapter) Inspect(_ context.Context, active *profile.Profile) target.Inspection { metadata, managed, metadataErr := a.readRecovery() diff --git a/internal/target/target.go b/internal/target/target.go index ef717fe..c875afa 100644 --- a/internal/target/target.go +++ b/internal/target/target.go @@ -44,5 +44,8 @@ type Target interface { Description() Description Availability(context.Context) Availability Inspect(context.Context, *profile.Profile) Inspection + // Confirmation returns a warning that must be accepted before SetDesired. + // An empty string means the operation needs no confirmation. + Confirmation(enabled bool) string SetDesired(context.Context, bool, *profile.Profile) OperationResult } diff --git a/internal/target/target_test.go b/internal/target/target_test.go index 9fb8fe2..266253c 100644 --- a/internal/target/target_test.go +++ b/internal/target/target_test.go @@ -20,6 +20,7 @@ func (seamTarget) Availability(context.Context) target.Availability { func (seamTarget) Inspect(context.Context, *profile.Profile) target.Inspection { return target.Inspection{State: target.Disabled, Evidence: "absent"} } +func (seamTarget) Confirmation(bool) string { return "" } func (seamTarget) SetDesired(context.Context, bool, *profile.Profile) target.OperationResult { return target.OperationResult{Inspection: target.Inspection{State: target.Enabled}} } diff --git a/internal/tui/model.go b/internal/tui/model.go index 94c6cb7..d9ce015 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -26,14 +26,15 @@ type formState struct { } type Model struct { - application app.Module - snapshot app.Snapshot - page page - selected int - form formState - confirm bool - status string - applying bool + application app.Module + snapshot app.Snapshot + page page + selected int + form formState + confirm bool + targetConfirm *app.Intent + status string + applying bool } type targetOperationDone struct { @@ -50,9 +51,18 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { if completed, ok := message.(targetOperationDone); ok { m.applying = false m.snapshot = completed.result.Snapshot - if completed.result.Err != nil { + if completed.result.Confirmation != "" { + intent := app.Intent{Kind: app.SetTargetDesired} + if m.targetConfirm != nil { + intent = *m.targetConfirm + } + m.targetConfirm = &intent + m.status = completed.result.Confirmation + } else if completed.result.Err != nil { + m.targetConfirm = nil m.status = "\x1b[31mError: " + completed.result.Err.Error() + "\x1b[0m" } else { + m.targetConfirm = nil m.status = completed.result.Notice } return m, nil @@ -74,6 +84,21 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { } func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) { + if m.targetConfirm != nil && !m.applying { + switch key.String() { + case "y": + intent := *m.targetConfirm + intent.Confirm = true + m.targetConfirm = nil + m.applying = true + m.status = "" + return m, func() tea.Msg { return targetOperationDone{result: m.application.Dispatch(intent)} } + case "n", "esc", "q": + m.targetConfirm = nil + m.status = "" + } + return m, nil + } targets := m.availableTargets() switch key.String() { case "p": @@ -93,8 +118,10 @@ func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) { desired := selected.State != target.Enabled m.applying = true m.status = "" + intent := app.Intent{Kind: app.SetTargetDesired, TargetID: selected.ID, Enabled: desired} + m.targetConfirm = &intent return m, func() tea.Msg { - return targetOperationDone{result: m.application.Dispatch(app.Intent{Kind: app.SetTargetDesired, TargetID: selected.ID, Enabled: desired})} + return targetOperationDone{result: m.application.Dispatch(intent)} } } case "q", "esc", "ctrl+c": @@ -288,7 +315,11 @@ func (m Model) mainView() string { fmt.Fprintf(&b, "\nUnavailable %s: %s\n", item.Description.Name, item.AvailabilityEvidence) } } - b.WriteString("\nSpace: toggle ↑/k ↓/j: select p: manage Proxy Profiles q/Esc: quit\n") + if m.targetConfirm != nil && !m.applying { + b.WriteString("\ny: confirm n/Esc: cancel\n") + } else { + b.WriteString("\nSpace: toggle ↑/k ↓/j: select p: manage Proxy Profiles q/Esc: quit\n") + } if m.status != "" { fmt.Fprintf(&b, "\n%s\n", m.status) } diff --git a/internal/tui/target_test.go b/internal/tui/target_test.go index 9ed336c..39185d5 100644 --- a/internal/tui/target_test.go +++ b/internal/tui/target_test.go @@ -49,6 +49,42 @@ func TestTargetPanelShowsOnlyAvailableTargetsAndAppliesAsynchronously(t *testing } } +func TestTargetRestartConfirmationCancelsWithoutConfirmedMutation(t *testing.T) { + initial := app.Snapshot{ActiveProfile: "office", Targets: []app.TargetSnapshot{{ID: "docker-daemon", Description: target.Description{Name: "Docker daemon"}, Available: true, State: target.Disabled}}} + module := &targetModule{snapshot: initial, result: app.Result{Snapshot: initial, Confirmation: "Docker will restart immediately and may disrupt running work."}} + model := NewModel(module) + updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace}) + model = updated.(Model) + updated, _ = model.Update(command()) + model = updated.(Model) + if !strings.Contains(model.View(), "restart immediately") || len(module.intents) != 1 || module.intents[0].Confirm { + t.Fatalf("warning = intents %#v\n%s", module.intents, model.View()) + } + updated, command = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}}) + model = updated.(Model) + if command != nil || len(module.intents) != 1 || strings.Contains(model.View(), "restart immediately") { + t.Fatalf("cancel mutated = intents %#v\n%s", module.intents, model.View()) + } + + updated, command = model.Update(tea.KeyMsg{Type: tea.KeySpace}) + model = updated.(Model) + updated, _ = model.Update(command()) + model = updated.(Model) + module.result = app.Result{ + Snapshot: app.Snapshot{ActiveProfile: "office", Targets: []app.TargetSnapshot{{ID: "docker-daemon", Description: target.Description{Name: "Docker daemon"}, Available: true, State: target.Enabled}}}, + Notice: "Docker daemon Target is now Enabled", + } + updated, command = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'y'}}) + model = updated.(Model) + if command == nil { + t.Fatal("confirmation did not start operation") + } + _ = command() + if len(module.intents) != 3 || !module.intents[2].Confirm { + t.Fatalf("confirmed intents = %#v", module.intents) + } +} + func TestUnavailableShellIsOutsideMainListWithDetectionExplanation(t *testing.T) { module := &targetModule{snapshot: app.Snapshot{Targets: []app.TargetSnapshot{ {ID: "shell", Description: target.Description{Name: "Shell"}, Available: false, AvailabilityEvidence: "Shell Target requires Bash or Zsh; neither was detected"},