Files
proxy/internal/dockerclient/dockerclient.go
T

551 lines
19 KiB
Go

// 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}
}