// Package dockerclient implements the Docker container/build defaults Target. // It only edits the owning user's Docker client config.json and never contacts // or changes a Docker daemon. package dockerclient import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io/fs" "net/url" "os" "path/filepath" "strings" "proxyctl/internal/profile" "proxyctl/internal/securefs" "proxyctl/internal/target" ) var managedKeys = []string{"httpProxy", "httpsProxy", "allProxy", "noProxy"} type Configuration struct { ConfigFile string RecoveryFile string OwnFiles bool UID int GID int TrustedRoots []string } // ResolveConfiguration keeps Docker client effects with the invoking user when // proxyctl itself was explicitly started through sudo. func ResolveConfiguration(environment profile.IdentityEnvironment) (Configuration, error) { profileConfiguration, err := profile.ResolveConfiguration(environment) if err != nil { return Configuration{}, err } owner := profileConfiguration.Owner current, err := environment.CurrentUser() if err != nil { return Configuration{}, fmt.Errorf("identify effective user: %w", err) } invokingUser := current.Username != owner.Username dockerDirectory := "" if value, ok := environment.LookupEnv("DOCKER_CONFIG"); ok && filepath.IsAbs(value) { if !invokingUser || pathWithin(value, owner.HomeDir) { dockerDirectory = value } } if dockerDirectory == "" { dockerDirectory = filepath.Join(owner.HomeDir, ".docker") } stateDirectory := "" if value, ok := environment.LookupEnv("XDG_STATE_HOME"); ok && filepath.IsAbs(value) { if !invokingUser || pathWithin(value, owner.HomeDir) { stateDirectory = value } } if stateDirectory == "" { stateDirectory = filepath.Join(owner.HomeDir, ".local", "state") } roots := []string{owner.HomeDir} if !pathWithin(dockerDirectory, owner.HomeDir) { roots = append(roots, filepath.Dir(dockerDirectory)) } if !pathWithin(stateDirectory, owner.HomeDir) { roots = append(roots, filepath.Dir(stateDirectory)) } return Configuration{ ConfigFile: filepath.Join(dockerDirectory, "config.json"), RecoveryFile: filepath.Join(stateDirectory, "proxyctl", "docker-containers.json"), OwnFiles: true, UID: owner.UID, GID: owner.GID, TrustedRoots: roots, }, nil } func ResolveConfigurationForCurrentProcess() (Configuration, error) { return ResolveConfiguration(systemEnvironment{}) } type systemEnvironment struct{} func (systemEnvironment) CurrentUser() (profile.UserIdentity, error) { return profile.SystemCurrentUser() } func (systemEnvironment) LookupUser(name string) (profile.UserIdentity, error) { return profile.SystemLookupUser(name) } func (systemEnvironment) LookupEnv(name string) (string, bool) { return os.LookupEnv(name) } func pathWithin(path, parent string) bool { relative, err := filepath.Rel(parent, path) return err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) } type Ownership struct{ UID, GID int } type FileSystem interface { ReadFile(string) ([]byte, error) AtomicWrite(string, []byte, os.FileMode, *Ownership) error Remove(string) error WithLock(string, *Ownership, func() error) error } type OSFileSystem struct{ TrustedRoots []string } func NewOSFileSystem(configuration Configuration) OSFileSystem { return OSFileSystem{TrustedRoots: append([]string(nil), configuration.TrustedRoots...)} } func (f OSFileSystem) store(path string, owner *Ownership) securefs.Store { root := "" if len(f.TrustedRoots) == 0 { root = string(filepath.Separator) } for _, candidate := range f.TrustedRoots { if pathWithin(path, candidate) && (root == "" || len(filepath.Clean(candidate)) > len(filepath.Clean(root))) { root = candidate } } var secureOwner *securefs.Owner if owner != nil { secureOwner = &securefs.Owner{UID: owner.UID, GID: owner.GID} } return securefs.Store{TrustedRoot: root, Owner: secureOwner} } func (f OSFileSystem) ReadFile(path string) ([]byte, error) { return f.store(path, nil).ReadFile(path) } func (f OSFileSystem) Remove(path string) error { return f.store(path, nil).Remove(path) } func (f OSFileSystem) AtomicWrite(path string, data []byte, mode os.FileMode, owner *Ownership) error { return f.store(path, owner).AtomicWrite(path, data, mode) } func (f OSFileSystem) WithLock(path string, owner *Ownership, action func() error) error { return f.store(path, owner).WithLock(path, action) } type priorValue struct { Present bool `json:"present"` Value json.RawMessage `json:"value,omitempty"` } type recovery struct { ConfigPresent bool `json:"configPresent"` ProxiesPresent bool `json:"proxiesPresent"` DefaultPresent bool `json:"defaultPresent"` Original map[string]priorValue `json:"original"` Fingerprint string `json:"fingerprint"` } type Adapter struct { configuration Configuration files FileSystem } var _ target.Target = (*Adapter)(nil) func New(configuration Configuration, files FileSystem) *Adapter { if files == nil { configured := NewOSFileSystem(configuration) files = configured } return &Adapter{configuration: configuration, files: files} } func (a *Adapter) ID() string { return "docker-containers" } func (a *Adapter) Description() target.Description { return target.Description{ Name: "Docker containers/builds", Summary: "Sets proxy defaults injected into future Docker containers and builds.", Details: "Affects new containers and builds through the owning user's Docker client configuration; it does not affect existing containers and is not Docker daemon registry access. It requires write access to the user's Docker configuration and does not contact the daemon.", } } func (a *Adapter) Confirmation(bool) string { return "" } func (a *Adapter) Availability(context.Context) target.Availability { if !a.usablePath(a.configuration.ConfigFile) || !a.usablePath(a.configuration.RecoveryFile) { return target.Availability{Evidence: "standard Docker client configuration paths are not usable for the owning user"} } return target.Availability{Available: true, Evidence: "standard Docker client configuration is available for the owning user"} } func (a *Adapter) usablePath(path string) bool { if !filepath.IsAbs(path) { return false } if len(a.configuration.TrustedRoots) == 0 { return true } for _, root := range a.configuration.TrustedRoots { if filepath.IsAbs(root) && pathWithin(path, root) && filepath.Clean(path) != filepath.Clean(root) { return true } } return false } func (a *Adapter) ConfigPath() string { return a.configuration.ConfigFile } func (a *Adapter) RecoveryPath() string { return a.configuration.RecoveryFile } func (a *Adapter) LockPath() string { return a.configuration.RecoveryFile + ".lock" } func (a *Adapter) Inspect(_ context.Context, active *profile.Profile) target.Inspection { metadata, managed, metadataErr := a.readRecovery() config, _, configErr := a.readConfig() if managed || (metadataErr != nil && !errors.Is(metadataErr, fs.ErrNotExist)) { if metadataErr != nil { return target.Inspection{State: target.Drifted, Evidence: "managed Docker client recovery metadata cannot be read"} } if configErr != nil { return target.Inspection{State: target.Drifted, Evidence: "managed Docker client configuration is missing, unreadable, or not safely mergeable"} } desired, desiredErr := desiredDefaults(active) if desiredErr == nil && fingerprintCurrent(config) == metadata.Fingerprint && fingerprintDefaults(desired) == metadata.Fingerprint { return target.Inspection{State: target.Enabled, Evidence: "managed Docker client proxy defaults match the Active Profile"} } return target.Inspection{State: target.Drifted, Evidence: "managed Docker client proxy defaults do not match the Active Profile"} } if configErr == nil && recognizesProxyDefaults(config) { return target.Inspection{State: target.External, Evidence: "recognized proxy defaults exist in Docker client config.json"} } return target.Inspection{State: target.Disabled, Evidence: "no recognized Docker client proxy defaults detected"} } func (a *Adapter) SetDesired(ctx context.Context, enabled bool, active *profile.Profile) target.OperationResult { if !a.Availability(ctx).Available { return target.OperationResult{Inspection: a.Inspect(ctx, active), Err: errors.New("Docker containers/builds Target is unavailable: standard Docker client configuration cannot be used")} } 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 inspection target.Inspection err := a.files.WithLock(a.LockPath(), a.ownership(), func() error { if enabled { inspection = a.Inspect(ctx, active) if inspection.State == target.Enabled { return nil } if err := a.enable(active); err != nil { inspection = a.Inspect(ctx, active) return err } } else if err := a.disable(); err != nil { inspection = a.Inspect(ctx, active) return err } inspection = a.Inspect(ctx, active) return nil }) if err != nil { if inspection.Evidence == "" { inspection = a.Inspect(ctx, active) } err = redactError(err, active) } return target.OperationResult{Inspection: inspection, Err: err} } func (a *Adapter) enable(active *profile.Profile) error { desired, err := desiredDefaults(active) if err != nil { return err } config, configPresent, err := a.readConfig() if err != nil { return fmt.Errorf("read Docker client configuration for safe JSON merge: %w", err) } metadata, managed, recoveryErr := a.readRecovery() if recoveryErr != nil && !errors.Is(recoveryErr, fs.ErrNotExist) { return fmt.Errorf("read Docker client recovery metadata: %w", recoveryErr) } proxies, proxiesPresent, err := childObject(config, "proxies") if err != nil { return err } defaults, defaultPresent, err := childObject(proxies, "default") if err != nil { return err } if !managed { metadata = recovery{ConfigPresent: configPresent, ProxiesPresent: proxiesPresent, DefaultPresent: defaultPresent, Original: make(map[string]priorValue)} for _, key := range managedKeys { value, present := defaults[key] metadata.Original[key] = priorValue{Present: present, Value: cloneRaw(value)} } } metadata.Fingerprint = fingerprintDefaults(desired) metadataData, err := json.MarshalIndent(metadata, "", " ") if err != nil { return err } if err := a.writeOwned(a.configuration.RecoveryFile, append(metadataData, '\n')); err != nil { return fmt.Errorf("persist Docker client recovery metadata before changing proxy defaults: %w", err) } applyDesired(defaults, desired) setChildObject(proxies, "default", defaults) setChildObject(config, "proxies", proxies) if err := a.writeConfig(config); err != nil { return fmt.Errorf("write Docker client proxy defaults after recovery metadata persisted: %w", err) } return nil } func (a *Adapter) disable() error { metadata, managed, err := a.readRecovery() if errors.Is(err, fs.ErrNotExist) || !managed { return nil } if err != nil { return fmt.Errorf("read Docker client recovery metadata: %w", err) } config, configPresent, err := a.readConfig() if err != nil && !errors.Is(err, fs.ErrNotExist) { return fmt.Errorf("read Docker client configuration for restoration: %w", err) } if !configPresent { config = make(map[string]json.RawMessage) } proxies, _, err := childObject(config, "proxies") if err != nil { return err } defaults, _, err := childObject(proxies, "default") if err != nil { return err } for _, key := range managedKeys { prior := metadata.Original[key] if prior.Present { defaults[key] = cloneRaw(prior.Value) } else { delete(defaults, key) } } if len(defaults) == 0 && !metadata.DefaultPresent { delete(proxies, "default") } else { setChildObject(proxies, "default", defaults) } if len(proxies) == 0 && !metadata.ProxiesPresent { delete(config, "proxies") } else { setChildObject(config, "proxies", proxies) } if len(config) == 0 && !metadata.ConfigPresent { if err := a.files.Remove(a.configuration.ConfigFile); err != nil && !errors.Is(err, fs.ErrNotExist) { return fmt.Errorf("remove newly created empty Docker client configuration: %w", err) } } else if err := a.writeConfig(config); err != nil { return fmt.Errorf("restore prior Docker client proxy defaults: %w", err) } if err := a.files.Remove(a.configuration.RecoveryFile); err != nil && !errors.Is(err, fs.ErrNotExist) { return fmt.Errorf("remove Docker client recovery metadata after restoration: %w", err) } return nil } func desiredDefaults(active *profile.Profile) (map[string]string, error) { if active == nil { return nil, errors.New("Active Profile is required") } normalized, err := profile.ValidateAndNormalize(*active) if err != nil { return nil, err } for field, raw := range map[string]string{"HTTP proxy": normalized.HTTPURL, "HTTPS proxy": normalized.HTTPSURL} { if raw == "" { continue } parsed, _ := url.Parse(raw) if parsed.Scheme != "http" && parsed.Scheme != "https" { return nil, fmt.Errorf("%s scheme %s cannot be represented by Docker's documented %sProxy default", field, parsed.Scheme, strings.ToLower(strings.Fields(field)[0])) } } return map[string]string{ "httpProxy": normalized.HTTPURL, "httpsProxy": normalized.HTTPSURL, "allProxy": normalized.AllURL, "noProxy": strings.Join(normalized.Bypass, ","), }, nil } func (a *Adapter) readRecovery() (recovery, bool, error) { data, err := a.files.ReadFile(a.configuration.RecoveryFile) if err != nil { return recovery{}, false, err } var value recovery if err := json.Unmarshal(data, &value); err != nil { return recovery{}, true, err } if value.Original == nil { return recovery{}, true, errors.New("Docker client recovery metadata is incomplete") } fingerprintBytes, fingerprintErr := hex.DecodeString(value.Fingerprint) if fingerprintErr != nil || len(fingerprintBytes) != sha256.Size { return recovery{}, true, errors.New("Docker client recovery metadata has an invalid fingerprint") } for _, key := range managedKeys { prior, present := value.Original[key] if !present || (prior.Present && !json.Valid(prior.Value)) { return recovery{}, true, errors.New("Docker client recovery metadata is incomplete") } } return value, true, nil } func (a *Adapter) readConfig() (map[string]json.RawMessage, bool, error) { data, err := a.files.ReadFile(a.configuration.ConfigFile) if errors.Is(err, fs.ErrNotExist) { return make(map[string]json.RawMessage), false, nil } if err != nil { return nil, false, err } object, err := decodeObject(data, "Docker client configuration") return object, true, err } func decodeObject(data []byte, description string) (map[string]json.RawMessage, error) { var object map[string]json.RawMessage if err := json.Unmarshal(data, &object); err != nil { return nil, fmt.Errorf("parse %s: %w", description, err) } if object == nil { return nil, fmt.Errorf("%s must be a JSON object", description) } return object, nil } func childObject(parent map[string]json.RawMessage, key string) (map[string]json.RawMessage, bool, error) { raw, present := parent[key] if !present { return make(map[string]json.RawMessage), false, nil } object, err := decodeObject(raw, "Docker client "+key+" setting") return object, true, err } func setChildObject(parent map[string]json.RawMessage, key string, child map[string]json.RawMessage) { data, _ := json.Marshal(child) parent[key] = data } func applyDesired(defaults map[string]json.RawMessage, desired map[string]string) { for _, key := range managedKeys { if value := desired[key]; value != "" { data, _ := json.Marshal(value) defaults[key] = data } else { delete(defaults, key) } } } func recognizesProxyDefaults(config map[string]json.RawMessage) bool { proxies, _, err := childObject(config, "proxies") if err != nil { return false } defaults, _, err := childObject(proxies, "default") if err != nil { return false } for _, key := range append(append([]string(nil), managedKeys...), "ftpProxy") { var value string if raw, ok := defaults[key]; ok && json.Unmarshal(raw, &value) == nil && value != "" { return true } } return false } type managedValue struct { Present bool `json:"present"` Valid bool `json:"valid"` Value string `json:"value,omitempty"` } func fingerprintDefaults(values map[string]string) string { snapshot := make(map[string]managedValue, len(managedKeys)) for _, key := range managedKeys { if value := values[key]; value != "" { snapshot[key] = managedValue{Present: true, Valid: true, Value: value} } else { snapshot[key] = managedValue{} } } return fingerprintSnapshot(snapshot) } func fingerprintCurrent(config map[string]json.RawMessage) string { snapshot := make(map[string]managedValue, len(managedKeys)) proxies, _, proxyErr := childObject(config, "proxies") defaults, _, defaultErr := childObject(proxies, "default") for _, key := range managedKeys { raw, present := defaults[key] value := managedValue{Present: present} if present && proxyErr == nil && defaultErr == nil && json.Unmarshal(raw, &value.Value) == nil { value.Valid = true } snapshot[key] = value } return fingerprintSnapshot(snapshot) } func fingerprintSnapshot(snapshot map[string]managedValue) string { data, _ := json.Marshal(snapshot) sum := sha256.Sum256(data) return hex.EncodeToString(sum[:]) } func cloneRaw(value json.RawMessage) json.RawMessage { return append(json.RawMessage(nil), value...) } func (a *Adapter) ownership() *Ownership { if !a.configuration.OwnFiles { return nil } return &Ownership{UID: a.configuration.UID, GID: a.configuration.GID} } func (a *Adapter) writeOwned(path string, data []byte) error { return a.files.AtomicWrite(path, data, 0o600, a.ownership()) } func (a *Adapter) writeConfig(config map[string]json.RawMessage) error { data, err := json.MarshalIndent(config, "", " ") if err != nil { return err } return a.writeOwned(a.configuration.ConfigFile, append(data, '\n')) } 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 redactError(err error, active *profile.Profile) error { message := profile.RedactText(err.Error()) if active != nil { for _, raw := range []string{active.SingleURL, active.HTTPURL, active.HTTPSURL, active.AllURL} { if raw != "" { message = strings.ReplaceAll(message, raw, profile.ObscureURL(raw)) } } } 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} }