// Package apt implements the persistent Debian-family APT Target. package apt import ( "bufio" "context" "errors" "fmt" "io/fs" "net" "net/url" "os" "os/exec" "path/filepath" "sort" "strings" "proxyctl/internal/profile" "proxyctl/internal/securefs" "proxyctl/internal/target" ) const ( overlayName = "99proxyctl" overlayHeader = "// Generated by proxyctl. Do not edit.\n" ) // Configuration names the Debian marker and the isolated APT configuration // fragment. TrustedRoot anchors all mutations and is injectable for tests. type Configuration struct { DebianVersionFile string ConfigDirectory string OverlayFile string TrustedRoot string } func DefaultConfiguration() Configuration { return Configuration{ DebianVersionFile: "/etc/debian_version", ConfigDirectory: "/etc/apt/apt.conf.d", OverlayFile: "/etc/apt/apt.conf.d/" + overlayName, TrustedRoot: "/", } } // FileSystem is the mutation seam used by the Adapter. Availability probes are // read-only os.Stat calls, while writes remain descriptor-anchored in securefs. type FileSystem interface { ReadFile(string) ([]byte, error) AtomicWrite(string, []byte, os.FileMode) error Remove(string) error } type OSFileSystem struct{ TrustedRoot string } func NewOSFileSystem(trustedRoot string) OSFileSystem { return OSFileSystem{TrustedRoot: trustedRoot} } func (f OSFileSystem) store() securefs.Store { return securefs.Store{TrustedRoot: f.TrustedRoot} } func (f OSFileSystem) ReadFile(path string) ([]byte, error) { return f.store().ReadFile(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) } // Runner isolates apt-config discovery and execution from adapter tests. type Runner interface { LookPath(string) (string, error) Output(context.Context, string, ...string) ([]byte, error) } type systemRunner struct{} func (systemRunner) LookPath(name string) (string, error) { return exec.LookPath(name) } func (systemRunner) Output(ctx context.Context, name string, args ...string) ([]byte, error) { return exec.CommandContext(ctx, name, args...).Output() } type Adapter struct { configuration Configuration files FileSystem runner Runner } func New(configuration Configuration, files FileSystem, runner Runner) *Adapter { defaults := DefaultConfiguration() if configuration.DebianVersionFile == "" { configuration.DebianVersionFile = defaults.DebianVersionFile } if configuration.ConfigDirectory == "" { configuration.ConfigDirectory = defaults.ConfigDirectory } if configuration.OverlayFile == "" { configuration.OverlayFile = filepath.Join(configuration.ConfigDirectory, overlayName) } if configuration.TrustedRoot == "" { configuration.TrustedRoot = defaults.TrustedRoot } if files == nil { files = NewOSFileSystem(configuration.TrustedRoot) } if runner == nil { runner = systemRunner{} } return &Adapter{configuration: configuration, files: files, runner: runner} } func (a *Adapter) ID() string { return "apt" } func (a *Adapter) Description() target.Description { return target.Description{ Name: "APT", Summary: "Persistently configures APT proxying across Shell and privilege boundaries.", Details: "APT supports proxy environment variables; this Target affects persistent APT package operations across Shell and privilege boundaries; it does not change Shell, desktop, Docker, or other application proxy settings. proxyctl owns only an isolated apt.conf.d overlay, leaving earlier configuration underneath so disabling restores any External setting. Writing /etc/apt normally requires pre-existing root authority; proxyctl never escalates privileges, and no service restart is performed.", } } func (a *Adapter) Confirmation(bool) string { return "" } func (a *Adapter) Availability(ctx context.Context) target.Availability { marker, err := os.Stat(a.configuration.DebianVersionFile) if err != nil || !marker.Mode().IsRegular() { return target.Availability{Evidence: "Debian-family marker /etc/debian_version was not detected"} } directory, err := os.Stat(a.configuration.ConfigDirectory) if err != nil || !directory.IsDir() { return target.Availability{Evidence: "APT configuration directory apt.conf.d was not detected"} } command, err := a.runner.LookPath("apt-config") if err != nil { return target.Availability{Evidence: "Debian APT tool apt-config was not detected"} } if _, err := a.runner.Output(ctx, command, "dump"); err != nil { return target.Availability{Evidence: "apt-config could not inspect effective APT configuration"} } return target.Availability{Available: true, Evidence: "Debian marker, apt-config, and apt.conf.d configuration capability detected"} } func (a *Adapter) OverlayPath() string { return a.configuration.OverlayFile } func (a *Adapter) Inspect(ctx context.Context, active *profile.Profile) target.Inspection { inspection, _ := a.inspect(ctx, active) return inspection } func (a *Adapter) inspect(ctx context.Context, active *profile.Profile) (target.Inspection, error) { overlay, overlayErr := a.files.ReadFile(a.configuration.OverlayFile) managed := overlayErr == nil && isOwnedOverlay(overlay) if overlayErr != nil && !errors.Is(overlayErr, fs.ErrNotExist) { return target.Inspection{State: target.Drifted, Evidence: "managed APT overlay cannot be read"}, fmt.Errorf("read managed APT overlay: %w", overlayErr) } effective, dumpErr := a.dump(ctx) if dumpErr != nil { if managed { return target.Inspection{State: target.Drifted, Evidence: "managed APT overlay exists but effective configuration cannot be inspected"}, dumpErr } return target.Inspection{State: target.Disabled, Evidence: "effective APT configuration could not be positively inspected"}, dumpErr } effectiveValues := parseConfiguration(effective) if managed { managedValues := parseConfiguration(overlay) if len(managedValues) == 0 { return target.Inspection{State: target.Drifted, Evidence: "managed APT overlay contains no recognized proxy settings"}, nil } if active == nil { return target.Inspection{State: target.Drifted, Evidence: "managed APT overlay exists without an Active Profile"}, nil } desired, err := translate(*active) if err == nil { coverExistingHostRules(desired.settings, effectiveValues) } if err == nil && equalSettings(managedValues, desired.settings) && equalSettings(effectiveValues, desired.settings) { return target.Inspection{State: target.Enabled, Evidence: "effective managed APT values match the Active Profile"}, nil } return target.Inspection{State: target.Drifted, Evidence: "effective managed APT values do not match the Active Profile"}, nil } if key, ok := recognizedProxy(effectiveValues); ok { return target.Inspection{State: target.External, Evidence: "recognized non-managed APT proxy setting " + key + " is effective"}, nil } return target.Inspection{State: target.Disabled, Evidence: "no recognized effective APT proxy configuration detected"}, nil } 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("APT Target is unavailable: Debian APT tooling or configuration capability was not detected")} } 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 mutationErr error if enabled { if existing, err := a.files.ReadFile(a.configuration.OverlayFile); err == nil && !isOwnedOverlay(existing) { return target.OperationResult{Inspection: a.Inspect(ctx, active), Err: errors.New("APT overlay path already contains configuration not owned by proxyctl; no changes made")} } else if err != nil && !errors.Is(err, fs.ErrNotExist) { return target.OperationResult{Inspection: a.Inspect(ctx, active), Err: redactError(fmt.Errorf("inspect managed APT overlay before writing: %w", err), active)} } translated, err := translate(*active) if err != nil { return target.OperationResult{Inspection: a.Inspect(ctx, active), Err: redactError(err, active)} } effective, err := a.dump(ctx) if err != nil { return target.OperationResult{Inspection: a.Inspect(ctx, active), Err: redactError(fmt.Errorf("inspect effective APT configuration before mutation: %w", err), active)} } coverExistingHostRules(translated.settings, parseConfiguration(effective)) translated.rendered = render(translated.settings) mutationErr = a.files.AtomicWrite(a.configuration.OverlayFile, translated.rendered, 0o600) if mutationErr != nil { mutationErr = fmt.Errorf("write managed APT overlay: %w", mutationErr) } } else { existing, err := a.files.ReadFile(a.configuration.OverlayFile) switch { case errors.Is(err, fs.ErrNotExist): case err != nil: mutationErr = fmt.Errorf("inspect managed APT overlay before removal: %w", err) case !isOwnedOverlay(existing): // A path collision is user-owned configuration, not managed state. default: if err := a.files.Remove(a.configuration.OverlayFile); err != nil && !errors.Is(err, fs.ErrNotExist) { mutationErr = fmt.Errorf("remove managed APT overlay: %w", err) } } } inspection, inspectErr := a.inspect(ctx, active) if mutationErr == nil && inspectErr != nil { mutationErr = fmt.Errorf("re-inspect effective APT configuration after mutation: %w", inspectErr) } if mutationErr == nil && enabled && inspection.State != target.Enabled { mutationErr = errors.New("managed APT overlay was written but did not become the effective Active Profile configuration") } if mutationErr == nil && !enabled && inspection.State == target.Drifted { mutationErr = errors.New("managed APT overlay removal did not complete") } return target.OperationResult{Inspection: inspection, Err: redactError(mutationErr, active)} } func (a *Adapter) dump(ctx context.Context) ([]byte, error) { command, err := a.runner.LookPath("apt-config") if err != nil { return nil, fmt.Errorf("locate apt-config: %w", err) } output, err := a.runner.Output(ctx, command, "dump") if err != nil { return nil, fmt.Errorf("run apt-config dump: %w", err) } return output, nil } type translation struct { settings map[string]string rendered []byte } func translate(input profile.Profile) (translation, error) { p, err := profile.ValidateAndNormalize(input) if err != nil { return translation{}, err } httpValue, httpsValue := p.HTTPURL, p.HTTPSURL if httpValue == "" { httpValue = p.AllURL } if httpsValue == "" { httpsValue = p.AllURL } for _, candidate := range []struct { field, value string }{{"HTTP proxy", p.HTTPURL}, {"HTTPS proxy", p.HTTPSURL}, {"ALL/SOCKS proxy", p.AllURL}} { if candidate.value == "" { continue } parsed, _ := url.Parse(candidate.value) if parsed.Scheme != "http" && parsed.Scheme != "https" && parsed.Scheme != "socks5h" { return translation{}, fmt.Errorf("APT cannot translate %s value %q: proxy scheme %q is incompatible", candidate.field, profile.ObscureURL(candidate.value), parsed.Scheme) } } settings := map[string]string{ "Acquire::http::Proxy": valueOrDirect(httpValue), "Acquire::https::Proxy": valueOrDirect(httpsValue), } for _, bypass := range p.Bypass { if strings.HasPrefix(bypass, ".") || strings.Contains(bypass, "/") || strings.Contains(bypass, ":") { return translation{}, fmt.Errorf("APT cannot translate Bypass entry %q; only exact hostnames and IPv4 addresses are supported", bypass) } if net.ParseIP(bypass) == nil && strings.ContainsAny(bypass, " \t\r\n;\"") { return translation{}, fmt.Errorf("APT cannot translate Bypass entry %q", bypass) } settings["Acquire::http::Proxy::"+bypass] = "DIRECT" settings["Acquire::https::Proxy::"+bypass] = "DIRECT" } return translation{settings: settings, rendered: render(settings)}, nil } func valueOrDirect(value string) string { if value == "" { return "DIRECT" } return value } var settingOrder = []string{"Acquire::http::Proxy", "Acquire::https::Proxy"} func render(settings map[string]string) []byte { var output strings.Builder output.WriteString(overlayHeader) for _, key := range settingOrder { if value, ok := settings[key]; ok { fmt.Fprintf(&output, "%s \"%s\";\n", key, aptQuote(value)) } } for _, protocol := range []string{"http", "https"} { prefix := "Acquire::" + protocol + "::Proxy::" var keys []string for key := range settings { if strings.HasPrefix(key, prefix) { keys = append(keys, key) } } sort.Strings(keys) for _, key := range keys { fmt.Fprintf(&output, "%s \"%s\";\n", key, aptQuote(settings[key])) } } return []byte(output.String()) } func isOwnedOverlay(data []byte) bool { return strings.HasPrefix(string(data), overlayHeader) } func aptQuote(value string) string { return strings.NewReplacer("\\", "\\\\", "\"", "\\\"").Replace(value) } func parseConfiguration(data []byte) map[string]string { settings := make(map[string]string) scanner := bufio.NewScanner(strings.NewReader(string(data))) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" || strings.HasPrefix(line, "//") || strings.HasPrefix(line, "#") { continue } key, rest, ok := strings.Cut(line, " ") if !ok { key, rest, ok = strings.Cut(line, "\t") } key, recognized := canonicalKey(key) if !ok || !recognized { continue } rest = strings.TrimSpace(rest) if len(rest) < 3 || rest[0] != '"' || rest[len(rest)-1] != ';' { continue } quoted := strings.TrimSpace(rest[:len(rest)-1]) if len(quoted) < 2 || quoted[len(quoted)-1] != '"' { continue } settings[key] = aptUnquote(quoted[1 : len(quoted)-1]) } return settings } func canonicalKey(key string) (string, bool) { parts := strings.Split(key, "::") if len(parts) < 3 || !strings.EqualFold(parts[0], "Acquire") || (!strings.EqualFold(parts[1], "http") && !strings.EqualFold(parts[1], "https")) || !strings.EqualFold(parts[2], "Proxy") { return "", false } canonical := "Acquire::" + strings.ToLower(parts[1]) + "::Proxy" if len(parts) == 3 { return canonical, true } host := strings.Join(parts[3:], "::") if host == "" { return "", false } return canonical + "::" + strings.ToLower(host), true } func recognizedProxy(settings map[string]string) (string, bool) { keys := make([]string, 0, len(settings)) for key := range settings { keys = append(keys, key) } sort.Strings(keys) for _, key := range keys { value := settings[key] if value != "" && !strings.EqualFold(value, "DIRECT") && !strings.EqualFold(value, "false") { return key, true } } return "", false } func aptUnquote(value string) string { var output strings.Builder escaped := false for _, r := range value { if escaped { output.WriteRune(r) escaped = false } else if r == '\\' { escaped = true } else { output.WriteRune(r) } } if escaped { output.WriteByte('\\') } return output.String() } func coverExistingHostRules(desired, effective map[string]string) { for key := range effective { var protocol, prefix string switch { case strings.HasPrefix(key, "Acquire::http::Proxy::"): protocol, prefix = "http", "Acquire::http::Proxy::" case strings.HasPrefix(key, "Acquire::https::Proxy::"): protocol, prefix = "https", "Acquire::https::Proxy::" default: continue } if _, alreadyCovered := desired[key]; alreadyCovered { continue } value := desired["Acquire::"+protocol+"::Proxy"] if value == "" { value = "DIRECT" } // A later fragment can shadow a more-specific earlier host rule only by // repeating that host key. The earlier file itself remains untouched and // becomes effective again when the owned overlay is removed. desired[prefix+strings.TrimPrefix(key, prefix)] = value } } func equalSettings(left, right map[string]string) bool { if len(left) != len(right) { return false } for key, value := range left { if right[key] != value { return false } } return true } 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 { 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 != "" { 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} }