feat: add persistent APT Target adapter
This commit is contained in:
@@ -0,0 +1,459 @@
|
||||
// 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 integration uses an isolated persistent configuration overlay so package operations remain reliable across privilege boundaries. Writing /etc/apt normally requires pre-existing root authority; proxyctl never escalates privileges.",
|
||||
}
|
||||
}
|
||||
|
||||
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 := make(map[string]string)
|
||||
if httpValue != "" {
|
||||
settings["Acquire::http::Proxy"] = httpValue
|
||||
}
|
||||
if httpsValue != "" {
|
||||
settings["Acquire::https::Proxy"] = 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
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
if !ok || !recognizedKey(key) {
|
||||
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 recognizedKey(key string) bool {
|
||||
return key == "Acquire::http::Proxy" || key == "Acquire::https::Proxy" ||
|
||||
strings.HasPrefix(key, "Acquire::http::Proxy::") || strings.HasPrefix(key, "Acquire::https::Proxy::")
|
||||
}
|
||||
|
||||
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}
|
||||
}
|
||||
Reference in New Issue
Block a user