627 lines
24 KiB
Go
627 lines
24 KiB
Go
// 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) 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) }
|