// 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/execsafe" "proxyctl/internal/profile" "proxyctl/internal/securefs" "proxyctl/internal/target" ) const ( service = "docker.service" recoveryVersion = 1 ) 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) { command, err := execsafe.LookPath(name) if err != nil { return nil, err } output, err := exec.CommandContext(ctx, command, args...).CombinedOutput() if err != nil { return output, fmt.Errorf("%s: %s: %w", name, strings.TrimSpace(string(output)), err) } return output, nil } type ProcessAuthority interface { EffectiveUID() int } type processAuthority struct{} func (processAuthority) EffectiveUID() int { return os.Geteuid() } type Adapter struct { configuration Configuration files FileSystem runner CommandRunner authority ProcessAuthority } var _ target.Diagnosable = (*Adapter)(nil) func New(configuration Configuration, files FileSystem, runner CommandRunner, authorities ...ProcessAuthority) *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{} } authority := ProcessAuthority(processAuthority{}) if len(authorities) != 0 && authorities[0] != nil { authority = authorities[0] } return &Adapter{configuration: configuration, files: files, runner: runner, authority: authority} } 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.", Scope: "daemon network operations such as registry access; not container networking or container/build defaults", Permissions: "system-file and systemd service authority; proxyctl never escalates privileges", Ownership: "managed proxy keys and recovery metadata; disabling restores original daemon.json values", RestartRisk: "high; every change reloads systemd and immediately restarts Docker", Details: "Affects Docker daemon network operations such as registry access; it does not change container networking, existing containers, or Docker container/build defaults. proxyctl owns managed proxy keys plus recovery metadata and restores the original daemon.json values on disable. System-file and systemd service permissions are required; every change reloads systemd and immediately restarts Docker, which may disrupt running work. 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." } // DiagnosticChecks performs only systemctl show and filesystem reads/metadata // probes; service reload/restart and all configuration mutations are excluded. func (a *Adapter) MutationAuthority(context.Context) target.Check { if a.authority.EffectiveUID() != 0 { return target.Check{Name: "mutation authority", Evidence: "Docker daemon changes require root authority; restart with sudo proxyctl"} } check := target.ReadOnlyWriteAuthority(a.configuration.DaemonConfigPath, a.configuration.RecoveryPath) check.Name = "mutation authority" return check } func (a *Adapter) DiagnosticChecks(ctx context.Context, _ *profile.Profile) []target.Check { commandCheck := target.Check{Name: "required command and service", OK: true, Evidence: "systemctl can inspect the loaded docker.service"} if availability, err := a.detect(ctx); err != nil || !availability.Available { commandCheck.OK = false if err != nil { commandCheck.Evidence = profile.RedactText("systemctl show failed: " + err.Error()) } else { commandCheck.Evidence = availability.Evidence } } schemaCheck := target.Check{Name: "configuration schema", OK: true, Evidence: "Docker daemon configuration and recovery metadata are absent or valid JSON"} if data, err := a.files.ReadFile(a.configuration.DaemonConfigPath); err == nil { if schemaErr := validateConfigSchema(data); schemaErr != nil { schemaCheck.OK = false schemaCheck.Evidence = profile.RedactText("Docker daemon configuration schema is invalid: " + schemaErr.Error()) } } else if !errors.Is(err, fs.ErrNotExist) { schemaCheck.OK = false schemaCheck.Evidence = profile.RedactText("Docker daemon configuration cannot be read: " + err.Error()) } if schemaCheck.OK { if _, _, err := a.readRecovery(); err != nil && !errors.Is(err, fs.ErrNotExist) { schemaCheck.OK = false schemaCheck.Evidence = profile.RedactText("Docker daemon recovery metadata is invalid or unreadable: " + err.Error()) } } serviceAuthority := target.Check{Name: "service control authority", OK: a.authority.EffectiveUID() == 0} if serviceAuthority.OK { serviceAuthority.Evidence = "effective process authority can control the system Docker service" } else { serviceAuthority.Evidence = "effective process authority cannot reload or restart the system Docker service; run proxyctl explicitly with sufficient authority" } return []target.Check{ commandCheck, schemaCheck, target.ReadOnlyWriteAuthority(a.configuration.DaemonConfigPath, a.configuration.RecoveryPath), serviceAuthority, } } 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 (a *Adapter) Detect(ctx context.Context, active *profile.Profile) target.Detection { availability, availabilityErr := a.detect(ctx) inspection, inspectionErr := a.inspect(active) return target.Detection{Availability: availability, Inspection: inspection, Err: errors.Join(availabilityErr, redact(inspectionErr, active))} } 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 { Version int `json:"version"` Original []byte `json:"original,omitempty"` OriginalExists bool `json:"originalExists"` OriginalMode uint32 `json:"originalMode"` ConfigFingerprint string `json:"configFingerprint"` RestartedFingerprint string `json:"restartedFingerprint,omitempty"` } func (a *Adapter) Inspect(_ context.Context, active *profile.Profile) target.Inspection { inspection, _ := a.inspect(active) return inspection } func (a *Adapter) inspect(active *profile.Profile) (target.Inspection, error) { 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"}, fmt.Errorf("inspect Docker daemon recovery metadata: %w", metadataErr) } if configErr != nil { return target.Inspection{State: target.Drifted, Evidence: "managed Docker daemon configuration is missing or unreadable"}, fmt.Errorf("inspect managed Docker daemon configuration: %w", configErr) } if err := validateConfigSchema(data); err != nil { return target.Inspection{State: target.Drifted, Evidence: "managed Docker daemon configuration is malformed"}, fmt.Errorf("inspect managed Docker daemon configuration: %w", err) } 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"}, nil } } return target.Inspection{State: target.Drifted, Evidence: "managed Docker daemon configuration does not match the Active Profile"}, nil } if configErr != nil && !errors.Is(configErr, fs.ErrNotExist) { return target.Inspection{State: target.Disabled, Evidence: "Docker daemon configuration could not be inspected"}, fmt.Errorf("inspect Docker daemon configuration: %w", configErr) } if configErr == nil { if err := validateConfigSchema(data); err != nil { return target.Inspection{State: target.Disabled, Evidence: "Docker daemon configuration could not be inspected"}, fmt.Errorf("inspect Docker daemon configuration: %w", err) } if recognizesProxy(data) { return target.Inspection{State: target.External, Evidence: "recognized proxy settings exist in Docker daemon configuration"}, nil } } return target.Inspection{State: target.Disabled, Evidence: "no recognized Docker daemon proxy configuration detected"}, nil } 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 != "" && (p.HTTPURL == "" || p.HTTPSURL == "") { return profile.Profile{}, errors.New("Docker daemon Target cannot use an ALL/SOCKS proxy as a fallback; provide both HTTP and HTTPS proxy URLs 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.Version = recoveryVersion 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 { // Managed proxy URLs may contain credentials. Keep the live configuration // owner-only; disable restores the original mode with the original data. if err := a.files.AtomicWrite(a.configuration.DaemonConfigPath, desired, 0o600); 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) } // daemon.json is a shared, whole-file configuration surface. Restore it only // while the complete current document is exactly the document proxyctl last // applied. This protects both managed proxy keys and unrelated administrator // edits from being overwritten by the original snapshot. current, currentErr := a.files.ReadFile(a.configuration.DaemonConfigPath) if currentErr != nil && !errors.Is(currentErr, fs.ErrNotExist) { return fmt.Errorf("read Docker daemon configuration before restore: %w", currentErr) } alreadyOriginal := metadata.OriginalExists && currentErr == nil && fingerprint(current) == fingerprint(metadata.Original) alreadyAbsent := !metadata.OriginalExists && errors.Is(currentErr, fs.ErrNotExist) if alreadyOriginal || alreadyAbsent { // Recovery metadata proves proxyctl previously changed the effective // daemon configuration. Even when the file has already been restored, // complete the service effects before relinquishing that recovery record. 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("clean Docker daemon recovery metadata for already-restored configuration: %w", err) } return nil } if currentErr != nil || fingerprint(current) != metadata.ConfigFingerprint { return errors.New("Docker daemon configuration changed after proxyctl applied it; refusing to overwrite current configuration or discard recovery metadata") } if metadata.OriginalExists { 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 fields map[string]json.RawMessage if err := json.Unmarshal(data, &fields); err != nil { return recovery{}, true, err } if fields == nil { return recovery{}, true, errors.New("Docker daemon recovery metadata must be a JSON object") } var value recovery if err := json.Unmarshal(data, &value); err != nil { return recovery{}, true, err } if err := validateRecovery(value, fields); err != nil { return recovery{}, true, err } return value, true, nil } func validateRecovery(value recovery, fields map[string]json.RawMessage) error { for _, name := range []string{"version", "originalExists", "originalMode", "configFingerprint"} { if _, present := fields[name]; !present { return fmt.Errorf("Docker daemon recovery metadata is missing required field %q", name) } } if value.Version != recoveryVersion { return fmt.Errorf("unsupported Docker daemon recovery metadata version %d", value.Version) } if !validFingerprint(value.ConfigFingerprint) { return errors.New("Docker daemon recovery metadata has an invalid configuration fingerprint") } if value.RestartedFingerprint != "" && (value.RestartedFingerprint != value.ConfigFingerprint || !validFingerprint(value.RestartedFingerprint)) { return errors.New("Docker daemon recovery metadata has an inconsistent restarted fingerprint") } _, hasOriginal := fields["original"] if value.OriginalExists { if !hasOriginal { return errors.New("Docker daemon recovery metadata is missing the recorded original configuration or mode") } if value.OriginalMode > 0o777 { return errors.New("Docker daemon recovery metadata has an invalid original file mode") } if err := validateConfigSchema(value.Original); err != nil { return fmt.Errorf("Docker daemon recovery metadata has an invalid original configuration: %w", err) } } else if hasOriginal || len(value.Original) != 0 || value.OriginalMode != 0 { return errors.New("Docker daemon recovery metadata records original data although the original did not exist") } return nil } func validFingerprint(value string) bool { decoded, err := hex.DecodeString(value) return err == nil && len(decoded) == sha256.Size } 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 validateConfigSchema(data []byte) error { if len(strings.TrimSpace(string(data))) == 0 { return errors.New("configuration must be a JSON object") } var document map[string]any if err := json.Unmarshal(data, &document); err != nil { return err } if document == nil { return errors.New("configuration must be a JSON object") } existing, present := document["proxies"] if !present { return nil } proxies, ok := existing.(map[string]any) if !ok { return errors.New("the existing proxies field is not an object") } for _, key := range []string{"http-proxy", "https-proxy", "no-proxy"} { if value, present := proxies[key]; present { if _, ok := value.(string); !ok { return fmt.Errorf("the existing proxies.%s field is not a string", key) } } } return nil } func mergeConfig(data []byte, p profile.Profile) ([]byte, error) { if len(strings.TrimSpace(string(data))) == 0 { data = []byte("{}") } if err := validateConfigSchema(data); err != nil { return nil, err } var document map[string]any if err := json.Unmarshal(data, &document); err != nil { return nil, err } 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) }