feat: fix proxy compatibility and establish TUI foundation
This commit is contained in:
+37
-3
@@ -21,8 +21,14 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
overlayName = "99proxyctl"
|
||||
overlayHeader = "// Generated by proxyctl. Do not edit.\n"
|
||||
// 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
|
||||
@@ -234,6 +240,8 @@ func (a *Adapter) SetDesired(ctx context.Context, enabled bool, active *profile.
|
||||
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)
|
||||
@@ -248,6 +256,9 @@ func (a *Adapter) SetDesired(ctx context.Context, enabled bool, active *profile.
|
||||
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 {
|
||||
@@ -262,6 +273,29 @@ func (a *Adapter) SetDesired(ctx context.Context, enabled bool, active *profile.
|
||||
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 {
|
||||
@@ -293,7 +327,7 @@ func translate(input profile.Profile) (translation, error) {
|
||||
}
|
||||
for _, candidate := range []struct {
|
||||
field, value string
|
||||
}{{"HTTP proxy", p.HTTPURL}, {"HTTPS proxy", p.HTTPSURL}, {"ALL/SOCKS proxy", p.AllURL}} {
|
||||
}{{"effective HTTP proxy", httpValue}, {"effective HTTPS proxy", httpsValue}} {
|
||||
if candidate.value == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ func TestAvailabilityRequiresDebianMarkerAPTToolAndConfigurationDirectory(t *tes
|
||||
mustWrite(t, filepath.Join(root, "etc", "debian_version"), "12\n")
|
||||
}
|
||||
runner := &fakeRunner{available: tc.tool, outputError: !tc.dumpWorks}
|
||||
a := apt.New(apt.Configuration{DebianVersionFile: filepath.Join(root, "etc", "debian_version"), ConfigDirectory: configDir, OverlayFile: filepath.Join(configDir, "99proxyctl"), TrustedRoot: root}, nil, runner)
|
||||
a := apt.New(apt.Configuration{DebianVersionFile: filepath.Join(root, "etc", "debian_version"), ConfigDirectory: configDir, TrustedRoot: root}, nil, runner)
|
||||
got := a.Availability(context.Background())
|
||||
if got.Available != tc.want || got.Evidence == "" {
|
||||
t.Fatalf("availability = %#v", got)
|
||||
@@ -225,6 +225,59 @@ func TestOverlayNameCollisionNeverRewritesOrRemovesUnownedConfiguration(t *testi
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultOverlayNameSortsAfterCommonAPTFragments(t *testing.T) {
|
||||
name := filepath.Base(apt.New(apt.Configuration{}, nil, nil).OverlayPath())
|
||||
if filepath.Ext(name) != ".conf" {
|
||||
t.Fatalf("default overlay name %q has no .conf extension; stricter APT versions ignore extensionless fragments", name)
|
||||
}
|
||||
for _, fragment := range []string{"proxy.conf", "99update-notifier", "01-vendor-ubuntu"} {
|
||||
if strings.Compare(name, fragment) <= 0 {
|
||||
t.Fatalf("default overlay name %q does not sort after common fragment %q; a later fragment shadows the overlay and enable can never become effective", name, fragment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnedLegacyOverlayIsRemovedOnEnableAndDisable(t *testing.T) {
|
||||
legacyContents := "// Generated by proxyctl. Do not edit.\nAcquire::http::Proxy \"http://stale.example:3128\";\n"
|
||||
|
||||
t.Run("enable", func(t *testing.T) {
|
||||
a, _ := newAdapter(t, nil)
|
||||
legacy := filepath.Join(filepath.Dir(a.OverlayPath()), "99proxyctl")
|
||||
mustWrite(t, legacy, legacyContents)
|
||||
if got := a.SetDesired(context.Background(), true, compatibleProfile()); got.Err != nil || got.Inspection.State != target.Enabled {
|
||||
t.Fatalf("enable = %#v", got)
|
||||
}
|
||||
if _, err := os.Stat(legacy); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("owned legacy overlay remains after enable: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("disable", func(t *testing.T) {
|
||||
a, _ := newAdapter(t, nil)
|
||||
legacy := filepath.Join(filepath.Dir(a.OverlayPath()), "99proxyctl")
|
||||
mustWrite(t, legacy, legacyContents)
|
||||
if got := a.SetDesired(context.Background(), false, nil); got.Err != nil {
|
||||
t.Fatalf("disable = %#v", got)
|
||||
}
|
||||
if _, err := os.Stat(legacy); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("owned legacy overlay remains after disable: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unowned legacy is untouched", func(t *testing.T) {
|
||||
a, _ := newAdapter(t, nil)
|
||||
legacy := filepath.Join(filepath.Dir(a.OverlayPath()), "99proxyctl")
|
||||
original := "Acquire::http::Proxy \"http://user-owned.example:3128\";\n"
|
||||
mustWrite(t, legacy, original)
|
||||
if got := a.SetDesired(context.Background(), true, compatibleProfile()); got.Err != nil || got.Inspection.State != target.Enabled {
|
||||
t.Fatalf("enable = %#v", got)
|
||||
}
|
||||
if got := readFile(t, legacy); got != original {
|
||||
t.Fatalf("unowned legacy overlay changed: %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDisablePermissionFailureReportsActualManagedState(t *testing.T) {
|
||||
files := &failingFS{}
|
||||
a, _ := newFailingAdapter(t, files)
|
||||
@@ -268,7 +321,7 @@ func TestIncompatibleProtocolOrBypassPreventsMutationAndNamesValue(t *testing.T)
|
||||
value string
|
||||
}{
|
||||
{"socks5 local DNS", func(p *profile.Profile) { p.HTTPURL = "socks5://unsupported.example:1080" }, "socks5://unsupported.example:1080"},
|
||||
{"incompatible overridden ALL", func(p *profile.Profile) { p.AllURL = "socks5://all.example:1080" }, "socks5://all.example:1080"},
|
||||
{"incompatible ALL fallback", func(p *profile.Profile) { p.HTTPURL = ""; p.AllURL = "socks5://all.example:1080" }, "socks5://all.example:1080"},
|
||||
{"domain suffix", func(p *profile.Profile) { p.Bypass = []string{".example.com"} }, ".example.com"},
|
||||
{"CIDR", func(p *profile.Profile) { p.Bypass = []string{"10.0.0.0/8"} }, "10.0.0.0/8"},
|
||||
{"IPv6", func(p *profile.Profile) { p.Bypass = []string{"2001:db8::1"} }, "2001:db8::1"},
|
||||
@@ -323,6 +376,22 @@ func TestManagedOverlayIsAtomicOwnerOnlyAndRejectsSymlinkVictims(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestCompleteHTTPProfileIgnoresAdditionalSOCKSProxy(t *testing.T) {
|
||||
a, _ := newAdapter(t, nil)
|
||||
p := &profile.Profile{
|
||||
Name: "dual", Mode: profile.AdvancedMode,
|
||||
HTTPURL: "http://localhost:7890", HTTPSURL: "http://localhost:7890", AllURL: "socks5://localhost:7890",
|
||||
}
|
||||
got := a.SetDesired(context.Background(), true, p)
|
||||
if got.Err != nil || got.Inspection.State != target.Enabled {
|
||||
t.Fatalf("enable = %#v", got)
|
||||
}
|
||||
contents := readFile(t, a.OverlayPath())
|
||||
if strings.Count(contents, `"http://localhost:7890"`) != 2 || strings.Contains(contents, "socks5") {
|
||||
t.Fatalf("dual-protocol translation:\n%s", contents)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvancedALLValueSuppliesMissingAPTProtocols(t *testing.T) {
|
||||
a, _ := newAdapter(t, nil)
|
||||
p := &profile.Profile{Name: "all", Mode: profile.AdvancedMode, AllURL: "socks5h://proxy.example:1080"}
|
||||
@@ -361,7 +430,7 @@ func newAdapter(t *testing.T, files apt.FileSystem) (*apt.Adapter, *fakeRunner)
|
||||
if failure, ok := files.(*failingFS); ok {
|
||||
failure.delegate = apt.NewOSFileSystem(root)
|
||||
}
|
||||
a := apt.New(apt.Configuration{DebianVersionFile: filepath.Join(root, "etc", "debian_version"), ConfigDirectory: dir, OverlayFile: filepath.Join(dir, "99proxyctl"), TrustedRoot: root}, files, runner)
|
||||
a := apt.New(apt.Configuration{DebianVersionFile: filepath.Join(root, "etc", "debian_version"), ConfigDirectory: dir, TrustedRoot: root}, files, runner)
|
||||
runner.overlay = a.OverlayPath()
|
||||
return a, runner
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user