770 lines
28 KiB
Go
770 lines
28 KiB
Go
package dockerclient_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
|
|
"proxyctl/internal/app"
|
|
"proxyctl/internal/dockerclient"
|
|
"proxyctl/internal/profile"
|
|
"proxyctl/internal/target"
|
|
"proxyctl/internal/target/targettest"
|
|
)
|
|
|
|
func TestDetectClassifiesMalformedClientConfigurationAsFailure(t *testing.T) {
|
|
a := newAdapter(t, nil)
|
|
writeConfig(t, a.ConfigPath(), `{"proxies":[]}`)
|
|
|
|
detection := a.Detect(context.Background(), activeProfile())
|
|
if detection.Err == nil || detection.Inspection.State != target.Disabled {
|
|
t.Fatalf("detection = %#v, want failed Disabled inspection", detection)
|
|
}
|
|
if got := a.Inspect(context.Background(), activeProfile()); got.State != target.Disabled {
|
|
t.Fatalf("maintenance inspection = %#v, want established Disabled semantics", got)
|
|
}
|
|
}
|
|
|
|
func TestAvailabilityAndDescriptionDescribeOnlyDockerClientDefaults(t *testing.T) {
|
|
a := newAdapter(t, nil)
|
|
availability := a.Availability(context.Background())
|
|
if !availability.Available || !strings.Contains(availability.Evidence, "Docker client configuration") {
|
|
t.Fatalf("availability = %#v", availability)
|
|
}
|
|
description := a.Description()
|
|
for _, phrase := range []string{"new containers and builds", "not Docker daemon registry access", "existing containers"} {
|
|
if !strings.Contains(description.Details, phrase) {
|
|
t.Errorf("details %q do not contain %q", description.Details, phrase)
|
|
}
|
|
}
|
|
if a.ID() != "docker-containers" {
|
|
t.Fatalf("ID = %q", a.ID())
|
|
}
|
|
|
|
root := t.TempDir()
|
|
outside := dockerclient.New(dockerclient.Configuration{
|
|
ConfigFile: filepath.Join(t.TempDir(), "config.json"), RecoveryFile: filepath.Join(root, "state.json"), TrustedRoots: []string{root},
|
|
}, nil)
|
|
if got := outside.Availability(context.Background()); got.Available {
|
|
t.Fatalf("path outside owning user's trusted configuration roots is available: %#v", got)
|
|
}
|
|
}
|
|
|
|
func TestDiagnosticChecksCoverSchemaAndAuthorityWithoutMutation(t *testing.T) {
|
|
files := &countingFS{FileSystem: dockerclient.OSFileSystem{}}
|
|
a := newAdapterWithFS(t, files)
|
|
checks := a.DiagnosticChecks(context.Background(), activeProfile())
|
|
for _, name := range []string{"required commands", "configuration schema", "write authority"} {
|
|
found := false
|
|
for _, check := range checks {
|
|
found = found || check.Name == name
|
|
}
|
|
if !found {
|
|
t.Errorf("diagnostics missing %q: %#v", name, checks)
|
|
}
|
|
}
|
|
if files.writes != 0 {
|
|
t.Fatalf("diagnostics wrote Docker client state %d times", files.writes)
|
|
}
|
|
for _, path := range []string{a.ConfigPath(), a.RecoveryPath(), a.LockPath()} {
|
|
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("diagnostics created %s: %v", path, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDiagnosticsAndEnableRejectMalformedManagedProxyTypesWithoutMutation(t *testing.T) {
|
|
for _, document := range []string{
|
|
`{"proxies":[]}`,
|
|
`{"proxies":{"default":[]}}`,
|
|
`{"proxies":{"default":{"httpProxy":42}}}`,
|
|
`{"proxies":{"default":{"httpsProxy":false}}}`,
|
|
`{"proxies":{"default":{"allProxy":{}}}}`,
|
|
`{"proxies":{"default":{"noProxy":[]}}}`,
|
|
} {
|
|
t.Run(document, func(t *testing.T) {
|
|
files := &countingFS{FileSystem: dockerclient.OSFileSystem{}}
|
|
a := newAdapterWithFS(t, files)
|
|
writeConfig(t, a.ConfigPath(), document)
|
|
before, err := os.ReadFile(a.ConfigPath())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
checks := a.DiagnosticChecks(context.Background(), activeProfile())
|
|
if checks[1].OK {
|
|
t.Fatalf("malformed schema passed diagnostics: %#v", checks)
|
|
}
|
|
writesBefore := files.writes
|
|
result := a.SetDesired(context.Background(), true, activeProfile())
|
|
if result.Err == nil {
|
|
t.Fatalf("enable accepted malformed schema: %#v", result)
|
|
}
|
|
after, err := os.ReadFile(a.ConfigPath())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(after) != string(before) || files.writes != writesBefore {
|
|
t.Fatalf("malformed config was modified: writes %d -> %d", writesBefore, files.writes)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDockerClientAdapterRunsReusableTargetContract(t *testing.T) {
|
|
targettest.Run(t, func(t *testing.T) targettest.Fixture {
|
|
a := newAdapter(t, nil)
|
|
return targettest.Fixture{
|
|
Target: a, Active: activeProfile(), Secret: "secret",
|
|
MakeExternal: func(t *testing.T) {
|
|
writeConfig(t, a.ConfigPath(), `{"auths":{"registry.example":{}},"proxies":{"default":{"httpProxy":"http://prior.example:3128"}}}`)
|
|
},
|
|
MakeDrifted: func(t *testing.T) {
|
|
updateDefaultKey(t, a.ConfigPath(), "httpProxy", json.RawMessage(`"http://changed.example:8080"`))
|
|
},
|
|
PermissionFailure: func(t *testing.T) target.OperationResult {
|
|
failed := newAdapterWithFS(t, &failingFS{FileSystem: dockerclient.OSFileSystem{}, permission: true})
|
|
return failed.SetDesired(context.Background(), true, activeProfile())
|
|
},
|
|
PartialFailure: func(t *testing.T) target.OperationResult {
|
|
failed := newAdapterWithFS(t, &failingFS{FileSystem: dockerclient.OSFileSystem{}, failConfig: true})
|
|
return failed.SetDesired(context.Background(), true, activeProfile())
|
|
},
|
|
RedactionFailure: func(t *testing.T) target.OperationResult {
|
|
failed := newAdapterWithFS(t, &failingFS{FileSystem: dockerclient.OSFileSystem{}, leak: true})
|
|
return failed.SetDesired(context.Background(), true, activeProfile())
|
|
},
|
|
RecoveryRetry: func(t *testing.T) (target.OperationResult, target.OperationResult, target.OperationResult) {
|
|
files := &failingFS{FileSystem: dockerclient.OSFileSystem{}, failRecoveryOnce: true}
|
|
retrying := newAdapterWithFS(t, files)
|
|
writeConfig(t, retrying.ConfigPath(), `{"proxies":{"default":{"httpProxy":"http://prior.example:3128"}}}`)
|
|
first := retrying.SetDesired(context.Background(), true, activeProfile())
|
|
retry := retrying.SetDesired(context.Background(), true, activeProfile())
|
|
disabled := retrying.SetDesired(context.Background(), false, nil)
|
|
return first, retry, disabled
|
|
},
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestEnableSafelyMergesAndDisableRestoresPriorDefaults(t *testing.T) {
|
|
a := newAdapter(t, nil)
|
|
original := `{
|
|
"auths": {"registry.example": {"auth": "opaque"}},
|
|
"credsStore": "pass",
|
|
"proxies": {
|
|
"tcp://remote.example:2376": {"httpProxy": "http://remote.example:3128"},
|
|
"default": {
|
|
"httpProxy": "http://prior.example:3128",
|
|
"ftpProxy": "http://ftp.example:2121",
|
|
"customOption": {"keep": true}
|
|
}
|
|
},
|
|
"pluginSetting": [1, 2, 3]
|
|
}`
|
|
writeConfig(t, a.ConfigPath(), original)
|
|
|
|
if got := a.SetDesired(context.Background(), true, activeProfile()); got.Err != nil || got.Inspection.State != target.Enabled {
|
|
t.Fatalf("enable = %#v", got)
|
|
}
|
|
config := readObject(t, a.ConfigPath())
|
|
if _, ok := config["auths"]; !ok {
|
|
t.Fatal("auths removed")
|
|
}
|
|
if _, ok := config["pluginSetting"]; !ok {
|
|
t.Fatal("unknown top-level setting removed")
|
|
}
|
|
proxies := nestedObject(t, config, "proxies")
|
|
if _, ok := proxies["tcp://remote.example:2376"]; !ok {
|
|
t.Fatal("per-daemon proxy setting removed")
|
|
}
|
|
defaults := nestedObject(t, proxies, "default")
|
|
assertJSONString(t, defaults, "httpProxy", "http://alice:secret@proxy.example:8080")
|
|
assertJSONString(t, defaults, "httpsProxy", "https://proxy.example:8443")
|
|
assertJSONString(t, defaults, "allProxy", "socks5h://proxy.example:1080")
|
|
assertJSONString(t, defaults, "noProxy", "localhost,.example.com,10.0.0.0/8")
|
|
if _, ok := defaults["ftpProxy"]; !ok {
|
|
t.Fatal("unmanaged default option removed")
|
|
}
|
|
|
|
// An unrelated edit made while enabled must survive restoration.
|
|
config["detachKeys"] = json.RawMessage(`"ctrl-x,x"`)
|
|
writeObject(t, a.ConfigPath(), config)
|
|
if got := a.SetDesired(context.Background(), false, nil); got.Err != nil || got.Inspection.State != target.External {
|
|
t.Fatalf("disable = %#v", got)
|
|
}
|
|
restored := readObject(t, a.ConfigPath())
|
|
if _, ok := restored["detachKeys"]; !ok {
|
|
t.Fatal("external unrelated edit was removed")
|
|
}
|
|
defaults = nestedObject(t, nestedObject(t, restored, "proxies"), "default")
|
|
assertJSONString(t, defaults, "httpProxy", "http://prior.example:3128")
|
|
for _, key := range []string{"httpsProxy", "allProxy", "noProxy"} {
|
|
if _, ok := defaults[key]; ok {
|
|
t.Errorf("managed key %s was not restored to absence", key)
|
|
}
|
|
}
|
|
if _, ok := defaults["customOption"]; !ok {
|
|
t.Fatal("custom default option removed")
|
|
}
|
|
}
|
|
|
|
func TestResetDriftedDockerClientRejectsAdministratorManagedKeyEditsWithoutWritesOrCleanup(t *testing.T) {
|
|
files := &countingFS{FileSystem: dockerclient.OSFileSystem{}}
|
|
a := newAdapterWithFS(t, files)
|
|
writeConfig(t, a.ConfigPath(), `{"auths":{"keep":{}},"proxies":{"default":{"httpProxy":"http://prior.example:3128"}}}`)
|
|
if result := a.SetDesired(context.Background(), true, activeProfile()); result.Err != nil {
|
|
t.Fatal(result.Err)
|
|
}
|
|
updateDefaultKey(t, a.ConfigPath(), "httpProxy", json.RawMessage(`"http://administrator.example:3128"`))
|
|
configBefore := readFile(t, a.ConfigPath())
|
|
metadataBefore := readFile(t, a.RecoveryPath())
|
|
writesBefore := files.writes
|
|
|
|
reset := app.NewMaintenance(profile.State{Profiles: []profile.Profile{*activeProfile()}, ActiveProfile: activeProfile().Name}, []target.Target{a}, immediateLock{}, nil).Reset(context.Background(), true)
|
|
if reset.Err == nil || len(reset.Targets) != 1 || reset.Targets[0].Inspection.State != target.Drifted || reset.Targets[0].Err == nil {
|
|
t.Fatalf("conflicting reset = %#v", reset)
|
|
}
|
|
if !strings.Contains(reset.Targets[0].Err.Error(), "changed after proxyctl applied") || files.writes != writesBefore {
|
|
t.Fatalf("conflict outcome=%#v writes=%d->%d", reset.Targets[0], writesBefore, files.writes)
|
|
}
|
|
if got := readFile(t, a.ConfigPath()); got != configBefore {
|
|
t.Fatalf("conflict rewrote config:\n%s", got)
|
|
}
|
|
if got := readFile(t, a.RecoveryPath()); got != metadataBefore {
|
|
t.Fatalf("conflict changed metadata:\n%s", got)
|
|
}
|
|
}
|
|
|
|
func TestResetDriftedDockerClientAlreadyAtOriginalOnlyCleansMetadata(t *testing.T) {
|
|
files := &countingFS{FileSystem: dockerclient.OSFileSystem{}}
|
|
a := newAdapterWithFS(t, files)
|
|
original := `{"auths":{"keep":{}},"proxies":{"default":{"httpProxy":"http://prior.example:3128","custom":{"keep":true}}}}`
|
|
writeConfig(t, a.ConfigPath(), original)
|
|
if result := a.SetDesired(context.Background(), true, activeProfile()); result.Err != nil {
|
|
t.Fatal(result.Err)
|
|
}
|
|
writeConfig(t, a.ConfigPath(), original)
|
|
writesBefore := files.writes
|
|
|
|
reset := app.NewMaintenance(profile.State{Profiles: []profile.Profile{*activeProfile()}, ActiveProfile: activeProfile().Name}, []target.Target{a}, immediateLock{}, nil).Reset(context.Background(), true)
|
|
if reset.Err != nil || len(reset.Targets) != 1 || reset.Targets[0].Inspection.State != target.External {
|
|
t.Fatalf("already-original reset = %#v", reset)
|
|
}
|
|
if files.writes != writesBefore || readFile(t, a.ConfigPath()) != original {
|
|
t.Fatalf("already-original reset wrote config: writes=%d->%d", writesBefore, files.writes)
|
|
}
|
|
if _, err := os.Stat(a.RecoveryPath()); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("recovery metadata remains: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestInspectionDistinguishesAllStatesAndManagedValueEditsDrift(t *testing.T) {
|
|
a := newAdapter(t, nil)
|
|
assertState(t, a, target.Disabled)
|
|
writeConfig(t, a.ConfigPath(), `{"proxies":{"default":{"ftpProxy":"http://ftp.example:2121"}}}`)
|
|
assertState(t, a, target.External)
|
|
writeConfig(t, a.ConfigPath(), `{"proxies":{"default":{"noProxy":"localhost"}}}`)
|
|
assertState(t, a, target.External)
|
|
if got := a.SetDesired(context.Background(), true, activeProfile()); got.Err != nil {
|
|
t.Fatal(got.Err)
|
|
}
|
|
assertState(t, a, target.Enabled)
|
|
updateDefaultKey(t, a.ConfigPath(), "noProxy", json.RawMessage(`"externally-edited.example"`))
|
|
assertState(t, a, target.Drifted)
|
|
}
|
|
|
|
func TestAddingEvenAnEmptyOrInvalidManagedValueIsDrift(t *testing.T) {
|
|
for _, value := range []json.RawMessage{json.RawMessage(`""`), json.RawMessage(`[]`)} {
|
|
a := newAdapter(t, nil)
|
|
p := *activeProfile()
|
|
p.AllURL = ""
|
|
if got := a.SetDesired(context.Background(), true, &p); got.Err != nil {
|
|
t.Fatal(got.Err)
|
|
}
|
|
updateDefaultKey(t, a.ConfigPath(), "allProxy", value)
|
|
if got := a.Inspect(context.Background(), &p); got.State != target.Drifted {
|
|
t.Fatalf("state = %s (%s), want Drifted", got.State, got.Evidence)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDocumentedBypassFormsAreRepresentedWithoutLoss(t *testing.T) {
|
|
a := newAdapter(t, nil)
|
|
p := *activeProfile()
|
|
p.Bypass = []string{"host.example", ".example.com", "192.0.2.1", "2001:db8::1", "10.0.0.0/8", "2001:db8::/32"}
|
|
if got := a.SetDesired(context.Background(), true, &p); got.Err != nil {
|
|
t.Fatal(got.Err)
|
|
}
|
|
defaults := nestedObject(t, nestedObject(t, readObject(t, a.ConfigPath()), "proxies"), "default")
|
|
assertJSONString(t, defaults, "noProxy", "host.example,.example.com,192.0.2.1,2001:db8::1,10.0.0.0/8,2001:db8::/32")
|
|
}
|
|
|
|
func TestUnsupportedProfileDataPreventsAnyMutation(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
edit func(*profile.Profile)
|
|
want string
|
|
}{
|
|
{"SOCKS HTTP proxy", func(p *profile.Profile) { p.HTTPURL = "socks5://proxy.example:1080" }, "HTTP proxy"},
|
|
{"SOCKS HTTPS proxy", func(p *profile.Profile) { p.HTTPSURL = "socks5h://proxy.example:1080" }, "HTTPS proxy"},
|
|
{"invalid bypass", func(p *profile.Profile) { p.Bypass = []string{"host:8080"} }, "Bypass"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
a := newAdapter(t, nil)
|
|
writeConfig(t, a.ConfigPath(), `{"auths":{"keep":{}}}`)
|
|
before := readFile(t, a.ConfigPath())
|
|
p := *activeProfile()
|
|
tc.edit(&p)
|
|
got := a.SetDesired(context.Background(), true, &p)
|
|
if got.Err == nil || !strings.Contains(got.Err.Error(), tc.want) || got.Inspection.State != target.Disabled {
|
|
t.Fatalf("result = %#v", got)
|
|
}
|
|
if after := readFile(t, a.ConfigPath()); after != before {
|
|
t.Fatalf("configuration mutated:\nbefore %s\nafter %s", before, after)
|
|
}
|
|
if _, err := os.Stat(a.RecoveryPath()); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("recovery metadata created: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIncompleteRecoveryMetadataPreventsUnsafeRestoration(t *testing.T) {
|
|
a := newAdapter(t, nil)
|
|
writeConfig(t, a.ConfigPath(), `{"proxies":{"default":{"httpProxy":"http://must-not-be-deleted.example:3128"}}}`)
|
|
writeConfig(t, a.RecoveryPath(), `{"configPresent":true,"proxiesPresent":true,"defaultPresent":true,"original":{},"fingerprint":"invalid"}`)
|
|
before := readFile(t, a.ConfigPath())
|
|
got := a.SetDesired(context.Background(), false, nil)
|
|
if got.Err == nil || got.Inspection.State != target.Drifted {
|
|
t.Fatalf("disable = %#v", got)
|
|
}
|
|
if after := readFile(t, a.ConfigPath()); after != before {
|
|
t.Fatalf("incomplete recovery metadata changed config: %s", after)
|
|
}
|
|
}
|
|
|
|
func TestUnmergeableJSONPreventsMutation(t *testing.T) {
|
|
for _, contents := range []string{`not json`, `[]`, `{"proxies":"wrong"}`, `{"proxies":{"default":[]}}`} {
|
|
t.Run(contents, func(t *testing.T) {
|
|
a := newAdapter(t, nil)
|
|
writeConfig(t, a.ConfigPath(), contents)
|
|
before := readFile(t, a.ConfigPath())
|
|
if got := a.SetDesired(context.Background(), true, activeProfile()); got.Err == nil {
|
|
t.Fatalf("enable accepted %q", contents)
|
|
}
|
|
if after := readFile(t, a.ConfigPath()); after != before {
|
|
t.Fatalf("unsafe JSON merge changed configuration to %q", after)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestConcurrentMutationsSerializeRecoveryAndConfiguration(t *testing.T) {
|
|
files := &interleavingFS{FileSystem: dockerclient.OSFileSystem{}, firstRecovery: make(chan struct{}), secondRecovery: make(chan struct{}), secondConfig: make(chan struct{})}
|
|
a := newAdapterWithFS(t, files)
|
|
first := *activeProfile()
|
|
first.Name = "first"
|
|
first.HTTPURL = "http://first.example:8080"
|
|
second := *activeProfile()
|
|
second.Name = "second"
|
|
second.HTTPURL = "http://second.example:8080"
|
|
|
|
firstDone := make(chan target.OperationResult, 1)
|
|
go func() { firstDone <- a.SetDesired(context.Background(), true, &first) }()
|
|
select {
|
|
case <-files.firstRecovery:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("first mutation did not reach recovery write")
|
|
}
|
|
secondDone := make(chan target.OperationResult, 1)
|
|
go func() { secondDone <- a.SetDesired(context.Background(), true, &second) }()
|
|
|
|
for name, done := range map[string]<-chan target.OperationResult{"first": firstDone, "second": secondDone} {
|
|
select {
|
|
case result := <-done:
|
|
if result.Err != nil {
|
|
t.Fatalf("%s mutation: %v", name, result.Err)
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatalf("%s mutation did not complete", name)
|
|
}
|
|
}
|
|
if got := a.Inspect(context.Background(), &second); got.State != target.Enabled {
|
|
t.Fatalf("final state = %s (%s), want second mutation consistently enabled", got.State, got.Evidence)
|
|
}
|
|
}
|
|
|
|
func TestDockerClientLockSerializesAcrossProcesses(t *testing.T) {
|
|
if root := os.Getenv("PROXYCTL_DOCKER_LOCK_HELPER_ROOT"); root != "" {
|
|
a := dockerclient.New(dockerclient.Configuration{
|
|
ConfigFile: filepath.Join(root, ".docker", "config.json"), RecoveryFile: filepath.Join(root, ".state", "proxyctl", "docker-containers.json"),
|
|
}, dockerclient.OSFileSystem{})
|
|
if result := a.SetDesired(context.Background(), true, activeProfile()); result.Err != nil {
|
|
t.Fatal(result.Err)
|
|
}
|
|
return
|
|
}
|
|
|
|
a := newAdapter(t, nil)
|
|
if err := os.MkdirAll(filepath.Dir(a.LockPath()), 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
lock, err := os.OpenFile(a.LockPath(), os.O_CREATE|os.O_RDWR, 0o600)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer lock.Close()
|
|
if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
root := filepath.Dir(filepath.Dir(filepath.Dir(a.RecoveryPath())))
|
|
command := exec.Command(os.Args[0], "-test.run=^TestDockerClientLockSerializesAcrossProcesses$")
|
|
command.Env = append(os.Environ(), "PROXYCTL_DOCKER_LOCK_HELPER_ROOT="+root)
|
|
done := make(chan error, 1)
|
|
go func() { done <- command.Run() }()
|
|
select {
|
|
case err := <-done:
|
|
t.Fatalf("child mutation did not wait for Docker client lock: %v", err)
|
|
case <-time.After(100 * time.Millisecond):
|
|
}
|
|
if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_UN); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
t.Fatalf("child mutation after unlock: %v", err)
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
_ = command.Process.Kill()
|
|
t.Fatal("child mutation remained blocked after unlock")
|
|
}
|
|
if got := a.Inspect(context.Background(), activeProfile()); got.State != target.Enabled {
|
|
t.Fatalf("state after serialized child mutation = %s (%s)", got.State, got.Evidence)
|
|
}
|
|
}
|
|
|
|
func TestWritesAreOwnerOnlyAtomicAndIdempotent(t *testing.T) {
|
|
files := &countingFS{FileSystem: dockerclient.OSFileSystem{}}
|
|
a := newAdapterWithFS(t, files)
|
|
if got := a.SetDesired(context.Background(), true, activeProfile()); got.Err != nil {
|
|
t.Fatal(got.Err)
|
|
}
|
|
writes := files.writes
|
|
if got := a.SetDesired(context.Background(), true, activeProfile()); got.Err != nil {
|
|
t.Fatal(got.Err)
|
|
}
|
|
if files.writes != writes {
|
|
t.Fatalf("idempotent enable performed %d additional writes", files.writes-writes)
|
|
}
|
|
for _, path := range []string{a.ConfigPath(), a.RecoveryPath(), a.LockPath()} {
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if info.Mode().Perm() != 0o600 {
|
|
t.Errorf("%s mode = %o", path, info.Mode().Perm())
|
|
}
|
|
matches, _ := filepath.Glob(filepath.Join(filepath.Dir(path), ".proxyctl-*"))
|
|
if len(matches) != 0 {
|
|
t.Errorf("atomic temporary files remain beside %s: %v", path, matches)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestOwnedWritesUseConfigurationOwner(t *testing.T) {
|
|
root := t.TempDir()
|
|
files := &ownershipFS{FileSystem: dockerclient.OSFileSystem{}}
|
|
a := dockerclient.New(dockerclient.Configuration{ConfigFile: filepath.Join(root, ".docker", "config.json"), RecoveryFile: filepath.Join(root, ".state", "proxyctl", "docker-containers.json"), OwnFiles: true, UID: 1234, GID: 1235}, files)
|
|
if got := a.SetDesired(context.Background(), true, activeProfile()); got.Err != nil {
|
|
t.Fatal(got.Err)
|
|
}
|
|
if len(files.owners) != 3 {
|
|
t.Fatalf("owners = %#v", files.owners)
|
|
}
|
|
for _, owner := range files.owners {
|
|
if owner == nil || owner.UID != 1234 || owner.GID != 1235 {
|
|
t.Fatalf("owner = %#v", owner)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDescriptorAnchoredWritesDoNotFollowDockerConfigSymlinks(t *testing.T) {
|
|
root, victim := t.TempDir(), t.TempDir()
|
|
if err := os.Symlink(victim, filepath.Join(root, ".docker")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
a := dockerclient.New(dockerclient.Configuration{ConfigFile: filepath.Join(root, ".docker", "config.json"), RecoveryFile: filepath.Join(root, "state", "docker-containers.json"), TrustedRoots: []string{root}}, nil)
|
|
if got := a.SetDesired(context.Background(), true, activeProfile()); got.Err == nil {
|
|
t.Fatal("enable followed symlinked Docker configuration ancestor")
|
|
}
|
|
entries, err := os.ReadDir(victim)
|
|
if err != nil || len(entries) != 0 {
|
|
t.Fatalf("victim changed: %v, %v", entries, err)
|
|
}
|
|
}
|
|
|
|
func TestErrorsRedactCredentialsIncludingRestoreFailures(t *testing.T) {
|
|
files := &failingFS{FileSystem: dockerclient.OSFileSystem{}}
|
|
a := newAdapterWithFS(t, files)
|
|
writeConfig(t, a.ConfigPath(), `{"proxies":{"default":{"httpProxy":"http://bob:prior-secret@prior.example:3128"}}}`)
|
|
if got := a.SetDesired(context.Background(), true, activeProfile()); got.Err != nil {
|
|
t.Fatal(got.Err)
|
|
}
|
|
files.failConfig = true
|
|
files.restoreLeak = true
|
|
got := a.SetDesired(context.Background(), false, nil)
|
|
if got.Err == nil || strings.Contains(got.Err.Error(), "prior-secret") || strings.Contains(got.Err.Error(), "secret") {
|
|
t.Fatalf("unredacted restore result = %#v", got)
|
|
}
|
|
}
|
|
|
|
func activeProfile() *profile.Profile {
|
|
return &profile.Profile{Name: "office", Mode: profile.AdvancedMode, HTTPURL: "http://alice:secret@proxy.example:8080", HTTPSURL: "https://proxy.example:8443", AllURL: "socks5h://proxy.example:1080", Bypass: []string{"localhost", ".example.com", "10.0.0.0/8"}}
|
|
}
|
|
|
|
func newAdapter(t *testing.T, files dockerclient.FileSystem) *dockerclient.Adapter {
|
|
t.Helper()
|
|
if files == nil {
|
|
files = dockerclient.OSFileSystem{}
|
|
}
|
|
return newAdapterWithFS(t, files)
|
|
}
|
|
|
|
func newAdapterWithFS(t *testing.T, files dockerclient.FileSystem) *dockerclient.Adapter {
|
|
t.Helper()
|
|
root := t.TempDir()
|
|
return dockerclient.New(dockerclient.Configuration{ConfigFile: filepath.Join(root, ".docker", "config.json"), RecoveryFile: filepath.Join(root, ".state", "proxyctl", "docker-containers.json")}, files)
|
|
}
|
|
|
|
func writeConfig(t *testing.T, path, contents string) {
|
|
t.Helper()
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func readFile(t *testing.T, path string) string {
|
|
t.Helper()
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return string(data)
|
|
}
|
|
|
|
func readObject(t *testing.T, path string) map[string]json.RawMessage {
|
|
t.Helper()
|
|
var object map[string]json.RawMessage
|
|
if err := json.Unmarshal([]byte(readFile(t, path)), &object); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return object
|
|
}
|
|
|
|
func writeObject(t *testing.T, path string, object map[string]json.RawMessage) {
|
|
t.Helper()
|
|
data, err := json.Marshal(object)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
writeConfig(t, path, string(data))
|
|
}
|
|
|
|
func nestedObject(t *testing.T, parent map[string]json.RawMessage, key string) map[string]json.RawMessage {
|
|
t.Helper()
|
|
var object map[string]json.RawMessage
|
|
if err := json.Unmarshal(parent[key], &object); err != nil {
|
|
t.Fatalf("%s is not an object: %v", key, err)
|
|
}
|
|
return object
|
|
}
|
|
|
|
func updateDefaultKey(t *testing.T, path, key string, value json.RawMessage) {
|
|
t.Helper()
|
|
config := readObject(t, path)
|
|
proxies := nestedObject(t, config, "proxies")
|
|
defaults := nestedObject(t, proxies, "default")
|
|
defaults[key] = value
|
|
defaultData, err := json.Marshal(defaults)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
proxies["default"] = defaultData
|
|
proxyData, err := json.Marshal(proxies)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
config["proxies"] = proxyData
|
|
writeObject(t, path, config)
|
|
}
|
|
|
|
func assertJSONString(t *testing.T, object map[string]json.RawMessage, key, want string) {
|
|
t.Helper()
|
|
var got string
|
|
if err := json.Unmarshal(object[key], &got); err != nil || got != want {
|
|
t.Fatalf("%s = %q (%v), want %q", key, got, err, want)
|
|
}
|
|
}
|
|
|
|
func assertState(t *testing.T, a *dockerclient.Adapter, want target.State) {
|
|
t.Helper()
|
|
got := a.Inspect(context.Background(), activeProfile())
|
|
if got.State != want {
|
|
t.Fatalf("state = %s (%s), want %s", got.State, got.Evidence, want)
|
|
}
|
|
}
|
|
|
|
type immediateLock struct{}
|
|
|
|
func (immediateLock) Lock(context.Context) (func(), error) { return func() {}, nil }
|
|
|
|
type failingFS struct {
|
|
dockerclient.FileSystem
|
|
permission, failConfig, leak, restoreLeak, failRecoveryOnce bool
|
|
}
|
|
|
|
func (f *failingFS) AtomicWrite(path string, data []byte, mode os.FileMode, owner *dockerclient.Ownership) error {
|
|
if f.permission {
|
|
return os.ErrPermission
|
|
}
|
|
if f.failRecoveryOnce && strings.HasSuffix(path, "docker-containers.json") {
|
|
f.failRecoveryOnce = false
|
|
return os.ErrPermission
|
|
}
|
|
if f.leak {
|
|
return errors.New("write http://alice:secret@proxy.example:8080 failed")
|
|
}
|
|
if f.failConfig && strings.HasSuffix(path, "config.json") {
|
|
if f.restoreLeak {
|
|
return errors.New("restore http://bob:prior-secret@prior.example:3128 and http://alice:secret@proxy.example:8080 failed")
|
|
}
|
|
return os.ErrPermission
|
|
}
|
|
return f.FileSystem.AtomicWrite(path, data, mode, owner)
|
|
}
|
|
|
|
type concurrentChangeFS struct {
|
|
dockerclient.FileSystem
|
|
configPath string
|
|
reads int
|
|
}
|
|
|
|
func (f *concurrentChangeFS) ReadFile(path string) ([]byte, error) {
|
|
if path == f.configPath {
|
|
f.reads++
|
|
if f.reads == 3 {
|
|
if err := os.WriteFile(path, []byte(`{"auths":{"registry.example":{"auth":"external-update"}}}`), 0o600); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
return f.FileSystem.ReadFile(path)
|
|
}
|
|
|
|
func TestConcurrentDockerConfigChangeIsNotOverwritten(t *testing.T) {
|
|
files := &concurrentChangeFS{FileSystem: dockerclient.OSFileSystem{}}
|
|
a := newAdapter(t, files)
|
|
files.configPath = a.ConfigPath()
|
|
writeConfig(t, a.ConfigPath(), `{"auths":{"registry.example":{"auth":"original"}}}`)
|
|
|
|
result := a.SetDesired(context.Background(), true, activeProfile())
|
|
if result.Err == nil || !strings.Contains(result.Err.Error(), "changed concurrently") {
|
|
t.Fatalf("concurrent update result = %#v", result)
|
|
}
|
|
if got := readFile(t, a.ConfigPath()); !strings.Contains(got, "external-update") || strings.Contains(got, "httpProxy") {
|
|
t.Fatalf("external Docker config update was overwritten: %s", got)
|
|
}
|
|
}
|
|
|
|
type countingFS struct {
|
|
dockerclient.FileSystem
|
|
writes int
|
|
}
|
|
|
|
func (f *countingFS) AtomicWrite(path string, data []byte, mode os.FileMode, owner *dockerclient.Ownership) error {
|
|
f.writes++
|
|
return f.FileSystem.AtomicWrite(path, data, mode, owner)
|
|
}
|
|
|
|
type interleavingFS struct {
|
|
dockerclient.FileSystem
|
|
mu sync.Mutex
|
|
recoveryWrites, configWrites int
|
|
firstRecovery, secondRecovery, secondConfig chan struct{}
|
|
}
|
|
|
|
func (f *interleavingFS) AtomicWrite(path string, data []byte, mode os.FileMode, owner *dockerclient.Ownership) error {
|
|
f.mu.Lock()
|
|
isRecovery := strings.HasSuffix(path, "docker-containers.json")
|
|
if isRecovery {
|
|
f.recoveryWrites++
|
|
call := f.recoveryWrites
|
|
if call == 1 {
|
|
close(f.firstRecovery)
|
|
}
|
|
} else {
|
|
f.configWrites++
|
|
}
|
|
recoveryCall, configCall := f.recoveryWrites, f.configWrites
|
|
f.mu.Unlock()
|
|
|
|
if isRecovery && recoveryCall == 1 {
|
|
select {
|
|
case <-f.secondRecovery:
|
|
case <-time.After(100 * time.Millisecond):
|
|
}
|
|
err := f.FileSystem.AtomicWrite(path, data, mode, owner)
|
|
return err
|
|
}
|
|
if isRecovery && recoveryCall == 2 {
|
|
err := f.FileSystem.AtomicWrite(path, data, mode, owner)
|
|
close(f.secondRecovery)
|
|
return err
|
|
}
|
|
if !isRecovery && configCall == 1 {
|
|
select {
|
|
case <-f.secondConfig:
|
|
case <-time.After(100 * time.Millisecond):
|
|
}
|
|
return f.FileSystem.AtomicWrite(path, data, mode, owner)
|
|
}
|
|
if !isRecovery && configCall == 2 {
|
|
err := f.FileSystem.AtomicWrite(path, data, mode, owner)
|
|
close(f.secondConfig)
|
|
return err
|
|
}
|
|
return f.FileSystem.AtomicWrite(path, data, mode, owner)
|
|
}
|
|
|
|
func (f *interleavingFS) WithLock(path string, owner *dockerclient.Ownership, action func() error) error {
|
|
return f.FileSystem.WithLock(path, owner, action)
|
|
}
|
|
|
|
type ownershipFS struct {
|
|
dockerclient.FileSystem
|
|
owners []*dockerclient.Ownership
|
|
}
|
|
|
|
func (f *ownershipFS) AtomicWrite(path string, data []byte, mode os.FileMode, owner *dockerclient.Ownership) error {
|
|
f.owners = append(f.owners, owner)
|
|
return f.FileSystem.AtomicWrite(path, data, mode, nil)
|
|
}
|
|
|
|
func (f *ownershipFS) WithLock(path string, owner *dockerclient.Ownership, action func() error) error {
|
|
f.owners = append(f.owners, owner)
|
|
return f.FileSystem.WithLock(path, nil, action)
|
|
}
|