Files
proxy/internal/apt/apt.go
T

557 lines
20 KiB
Go

// 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 sorts after digit-prefixed and common letter-named fragments
// (such as a hand-written proxy.conf) because APT applies apt.conf.d entries
// in lexical order with later values winning; the overlay must be effective
// for Enabled to be truthful. legacyOverlayName is the pre-rename overlay
// removed on the next mutation so upgrades never leave stale fragments.
overlayName = "zz-proxyctl.conf"
legacyOverlayName = "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
}
var _ target.Diagnosable = (*Adapter)(nil)
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.",
Scope: "APT package operations; not Shell, desktop, Docker, or other applications",
Permissions: "pre-existing authority to write /etc/apt; proxyctl never escalates privileges",
Ownership: "isolated apt.conf.d overlay; disabling reveals any earlier External configuration",
RestartRisk: "none",
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 "" }
// DiagnosticChecks observes the APT command, configuration schema, and current
// filesystem authority without changing the overlay or effective settings.
func (a *Adapter) DiagnosticChecks(ctx context.Context, _ *profile.Profile) []target.Check {
commandCheck := target.Check{Name: "required command", OK: true, Evidence: "apt-config is available"}
command, err := a.runner.LookPath("apt-config")
if err != nil {
commandCheck.OK = false
commandCheck.Evidence = "apt-config is unavailable: " + profile.RedactText(err.Error())
} else if _, err := a.runner.Output(ctx, command, "dump"); err != nil {
commandCheck.OK = false
commandCheck.Evidence = "apt-config dump failed: " + profile.RedactText(err.Error())
}
marker, markerErr := os.Stat(a.configuration.DebianVersionFile)
directory, directoryErr := os.Stat(a.configuration.ConfigDirectory)
schemaOK := markerErr == nil && marker.Mode().IsRegular() && directoryErr == nil && directory.IsDir()
schemaEvidence := "Debian marker and apt.conf.d configuration directory are present"
if !schemaOK {
schemaEvidence = profile.RedactText(fmt.Sprintf("Debian marker or apt.conf.d configuration directory is unusable: marker=%v directory=%v", markerErr, directoryErr))
}
return []target.Check{
commandCheck,
{Name: "configuration schema", OK: schemaOK, Evidence: schemaEvidence},
target.ReadOnlyWriteAuthority(a.configuration.OverlayFile),
}
}
func (a *Adapter) Availability(ctx context.Context) target.Availability {
availability, _ := a.detectAvailability(ctx)
return availability
}
func (a *Adapter) detectAvailability(ctx context.Context) (target.Availability, error) {
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"}, nil
}
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"}, nil
}
command, err := a.runner.LookPath("apt-config")
if err != nil {
return target.Availability{Evidence: "Debian APT tool apt-config was not detected"}, nil
}
if _, err := a.runner.Output(ctx, command, "dump"); err != nil {
return target.Availability{Evidence: "apt-config could not inspect effective APT configuration"}, fmt.Errorf("inspect effective APT configuration: %w", err)
}
return target.Availability{Available: true, Evidence: "Debian marker, apt-config, and apt.conf.d configuration capability detected"}, nil
}
func (a *Adapter) Detect(ctx context.Context, active *profile.Profile) target.Detection {
availability, availabilityErr := a.detectAvailability(ctx)
inspection, inspectionErr := a.inspect(ctx, active)
return target.Detection{Availability: availability, Inspection: inspection, Err: errors.Join(availabilityErr, inspectionErr)}
}
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 {
mutationErr = a.removeLegacyOverlay()
}
} 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)
}
}
if mutationErr == nil {
mutationErr = a.removeLegacyOverlay()
}
}
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)}
}
// removeLegacyOverlay deletes the pre-rename overlay when proxyctl owns it, so
// upgrades never leave a stale credential-bearing fragment behind. A legacy
// file without the managed header is user-owned and stays untouched.
func (a *Adapter) removeLegacyOverlay() error {
legacy := filepath.Join(a.configuration.ConfigDirectory, legacyOverlayName)
if legacy == a.configuration.OverlayFile {
return nil
}
data, err := a.files.ReadFile(legacy)
switch {
case errors.Is(err, fs.ErrNotExist):
return nil
case err != nil:
return fmt.Errorf("inspect legacy APT overlay: %w", err)
case !isOwnedOverlay(data):
return nil
}
if err := a.files.Remove(legacy); err != nil && !errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("remove legacy APT overlay: %w", err)
}
return nil
}
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
}{{"effective HTTP proxy", httpValue}, {"effective HTTPS proxy", httpsValue}} {
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}
}