merge: integrate final ticket 07 recovery
This commit is contained in:
+29
-36
@@ -12,7 +12,6 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"golang.org/x/sys/unix"
|
|
||||||
|
|
||||||
"proxyctl/internal/app"
|
"proxyctl/internal/app"
|
||||||
"proxyctl/internal/apt"
|
"proxyctl/internal/apt"
|
||||||
@@ -198,7 +197,16 @@ func runMaintenanceCommand(args []string, input io.Reader, output io.Writer, sto
|
|||||||
if args[0] != "reset" {
|
if args[0] != "reset" {
|
||||||
return errors.New("invalid maintenance command")
|
return errors.New("invalid maintenance command")
|
||||||
}
|
}
|
||||||
fmt.Fprint(output, "Reset every managed Target and restore recorded original state? [y/N] ")
|
readOnlyState, readOnlyErr := store.ReadOnlyLoad()
|
||||||
|
if readOnlyErr != nil {
|
||||||
|
return fmt.Errorf("inspect Profile state before reset confirmation: %w", readOnlyErr)
|
||||||
|
}
|
||||||
|
warning := app.NewMaintenance(readOnlyState, targets, nil, nil).ResetConfirmation(context.Background())
|
||||||
|
fmt.Fprintln(output, "Reset every managed Target and restore recorded original state?")
|
||||||
|
if warning != "" {
|
||||||
|
fmt.Fprintln(output, warning)
|
||||||
|
}
|
||||||
|
fmt.Fprint(output, "Continue? [y/N] ")
|
||||||
answer, _ := bufio.NewReader(input).ReadString('\n')
|
answer, _ := bufio.NewReader(input).ReadString('\n')
|
||||||
answer = strings.ToLower(strings.TrimSpace(answer))
|
answer = strings.ToLower(strings.TrimSpace(answer))
|
||||||
if answer != "y" && answer != "yes" {
|
if answer != "y" && answer != "yes" {
|
||||||
@@ -223,8 +231,15 @@ func runMaintenanceCommand(args []string, input io.Reader, output io.Writer, sto
|
|||||||
fmt.Fprintf(output, "%s: skipped (%s) — %s\n", item.TargetID, item.Inspection.State, profile.RedactText(item.Inspection.Evidence))
|
fmt.Fprintf(output, "%s: skipped (%s) — %s\n", item.TargetID, item.Inspection.State, profile.RedactText(item.Inspection.Evidence))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if item.Err != nil {
|
if item.OperationErr != nil {
|
||||||
fmt.Fprintf(output, "%s: failed (%s) — %s\n", item.TargetID, item.Inspection.State, profile.RedactText(item.Err.Error()))
|
fmt.Fprintf(output, "%s: failed (%s) — %s; fresh inspection: %s\n", item.TargetID, item.Inspection.State, profile.RedactText(item.OperationErr.Error()), profile.RedactText(item.Inspection.Evidence))
|
||||||
|
if item.LogErr != nil {
|
||||||
|
fmt.Fprintf(output, "%s: operation logging also failed — %s\n", item.TargetID, profile.RedactText(item.LogErr.Error()))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if item.LogErr != nil {
|
||||||
|
fmt.Fprintf(output, "%s: reset (%s) — %s; operation logging failed — %s\n", item.TargetID, item.Inspection.State, profile.RedactText(item.Inspection.Evidence), profile.RedactText(item.LogErr.Error()))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
fmt.Fprintf(output, "%s: reset (%s) — %s\n", item.TargetID, item.Inspection.State, profile.RedactText(item.Inspection.Evidence))
|
fmt.Fprintf(output, "%s: reset (%s) — %s\n", item.TargetID, item.Inspection.State, profile.RedactText(item.Inspection.Evidence))
|
||||||
@@ -244,44 +259,22 @@ func diagnosticChecks(configuration profile.Configuration) []target.Check {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func storagePermissions(configuration profile.Configuration) (bool, string) {
|
func storagePermissions(configuration profile.Configuration) (bool, string) {
|
||||||
ok, evidence := writableLocation(configuration.Path)
|
authority := target.ReadOnlyWriteAuthority(configuration.Path)
|
||||||
info, err := os.Stat(configuration.Path)
|
if !authority.OK {
|
||||||
|
return false, authority.Evidence
|
||||||
|
}
|
||||||
|
info, err := os.Lstat(configuration.Path)
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
return ok, evidence + "; Profile configuration does not exist yet"
|
return true, authority.Evidence + "; Profile configuration does not exist yet"
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, evidence + "; Profile configuration cannot be inspected: " + err.Error()
|
return false, authority.Evidence + "; Profile configuration cannot be inspected: " + err.Error()
|
||||||
}
|
}
|
||||||
if info.Mode().Perm() != 0o600 {
|
if info.Mode().Perm() != 0o600 {
|
||||||
return false, fmt.Sprintf("%s; Profile configuration mode is %o, want 600", evidence, info.Mode().Perm())
|
return false, fmt.Sprintf("%s; Profile configuration mode is %o, want 600", authority.Evidence, info.Mode().Perm())
|
||||||
}
|
}
|
||||||
if stat, present := info.Sys().(*syscall.Stat_t); present && int(stat.Uid) != configuration.Owner.UID {
|
if stat, present := info.Sys().(*syscall.Stat_t); present && int(stat.Uid) != configuration.Owner.UID {
|
||||||
return false, fmt.Sprintf("%s; Profile configuration owner uid is %d, want %d", evidence, stat.Uid, configuration.Owner.UID)
|
return false, fmt.Sprintf("%s; Profile configuration owner uid is %d, want %d", authority.Evidence, stat.Uid, configuration.Owner.UID)
|
||||||
}
|
|
||||||
return ok, evidence + "; Profile configuration is owner-only"
|
|
||||||
}
|
|
||||||
|
|
||||||
// writableLocation observes current authority without creating or changing files.
|
|
||||||
func writableLocation(path string) (bool, string) {
|
|
||||||
candidate := path
|
|
||||||
for {
|
|
||||||
info, err := os.Stat(candidate)
|
|
||||||
if err == nil {
|
|
||||||
if !info.IsDir() {
|
|
||||||
candidate = filepath.Dir(candidate)
|
|
||||||
}
|
|
||||||
if err := unix.Access(candidate, unix.W_OK); err != nil {
|
|
||||||
return false, candidate + " is not writable: " + err.Error()
|
|
||||||
}
|
|
||||||
return true, candidate + " is writable"
|
|
||||||
}
|
|
||||||
if !os.IsNotExist(err) {
|
|
||||||
return false, candidate + " cannot be inspected: " + err.Error()
|
|
||||||
}
|
|
||||||
parent := filepath.Dir(candidate)
|
|
||||||
if parent == candidate {
|
|
||||||
return false, path + " has no writable existing ancestor"
|
|
||||||
}
|
|
||||||
candidate = parent
|
|
||||||
}
|
}
|
||||||
|
return true, authority.Evidence + "; Profile configuration is owner-only"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ func TestProductionCompositionRegistersAPTContainerGNOMEAndDaemonTargets(t *test
|
|||||||
var ids []string
|
var ids []string
|
||||||
for _, adapter := range targets {
|
for _, adapter := range targets {
|
||||||
ids = append(ids, adapter.ID())
|
ids = append(ids, adapter.ID())
|
||||||
|
if _, ok := adapter.(target.Diagnosable); !ok {
|
||||||
|
t.Errorf("production Target %s does not contribute doctor checks", adapter.ID())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
want := []string{"apt", "docker-containers", "gnome", "docker-daemon"}
|
want := []string{"apt", "docker-containers", "gnome", "docker-daemon"}
|
||||||
if strings.Join(ids, ",") != strings.Join(want, ",") {
|
if strings.Join(ids, ",") != strings.Join(want, ",") {
|
||||||
|
|||||||
@@ -4,12 +4,16 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"proxyctl/internal/app"
|
||||||
|
"proxyctl/internal/gnome"
|
||||||
"proxyctl/internal/operationlock"
|
"proxyctl/internal/operationlock"
|
||||||
"proxyctl/internal/oplog"
|
"proxyctl/internal/oplog"
|
||||||
"proxyctl/internal/profile"
|
"proxyctl/internal/profile"
|
||||||
@@ -26,11 +30,16 @@ func (failingIdentityEnvironment) LookupUser(string) (profile.UserIdentity, erro
|
|||||||
}
|
}
|
||||||
func (failingIdentityEnvironment) LookupEnv(string) (string, bool) { return "", false }
|
func (failingIdentityEnvironment) LookupEnv(string) (string, bool) { return "", false }
|
||||||
|
|
||||||
|
type failingOperationLogger struct{ err error }
|
||||||
|
|
||||||
|
func (l failingOperationLogger) Write(app.LogEntry) error { return l.err }
|
||||||
|
|
||||||
type processFakeTarget struct {
|
type processFakeTarget struct {
|
||||||
id string
|
id string
|
||||||
state target.State
|
state target.State
|
||||||
err error
|
err error
|
||||||
calls int
|
calls int
|
||||||
|
confirmation string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *processFakeTarget) ID() string { return f.id }
|
func (f *processFakeTarget) ID() string { return f.id }
|
||||||
@@ -38,7 +47,7 @@ func (f *processFakeTarget) Description() target.Description { return target.Des
|
|||||||
func (f *processFakeTarget) Availability(context.Context) target.Availability {
|
func (f *processFakeTarget) Availability(context.Context) target.Availability {
|
||||||
return target.Availability{Available: true, Evidence: "fake executable found"}
|
return target.Availability{Available: true, Evidence: "fake executable found"}
|
||||||
}
|
}
|
||||||
func (f *processFakeTarget) Confirmation(bool) string { return "" }
|
func (f *processFakeTarget) Confirmation(bool) string { return f.confirmation }
|
||||||
func (f *processFakeTarget) Inspect(context.Context, *profile.Profile) target.Inspection {
|
func (f *processFakeTarget) Inspect(context.Context, *profile.Profile) target.Inspection {
|
||||||
return target.Inspection{State: f.state, Evidence: "fake evidence for http://alice:secret@proxy.test"}
|
return target.Inspection{State: f.state, Evidence: "fake evidence for http://alice:secret@proxy.test"}
|
||||||
}
|
}
|
||||||
@@ -96,6 +105,69 @@ func TestDoctorReturnsFailureWhenAnyCheckFails(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDoctorStorageAuthorityRejectsSymlinkedPathsWithoutTouchingVictim(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
path func(t *testing.T, root, victim string) string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "final path",
|
||||||
|
path: func(t *testing.T, root, victim string) string {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(root, "config", "profiles.json")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink(victim, path); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ancestor path",
|
||||||
|
path: func(t *testing.T, root, victim string) string {
|
||||||
|
t.Helper()
|
||||||
|
link := filepath.Join(root, "config")
|
||||||
|
if err := os.Symlink(filepath.Dir(victim), link); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return filepath.Join(link, filepath.Base(victim))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
victim := filepath.Join(t.TempDir(), "victim.json")
|
||||||
|
const content = `{"profiles":[]}`
|
||||||
|
if err := os.WriteFile(victim, []byte(content), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
before, err := os.Stat(victim)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
configuration := profile.Configuration{Path: test.path(t, root, victim), Owner: profile.UserIdentity{UID: os.Getuid(), GID: os.Getgid()}}
|
||||||
|
|
||||||
|
checks := diagnosticChecks(configuration)
|
||||||
|
if len(checks) != 2 || checks[1].OK || !strings.Contains(checks[1].Evidence, "symbolic link") {
|
||||||
|
t.Fatalf("Profile storage diagnostics accepted symlink: %#v", checks)
|
||||||
|
}
|
||||||
|
after, err := os.Stat(victim)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := os.ReadFile(victim)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(got) != content || !reflect.DeepEqual(before.Sys(), after.Sys()) {
|
||||||
|
t.Fatalf("storage diagnostics changed victim: before=%#v after=%#v content=%q", before, after, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDoctorStoragePermissionCheckIsReadOnly(t *testing.T) {
|
func TestDoctorStoragePermissionCheckIsReadOnly(t *testing.T) {
|
||||||
path := filepath.Join(t.TempDir(), "proxyctl", "profiles.json")
|
path := filepath.Join(t.TempDir(), "proxyctl", "profiles.json")
|
||||||
writeConfig(t, path, `{"profiles":[]}`)
|
writeConfig(t, path, `{"profiles":[]}`)
|
||||||
@@ -151,14 +223,21 @@ func TestCommandsRunAsRealProcessesInsideIsolatedUserState(t *testing.T) {
|
|||||||
if !strings.Contains(string(doctorOutput), "Profile validity") || (doctorErr != nil && !strings.Contains(string(doctorOutput), "[FAIL]")) {
|
if !strings.Contains(string(doctorOutput), "Profile validity") || (doctorErr != nil && !strings.Contains(string(doctorOutput), "[FAIL]")) {
|
||||||
t.Fatalf("doctor process err=%v output=%q", doctorErr, doctorOutput)
|
t.Fatalf("doctor process err=%v output=%q", doctorErr, doctorOutput)
|
||||||
}
|
}
|
||||||
|
resetMarker := filepath.Join(root, "home", "reset-mutation-marker")
|
||||||
resetOutput, err := invoke("no\n", "reset")
|
resetOutput, err := invoke("no\n", "reset")
|
||||||
if err != nil || !strings.Contains(string(resetOutput), "Reset cancelled") {
|
if err != nil || !strings.Contains(string(resetOutput), "Reset cancelled") || !strings.Contains(string(resetOutput), "Docker will restart immediately") {
|
||||||
t.Fatalf("reset cancellation process err=%v output=%q", err, resetOutput)
|
t.Fatalf("reset cancellation process err=%v output=%q", err, resetOutput)
|
||||||
}
|
}
|
||||||
|
if _, statErr := os.Stat(resetMarker); !errors.Is(statErr, os.ErrNotExist) {
|
||||||
|
t.Fatalf("cancelled real process mutated reset fixture: %v", statErr)
|
||||||
|
}
|
||||||
resetOutput, err = invoke("yes\n", "reset")
|
resetOutput, err = invoke("yes\n", "reset")
|
||||||
if err != nil || !strings.Contains(string(resetOutput), "shell: skipped (Disabled)") {
|
if err != nil || !strings.Contains(string(resetOutput), "shell: skipped (Disabled)") || !strings.Contains(string(resetOutput), "docker-daemon-test: reset (Disabled)") {
|
||||||
t.Fatalf("confirmed reset process err=%v output=%q", err, resetOutput)
|
t.Fatalf("confirmed reset process err=%v output=%q", err, resetOutput)
|
||||||
}
|
}
|
||||||
|
if data, readErr := os.ReadFile(resetMarker); readErr != nil || string(data) != "reset\n" {
|
||||||
|
t.Fatalf("confirmed real process marker err=%v data=%q", readErr, data)
|
||||||
|
}
|
||||||
installOutput, err := invoke("", "shell", "install")
|
installOutput, err := invoke("", "shell", "install")
|
||||||
if err != nil || !strings.Contains(string(installOutput), "installed") {
|
if err != nil || !strings.Contains(string(installOutput), "installed") {
|
||||||
t.Fatalf("shell install process err=%v output=%q", err, installOutput)
|
t.Fatalf("shell install process err=%v output=%q", err, installOutput)
|
||||||
@@ -206,6 +285,134 @@ func isolatedProcessEnvironment(root string) []string {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type passwordEchoRunner struct {
|
||||||
|
values map[string]string
|
||||||
|
failPasswordSet bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPasswordEchoRunner(savedPassword string) *passwordEchoRunner {
|
||||||
|
return &passwordEchoRunner{values: map[string]string{
|
||||||
|
"org.gnome.system.proxy mode": "'auto'", "org.gnome.system.proxy autoconfig-url": "''", "org.gnome.system.proxy ignore-hosts": "[]",
|
||||||
|
"org.gnome.system.proxy.http host": "''", "org.gnome.system.proxy.http port": "0", "org.gnome.system.proxy.http use-authentication": "false",
|
||||||
|
"org.gnome.system.proxy.http authentication-user": "''", "org.gnome.system.proxy.http authentication-password": "'" + savedPassword + "'",
|
||||||
|
"org.gnome.system.proxy.https host": "''", "org.gnome.system.proxy.https port": "0", "org.gnome.system.proxy.socks host": "''", "org.gnome.system.proxy.socks port": "0",
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *passwordEchoRunner) Run(_ context.Context, command gnome.Command) (string, error) {
|
||||||
|
if len(command.Args) == 1 && command.Args[0] == "list-schemas" {
|
||||||
|
return "org.gnome.system.proxy\norg.gnome.system.proxy.http\norg.gnome.system.proxy.https\norg.gnome.system.proxy.socks\n", nil
|
||||||
|
}
|
||||||
|
if len(command.Args) == 3 && command.Args[0] == "get" {
|
||||||
|
return r.values[command.Args[1]+" "+command.Args[2]] + "\n", nil
|
||||||
|
}
|
||||||
|
if len(command.Args) == 4 && command.Args[0] == "set" {
|
||||||
|
if r.failPasswordSet && command.Args[2] == "authentication-password" {
|
||||||
|
return "", fmt.Errorf("runner echoed saved argument %s: %w", command.Args[3], os.ErrPermission)
|
||||||
|
}
|
||||||
|
r.values[command.Args[1]+" "+command.Args[2]] = command.Args[3]
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("unexpected command: %v", command.Args)
|
||||||
|
}
|
||||||
|
|
||||||
|
func passwordRestoreFixture(t *testing.T, secret string) (*gnome.Adapter, *passwordEchoRunner) {
|
||||||
|
t.Helper()
|
||||||
|
root := t.TempDir()
|
||||||
|
runner := newPasswordEchoRunner(secret)
|
||||||
|
statePath := filepath.Join(root, "state", "gnome.json")
|
||||||
|
adapter := gnome.New(gnome.Configuration{CommandName: "gsettings", UID: os.Getuid(), GID: os.Getgid(), HomeDir: root, StateFile: statePath}, runner, gnome.NewFileState(statePath, -1, -1, root))
|
||||||
|
active := &profile.Profile{Name: "office", Mode: profile.AdvancedMode, HTTPURL: "http://proxy.example:8080", Bypass: []string{"localhost"}}
|
||||||
|
if result := adapter.SetDesired(context.Background(), true, active); result.Err != nil || result.Inspection.State != target.Enabled {
|
||||||
|
t.Fatalf("prepare managed GNOME Target: %#v", result)
|
||||||
|
}
|
||||||
|
runner.failPasswordSet = true
|
||||||
|
return adapter, runner
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResetSuppressesSavedGNOMEPasswordFromOperationCLIAndLogsWithoutActiveProfile(t *testing.T) {
|
||||||
|
const secret = "GNOME-ORIGINAL-PASSWORD-FULL-CHAIN-07"
|
||||||
|
|
||||||
|
direct, _ := passwordRestoreFixture(t, secret)
|
||||||
|
operation := direct.SetDesired(context.Background(), false, nil)
|
||||||
|
if operation.Err == nil {
|
||||||
|
t.Fatal("password restore unexpectedly succeeded")
|
||||||
|
}
|
||||||
|
if !errors.Is(operation.Err, os.ErrPermission) {
|
||||||
|
t.Fatalf("OperationResult lost safe permission classification: %v", operation.Err)
|
||||||
|
}
|
||||||
|
if rendered := fmt.Sprint(operation); strings.Contains(rendered, secret) || strings.Contains(rendered, "runner echoed") {
|
||||||
|
t.Fatalf("unsafe OperationResult: %s", rendered)
|
||||||
|
}
|
||||||
|
|
||||||
|
managed, _ := passwordRestoreFixture(t, secret)
|
||||||
|
root := t.TempDir()
|
||||||
|
config := filepath.Join(root, "config", "profiles.json")
|
||||||
|
writeConfig(t, config, `{"profiles":[]}`)
|
||||||
|
store := profile.NewFileStore(config)
|
||||||
|
stateDir := filepath.Join(root, "state")
|
||||||
|
lock := operationlock.New(filepath.Join(stateDir, "operation.lock"))
|
||||||
|
logger := oplog.New(filepath.Join(stateDir, "operations.log"), 1024, 1)
|
||||||
|
var output bytes.Buffer
|
||||||
|
err := runMaintenanceCommand([]string{"reset"}, strings.NewReader("yes\n"), &output, store, []target.Target{managed}, lock, logger, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("reset unexpectedly succeeded")
|
||||||
|
}
|
||||||
|
logData, readErr := os.ReadFile(filepath.Join(stateDir, "operations.log"))
|
||||||
|
if readErr != nil {
|
||||||
|
t.Fatal(readErr)
|
||||||
|
}
|
||||||
|
for surface, value := range map[string]string{"CLI output": output.String(), "returned error": err.Error(), "operation log": string(logData)} {
|
||||||
|
if strings.Contains(value, secret) || strings.Contains(value, "runner echoed") {
|
||||||
|
t.Fatalf("%s exposed saved password or raw runner error: %q", surface, value)
|
||||||
|
}
|
||||||
|
if !strings.Contains(value, "sensitive argument and runner error suppressed") {
|
||||||
|
t.Fatalf("%s lacks fixed safe failure message: %q", surface, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResetPromptIncludesEveryManagedTargetWarningAndCancellationDoesNotMutate(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
config := filepath.Join(root, "profiles.json")
|
||||||
|
writeConfig(t, config, `{"profiles":[]}`)
|
||||||
|
store := profile.NewFileStore(config)
|
||||||
|
daemon := &processFakeTarget{id: "docker-daemon", state: target.Drifted, confirmation: "Docker will restart immediately and may disrupt running containers and other work."}
|
||||||
|
other := &processFakeTarget{id: "other", state: target.Enabled, confirmation: "Other managed warning."}
|
||||||
|
external := &processFakeTarget{id: "external", state: target.External, confirmation: "must not appear"}
|
||||||
|
var output bytes.Buffer
|
||||||
|
|
||||||
|
err := runMaintenanceCommand([]string{"reset"}, strings.NewReader("no\n"), &output, store, []target.Target{daemon, other, external}, operationlock.New(filepath.Join(root, "operation.lock")), nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
text := output.String()
|
||||||
|
for _, want := range []string{"docker-daemon: Docker will restart immediately", "other: Other managed warning", "Reset cancelled"} {
|
||||||
|
if !strings.Contains(text, want) {
|
||||||
|
t.Errorf("reset output missing %q: %q", want, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(text, "must not appear") || daemon.calls+other.calls+external.calls != 0 {
|
||||||
|
t.Fatalf("cancel output=%q calls=%d/%d/%d", text, daemon.calls, other.calls, external.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResetCommandReportsCompletedTargetTruthWhenOperationLoggingFails(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
config := filepath.Join(root, "profiles.json")
|
||||||
|
writeConfig(t, config, `{"profiles":[]}`)
|
||||||
|
managed := &processFakeTarget{id: "managed", state: target.Enabled}
|
||||||
|
var output bytes.Buffer
|
||||||
|
|
||||||
|
err := runMaintenanceCommand([]string{"reset"}, strings.NewReader("yes\n"), &output, profile.NewFileStore(config), []target.Target{managed}, operationlock.New(filepath.Join(root, "operation.lock")), failingOperationLogger{err: errors.New("log device full")}, nil)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "log device full") {
|
||||||
|
t.Fatalf("logger failure err=%v output=%q", err, output.String())
|
||||||
|
}
|
||||||
|
if managed.calls != 1 || managed.state != target.Disabled || !strings.Contains(output.String(), "managed: reset (Disabled)") || !strings.Contains(output.String(), "operation logging failed") {
|
||||||
|
t.Fatalf("logger failure truth output=%q calls=%d state=%s", output.String(), managed.calls, managed.state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResetCommandCancellationAndTruthfulPartialFailureWithFakeTargets(t *testing.T) {
|
func TestResetCommandCancellationAndTruthfulPartialFailureWithFakeTargets(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
config := filepath.Join(root, "xdg-config", "proxyctl", "profiles.json")
|
config := filepath.Join(root, "xdg-config", "proxyctl", "profiles.json")
|
||||||
@@ -232,10 +439,10 @@ func TestResetCommandCancellationAndTruthfulPartialFailureWithFakeTargets(t *tes
|
|||||||
t.Fatal("partial reset returned success")
|
t.Fatal("partial reset returned success")
|
||||||
}
|
}
|
||||||
text := output.String()
|
text := output.String()
|
||||||
if good.calls != 1 || bad.calls != 1 || external.calls != 0 || !strings.Contains(text, "good: reset (Disabled)") || !strings.Contains(text, "bad: failed (Drifted)") || !strings.Contains(text, "external: skipped (External)") {
|
if good.calls != 1 || bad.calls != 1 || external.calls != 0 || !strings.Contains(text, "good: reset (Disabled)") || !strings.Contains(text, "bad: failed (Drifted)") || !strings.Contains(text, "fresh inspection: fake evidence for http://alice:REDACTED@proxy.test") || !strings.Contains(text, "external: skipped (External)") {
|
||||||
t.Fatalf("partial reset output=%q calls=%d/%d/%d", text, good.calls, bad.calls, external.calls)
|
t.Fatalf("partial reset output=%q calls=%d/%d/%d", text, good.calls, bad.calls, external.calls)
|
||||||
}
|
}
|
||||||
if strings.Contains(text, "hunter2") || strings.Contains(err.Error(), "hunter2") {
|
if strings.Contains(text, "hunter2") || strings.Contains(text, "secret") || strings.Contains(err.Error(), "hunter2") {
|
||||||
t.Fatalf("reset leaked credentials: output=%q err=%v", text, err)
|
t.Fatalf("reset leaked credentials: output=%q err=%v", text, err)
|
||||||
}
|
}
|
||||||
logData, readErr := os.ReadFile(filepath.Join(stateDir, "operations.log"))
|
logData, readErr := os.ReadFile(filepath.Join(stateDir, "operations.log"))
|
||||||
|
|||||||
@@ -3,21 +3,47 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"os/user"
|
"os/user"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"proxyctl/internal/profile"
|
"proxyctl/internal/profile"
|
||||||
"proxyctl/internal/target"
|
"proxyctl/internal/target"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type processResetTarget struct{ marker string }
|
||||||
|
|
||||||
|
func (t processResetTarget) ID() string { return "docker-daemon-test" }
|
||||||
|
func (t processResetTarget) Description() target.Description {
|
||||||
|
return target.Description{Name: "Docker daemon"}
|
||||||
|
}
|
||||||
|
func (t processResetTarget) Availability(context.Context) target.Availability {
|
||||||
|
return target.Availability{Available: true, Evidence: "isolated process fixture"}
|
||||||
|
}
|
||||||
|
func (t processResetTarget) Inspect(context.Context, *profile.Profile) target.Inspection {
|
||||||
|
if _, err := os.Stat(t.marker); err == nil {
|
||||||
|
return target.Inspection{State: target.Disabled, Evidence: "isolated managed configuration removed"}
|
||||||
|
}
|
||||||
|
return target.Inspection{State: target.Drifted, Evidence: "isolated recovery metadata exists"}
|
||||||
|
}
|
||||||
|
func (t processResetTarget) Confirmation(bool) string {
|
||||||
|
return "Docker will restart immediately. This may disrupt running containers and other work."
|
||||||
|
}
|
||||||
|
func (t processResetTarget) SetDesired(context.Context, bool, *profile.Profile) target.OperationResult {
|
||||||
|
err := os.WriteFile(t.marker, []byte("reset\n"), 0o600)
|
||||||
|
return target.OperationResult{Inspection: t.Inspect(context.Background(), nil), Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
// This file is compiled only into the process-smoke binary. It gives that real
|
// This file is compiled only into the process-smoke binary. It gives that real
|
||||||
// executable an identity whose home is inside the test sandbox and omits
|
// executable an identity whose home is inside the test sandbox and omits
|
||||||
// production system Targets, so no command can reach developer or host state.
|
// production system Targets, so no command can reach developer or host state.
|
||||||
func init() {
|
func init() {
|
||||||
processProductionTargets = func(profile.IdentityEnvironment) ([]target.Target, error) {
|
processProductionTargets = func(profile.IdentityEnvironment) ([]target.Target, error) {
|
||||||
return []target.Target{}, nil
|
home := os.Getenv("PROXYCTL_PROCESS_TEST_HOME")
|
||||||
|
return []target.Target{processResetTarget{marker: filepath.Join(home, "reset-mutation-marker")}}, nil
|
||||||
}
|
}
|
||||||
processCurrentUser = func() (profile.UserIdentity, error) {
|
processCurrentUser = func() (profile.UserIdentity, error) {
|
||||||
home := os.Getenv("PROXYCTL_PROCESS_TEST_HOME")
|
home := os.Getenv("PROXYCTL_PROCESS_TEST_HOME")
|
||||||
|
|||||||
+42
-10
@@ -80,9 +80,9 @@ func (m *Maintenance) Doctor(ctx context.Context, applicationChecks []target.Che
|
|||||||
active := activeFromState(m.state)
|
active := activeFromState(m.state)
|
||||||
for _, adapter := range m.targets {
|
for _, adapter := range m.targets {
|
||||||
availability := adapter.Availability(ctx)
|
availability := adapter.Availability(ctx)
|
||||||
report.Diagnostics = append(report.Diagnostics, Diagnostic{TargetID: adapter.ID(), Check: target.Check{Name: "Target availability", OK: availability.Available, Evidence: availability.Evidence}})
|
report.Diagnostics = append(report.Diagnostics, Diagnostic{TargetID: adapter.ID(), Check: target.Check{Name: "Target availability", OK: availability.Available, Evidence: profile.RedactText(availability.Evidence)}})
|
||||||
inspection := adapter.Inspect(ctx, active)
|
inspection := adapter.Inspect(ctx, active)
|
||||||
report.Diagnostics = append(report.Diagnostics, Diagnostic{TargetID: adapter.ID(), Check: target.Check{Name: "detected state", OK: inspection.State != target.Drifted, Evidence: string(inspection.State) + ": " + inspection.Evidence}})
|
report.Diagnostics = append(report.Diagnostics, Diagnostic{TargetID: adapter.ID(), Check: target.Check{Name: "detected state", OK: inspection.State != target.Drifted, Evidence: profile.RedactText(string(inspection.State) + ": " + inspection.Evidence)}})
|
||||||
if diagnosable, ok := adapter.(target.Diagnosable); ok {
|
if diagnosable, ok := adapter.(target.Diagnosable); ok {
|
||||||
for _, check := range diagnosable.DiagnosticChecks(ctx, active) {
|
for _, check := range diagnosable.DiagnosticChecks(ctx, active) {
|
||||||
check.Evidence = profile.RedactText(check.Evidence)
|
check.Evidence = profile.RedactText(check.Evidence)
|
||||||
@@ -94,10 +94,12 @@ func (m *Maintenance) Doctor(ctx context.Context, applicationChecks []target.Che
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ResetTargetResult struct {
|
type ResetTargetResult struct {
|
||||||
TargetID string
|
TargetID string
|
||||||
Inspection target.Inspection
|
Inspection target.Inspection
|
||||||
Skipped bool
|
Skipped bool
|
||||||
Err error
|
OperationErr error
|
||||||
|
LogErr error
|
||||||
|
Err error
|
||||||
}
|
}
|
||||||
type ResetResult struct {
|
type ResetResult struct {
|
||||||
Cancelled bool
|
Cancelled bool
|
||||||
@@ -105,6 +107,23 @@ type ResetResult struct {
|
|||||||
Err error
|
Err error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResetConfirmation performs only read-only inspection and returns every
|
||||||
|
// warning required by Targets that a reset would attempt to disable.
|
||||||
|
func (m *Maintenance) ResetConfirmation(ctx context.Context) string {
|
||||||
|
active := activeFromState(m.state)
|
||||||
|
var warnings []string
|
||||||
|
for _, adapter := range m.targets {
|
||||||
|
inspection := adapter.Inspect(ctx, active)
|
||||||
|
if inspection.State != target.Enabled && inspection.State != target.Drifted {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if warning := adapter.Confirmation(false); warning != "" {
|
||||||
|
warnings = append(warnings, adapter.ID()+": "+warning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(warnings, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Maintenance) Reset(ctx context.Context, confirmed bool) ResetResult {
|
func (m *Maintenance) Reset(ctx context.Context, confirmed bool) ResetResult {
|
||||||
if !confirmed {
|
if !confirmed {
|
||||||
return ResetResult{Cancelled: true}
|
return ResetResult{Cancelled: true}
|
||||||
@@ -126,17 +145,30 @@ func (m *Maintenance) Reset(ctx context.Context, confirmed bool) ResetResult {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
operation := adapter.SetDesired(ctx, false, active)
|
operation := adapter.SetDesired(ctx, false, active)
|
||||||
detail, outcome := operation.Inspection.Evidence, "success"
|
finalInspection := operation.Inspection
|
||||||
|
detail, outcome := finalInspection.Evidence, "success"
|
||||||
if operation.Err != nil {
|
if operation.Err != nil {
|
||||||
|
// OperationResult inspection is explicitly advisory on failure. Reset
|
||||||
|
// must publish and log a fresh observation just like interactive
|
||||||
|
// Target operations do.
|
||||||
|
finalInspection = adapter.Inspect(ctx, active)
|
||||||
outcome = "failure"
|
outcome = "failure"
|
||||||
detail = operation.Err.Error()
|
detail = fmt.Sprintf("%v; fresh inspection %s: %s", operation.Err, finalInspection.State, finalInspection.Evidence)
|
||||||
result.Err = errors.Join(result.Err, fmt.Errorf("reset %s Target: %w", adapter.ID(), redactError(operation.Err)))
|
result.Err = errors.Join(result.Err, fmt.Errorf("reset %s Target: %w", adapter.ID(), redactError(operation.Err)))
|
||||||
}
|
}
|
||||||
detail = RedactLogDetail(detail)
|
detail = RedactLogDetail(detail)
|
||||||
|
operationErr := redactError(operation.Err)
|
||||||
|
var logErr error
|
||||||
if m.logger != nil {
|
if m.logger != nil {
|
||||||
_ = m.logger.Write(LogEntry{Time: time.Now(), Operation: "reset", Target: adapter.ID(), Result: outcome, Detail: detail})
|
if err := m.logger.Write(LogEntry{Time: time.Now(), Operation: "reset", Target: adapter.ID(), Result: outcome, Detail: detail}); err != nil {
|
||||||
|
logErr = redactError(err)
|
||||||
|
result.Err = errors.Join(result.Err, fmt.Errorf("record reset %s Target operation log: %w", adapter.ID(), logErr))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
result.Targets = append(result.Targets, ResetTargetResult{TargetID: adapter.ID(), Inspection: operation.Inspection, Err: redactError(operation.Err)})
|
result.Targets = append(result.Targets, ResetTargetResult{
|
||||||
|
TargetID: adapter.ID(), Inspection: finalInspection,
|
||||||
|
OperationErr: operationErr, LogErr: logErr, Err: errors.Join(operationErr, logErr),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ type maintenanceTarget struct {
|
|||||||
checks []target.Check
|
checks []target.Check
|
||||||
disableErr error
|
disableErr error
|
||||||
disableCalls int
|
disableCalls int
|
||||||
|
confirmation string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *maintenanceTarget) ID() string { return f.id }
|
func (f *maintenanceTarget) ID() string { return f.id }
|
||||||
@@ -26,7 +27,7 @@ func (f *maintenanceTarget) Description() target.Description {
|
|||||||
return target.Description{Name: strings.ToUpper(f.id)}
|
return target.Description{Name: strings.ToUpper(f.id)}
|
||||||
}
|
}
|
||||||
func (f *maintenanceTarget) Availability(context.Context) target.Availability { return f.available }
|
func (f *maintenanceTarget) Availability(context.Context) target.Availability { return f.available }
|
||||||
func (f *maintenanceTarget) Confirmation(bool) string { return "" }
|
func (f *maintenanceTarget) Confirmation(bool) string { return f.confirmation }
|
||||||
func (f *maintenanceTarget) Inspect(context.Context, *profile.Profile) target.Inspection {
|
func (f *maintenanceTarget) Inspect(context.Context, *profile.Profile) target.Inspection {
|
||||||
return f.inspection
|
return f.inspection
|
||||||
}
|
}
|
||||||
@@ -43,6 +44,28 @@ func (f *maintenanceTarget) DiagnosticChecks(context.Context, *profile.Profile)
|
|||||||
return append([]target.Check(nil), f.checks...)
|
return append([]target.Check(nil), f.checks...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type staleFailureTarget struct{ inspectCalls int }
|
||||||
|
|
||||||
|
func (*staleFailureTarget) ID() string { return "stale" }
|
||||||
|
func (*staleFailureTarget) Description() target.Description { return target.Description{Name: "stale"} }
|
||||||
|
func (*staleFailureTarget) Availability(context.Context) target.Availability {
|
||||||
|
return target.Availability{Available: true}
|
||||||
|
}
|
||||||
|
func (*staleFailureTarget) Confirmation(bool) string { return "" }
|
||||||
|
func (f *staleFailureTarget) Inspect(context.Context, *profile.Profile) target.Inspection {
|
||||||
|
f.inspectCalls++
|
||||||
|
if f.inspectCalls == 1 {
|
||||||
|
return target.Inspection{State: target.Enabled, Evidence: "initial managed state"}
|
||||||
|
}
|
||||||
|
return target.Inspection{State: target.External, Evidence: "fresh evidence http://alice:secret@proxy.example:8080"}
|
||||||
|
}
|
||||||
|
func (*staleFailureTarget) SetDesired(context.Context, bool, *profile.Profile) target.OperationResult {
|
||||||
|
return target.OperationResult{
|
||||||
|
Inspection: target.Inspection{State: target.Drifted, Evidence: "stale returned evidence"},
|
||||||
|
Err: errors.New("failed https://bob:hunter2@proxy.example/path"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type recordingLock struct{ held, acquired int }
|
type recordingLock struct{ held, acquired int }
|
||||||
|
|
||||||
func (l *recordingLock) Lock(context.Context) (func(), error) {
|
func (l *recordingLock) Lock(context.Context) (func(), error) {
|
||||||
@@ -51,11 +74,14 @@ func (l *recordingLock) Lock(context.Context) (func(), error) {
|
|||||||
return func() { l.held-- }, nil
|
return func() { l.held-- }, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type recordingLog struct{ entries []app.LogEntry }
|
type recordingLog struct {
|
||||||
|
entries []app.LogEntry
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
func (l *recordingLog) Write(entry app.LogEntry) error {
|
func (l *recordingLog) Write(entry app.LogEntry) error {
|
||||||
l.entries = append(l.entries, entry)
|
l.entries = append(l.entries, entry)
|
||||||
return nil
|
return l.err
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDoctorReportsReadOnlyChecksEvidenceAndRedactsCredentials(t *testing.T) {
|
func TestDoctorReportsReadOnlyChecksEvidenceAndRedactsCredentials(t *testing.T) {
|
||||||
@@ -111,6 +137,57 @@ func TestApplicationSerializesAndLogsTargetMutations(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResetFailureUsesFreshInspectionForResultAndRedactedLog(t *testing.T) {
|
||||||
|
adapter := &staleFailureTarget{}
|
||||||
|
log := &recordingLog{}
|
||||||
|
result := app.NewMaintenance(profile.State{}, []target.Target{adapter}, &recordingLock{}, log).Reset(context.Background(), true)
|
||||||
|
if result.Err == nil || adapter.inspectCalls != 2 || len(result.Targets) != 1 {
|
||||||
|
t.Fatalf("reset result=%#v inspect calls=%d", result, adapter.inspectCalls)
|
||||||
|
}
|
||||||
|
got := result.Targets[0].Inspection
|
||||||
|
if got.State != target.External || !strings.Contains(got.Evidence, "fresh evidence") || strings.Contains(got.Evidence, "stale") {
|
||||||
|
t.Fatalf("failure inspection = %#v", got)
|
||||||
|
}
|
||||||
|
if len(log.entries) != 1 || !strings.Contains(log.entries[0].Detail, "fresh inspection External") || strings.Contains(log.entries[0].Detail, "secret") || strings.Contains(log.entries[0].Detail, "hunter2") {
|
||||||
|
t.Fatalf("failure log = %#v", log.entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResetConfirmationAggregatesWarningsOnlyForManagedCandidatesWithoutMutation(t *testing.T) {
|
||||||
|
managed := &maintenanceTarget{id: "managed", inspection: target.Inspection{State: target.Enabled, Evidence: "managed"}}
|
||||||
|
drifted := &maintenanceTarget{id: "drifted", inspection: target.Inspection{State: target.Drifted, Evidence: "recovery metadata remains"}}
|
||||||
|
external := &maintenanceTarget{id: "external", inspection: target.Inspection{State: target.External, Evidence: "administrator owned"}}
|
||||||
|
managed.confirmation = "managed warning"
|
||||||
|
drifted.confirmation = "drifted warning"
|
||||||
|
external.confirmation = "must not be shown"
|
||||||
|
maintenance := app.NewMaintenance(profile.State{}, []target.Target{managed, drifted, external}, &recordingLock{}, nil)
|
||||||
|
|
||||||
|
confirmation := maintenance.ResetConfirmation(context.Background())
|
||||||
|
for _, want := range []string{"managed: managed warning", "drifted: drifted warning"} {
|
||||||
|
if !strings.Contains(confirmation, want) {
|
||||||
|
t.Errorf("confirmation missing %q: %q", want, confirmation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(confirmation, "must not be shown") || managed.disableCalls+drifted.disableCalls+external.disableCalls != 0 {
|
||||||
|
t.Fatalf("confirmation=%q mutation calls=%d/%d/%d", confirmation, managed.disableCalls, drifted.disableCalls, external.disableCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResetLoggerFailureIsReportedWithoutUndoingCompletedConfiguration(t *testing.T) {
|
||||||
|
adapter := &maintenanceTarget{id: "managed", inspection: target.Inspection{State: target.Enabled, Evidence: "managed"}}
|
||||||
|
logger := &recordingLog{err: errors.New("operation log disk full")}
|
||||||
|
|
||||||
|
result := app.NewMaintenance(profile.State{}, []target.Target{adapter}, &recordingLock{}, logger).Reset(context.Background(), true)
|
||||||
|
|
||||||
|
if result.Err == nil || !strings.Contains(result.Err.Error(), "operation log disk full") || len(result.Targets) != 1 {
|
||||||
|
t.Fatalf("reset result = %#v", result)
|
||||||
|
}
|
||||||
|
item := result.Targets[0]
|
||||||
|
if item.Inspection.State != target.Disabled || item.OperationErr != nil || item.LogErr == nil || item.Err == nil || adapter.disableCalls != 1 {
|
||||||
|
t.Fatalf("logger failure outcome=%#v disable calls=%d", item, adapter.disableCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResetRequiresConfirmationAndReportsTruthfulPartialResults(t *testing.T) {
|
func TestResetRequiresConfirmationAndReportsTruthfulPartialResults(t *testing.T) {
|
||||||
good := &maintenanceTarget{id: "good", available: target.Availability{Available: true}, inspection: target.Inspection{State: target.Enabled, Evidence: "managed"}}
|
good := &maintenanceTarget{id: "good", available: target.Availability{Available: true}, inspection: target.Inspection{State: target.Enabled, Evidence: "managed"}}
|
||||||
failure := errors.New("remove https://alice:secret@proxy.example failed: permission denied")
|
failure := errors.New("remove https://alice:secret@proxy.example failed: permission denied")
|
||||||
|
|||||||
@@ -82,6 +82,8 @@ type Adapter struct {
|
|||||||
runner Runner
|
runner Runner
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var _ target.Diagnosable = (*Adapter)(nil)
|
||||||
|
|
||||||
func New(configuration Configuration, files FileSystem, runner Runner) *Adapter {
|
func New(configuration Configuration, files FileSystem, runner Runner) *Adapter {
|
||||||
defaults := DefaultConfiguration()
|
defaults := DefaultConfiguration()
|
||||||
if configuration.DebianVersionFile == "" {
|
if configuration.DebianVersionFile == "" {
|
||||||
@@ -116,6 +118,32 @@ func (a *Adapter) Description() target.Description {
|
|||||||
|
|
||||||
func (a *Adapter) Confirmation(bool) string { return "" }
|
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 {
|
func (a *Adapter) Availability(ctx context.Context) target.Availability {
|
||||||
marker, err := os.Stat(a.configuration.DebianVersionFile)
|
marker, err := os.Stat(a.configuration.DebianVersionFile)
|
||||||
if err != nil || !marker.Mode().IsRegular() {
|
if err != nil || !marker.Mode().IsRegular() {
|
||||||
|
|||||||
@@ -46,6 +46,30 @@ func TestAvailabilityRequiresDebianMarkerAPTToolAndConfigurationDirectory(t *tes
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDiagnosticChecksCoverCommandSchemaAndAuthorityWithoutMutation(t *testing.T) {
|
||||||
|
a, _ := newAdapter(t, nil)
|
||||||
|
checks := a.DiagnosticChecks(context.Background(), compatibleProfile())
|
||||||
|
assertDiagnosticNames(t, checks, "required command", "configuration schema", "write authority")
|
||||||
|
if _, err := os.Stat(a.OverlayPath()); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatalf("diagnostics created APT overlay: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertDiagnosticNames(t *testing.T, checks []target.Check, names ...string) {
|
||||||
|
t.Helper()
|
||||||
|
for _, name := range names {
|
||||||
|
found := false
|
||||||
|
for _, check := range checks {
|
||||||
|
if check.Name == name {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("diagnostics missing %q: %#v", name, checks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDescriptionExplainsEnvironmentPersistenceAndPrivilegeBoundary(t *testing.T) {
|
func TestDescriptionExplainsEnvironmentPersistenceAndPrivilegeBoundary(t *testing.T) {
|
||||||
a, _ := newAdapter(t, nil)
|
a, _ := newAdapter(t, nil)
|
||||||
got := a.Description()
|
got := a.Description()
|
||||||
|
|||||||
@@ -154,7 +154,10 @@ type Adapter struct {
|
|||||||
files FileSystem
|
files FileSystem
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ target.Target = (*Adapter)(nil)
|
var (
|
||||||
|
_ target.Target = (*Adapter)(nil)
|
||||||
|
_ target.Diagnosable = (*Adapter)(nil)
|
||||||
|
)
|
||||||
|
|
||||||
func New(configuration Configuration, files FileSystem) *Adapter {
|
func New(configuration Configuration, files FileSystem) *Adapter {
|
||||||
if files == nil {
|
if files == nil {
|
||||||
@@ -174,6 +177,34 @@ func (a *Adapter) Description() target.Description {
|
|||||||
}
|
}
|
||||||
func (a *Adapter) Confirmation(bool) string { return "" }
|
func (a *Adapter) Confirmation(bool) string { return "" }
|
||||||
|
|
||||||
|
// DiagnosticChecks validates the readable JSON surfaces and observes write
|
||||||
|
// authority without taking the adapter lock or creating any files.
|
||||||
|
func (a *Adapter) DiagnosticChecks(_ context.Context, _ *profile.Profile) []target.Check {
|
||||||
|
commandCheck := target.Check{Name: "required commands", OK: true, Evidence: "Docker client defaults require no external commands"}
|
||||||
|
schemaCheck := target.Check{Name: "configuration schema", OK: true, Evidence: "Docker client configuration and recovery metadata are absent or valid JSON objects"}
|
||||||
|
if config, _, err := a.readConfig(); err != nil {
|
||||||
|
schemaCheck.OK = false
|
||||||
|
schemaCheck.Evidence = profile.RedactText("Docker client config.json is invalid or unreadable: " + err.Error())
|
||||||
|
} else if proxies, _, err := childObject(config, "proxies"); err != nil {
|
||||||
|
schemaCheck.OK = false
|
||||||
|
schemaCheck.Evidence = profile.RedactText(err.Error())
|
||||||
|
} else if defaults, _, err := childObject(proxies, "default"); err != nil {
|
||||||
|
schemaCheck.OK = false
|
||||||
|
schemaCheck.Evidence = profile.RedactText(err.Error())
|
||||||
|
} else if err := validateManagedProxyStrings(defaults); err != nil {
|
||||||
|
schemaCheck.OK = false
|
||||||
|
schemaCheck.Evidence = err.Error()
|
||||||
|
} else if _, _, err := a.readRecovery(); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||||
|
schemaCheck.OK = false
|
||||||
|
schemaCheck.Evidence = profile.RedactText("Docker client recovery metadata is invalid or unreadable: " + err.Error())
|
||||||
|
}
|
||||||
|
return []target.Check{
|
||||||
|
commandCheck,
|
||||||
|
schemaCheck,
|
||||||
|
target.ReadOnlyWriteAuthority(a.configuration.ConfigFile, a.configuration.RecoveryFile),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (a *Adapter) Availability(context.Context) target.Availability {
|
func (a *Adapter) Availability(context.Context) target.Availability {
|
||||||
if !a.usablePath(a.configuration.ConfigFile) || !a.usablePath(a.configuration.RecoveryFile) {
|
if !a.usablePath(a.configuration.ConfigFile) || !a.usablePath(a.configuration.RecoveryFile) {
|
||||||
return target.Availability{Evidence: "standard Docker client configuration paths are not usable for the owning user"}
|
return target.Availability{Evidence: "standard Docker client configuration paths are not usable for the owning user"}
|
||||||
@@ -277,6 +308,9 @@ func (a *Adapter) enable(active *profile.Profile) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := validateManagedProxyStrings(defaults); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if !managed {
|
if !managed {
|
||||||
metadata = recovery{ConfigPresent: configPresent, ProxiesPresent: proxiesPresent, DefaultPresent: defaultPresent, Original: make(map[string]priorValue)}
|
metadata = recovery{ConfigPresent: configPresent, ProxiesPresent: proxiesPresent, DefaultPresent: defaultPresent, Original: make(map[string]priorValue)}
|
||||||
for _, key := range managedKeys {
|
for _, key := range managedKeys {
|
||||||
@@ -324,6 +358,19 @@ func (a *Adapter) disable() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := validateManagedProxyStrings(defaults); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
currentFingerprint := fingerprintCurrent(config)
|
||||||
|
if currentFingerprint == fingerprintOriginal(metadata) {
|
||||||
|
if err := a.files.Remove(a.configuration.RecoveryFile); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return fmt.Errorf("clean Docker client recovery metadata for already-restored proxy defaults: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if currentFingerprint != metadata.Fingerprint {
|
||||||
|
return errors.New("Docker client managed proxy defaults changed after proxyctl applied them; refusing to overwrite current configuration or discard recovery metadata")
|
||||||
|
}
|
||||||
for _, key := range managedKeys {
|
for _, key := range managedKeys {
|
||||||
prior := metadata.Original[key]
|
prior := metadata.Original[key]
|
||||||
if prior.Present {
|
if prior.Present {
|
||||||
@@ -435,6 +482,20 @@ func childObject(parent map[string]json.RawMessage, key string) (map[string]json
|
|||||||
object, err := decodeObject(raw, "Docker client "+key+" setting")
|
object, err := decodeObject(raw, "Docker client "+key+" setting")
|
||||||
return object, true, err
|
return object, true, err
|
||||||
}
|
}
|
||||||
|
func validateManagedProxyStrings(defaults map[string]json.RawMessage) error {
|
||||||
|
for _, key := range managedKeys {
|
||||||
|
raw, present := defaults[key]
|
||||||
|
if !present {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var value string
|
||||||
|
if err := json.Unmarshal(raw, &value); err != nil {
|
||||||
|
return fmt.Errorf("Docker client proxies.default.%s must be a JSON string", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func setChildObject(parent map[string]json.RawMessage, key string, child map[string]json.RawMessage) {
|
func setChildObject(parent map[string]json.RawMessage, key string, child map[string]json.RawMessage) {
|
||||||
data, _ := json.Marshal(child)
|
data, _ := json.Marshal(child)
|
||||||
parent[key] = data
|
parent[key] = data
|
||||||
@@ -485,6 +546,19 @@ func fingerprintDefaults(values map[string]string) string {
|
|||||||
return fingerprintSnapshot(snapshot)
|
return fingerprintSnapshot(snapshot)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func fingerprintOriginal(metadata recovery) string {
|
||||||
|
snapshot := make(map[string]managedValue, len(managedKeys))
|
||||||
|
for _, key := range managedKeys {
|
||||||
|
prior := metadata.Original[key]
|
||||||
|
value := managedValue{Present: prior.Present}
|
||||||
|
if prior.Present && json.Unmarshal(prior.Value, &value.Value) == nil {
|
||||||
|
value.Valid = true
|
||||||
|
}
|
||||||
|
snapshot[key] = value
|
||||||
|
}
|
||||||
|
return fingerprintSnapshot(snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
func fingerprintCurrent(config map[string]json.RawMessage) string {
|
func fingerprintCurrent(config map[string]json.RawMessage) string {
|
||||||
snapshot := make(map[string]managedValue, len(managedKeys))
|
snapshot := make(map[string]managedValue, len(managedKeys))
|
||||||
proxies, _, proxyErr := childObject(config, "proxies")
|
proxies, _, proxyErr := childObject(config, "proxies")
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"proxyctl/internal/app"
|
||||||
"proxyctl/internal/dockerclient"
|
"proxyctl/internal/dockerclient"
|
||||||
"proxyctl/internal/profile"
|
"proxyctl/internal/profile"
|
||||||
"proxyctl/internal/target"
|
"proxyctl/internal/target"
|
||||||
@@ -44,6 +45,66 @@ func TestAvailabilityAndDescriptionDescribeOnlyDockerClientDefaults(t *testing.T
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
func TestDockerClientAdapterRunsReusableTargetContract(t *testing.T) {
|
||||||
targettest.Run(t, func(t *testing.T) targettest.Fixture {
|
targettest.Run(t, func(t *testing.T) targettest.Fixture {
|
||||||
a := newAdapter(t, nil)
|
a := newAdapter(t, nil)
|
||||||
@@ -142,6 +203,56 @@ func TestEnableSafelyMergesAndDisableRestoresPriorDefaults(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
func TestInspectionDistinguishesAllStatesAndManagedValueEditsDrift(t *testing.T) {
|
||||||
a := newAdapter(t, nil)
|
a := newAdapter(t, nil)
|
||||||
assertState(t, a, target.Disabled)
|
assertState(t, a, target.Disabled)
|
||||||
@@ -503,6 +614,10 @@ func assertState(t *testing.T, a *dockerclient.Adapter, want target.State) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type immediateLock struct{}
|
||||||
|
|
||||||
|
func (immediateLock) Lock(context.Context) (func(), error) { return func() {}, nil }
|
||||||
|
|
||||||
type failingFS struct {
|
type failingFS struct {
|
||||||
dockerclient.FileSystem
|
dockerclient.FileSystem
|
||||||
permission, failConfig, leak, restoreLeak, failRecoveryOnce bool
|
permission, failConfig, leak, restoreLeak, failRecoveryOnce bool
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ import (
|
|||||||
"proxyctl/internal/target"
|
"proxyctl/internal/target"
|
||||||
)
|
)
|
||||||
|
|
||||||
const service = "docker.service"
|
const (
|
||||||
|
service = "docker.service"
|
||||||
|
recoveryVersion = 1
|
||||||
|
)
|
||||||
|
|
||||||
type Configuration struct {
|
type Configuration struct {
|
||||||
DaemonConfigPath string
|
DaemonConfigPath string
|
||||||
@@ -68,13 +71,24 @@ func (ExecRunner) Run(ctx context.Context, name string, args ...string) ([]byte,
|
|||||||
return output, nil
|
return output, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ProcessAuthority interface {
|
||||||
|
EffectiveUID() int
|
||||||
|
}
|
||||||
|
|
||||||
|
type processAuthority struct{}
|
||||||
|
|
||||||
|
func (processAuthority) EffectiveUID() int { return os.Geteuid() }
|
||||||
|
|
||||||
type Adapter struct {
|
type Adapter struct {
|
||||||
configuration Configuration
|
configuration Configuration
|
||||||
files FileSystem
|
files FileSystem
|
||||||
runner CommandRunner
|
runner CommandRunner
|
||||||
|
authority ProcessAuthority
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(configuration Configuration, files FileSystem, runner CommandRunner) *Adapter {
|
var _ target.Diagnosable = (*Adapter)(nil)
|
||||||
|
|
||||||
|
func New(configuration Configuration, files FileSystem, runner CommandRunner, authorities ...ProcessAuthority) *Adapter {
|
||||||
if configuration.DaemonConfigPath == "" {
|
if configuration.DaemonConfigPath == "" {
|
||||||
configuration.DaemonConfigPath = "/etc/docker/daemon.json"
|
configuration.DaemonConfigPath = "/etc/docker/daemon.json"
|
||||||
}
|
}
|
||||||
@@ -88,7 +102,11 @@ func New(configuration Configuration, files FileSystem, runner CommandRunner) *A
|
|||||||
if runner == nil {
|
if runner == nil {
|
||||||
runner = ExecRunner{}
|
runner = ExecRunner{}
|
||||||
}
|
}
|
||||||
return &Adapter{configuration: configuration, files: files, runner: runner}
|
authority := ProcessAuthority(processAuthority{})
|
||||||
|
if len(authorities) != 0 && authorities[0] != nil {
|
||||||
|
authority = authorities[0]
|
||||||
|
}
|
||||||
|
return &Adapter{configuration: configuration, files: files, runner: runner, authority: authority}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Adapter) ID() string { return "docker-daemon" }
|
func (a *Adapter) ID() string { return "docker-daemon" }
|
||||||
@@ -102,6 +120,49 @@ func (a *Adapter) Description() target.Description {
|
|||||||
func (a *Adapter) Confirmation(bool) string {
|
func (a *Adapter) Confirmation(bool) string {
|
||||||
return "Docker will restart immediately. This may disrupt running containers and other work. Confirm before proxyctl changes daemon configuration."
|
return "Docker will restart immediately. This may disrupt running containers and other work. Confirm before proxyctl changes daemon configuration."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DiagnosticChecks performs only systemctl show and filesystem reads/metadata
|
||||||
|
// probes; service reload/restart and all configuration mutations are excluded.
|
||||||
|
func (a *Adapter) DiagnosticChecks(ctx context.Context, _ *profile.Profile) []target.Check {
|
||||||
|
commandCheck := target.Check{Name: "required command and service", OK: true, Evidence: "systemctl can inspect the loaded docker.service"}
|
||||||
|
if availability, err := a.detect(ctx); err != nil || !availability.Available {
|
||||||
|
commandCheck.OK = false
|
||||||
|
if err != nil {
|
||||||
|
commandCheck.Evidence = profile.RedactText("systemctl show failed: " + err.Error())
|
||||||
|
} else {
|
||||||
|
commandCheck.Evidence = availability.Evidence
|
||||||
|
}
|
||||||
|
}
|
||||||
|
schemaCheck := target.Check{Name: "configuration schema", OK: true, Evidence: "Docker daemon configuration and recovery metadata are absent or valid JSON"}
|
||||||
|
if data, err := a.files.ReadFile(a.configuration.DaemonConfigPath); err == nil {
|
||||||
|
if schemaErr := validateConfigSchema(data); schemaErr != nil {
|
||||||
|
schemaCheck.OK = false
|
||||||
|
schemaCheck.Evidence = profile.RedactText("Docker daemon configuration schema is invalid: " + schemaErr.Error())
|
||||||
|
}
|
||||||
|
} else if !errors.Is(err, fs.ErrNotExist) {
|
||||||
|
schemaCheck.OK = false
|
||||||
|
schemaCheck.Evidence = profile.RedactText("Docker daemon configuration cannot be read: " + err.Error())
|
||||||
|
}
|
||||||
|
if schemaCheck.OK {
|
||||||
|
if _, _, err := a.readRecovery(); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||||
|
schemaCheck.OK = false
|
||||||
|
schemaCheck.Evidence = profile.RedactText("Docker daemon recovery metadata is invalid or unreadable: " + err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
serviceAuthority := target.Check{Name: "service control authority", OK: a.authority.EffectiveUID() == 0}
|
||||||
|
if serviceAuthority.OK {
|
||||||
|
serviceAuthority.Evidence = "effective process authority can control the system Docker service"
|
||||||
|
} else {
|
||||||
|
serviceAuthority.Evidence = "effective process authority cannot reload or restart the system Docker service; run proxyctl explicitly with sufficient authority"
|
||||||
|
}
|
||||||
|
return []target.Check{
|
||||||
|
commandCheck,
|
||||||
|
schemaCheck,
|
||||||
|
target.ReadOnlyWriteAuthority(a.configuration.DaemonConfigPath, a.configuration.RecoveryPath),
|
||||||
|
serviceAuthority,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (a *Adapter) Availability(ctx context.Context) target.Availability {
|
func (a *Adapter) Availability(ctx context.Context) target.Availability {
|
||||||
availability, _ := a.detect(ctx)
|
availability, _ := a.detect(ctx)
|
||||||
return availability
|
return availability
|
||||||
@@ -130,9 +191,10 @@ func parseProperties(output string) map[string]string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type recovery struct {
|
type recovery struct {
|
||||||
|
Version int `json:"version"`
|
||||||
Original []byte `json:"original,omitempty"`
|
Original []byte `json:"original,omitempty"`
|
||||||
OriginalExists bool `json:"originalExists"`
|
OriginalExists bool `json:"originalExists"`
|
||||||
OriginalMode uint32 `json:"originalMode,omitempty"`
|
OriginalMode uint32 `json:"originalMode"`
|
||||||
ConfigFingerprint string `json:"configFingerprint"`
|
ConfigFingerprint string `json:"configFingerprint"`
|
||||||
RestartedFingerprint string `json:"restartedFingerprint,omitempty"`
|
RestartedFingerprint string `json:"restartedFingerprint,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -219,6 +281,7 @@ func (a *Adapter) enable(ctx context.Context, p profile.Profile) error {
|
|||||||
return fmt.Errorf("read Docker daemon configuration: %w", readErr)
|
return fmt.Errorf("read Docker daemon configuration: %w", readErr)
|
||||||
}
|
}
|
||||||
if !managed {
|
if !managed {
|
||||||
|
metadata.Version = recoveryVersion
|
||||||
metadata.OriginalExists = readErr == nil
|
metadata.OriginalExists = readErr == nil
|
||||||
if metadata.OriginalExists {
|
if metadata.OriginalExists {
|
||||||
metadata.Original = append([]byte(nil), current...)
|
metadata.Original = append([]byte(nil), current...)
|
||||||
@@ -273,12 +336,36 @@ func (a *Adapter) disable(ctx context.Context) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read Docker daemon recovery metadata: %w", err)
|
return fmt.Errorf("read Docker daemon recovery metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// daemon.json is a shared, whole-file configuration surface. Restore it only
|
||||||
|
// while the complete current document is exactly the document proxyctl last
|
||||||
|
// applied. This protects both managed proxy keys and unrelated administrator
|
||||||
|
// edits from being overwritten by the original snapshot.
|
||||||
|
current, currentErr := a.files.ReadFile(a.configuration.DaemonConfigPath)
|
||||||
|
if currentErr != nil && !errors.Is(currentErr, fs.ErrNotExist) {
|
||||||
|
return fmt.Errorf("read Docker daemon configuration before restore: %w", currentErr)
|
||||||
|
}
|
||||||
|
alreadyOriginal := metadata.OriginalExists && currentErr == nil && fingerprint(current) == fingerprint(metadata.Original)
|
||||||
|
alreadyAbsent := !metadata.OriginalExists && errors.Is(currentErr, fs.ErrNotExist)
|
||||||
|
if alreadyOriginal || alreadyAbsent {
|
||||||
|
// Recovery metadata proves proxyctl previously changed the effective
|
||||||
|
// daemon configuration. Even when the file has already been restored,
|
||||||
|
// complete the service effects before relinquishing that recovery record.
|
||||||
|
if err := a.reloadRestartInspect(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := a.files.Remove(a.configuration.RecoveryPath); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return fmt.Errorf("clean Docker daemon recovery metadata for already-restored configuration: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if currentErr != nil || fingerprint(current) != metadata.ConfigFingerprint {
|
||||||
|
return errors.New("Docker daemon configuration changed after proxyctl applied it; refusing to overwrite current configuration or discard recovery metadata")
|
||||||
|
}
|
||||||
|
|
||||||
if metadata.OriginalExists {
|
if metadata.OriginalExists {
|
||||||
current, _ := a.files.ReadFile(a.configuration.DaemonConfigPath)
|
if err := a.files.AtomicWrite(a.configuration.DaemonConfigPath, metadata.Original, os.FileMode(metadata.OriginalMode)); err != nil {
|
||||||
if fingerprint(current) != fingerprint(metadata.Original) {
|
return fmt.Errorf("restore Docker daemon configuration: %w", err)
|
||||||
if err := a.files.AtomicWrite(a.configuration.DaemonConfigPath, metadata.Original, os.FileMode(metadata.OriginalMode)); err != nil {
|
|
||||||
return fmt.Errorf("restore Docker daemon configuration: %w", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else if err := a.files.Remove(a.configuration.DaemonConfigPath); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
} else if err := a.files.Remove(a.configuration.DaemonConfigPath); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||||
return fmt.Errorf("remove managed Docker daemon configuration: %w", err)
|
return fmt.Errorf("remove managed Docker daemon configuration: %w", err)
|
||||||
@@ -314,12 +401,59 @@ func (a *Adapter) readRecovery() (recovery, bool, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return recovery{}, false, err
|
return recovery{}, false, err
|
||||||
}
|
}
|
||||||
|
var fields map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(data, &fields); err != nil {
|
||||||
|
return recovery{}, true, err
|
||||||
|
}
|
||||||
|
if fields == nil {
|
||||||
|
return recovery{}, true, errors.New("Docker daemon recovery metadata must be a JSON object")
|
||||||
|
}
|
||||||
var value recovery
|
var value recovery
|
||||||
if err := json.Unmarshal(data, &value); err != nil {
|
if err := json.Unmarshal(data, &value); err != nil {
|
||||||
return recovery{}, true, err
|
return recovery{}, true, err
|
||||||
}
|
}
|
||||||
|
if err := validateRecovery(value, fields); err != nil {
|
||||||
|
return recovery{}, true, err
|
||||||
|
}
|
||||||
return value, true, nil
|
return value, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateRecovery(value recovery, fields map[string]json.RawMessage) error {
|
||||||
|
for _, name := range []string{"version", "originalExists", "originalMode", "configFingerprint"} {
|
||||||
|
if _, present := fields[name]; !present {
|
||||||
|
return fmt.Errorf("Docker daemon recovery metadata is missing required field %q", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if value.Version != recoveryVersion {
|
||||||
|
return fmt.Errorf("unsupported Docker daemon recovery metadata version %d", value.Version)
|
||||||
|
}
|
||||||
|
if !validFingerprint(value.ConfigFingerprint) {
|
||||||
|
return errors.New("Docker daemon recovery metadata has an invalid configuration fingerprint")
|
||||||
|
}
|
||||||
|
if value.RestartedFingerprint != "" && (value.RestartedFingerprint != value.ConfigFingerprint || !validFingerprint(value.RestartedFingerprint)) {
|
||||||
|
return errors.New("Docker daemon recovery metadata has an inconsistent restarted fingerprint")
|
||||||
|
}
|
||||||
|
_, hasOriginal := fields["original"]
|
||||||
|
if value.OriginalExists {
|
||||||
|
if !hasOriginal {
|
||||||
|
return errors.New("Docker daemon recovery metadata is missing the recorded original configuration or mode")
|
||||||
|
}
|
||||||
|
if value.OriginalMode > 0o777 {
|
||||||
|
return errors.New("Docker daemon recovery metadata has an invalid original file mode")
|
||||||
|
}
|
||||||
|
if err := validateConfigSchema(value.Original); err != nil {
|
||||||
|
return fmt.Errorf("Docker daemon recovery metadata has an invalid original configuration: %w", err)
|
||||||
|
}
|
||||||
|
} else if hasOriginal || len(value.Original) != 0 || value.OriginalMode != 0 {
|
||||||
|
return errors.New("Docker daemon recovery metadata records original data although the original did not exist")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validFingerprint(value string) bool {
|
||||||
|
decoded, err := hex.DecodeString(value)
|
||||||
|
return err == nil && len(decoded) == sha256.Size
|
||||||
|
}
|
||||||
func (a *Adapter) writeRecovery(value recovery) error {
|
func (a *Adapter) writeRecovery(value recovery) error {
|
||||||
data, err := json.MarshalIndent(value, "", " ")
|
data, err := json.MarshalIndent(value, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -328,16 +462,45 @@ func (a *Adapter) writeRecovery(value recovery) error {
|
|||||||
return a.files.AtomicWrite(a.configuration.RecoveryPath, append(data, '\n'), 0o600)
|
return a.files.AtomicWrite(a.configuration.RecoveryPath, append(data, '\n'), 0o600)
|
||||||
}
|
}
|
||||||
|
|
||||||
func mergeConfig(data []byte, p profile.Profile) ([]byte, error) {
|
func validateConfigSchema(data []byte) error {
|
||||||
|
if len(strings.TrimSpace(string(data))) == 0 {
|
||||||
|
return errors.New("configuration must be a JSON object")
|
||||||
|
}
|
||||||
var document map[string]any
|
var document map[string]any
|
||||||
|
if err := json.Unmarshal(data, &document); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if document == nil {
|
||||||
|
return errors.New("configuration must be a JSON object")
|
||||||
|
}
|
||||||
|
existing, present := document["proxies"]
|
||||||
|
if !present {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
proxies, ok := existing.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return errors.New("the existing proxies field is not an object")
|
||||||
|
}
|
||||||
|
for _, key := range []string{"http-proxy", "https-proxy", "no-proxy"} {
|
||||||
|
if value, present := proxies[key]; present {
|
||||||
|
if _, ok := value.(string); !ok {
|
||||||
|
return fmt.Errorf("the existing proxies.%s field is not a string", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeConfig(data []byte, p profile.Profile) ([]byte, error) {
|
||||||
if len(strings.TrimSpace(string(data))) == 0 {
|
if len(strings.TrimSpace(string(data))) == 0 {
|
||||||
data = []byte("{}")
|
data = []byte("{}")
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(data, &document); err != nil {
|
if err := validateConfigSchema(data); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if document == nil {
|
var document map[string]any
|
||||||
document = map[string]any{}
|
if err := json.Unmarshal(data, &document); err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
proxies := map[string]any{}
|
proxies := map[string]any{}
|
||||||
if existing, ok := document["proxies"].(map[string]any); ok {
|
if existing, ok := document["proxies"].(map[string]any); ok {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"proxyctl/internal/app"
|
||||||
"proxyctl/internal/dockerdaemon"
|
"proxyctl/internal/dockerdaemon"
|
||||||
"proxyctl/internal/profile"
|
"proxyctl/internal/profile"
|
||||||
"proxyctl/internal/target"
|
"proxyctl/internal/target"
|
||||||
@@ -22,6 +23,7 @@ type recorder struct{ events []string }
|
|||||||
type fakeRunner struct {
|
type fakeRunner struct {
|
||||||
recorder *recorder
|
recorder *recorder
|
||||||
show string
|
show string
|
||||||
|
active string
|
||||||
fail map[string]error
|
fail map[string]error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +37,7 @@ func (r *fakeRunner) Run(_ context.Context, name string, args ...string) ([]byte
|
|||||||
return []byte(r.show), nil
|
return []byte(r.show), nil
|
||||||
}
|
}
|
||||||
if strings.Contains(command, " is-active ") {
|
if strings.Contains(command, " is-active ") {
|
||||||
return []byte("active\n"), nil
|
return []byte(r.active), nil
|
||||||
}
|
}
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -72,7 +74,7 @@ func fixture(t *testing.T) (*dockerdaemon.Adapter, *recorder, string, string, *f
|
|||||||
daemonPath := filepath.Join(directory, "etc", "docker", "daemon.json")
|
daemonPath := filepath.Join(directory, "etc", "docker", "daemon.json")
|
||||||
recoveryPath := filepath.Join(directory, "state", "docker-daemon.json")
|
recoveryPath := filepath.Join(directory, "state", "docker-daemon.json")
|
||||||
record := &recorder{}
|
record := &recorder{}
|
||||||
runner := &fakeRunner{recorder: record, show: "LoadState=loaded\nFragmentPath=/lib/systemd/system/docker.service\n", fail: map[string]error{}}
|
runner := &fakeRunner{recorder: record, show: "LoadState=loaded\nFragmentPath=/lib/systemd/system/docker.service\n", active: "active\n", fail: map[string]error{}}
|
||||||
adapter := dockerdaemon.New(dockerdaemon.Configuration{DaemonConfigPath: daemonPath, RecoveryPath: recoveryPath, TrustedRoot: directory}, recordingFS{recorder: record}, runner)
|
adapter := dockerdaemon.New(dockerdaemon.Configuration{DaemonConfigPath: daemonPath, RecoveryPath: recoveryPath, TrustedRoot: directory}, recordingFS{recorder: record}, runner)
|
||||||
return adapter, record, daemonPath, recoveryPath, runner
|
return adapter, record, daemonPath, recoveryPath, runner
|
||||||
}
|
}
|
||||||
@@ -102,6 +104,159 @@ func (f *faultFS) AtomicWrite(path string, data []byte, mode os.FileMode) error
|
|||||||
return f.recordingFS.AtomicWrite(path, data, mode)
|
return f.recordingFS.AtomicWrite(path, data, mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type fixedAuthority int
|
||||||
|
|
||||||
|
func (a fixedAuthority) EffectiveUID() int { return int(a) }
|
||||||
|
|
||||||
|
type immediateLock struct{}
|
||||||
|
|
||||||
|
func (immediateLock) Lock(context.Context) (func(), error) { return func() {}, nil }
|
||||||
|
|
||||||
|
func TestUnsafeRecoveryMetadataIsDriftedAndResetNeverMutatesDaemon(t *testing.T) {
|
||||||
|
fingerprint := strings.Repeat("a", 64)
|
||||||
|
cases := map[string]string{
|
||||||
|
"empty object": `{}`,
|
||||||
|
"truncated": `{"version":1`,
|
||||||
|
"unsupported version": `{"version":2,"originalExists":false,"configFingerprint":"` + fingerprint + `"}`,
|
||||||
|
"missing original": `{"version":1,"originalExists":true,"originalMode":384,"configFingerprint":"` + fingerprint + `"}`,
|
||||||
|
"unexpected original": `{"version":1,"originalExists":false,"original":"e30=","configFingerprint":"` + fingerprint + `"}`,
|
||||||
|
"mode without original": `{"version":1,"originalExists":false,"originalMode":384,"configFingerprint":"` + fingerprint + `"}`,
|
||||||
|
"invalid mode": `{"version":1,"originalExists":true,"original":"e30=","originalMode":4096,"configFingerprint":"` + fingerprint + `"}`,
|
||||||
|
"invalid fingerprint": `{"version":1,"originalExists":false,"configFingerprint":"short"}`,
|
||||||
|
"contradictory restart record": `{"version":1,"originalExists":false,"configFingerprint":"` + fingerprint + `","restartedFingerprint":"` + strings.Repeat("b", 64) + `"}`,
|
||||||
|
}
|
||||||
|
for name, metadata := range cases {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
a, record, daemonPath, recoveryPath, _ := fixture(t)
|
||||||
|
originalConfig := []byte(`{"log-level":"debug"}`)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(daemonPath), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(daemonPath, originalConfig, 0o640); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(recoveryPath), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(recoveryPath, []byte(metadata), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
record.events = nil
|
||||||
|
|
||||||
|
if inspection := a.Inspect(context.Background(), nil); inspection.State != target.Drifted {
|
||||||
|
t.Fatalf("inspection = %#v", inspection)
|
||||||
|
}
|
||||||
|
reset := app.NewMaintenance(profile.State{}, []target.Target{a}, immediateLock{}, nil).Reset(context.Background(), true)
|
||||||
|
if reset.Err == nil || len(reset.Targets) != 1 || reset.Targets[0].Err == nil {
|
||||||
|
t.Fatalf("unsafe reset = %#v", reset)
|
||||||
|
}
|
||||||
|
if events := mutationEvents(record.events); len(events) != 0 {
|
||||||
|
t.Fatalf("unsafe metadata caused mutation: %#v", events)
|
||||||
|
}
|
||||||
|
if got, err := os.ReadFile(daemonPath); err != nil || !reflect.DeepEqual(got, originalConfig) {
|
||||||
|
t.Fatalf("daemon changed to %q: %v", got, err)
|
||||||
|
}
|
||||||
|
if got, err := os.ReadFile(recoveryPath); err != nil || string(got) != metadata {
|
||||||
|
t.Fatalf("recovery changed to %q: %v", got, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiagnosticChecksCoverSystemdSchemaAndAuthorityWithoutMutation(t *testing.T) {
|
||||||
|
a, record, daemonPath, recoveryPath, runner := fixture(t)
|
||||||
|
a = dockerdaemon.New(dockerdaemon.Configuration{DaemonConfigPath: daemonPath, RecoveryPath: recoveryPath, TrustedRoot: filepath.Dir(filepath.Dir(daemonPath))}, recordingFS{recorder: record}, runner, fixedAuthority(0))
|
||||||
|
checks := a.DiagnosticChecks(context.Background(), activeProfile())
|
||||||
|
for _, name := range []string{"required command and service", "configuration schema", "write authority", "service control authority"} {
|
||||||
|
found := false
|
||||||
|
for _, check := range checks {
|
||||||
|
found = found || check.Name == name
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("diagnostics missing %q: %#v", name, checks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, event := range record.events {
|
||||||
|
if strings.HasPrefix(event, "write ") || strings.HasPrefix(event, "remove ") || strings.Contains(event, " restart ") || strings.Contains(event, "daemon-reload") {
|
||||||
|
t.Fatalf("diagnostics mutated daemon state: %#v", record.events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, path := range []string{daemonPath, recoveryPath} {
|
||||||
|
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatalf("diagnostics created %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiagnosticServiceControlAuthorityUsesInjectedEUIDWithoutMutation(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
uid int
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{name: "unprivileged", uid: 1000, ok: false},
|
||||||
|
{name: "root", uid: 0, ok: true},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
_, record, daemonPath, recoveryPath, runner := fixture(t)
|
||||||
|
a := dockerdaemon.New(dockerdaemon.Configuration{DaemonConfigPath: daemonPath, RecoveryPath: recoveryPath}, recordingFS{recorder: record}, runner, fixedAuthority(test.uid))
|
||||||
|
checks := a.DiagnosticChecks(context.Background(), activeProfile())
|
||||||
|
var found *target.Check
|
||||||
|
for i := range checks {
|
||||||
|
if checks[i].Name == "service control authority" {
|
||||||
|
found = &checks[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found == nil || found.OK != test.ok {
|
||||||
|
t.Fatalf("service authority check = %#v", found)
|
||||||
|
}
|
||||||
|
for _, event := range record.events {
|
||||||
|
if strings.Contains(event, "restart") || strings.Contains(event, "daemon-reload") {
|
||||||
|
t.Fatalf("authority diagnosis mutated service: %#v", record.events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiagnosticsAndEnableRejectMalformedDaemonProxyTypesWithoutMutation(t *testing.T) {
|
||||||
|
for _, document := range []string{
|
||||||
|
`[]`,
|
||||||
|
`{"proxies":[]}`,
|
||||||
|
`{"proxies":{"http-proxy":42}}`,
|
||||||
|
`{"proxies":{"https-proxy":false}}`,
|
||||||
|
`{"proxies":{"no-proxy":[]}}`,
|
||||||
|
} {
|
||||||
|
t.Run(document, func(t *testing.T) {
|
||||||
|
a, record, daemonPath, _, _ := fixture(t)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(daemonPath), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(daemonPath, []byte(document), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
checks := a.DiagnosticChecks(context.Background(), activeProfile())
|
||||||
|
if checks[1].OK {
|
||||||
|
t.Fatalf("malformed schema passed diagnostics: %#v", checks)
|
||||||
|
}
|
||||||
|
record.events = nil
|
||||||
|
result := a.SetDesired(context.Background(), true, activeProfile())
|
||||||
|
if result.Err == nil {
|
||||||
|
t.Fatalf("enable accepted malformed schema: %#v", result)
|
||||||
|
}
|
||||||
|
for _, event := range record.events {
|
||||||
|
if strings.HasPrefix(event, "write ") || strings.Contains(event, "restart") || strings.Contains(event, "daemon-reload") {
|
||||||
|
t.Fatalf("malformed schema caused mutation: %#v", record.events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
got, err := os.ReadFile(daemonPath)
|
||||||
|
if err != nil || string(got) != document {
|
||||||
|
t.Fatalf("daemon config changed: %q, %v", got, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDockerDaemonRunsReusableTargetContract(t *testing.T) {
|
func TestDockerDaemonRunsReusableTargetContract(t *testing.T) {
|
||||||
targettest.Run(t, func(t *testing.T) targettest.Fixture {
|
targettest.Run(t, func(t *testing.T) targettest.Fixture {
|
||||||
a, _, daemonPath, _, _ := fixture(t)
|
a, _, daemonPath, _, _ := fixture(t)
|
||||||
@@ -201,6 +356,163 @@ func TestDockerDaemonEnableOrdersManagedWriteReloadRestartAndPostInspect(t *test
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDockerDaemonDisableAndResetRejectDriftWithoutMutatingDaemonOrRecovery(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
mutate func(t *testing.T, managed []byte) []byte
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "unrelated administrator edit",
|
||||||
|
mutate: func(t *testing.T, managed []byte) []byte {
|
||||||
|
t.Helper()
|
||||||
|
var document map[string]any
|
||||||
|
if err := json.Unmarshal(managed, &document); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
document["log-level"] = "debug"
|
||||||
|
changed, err := json.MarshalIndent(document, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return append(changed, '\n')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "managed proxy edit",
|
||||||
|
mutate: func(t *testing.T, managed []byte) []byte {
|
||||||
|
t.Helper()
|
||||||
|
var document map[string]any
|
||||||
|
if err := json.Unmarshal(managed, &document); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
document["proxies"].(map[string]any)["http-proxy"] = "http://administrator.example:3128"
|
||||||
|
changed, err := json.MarshalIndent(document, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return append(changed, '\n')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
a, record, daemonPath, recoveryPath, _ := fixture(t)
|
||||||
|
if result := a.SetDesired(context.Background(), true, activeProfile()); result.Err != nil {
|
||||||
|
t.Fatal(result.Err)
|
||||||
|
}
|
||||||
|
managed, err := os.ReadFile(daemonPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
changed := test.mutate(t, managed)
|
||||||
|
if err := os.WriteFile(daemonPath, changed, 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
recoveryBefore, err := os.ReadFile(recoveryPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
record.events = nil
|
||||||
|
|
||||||
|
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].Err == nil || reset.Targets[0].Inspection.State != target.Drifted {
|
||||||
|
t.Fatalf("conflicting reset = %#v", reset)
|
||||||
|
}
|
||||||
|
if events := mutationEvents(record.events); len(events) != 0 {
|
||||||
|
t.Fatalf("conflicting reset mutated daemon state: %#v", events)
|
||||||
|
}
|
||||||
|
if got, err := os.ReadFile(daemonPath); err != nil || !reflect.DeepEqual(got, changed) {
|
||||||
|
t.Fatalf("current daemon configuration changed to %q: %v", got, err)
|
||||||
|
}
|
||||||
|
if got, err := os.ReadFile(recoveryPath); err != nil || !reflect.DeepEqual(got, recoveryBefore) {
|
||||||
|
t.Fatalf("recovery metadata changed to %q: %v", got, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDockerDaemonResetAlreadyRestoredReloadsRestartsAndInspectsBeforeMetadataCleanup(t *testing.T) {
|
||||||
|
a, record, daemonPath, recoveryPath, _ := fixture(t)
|
||||||
|
original := []byte("{\n \"log-level\": \"debug\",\n \"proxies\": {\"http-proxy\": \"http://prior.example:3128\"}\n}\n")
|
||||||
|
prepareAlreadyRestoredDaemon(t, a, daemonPath, original)
|
||||||
|
record.events = nil
|
||||||
|
|
||||||
|
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].Err != nil || reset.Targets[0].Inspection.State != target.External {
|
||||||
|
t.Fatalf("already-restored reset = %#v", reset)
|
||||||
|
}
|
||||||
|
assertSubsequence(t, record.events, []string{"systemctl daemon-reload", "systemctl restart docker.service", "systemctl is-active docker.service", "remove docker-daemon.json"})
|
||||||
|
if contains(record.events, "write daemon.json") {
|
||||||
|
t.Fatalf("already-restored reset rewrote daemon config: %#v", record.events)
|
||||||
|
}
|
||||||
|
if got, err := os.ReadFile(daemonPath); err != nil || !reflect.DeepEqual(got, original) {
|
||||||
|
t.Fatalf("already-restored daemon configuration changed to %q: %v", got, err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(recoveryPath); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatalf("recovery metadata remains: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDockerDaemonAlreadyRestoredPreservesMetadataAndReportsFreshDriftOnEveryServiceFailure(t *testing.T) {
|
||||||
|
failures := []struct {
|
||||||
|
name string
|
||||||
|
command string
|
||||||
|
active string
|
||||||
|
}{
|
||||||
|
{name: "daemon reload command", command: "systemctl daemon-reload"},
|
||||||
|
{name: "restart command", command: "systemctl restart docker.service"},
|
||||||
|
{name: "active inspection command", command: "systemctl is-active docker.service"},
|
||||||
|
{name: "inactive service state", active: "failed\n"},
|
||||||
|
}
|
||||||
|
for _, failure := range failures {
|
||||||
|
t.Run(failure.name, func(t *testing.T) {
|
||||||
|
a, record, daemonPath, recoveryPath, runner := fixture(t)
|
||||||
|
original := []byte("{\n \"log-level\": \"debug\"\n}\n")
|
||||||
|
prepareAlreadyRestoredDaemon(t, a, daemonPath, original)
|
||||||
|
metadataBefore, err := os.ReadFile(recoveryPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if failure.command != "" {
|
||||||
|
runner.fail[failure.command] = errors.New("service failure")
|
||||||
|
}
|
||||||
|
if failure.active != "" {
|
||||||
|
runner.active = failure.active
|
||||||
|
}
|
||||||
|
record.events = nil
|
||||||
|
|
||||||
|
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 {
|
||||||
|
t.Fatalf("failed already-restored reset = %#v", reset)
|
||||||
|
}
|
||||||
|
if contains(record.events, "write daemon.json") || contains(record.events, "remove docker-daemon.json") {
|
||||||
|
t.Fatalf("service failure mutated configuration or metadata: %#v", record.events)
|
||||||
|
}
|
||||||
|
if got, err := os.ReadFile(daemonPath); err != nil || !reflect.DeepEqual(got, original) {
|
||||||
|
t.Fatalf("daemon config changed to %q: %v", got, err)
|
||||||
|
}
|
||||||
|
if got, err := os.ReadFile(recoveryPath); err != nil || !reflect.DeepEqual(got, metadataBefore) {
|
||||||
|
t.Fatalf("metadata changed to %q: %v", got, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareAlreadyRestoredDaemon(t *testing.T, a *dockerdaemon.Adapter, daemonPath string, original []byte) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(daemonPath), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(daemonPath, original, 0o640); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result := a.SetDesired(context.Background(), true, activeProfile()); result.Err != nil {
|
||||||
|
t.Fatal(result.Err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(daemonPath, original, 0o640); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDockerDaemonPreservesAndRestoresExistingConfiguration(t *testing.T) {
|
func TestDockerDaemonPreservesAndRestoresExistingConfiguration(t *testing.T) {
|
||||||
a, _, daemonPath, recoveryPath, _ := fixture(t)
|
a, _, daemonPath, recoveryPath, _ := fixture(t)
|
||||||
original := []byte("{\n \"log-level\": \"debug\",\n \"proxies\": {\"http-proxy\": \"http://prior.example:3128\"}\n}\n")
|
original := []byte("{\n \"log-level\": \"debug\",\n \"proxies\": {\"http-proxy\": \"http://prior.example:3128\"}\n}\n")
|
||||||
|
|||||||
+150
-7
@@ -26,14 +26,21 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
proxySchema = "org.gnome.system.proxy"
|
gnomeRecoveryVersion = 1
|
||||||
httpSchema = proxySchema + ".http"
|
proxySchema = "org.gnome.system.proxy"
|
||||||
httpsSchema = proxySchema + ".https"
|
httpSchema = proxySchema + ".http"
|
||||||
socksSchema = proxySchema + ".socks"
|
httpsSchema = proxySchema + ".https"
|
||||||
|
socksSchema = proxySchema + ".socks"
|
||||||
)
|
)
|
||||||
|
|
||||||
var requiredSchemas = []string{proxySchema, httpSchema, httpsSchema, socksSchema}
|
var requiredSchemas = []string{proxySchema, httpSchema, httpsSchema, socksSchema}
|
||||||
|
|
||||||
|
var managedSettings = []struct{ schema, key string }{
|
||||||
|
{proxySchema, "mode"}, {proxySchema, "autoconfig-url"}, {proxySchema, "ignore-hosts"},
|
||||||
|
{httpSchema, "host"}, {httpSchema, "port"}, {httpSchema, "use-authentication"}, {httpSchema, "authentication-user"}, {httpSchema, "authentication-password"},
|
||||||
|
{httpsSchema, "host"}, {httpsSchema, "port"}, {socksSchema, "host"}, {socksSchema, "port"},
|
||||||
|
}
|
||||||
|
|
||||||
// Command is the complete command contract used by the adapter. UID, GID, and
|
// Command is the complete command contract used by the adapter. UID, GID, and
|
||||||
// Env make user-session identity explicit when proxyctl itself runs under sudo.
|
// Env make user-session identity explicit when proxyctl itself runs under sudo.
|
||||||
type Command struct {
|
type Command struct {
|
||||||
@@ -220,6 +227,7 @@ type settings struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type recovery struct {
|
type recovery struct {
|
||||||
|
Version int `json:"version"`
|
||||||
Original settings `json:"original"`
|
Original settings `json:"original"`
|
||||||
LastFingerprint string `json:"lastFingerprint"`
|
LastFingerprint string `json:"lastFingerprint"`
|
||||||
}
|
}
|
||||||
@@ -230,7 +238,10 @@ type Adapter struct {
|
|||||||
state StateStore
|
state StateStore
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ target.Target = (*Adapter)(nil)
|
var (
|
||||||
|
_ target.Target = (*Adapter)(nil)
|
||||||
|
_ target.Diagnosable = (*Adapter)(nil)
|
||||||
|
)
|
||||||
|
|
||||||
func New(configuration Configuration, runner CommandRunner, state StateStore) *Adapter {
|
func New(configuration Configuration, runner CommandRunner, state StateStore) *Adapter {
|
||||||
if configuration.CommandName == "" {
|
if configuration.CommandName == "" {
|
||||||
@@ -271,6 +282,65 @@ func (a *Adapter) Description() target.Description {
|
|||||||
|
|
||||||
func (a *Adapter) Confirmation(bool) string { return "" }
|
func (a *Adapter) Confirmation(bool) string { return "" }
|
||||||
|
|
||||||
|
// DiagnosticChecks uses only gsettings list/get operations and filesystem
|
||||||
|
// metadata probes. In particular, it never invokes gsettings set or locks or
|
||||||
|
// writes the recovery StateStore.
|
||||||
|
func (a *Adapter) DiagnosticChecks(ctx context.Context, _ *profile.Profile) []target.Check {
|
||||||
|
commandCheck := target.Check{Name: "required command", OK: true, Evidence: a.configuration.CommandName + " is usable for the owning user"}
|
||||||
|
schemaCheck := target.Check{Name: "required schemas", OK: true, Evidence: "all GNOME proxy schemas are present"}
|
||||||
|
sessionCheck := target.Check{Name: "desktop session", OK: true, Evidence: "GNOME proxy settings are readable through the configured session bus"}
|
||||||
|
output, err := a.run(ctx, "list-schemas")
|
||||||
|
if err != nil {
|
||||||
|
commandCheck.OK = false
|
||||||
|
commandCheck.Evidence = profile.RedactText(a.configuration.CommandName + " failed: " + err.Error())
|
||||||
|
schemaCheck.OK = false
|
||||||
|
schemaCheck.Evidence = "GNOME schemas could not be listed"
|
||||||
|
sessionCheck.OK = false
|
||||||
|
sessionCheck.Evidence = "desktop session could not be inspected"
|
||||||
|
} else {
|
||||||
|
present := make(map[string]bool)
|
||||||
|
for _, schema := range strings.Fields(output) {
|
||||||
|
present[schema] = true
|
||||||
|
}
|
||||||
|
var missing []string
|
||||||
|
for _, schema := range requiredSchemas {
|
||||||
|
if !present[schema] {
|
||||||
|
missing = append(missing, schema)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(missing) != 0 {
|
||||||
|
schemaCheck.OK = false
|
||||||
|
schemaCheck.Evidence = "missing GNOME proxy schemas: " + strings.Join(missing, ", ")
|
||||||
|
sessionCheck.OK = false
|
||||||
|
sessionCheck.Evidence = "desktop session settings cannot be checked without required schemas"
|
||||||
|
} else if _, err := a.readSettings(ctx); err != nil {
|
||||||
|
sessionCheck.OK = false
|
||||||
|
sessionCheck.Evidence = profile.RedactText("GNOME proxy settings are unreadable: " + err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
authorityCheck := target.ReadOnlyWriteAuthority(a.configuration.StateFile)
|
||||||
|
if !commandCheck.OK || !schemaCheck.OK || !sessionCheck.OK {
|
||||||
|
authorityCheck.OK = false
|
||||||
|
authorityCheck.Evidence += "; GNOME setting authority could not be inspected"
|
||||||
|
} else {
|
||||||
|
for _, setting := range managedSettings {
|
||||||
|
output, err := a.run(ctx, "writable", setting.schema, setting.key)
|
||||||
|
if err != nil || strings.TrimSpace(output) != "true" {
|
||||||
|
authorityCheck.OK = false
|
||||||
|
detail := strings.TrimSpace(output)
|
||||||
|
if err != nil {
|
||||||
|
detail = err.Error()
|
||||||
|
}
|
||||||
|
authorityCheck.Evidence += profile.RedactText(fmt.Sprintf("; %s %s is not writable: %s", setting.schema, setting.key, detail))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if authorityCheck.OK {
|
||||||
|
authorityCheck.Evidence += "; all managed GNOME settings report writable"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return []target.Check{commandCheck, schemaCheck, sessionCheck, authorityCheck}
|
||||||
|
}
|
||||||
|
|
||||||
func (a *Adapter) Availability(ctx context.Context) target.Availability {
|
func (a *Adapter) Availability(ctx context.Context) target.Availability {
|
||||||
output, err := a.run(ctx, "list-schemas")
|
output, err := a.run(ctx, "list-schemas")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -353,6 +423,7 @@ func (a *Adapter) enable(ctx context.Context, active profile.Profile) error {
|
|||||||
return fmt.Errorf("read GNOME recovery metadata: %w", err)
|
return fmt.Errorf("read GNOME recovery metadata: %w", err)
|
||||||
}
|
}
|
||||||
if !managed {
|
if !managed {
|
||||||
|
metadata.Version = gnomeRecoveryVersion
|
||||||
original, err := a.readSettings(ctx)
|
original, err := a.readSettings(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("snapshot original GNOME settings: %w", err)
|
return fmt.Errorf("snapshot original GNOME settings: %w", err)
|
||||||
@@ -412,13 +483,59 @@ func (a *Adapter) readRecovery() (recovery, bool, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return recovery{}, false, err
|
return recovery{}, false, err
|
||||||
}
|
}
|
||||||
|
var fields map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(data, &fields); err != nil {
|
||||||
|
return recovery{}, true, err
|
||||||
|
}
|
||||||
|
if fields == nil {
|
||||||
|
return recovery{}, true, errors.New("GNOME recovery metadata must be a JSON object")
|
||||||
|
}
|
||||||
|
for _, name := range []string{"version", "original", "lastFingerprint"} {
|
||||||
|
if _, present := fields[name]; !present {
|
||||||
|
return recovery{}, true, fmt.Errorf("GNOME recovery metadata is missing required field %q", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var originalFields map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(fields["original"], &originalFields); err != nil || originalFields == nil {
|
||||||
|
return recovery{}, true, errors.New("GNOME recovery metadata original settings must be a JSON object")
|
||||||
|
}
|
||||||
|
for _, name := range []string{"mode", "autoConfigUrl", "ignoreHosts", "httpHost", "httpPort", "httpUseAuthentication", "httpAuthenticationUser", "httpAuthenticationPassword", "httpsHost", "httpsPort", "socksHost", "socksPort"} {
|
||||||
|
if _, present := originalFields[name]; !present {
|
||||||
|
return recovery{}, true, fmt.Errorf("GNOME recovery metadata original settings are missing required field %q", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
var value recovery
|
var value recovery
|
||||||
if err := json.Unmarshal(data, &value); err != nil {
|
if err := json.Unmarshal(data, &value); err != nil {
|
||||||
return recovery{}, true, err
|
return recovery{}, true, err
|
||||||
}
|
}
|
||||||
|
if err := validateRecovery(value); err != nil {
|
||||||
|
return recovery{}, true, err
|
||||||
|
}
|
||||||
return value, true, nil
|
return value, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateRecovery(value recovery) error {
|
||||||
|
if value.Version != gnomeRecoveryVersion {
|
||||||
|
return fmt.Errorf("unsupported GNOME recovery metadata version %d", value.Version)
|
||||||
|
}
|
||||||
|
decoded, err := hex.DecodeString(value.LastFingerprint)
|
||||||
|
if err != nil || len(decoded) != sha256.Size {
|
||||||
|
return errors.New("GNOME recovery metadata has an invalid fingerprint")
|
||||||
|
}
|
||||||
|
if value.Original.Mode != "none" && value.Original.Mode != "manual" && value.Original.Mode != "auto" {
|
||||||
|
return errors.New("GNOME recovery metadata has an invalid original proxy mode")
|
||||||
|
}
|
||||||
|
if value.Original.IgnoreHosts == nil {
|
||||||
|
return errors.New("GNOME recovery metadata has an incomplete original ignore-hosts snapshot")
|
||||||
|
}
|
||||||
|
for _, port := range []int{value.Original.HTTPPort, value.Original.HTTPSPort, value.Original.SocksPort} {
|
||||||
|
if port < 0 || port > 65535 {
|
||||||
|
return errors.New("GNOME recovery metadata has an invalid original proxy port")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (a *Adapter) command(args ...string) Command {
|
func (a *Adapter) command(args ...string) Command {
|
||||||
return Command{Name: a.configuration.CommandName, Args: append([]string(nil), args...), UID: a.configuration.UID, GID: a.configuration.GID, Env: map[string]string{
|
return Command{Name: a.configuration.CommandName, Args: append([]string(nil), args...), UID: a.configuration.UID, GID: a.configuration.GID, Env: map[string]string{
|
||||||
"HOME": a.configuration.HomeDir, "USER": a.configuration.Username, "LOGNAME": a.configuration.Username,
|
"HOME": a.configuration.HomeDir, "USER": a.configuration.Username, "LOGNAME": a.configuration.Username,
|
||||||
@@ -443,6 +560,13 @@ func (a *Adapter) set(ctx context.Context, schema, key, value string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *Adapter) setSensitiveString(ctx context.Context, schema, key, value string) error {
|
||||||
|
if _, err := a.run(ctx, "set", schema, key, value); err != nil {
|
||||||
|
return safeRedaction("set GNOME authentication password failed; sensitive argument and runner error suppressed", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (a *Adapter) readSettings(ctx context.Context) (settings, error) {
|
func (a *Adapter) readSettings(ctx context.Context) (settings, error) {
|
||||||
var result settings
|
var result settings
|
||||||
var err error
|
var err error
|
||||||
@@ -467,7 +591,7 @@ func (a *Adapter) readSettings(ctx context.Context) (settings, error) {
|
|||||||
if result.HTTPAuthUser, err = a.getString(ctx, httpSchema, "authentication-user"); err != nil {
|
if result.HTTPAuthUser, err = a.getString(ctx, httpSchema, "authentication-user"); err != nil {
|
||||||
return settings{}, err
|
return settings{}, err
|
||||||
}
|
}
|
||||||
if result.HTTPAuthPassword, err = a.getString(ctx, httpSchema, "authentication-password"); err != nil {
|
if result.HTTPAuthPassword, err = a.getSensitiveString(ctx, httpSchema, "authentication-password"); err != nil {
|
||||||
return settings{}, err
|
return settings{}, err
|
||||||
}
|
}
|
||||||
if result.HTTPSHost, err = a.getString(ctx, httpsSchema, "host"); err != nil {
|
if result.HTTPSHost, err = a.getString(ctx, httpsSchema, "host"); err != nil {
|
||||||
@@ -492,6 +616,22 @@ func (a *Adapter) getString(ctx context.Context, schema, key string) (string, er
|
|||||||
}
|
}
|
||||||
return parseString(raw)
|
return parseString(raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getSensitiveString deliberately suppresses both command error detail and raw
|
||||||
|
// output. A password value is secret even when malformed, and command runners
|
||||||
|
// are not trusted to keep that value out of their errors.
|
||||||
|
func (a *Adapter) getSensitiveString(ctx context.Context, schema, key string) (string, error) {
|
||||||
|
raw, err := a.run(ctx, "get", schema, key)
|
||||||
|
if err != nil {
|
||||||
|
return "", safeRedaction(fmt.Sprintf("get %s %s failed; sensitive output suppressed", schema, key), err)
|
||||||
|
}
|
||||||
|
value, err := parseString(strings.TrimSpace(raw))
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.New("parse " + schema + " " + key + " failed; sensitive output suppressed")
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (a *Adapter) getInt(ctx context.Context, schema, key string) (int, error) {
|
func (a *Adapter) getInt(ctx context.Context, schema, key string) (int, error) {
|
||||||
raw, err := a.get(ctx, schema, key)
|
raw, err := a.get(ctx, schema, key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -531,7 +671,7 @@ func (a *Adapter) writeSettings(ctx context.Context, value settings) error {
|
|||||||
{proxySchema, "autoconfig-url", quote(value.AutoConfigURL)},
|
{proxySchema, "autoconfig-url", quote(value.AutoConfigURL)},
|
||||||
{proxySchema, "ignore-hosts", quoteStrings(value.IgnoreHosts)},
|
{proxySchema, "ignore-hosts", quoteStrings(value.IgnoreHosts)},
|
||||||
{httpSchema, "host", quote(value.HTTPHost)}, {httpSchema, "port", strconv.Itoa(value.HTTPPort)},
|
{httpSchema, "host", quote(value.HTTPHost)}, {httpSchema, "port", strconv.Itoa(value.HTTPPort)},
|
||||||
{httpSchema, "use-authentication", strconv.FormatBool(value.HTTPUseAuth)}, {httpSchema, "authentication-user", quote(value.HTTPAuthUser)}, {httpSchema, "authentication-password", quote(value.HTTPAuthPassword)},
|
{httpSchema, "use-authentication", strconv.FormatBool(value.HTTPUseAuth)}, {httpSchema, "authentication-user", quote(value.HTTPAuthUser)},
|
||||||
{httpsSchema, "host", quote(value.HTTPSHost)}, {httpsSchema, "port", strconv.Itoa(value.HTTPSPort)},
|
{httpsSchema, "host", quote(value.HTTPSHost)}, {httpsSchema, "port", strconv.Itoa(value.HTTPSPort)},
|
||||||
{socksSchema, "host", quote(value.SocksHost)}, {socksSchema, "port", strconv.Itoa(value.SocksPort)},
|
{socksSchema, "host", quote(value.SocksHost)}, {socksSchema, "port", strconv.Itoa(value.SocksPort)},
|
||||||
}
|
}
|
||||||
@@ -540,6 +680,9 @@ func (a *Adapter) writeSettings(ctx context.Context, value settings) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err := a.setSensitiveString(ctx, httpSchema, "authentication-password", quote(value.HTTPAuthPassword)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return a.set(ctx, proxySchema, "mode", quote(value.Mode))
|
return a.set(ctx, proxySchema, "mode", quote(value.Mode))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ func (r *fakeRunner) Run(_ context.Context, c gnome.Command) (string, error) {
|
|||||||
}
|
}
|
||||||
return value + "\n", nil
|
return value + "\n", nil
|
||||||
}
|
}
|
||||||
|
if len(c.Args) == 3 && c.Args[0] == "writable" {
|
||||||
|
return "true\n", nil
|
||||||
|
}
|
||||||
if len(c.Args) == 4 && c.Args[0] == "set" {
|
if len(c.Args) == 4 && c.Args[0] == "set" {
|
||||||
r.values[c.Args[1]+" "+c.Args[2]] = c.Args[3]
|
r.values[c.Args[1]+" "+c.Args[2]] = c.Args[3]
|
||||||
return "", nil
|
return "", nil
|
||||||
@@ -105,6 +108,157 @@ func active() *profile.Profile {
|
|||||||
return &profile.Profile{Name: "office", Mode: profile.AdvancedMode, HTTPURL: "http://proxy.example:8080", HTTPSURL: "http://secure.example:8443", AllURL: "socks5://socks.example:1080", Bypass: []string{"localhost", ".example.com", "10.0.0.0/8", "2001:db8::1"}}
|
return &profile.Profile{Name: "office", Mode: profile.AdvancedMode, HTTPURL: "http://proxy.example:8080", HTTPSURL: "http://secure.example:8443", AllURL: "socks5://socks.example:1080", Bypass: []string{"localhost", ".example.com", "10.0.0.0/8", "2001:db8::1"}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAuthenticationPasswordSetFailureSuppressesRunnerErrorAndProfileArgument(t *testing.T) {
|
||||||
|
const profilePassword = "GNOME-PROFILE-PASSWORD-07"
|
||||||
|
r, state := newRunner(), &memoryState{}
|
||||||
|
r.fail = func(c gnome.Command) error {
|
||||||
|
if len(c.Args) == 4 && c.Args[0] == "set" && c.Args[2] == "authentication-password" {
|
||||||
|
return fmt.Errorf("runner echoed argument %s: %w", c.Args[3], os.ErrPermission)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
p := *active()
|
||||||
|
p.HTTPURL = "http://alice:" + profilePassword + "@proxy.example:8080"
|
||||||
|
result := adapter(r, state).SetDesired(context.Background(), true, &p)
|
||||||
|
if result.Err == nil || !errors.Is(result.Err, os.ErrPermission) {
|
||||||
|
t.Fatalf("set error classification = %#v", result)
|
||||||
|
}
|
||||||
|
if strings.Contains(result.Err.Error(), profilePassword) || strings.Contains(result.Err.Error(), "runner echoed") {
|
||||||
|
t.Fatalf("set exposed runner error or password argument: %v", result.Err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.Err.Error(), "sensitive argument and runner error suppressed") {
|
||||||
|
t.Fatalf("set lacks fixed safe message: %v", result.Err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthenticationPasswordRestoreFailureSuppressesRunnerErrorAndSavedArgument(t *testing.T) {
|
||||||
|
const savedPassword = "GNOME-SAVED-ORIGINAL-PASSWORD-07"
|
||||||
|
r, state := newRunner(), &memoryState{}
|
||||||
|
r.values["org.gnome.system.proxy mode"] = "'auto'"
|
||||||
|
r.values["org.gnome.system.proxy.http authentication-password"] = "'" + savedPassword + "'"
|
||||||
|
a := adapter(r, state)
|
||||||
|
if result := a.SetDesired(context.Background(), true, active()); result.Err != nil {
|
||||||
|
t.Fatalf("enable = %#v", result)
|
||||||
|
}
|
||||||
|
r.fail = func(c gnome.Command) error {
|
||||||
|
if reflect.DeepEqual(c.Args[:min(len(c.Args), 3)], []string{"set", "org.gnome.system.proxy.http", "authentication-password"}) {
|
||||||
|
return fmt.Errorf("runner echoed argument %s: %w", c.Args[3], os.ErrPermission)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := a.SetDesired(context.Background(), false, nil)
|
||||||
|
if result.Err == nil || !errors.Is(result.Err, os.ErrPermission) {
|
||||||
|
t.Fatalf("restore error classification = %#v", result)
|
||||||
|
}
|
||||||
|
if strings.Contains(result.Err.Error(), savedPassword) || strings.Contains(result.Err.Error(), "runner echoed") {
|
||||||
|
t.Fatalf("restore exposed runner error or password argument: %v", result.Err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.Err.Error(), "sensitive argument and runner error suppressed") {
|
||||||
|
t.Fatalf("restore lacks fixed safe message: %v", result.Err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIncompleteRecoveryMetadataRejectsMutationAndPreservesGNOMESettings(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"empty object": `{}`,
|
||||||
|
"missing original": `{"version":1,"lastFingerprint":"` + strings.Repeat("a", 64) + `"}`,
|
||||||
|
"incomplete original": `{"version":1,"lastFingerprint":"` + strings.Repeat("a", 64) + `","original":{"mode":"none"}}`,
|
||||||
|
"invalid fingerprint": `{"version":1,"lastFingerprint":"short","original":{}}`,
|
||||||
|
}
|
||||||
|
for name, metadata := range cases {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
r, state := newRunner(), &memoryState{data: []byte(metadata)}
|
||||||
|
before := make(map[string]string, len(r.values))
|
||||||
|
for key, value := range r.values {
|
||||||
|
before[key] = value
|
||||||
|
}
|
||||||
|
a := adapter(r, state)
|
||||||
|
if inspection := a.Inspect(context.Background(), nil); inspection.State != target.Drifted {
|
||||||
|
t.Fatalf("inspection = %#v", inspection)
|
||||||
|
}
|
||||||
|
callStart := len(r.calls)
|
||||||
|
result := a.SetDesired(context.Background(), false, nil)
|
||||||
|
if result.Err == nil || result.Inspection.State != target.Drifted {
|
||||||
|
t.Fatalf("unsafe disable = %#v", result)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(r.values, before) || string(state.data) != metadata {
|
||||||
|
t.Fatalf("configuration mutated: values=%#v metadata=%q", r.values, state.data)
|
||||||
|
}
|
||||||
|
for _, call := range r.calls[callStart:] {
|
||||||
|
if len(call.Args) > 0 && call.Args[0] == "set" {
|
||||||
|
t.Fatalf("unsafe metadata caused set: %#v", call)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiagnosticsSuppressSensitiveGSettingsOutputAndRunnerErrors(t *testing.T) {
|
||||||
|
const secret = "GNOME-DIAGNOSTIC-SECRET-7f3a"
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
value string
|
||||||
|
fail bool
|
||||||
|
}{
|
||||||
|
{name: "malformed value", value: "unterminated-" + secret},
|
||||||
|
{name: "runner error", value: secret, fail: true},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
r := newRunner()
|
||||||
|
key := "org.gnome.system.proxy.http authentication-password"
|
||||||
|
r.values[key] = test.value
|
||||||
|
if test.fail {
|
||||||
|
r.fail = func(c gnome.Command) error {
|
||||||
|
if reflect.DeepEqual(c.Args, []string{"get", "org.gnome.system.proxy.http", "authentication-password"}) {
|
||||||
|
return errors.New("runner exposed " + secret)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a := adapter(r, &memoryState{})
|
||||||
|
checks := a.DiagnosticChecks(context.Background(), active())
|
||||||
|
for _, check := range checks {
|
||||||
|
if strings.Contains(check.Evidence, secret) {
|
||||||
|
t.Fatalf("diagnostic evidence exposed password: %#v", checks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result := a.SetDesired(context.Background(), true, active())
|
||||||
|
operationError := ""
|
||||||
|
if result.Err != nil {
|
||||||
|
operationError = result.Err.Error()
|
||||||
|
}
|
||||||
|
for _, evidence := range []string{a.Availability(context.Background()).Evidence, a.Inspect(context.Background(), active()).Evidence, operationError} {
|
||||||
|
if strings.Contains(evidence, secret) {
|
||||||
|
t.Fatalf("Target evidence or loggable operation error exposed password: %q", evidence)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiagnosticChecksCoverCommandSchemasSessionAndAuthorityWithoutMutation(t *testing.T) {
|
||||||
|
r, state := newRunner(), &memoryState{}
|
||||||
|
a := adapter(r, state)
|
||||||
|
checks := a.DiagnosticChecks(context.Background(), active())
|
||||||
|
for _, name := range []string{"required command", "required schemas", "desktop session", "write authority"} {
|
||||||
|
found := false
|
||||||
|
for _, check := range checks {
|
||||||
|
found = found || check.Name == name
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("diagnostics missing %q: %#v", name, checks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, call := range r.calls {
|
||||||
|
if len(call.Args) > 0 && call.Args[0] == "set" {
|
||||||
|
t.Fatalf("diagnostics mutated GNOME settings: %#v", call)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if state.data != nil {
|
||||||
|
t.Fatalf("diagnostics wrote GNOME recovery state: %q", state.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGNOMEAdapterRunsReusableTargetContract(t *testing.T) {
|
func TestGNOMEAdapterRunsReusableTargetContract(t *testing.T) {
|
||||||
targettest.Run(t, func(t *testing.T) targettest.Fixture {
|
targettest.Run(t, func(t *testing.T) targettest.Fixture {
|
||||||
r, state := newRunner(), &memoryState{}
|
r, state := newRunner(), &memoryState{}
|
||||||
|
|||||||
+52
-38
@@ -14,16 +14,15 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"golang.org/x/sys/unix"
|
|
||||||
|
|
||||||
"proxyctl/internal/profile"
|
"proxyctl/internal/profile"
|
||||||
"proxyctl/internal/securefs"
|
"proxyctl/internal/securefs"
|
||||||
"proxyctl/internal/target"
|
"proxyctl/internal/target"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
integrationStart = "# >>> proxyctl shell integration >>>"
|
integrationStart = "# >>> proxyctl shell integration >>>"
|
||||||
integrationEnd = "# <<< proxyctl shell integration <<<"
|
integrationEnd = "# <<< proxyctl shell integration <<<"
|
||||||
|
shellRecoveryVersion = 1
|
||||||
)
|
)
|
||||||
|
|
||||||
var proxyVariables = []string{
|
var proxyVariables = []string{
|
||||||
@@ -149,7 +148,8 @@ func (f OSFileSystem) AtomicWrite(path string, data []byte, mode os.FileMode, ow
|
|||||||
}
|
}
|
||||||
|
|
||||||
type recovery struct {
|
type recovery struct {
|
||||||
Original map[string]string `json:"original,omitempty"`
|
Version int `json:"version"`
|
||||||
|
Original map[string]string `json:"original"`
|
||||||
OriginalScript *string `json:"originalScript,omitempty"`
|
OriginalScript *string `json:"originalScript,omitempty"`
|
||||||
Fingerprint string `json:"fingerprint"`
|
Fingerprint string `json:"fingerprint"`
|
||||||
}
|
}
|
||||||
@@ -192,41 +192,9 @@ func (a *Adapter) StartupPath() string { return a.configuration.StartupFile
|
|||||||
|
|
||||||
// DiagnosticChecks performs no writes or command execution.
|
// DiagnosticChecks performs no writes or command execution.
|
||||||
func (a *Adapter) DiagnosticChecks(_ context.Context, _ *profile.Profile) []target.Check {
|
func (a *Adapter) DiagnosticChecks(_ context.Context, _ *profile.Profile) []target.Check {
|
||||||
paths := []string{a.configuration.EnvironmentFile, a.configuration.RecoveryFile}
|
|
||||||
writable := true
|
|
||||||
var evidence []string
|
|
||||||
for _, path := range paths {
|
|
||||||
ok, detail := writablePath(path)
|
|
||||||
writable = writable && ok
|
|
||||||
evidence = append(evidence, detail)
|
|
||||||
}
|
|
||||||
return []target.Check{
|
return []target.Check{
|
||||||
{Name: "required commands", OK: true, Evidence: "Shell Target requires no external commands"},
|
{Name: "required commands", OK: true, Evidence: "Shell Target requires no external commands"},
|
||||||
{Name: "write authority", OK: writable, Evidence: strings.Join(evidence, "; ")},
|
target.ReadOnlyWriteAuthority(a.configuration.EnvironmentFile, a.configuration.RecoveryFile),
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func writablePath(path string) (bool, string) {
|
|
||||||
candidate := path
|
|
||||||
for {
|
|
||||||
info, err := os.Stat(candidate)
|
|
||||||
if err == nil {
|
|
||||||
if !info.IsDir() {
|
|
||||||
candidate = filepath.Dir(candidate)
|
|
||||||
}
|
|
||||||
if err := unix.Access(candidate, unix.W_OK); err != nil {
|
|
||||||
return false, candidate + " is not writable: " + err.Error()
|
|
||||||
}
|
|
||||||
return true, candidate + " is writable"
|
|
||||||
}
|
|
||||||
if !errors.Is(err, fs.ErrNotExist) {
|
|
||||||
return false, candidate + " cannot be inspected: " + err.Error()
|
|
||||||
}
|
|
||||||
parent := filepath.Dir(candidate)
|
|
||||||
if parent == candidate {
|
|
||||||
return false, path + " has no writable existing ancestor"
|
|
||||||
}
|
|
||||||
candidate = parent
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,6 +256,7 @@ func (a *Adapter) enable(active profile.Profile) error {
|
|||||||
return fmt.Errorf("read Shell recovery metadata: %w", err)
|
return fmt.Errorf("read Shell recovery metadata: %w", err)
|
||||||
}
|
}
|
||||||
if !managed {
|
if !managed {
|
||||||
|
metadata.Version = shellRecoveryVersion
|
||||||
if existing, readErr := a.files.ReadFile(a.configuration.EnvironmentFile); readErr == nil && recognizesProxy(existing) {
|
if existing, readErr := a.files.ReadFile(a.configuration.EnvironmentFile); readErr == nil && recognizesProxy(existing) {
|
||||||
original := string(existing)
|
original := string(existing)
|
||||||
metadata.OriginalScript = &original
|
metadata.OriginalScript = &original
|
||||||
@@ -344,12 +313,57 @@ func (a *Adapter) readRecovery() (recovery, bool, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return recovery{}, false, err
|
return recovery{}, false, err
|
||||||
}
|
}
|
||||||
|
var fields map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(data, &fields); err != nil {
|
||||||
|
return recovery{}, true, err
|
||||||
|
}
|
||||||
|
if fields == nil {
|
||||||
|
return recovery{}, true, errors.New("Shell recovery metadata must be a JSON object")
|
||||||
|
}
|
||||||
var value recovery
|
var value recovery
|
||||||
if err := json.Unmarshal(data, &value); err != nil {
|
if err := json.Unmarshal(data, &value); err != nil {
|
||||||
return recovery{}, true, err
|
return recovery{}, true, err
|
||||||
}
|
}
|
||||||
|
if err := validateRecovery(value, fields); err != nil {
|
||||||
|
return recovery{}, true, err
|
||||||
|
}
|
||||||
return value, true, nil
|
return value, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateRecovery(value recovery, fields map[string]json.RawMessage) error {
|
||||||
|
for _, name := range []string{"version", "original", "fingerprint"} {
|
||||||
|
if _, present := fields[name]; !present {
|
||||||
|
return fmt.Errorf("Shell recovery metadata is missing required field %q", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if value.Version != shellRecoveryVersion {
|
||||||
|
return fmt.Errorf("unsupported Shell recovery metadata version %d", value.Version)
|
||||||
|
}
|
||||||
|
decoded, err := hex.DecodeString(value.Fingerprint)
|
||||||
|
if err != nil || len(decoded) != sha256.Size {
|
||||||
|
return errors.New("Shell recovery metadata has an invalid fingerprint")
|
||||||
|
}
|
||||||
|
_, hasOriginalScript := fields["originalScript"]
|
||||||
|
if hasOriginalScript {
|
||||||
|
if value.OriginalScript == nil || value.Original != nil || len(*value.OriginalScript) == 0 || !recognizesProxy([]byte(*value.OriginalScript)) {
|
||||||
|
return errors.New("Shell recovery metadata has inconsistent original script state")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if value.Original == nil {
|
||||||
|
return errors.New("Shell recovery metadata is missing the original environment snapshot")
|
||||||
|
}
|
||||||
|
for name := range value.Original {
|
||||||
|
known := false
|
||||||
|
for _, candidate := range proxyVariables {
|
||||||
|
known = known || name == candidate
|
||||||
|
}
|
||||||
|
if !known {
|
||||||
|
return fmt.Errorf("Shell recovery metadata contains unsupported variable %q", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
func (a *Adapter) ownership() *Ownership {
|
func (a *Adapter) ownership() *Ownership {
|
||||||
if !a.configuration.OwnFiles {
|
if !a.configuration.OwnFiles {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -15,6 +16,70 @@ import (
|
|||||||
"proxyctl/internal/target/targettest"
|
"proxyctl/internal/target/targettest"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestShellDiagnosticsRejectSymlinkedPathsWithoutTouchingVictim(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
path func(t *testing.T, root, victim string) string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "final path",
|
||||||
|
path: func(t *testing.T, root, victim string) string {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(root, "config", "shell.env")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink(victim, path); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ancestor path",
|
||||||
|
path: func(t *testing.T, root, victim string) string {
|
||||||
|
t.Helper()
|
||||||
|
link := filepath.Join(root, "config")
|
||||||
|
if err := os.Symlink(filepath.Dir(victim), link); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return filepath.Join(link, filepath.Base(victim))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
victim := filepath.Join(t.TempDir(), "victim")
|
||||||
|
const content = "shell-diagnostic-victim"
|
||||||
|
if err := os.WriteFile(victim, []byte(content), 0o640); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
before, err := os.Stat(victim)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
environmentPath := test.path(t, root, victim)
|
||||||
|
a := shell.New(shell.Configuration{Shell: "/bin/bash", EnvironmentFile: environmentPath, RecoveryFile: filepath.Join(root, "state", "shell.json")}, nil, shell.MapEnvironment{})
|
||||||
|
|
||||||
|
checks := a.DiagnosticChecks(context.Background(), nil)
|
||||||
|
if len(checks) != 2 || checks[1].OK || !strings.Contains(checks[1].Evidence, "symbolic link") {
|
||||||
|
t.Fatalf("Shell diagnostics accepted symlink: %#v", checks)
|
||||||
|
}
|
||||||
|
after, err := os.Stat(victim)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := os.ReadFile(victim)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(got) != content || !reflect.DeepEqual(before.Sys(), after.Sys()) {
|
||||||
|
t.Fatalf("diagnostics changed victim: before=%#v after=%#v content=%q", before, after, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestShellAvailabilitySupportsOnlyBashAndZsh(t *testing.T) {
|
func TestShellAvailabilitySupportsOnlyBashAndZsh(t *testing.T) {
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
shell string
|
shell string
|
||||||
@@ -77,6 +142,43 @@ func TestShellAdapterRunsReusableTargetContract(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIncompleteRecoveryMetadataRejectsRestoreWithoutChangingShellConfiguration(t *testing.T) {
|
||||||
|
fingerprint := strings.Repeat("a", 64)
|
||||||
|
cases := map[string]string{
|
||||||
|
"empty object": `{}`,
|
||||||
|
"missing restoration": `{"version":1,"fingerprint":"` + fingerprint + `","original":null}`,
|
||||||
|
"invalid fingerprint": `{"version":1,"fingerprint":"short","original":{}}`,
|
||||||
|
"conflicting originals": `{"version":1,"fingerprint":"` + fingerprint + `","original":{"HTTP_PROXY":"http://prior.example:3128"},"originalScript":"export HTTP_PROXY=http://prior.example:3128\\n"}`,
|
||||||
|
}
|
||||||
|
for name, metadata := range cases {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
a := newAdapter(t, "/bin/bash", nil)
|
||||||
|
environment := []byte("export HTTP_PROXY='http://managed.example:8080'\n")
|
||||||
|
for path, data := range map[string][]byte{a.EnvironmentPath(): environment, a.RecoveryPath(): []byte(metadata)} {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if inspection := a.Inspect(context.Background(), nil); inspection.State != target.Drifted {
|
||||||
|
t.Fatalf("inspection = %#v", inspection)
|
||||||
|
}
|
||||||
|
result := a.SetDesired(context.Background(), false, nil)
|
||||||
|
if result.Err == nil || result.Inspection.State != target.Drifted {
|
||||||
|
t.Fatalf("unsafe disable = %#v", result)
|
||||||
|
}
|
||||||
|
if got := readFile(t, a.EnvironmentPath()); got != string(environment) {
|
||||||
|
t.Fatalf("environment changed to %q", got)
|
||||||
|
}
|
||||||
|
if got := readFile(t, a.RecoveryPath()); got != metadata {
|
||||||
|
t.Fatalf("recovery changed to %q", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRecoveryFailureRetryPreservesOriginalShellState(t *testing.T) {
|
func TestRecoveryFailureRetryPreservesOriginalShellState(t *testing.T) {
|
||||||
files := &failingFS{FileSystem: shell.OSFileSystem{}, failRecoveryOnce: true}
|
files := &failingFS{FileSystem: shell.OSFileSystem{}, failRecoveryOnce: true}
|
||||||
a := newAdapterWithFS(t, "/bin/bash", map[string]string{"HTTP_PROXY": "http://prior.example:3128"}, files)
|
a := newAdapterWithFS(t, "/bin/bash", map[string]string{"HTTP_PROXY": "http://prior.example:3128"}, files)
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package target
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io/fs"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
|
||||||
|
"proxyctl/internal/profile"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ReadOnlyWriteAuthority observes whether the current process can write the
|
||||||
|
// configured locations. It never creates a probe file or changes permissions.
|
||||||
|
func ReadOnlyWriteAuthority(paths ...string) Check {
|
||||||
|
check := Check{Name: "write authority", OK: true}
|
||||||
|
var evidence []string
|
||||||
|
for _, path := range paths {
|
||||||
|
ok, detail := readOnlyWritablePath(path)
|
||||||
|
check.OK = check.OK && ok
|
||||||
|
evidence = append(evidence, detail)
|
||||||
|
}
|
||||||
|
check.Evidence = profile.RedactText(strings.Join(evidence, "; "))
|
||||||
|
return check
|
||||||
|
}
|
||||||
|
|
||||||
|
// readOnlyWritablePath anchors traversal at an open root descriptor and refuses
|
||||||
|
// symbolic links in every component. The authority observation therefore
|
||||||
|
// cannot be redirected to a victim between path lookup steps, and it never
|
||||||
|
// opens victim content or mutates filesystem metadata.
|
||||||
|
func readOnlyWritablePath(path string) (bool, string) {
|
||||||
|
absolute, err := filepath.Abs(path)
|
||||||
|
if err != nil {
|
||||||
|
return false, path + " cannot be inspected: " + err.Error()
|
||||||
|
}
|
||||||
|
absolute = filepath.Clean(absolute)
|
||||||
|
components := strings.Split(strings.TrimPrefix(absolute, string(filepath.Separator)), string(filepath.Separator))
|
||||||
|
if len(components) == 1 && components[0] == "" {
|
||||||
|
components = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
current, err := unix.Open(string(filepath.Separator), unix.O_PATH|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
|
||||||
|
if err != nil {
|
||||||
|
return false, absolute + " cannot be inspected: " + err.Error()
|
||||||
|
}
|
||||||
|
defer func() { _ = unix.Close(current) }()
|
||||||
|
currentPath := string(filepath.Separator)
|
||||||
|
|
||||||
|
for index, component := range components {
|
||||||
|
child, openErr := unix.Openat(current, component, unix.O_PATH|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
|
||||||
|
if errors.Is(openErr, fs.ErrNotExist) {
|
||||||
|
return writableDirectory(current, currentPath, absolute)
|
||||||
|
}
|
||||||
|
if openErr != nil {
|
||||||
|
return false, filepath.Join(currentPath, component) + " cannot be inspected without following links: " + openErr.Error()
|
||||||
|
}
|
||||||
|
var stat unix.Stat_t
|
||||||
|
statErr := unix.Fstat(child, &stat)
|
||||||
|
if statErr != nil {
|
||||||
|
_ = unix.Close(child)
|
||||||
|
return false, filepath.Join(currentPath, component) + " cannot be inspected: " + statErr.Error()
|
||||||
|
}
|
||||||
|
kind := stat.Mode & unix.S_IFMT
|
||||||
|
componentPath := filepath.Join(currentPath, component)
|
||||||
|
if kind == unix.S_IFLNK {
|
||||||
|
_ = unix.Close(child)
|
||||||
|
return false, componentPath + " is a symbolic link and is unavailable for safe write observation"
|
||||||
|
}
|
||||||
|
if index == len(components)-1 {
|
||||||
|
_ = unix.Close(child)
|
||||||
|
return writableDirectory(current, currentPath, absolute)
|
||||||
|
}
|
||||||
|
if kind != unix.S_IFDIR {
|
||||||
|
_ = unix.Close(child)
|
||||||
|
return false, componentPath + " is not a directory"
|
||||||
|
}
|
||||||
|
_ = unix.Close(current)
|
||||||
|
current = child
|
||||||
|
currentPath = componentPath
|
||||||
|
}
|
||||||
|
return writableDirectory(current, currentPath, absolute)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writableDirectory(descriptor int, directory, targetPath string) (bool, string) {
|
||||||
|
if err := unix.Faccessat(descriptor, ".", unix.W_OK|unix.X_OK, unix.AT_EACCESS); err != nil {
|
||||||
|
return false, directory + " is not writable for " + targetPath + ": " + err.Error()
|
||||||
|
}
|
||||||
|
return true, directory + " is writable for " + targetPath
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package target_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"proxyctl/internal/target"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestReadOnlyWriteAuthorityRejectsSymlinkedFinalAndAncestorWithoutTouchingVictim(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
path func(root, victim string) string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "final path",
|
||||||
|
path: func(root, victim string) string {
|
||||||
|
link := filepath.Join(root, "managed.json")
|
||||||
|
if err := os.Symlink(victim, link); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return link
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ancestor path",
|
||||||
|
path: func(root, victim string) string {
|
||||||
|
victimDir := filepath.Dir(victim)
|
||||||
|
link := filepath.Join(root, "managed")
|
||||||
|
if err := os.Symlink(victimDir, link); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return filepath.Join(link, filepath.Base(victim))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
victimDir := t.TempDir()
|
||||||
|
victim := filepath.Join(victimDir, "victim.json")
|
||||||
|
const content = "VICTIM-CONTENT-MUST-STAY"
|
||||||
|
if err := os.WriteFile(victim, []byte(content), 0o640); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
beforeContent, err := os.ReadFile(victim)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
before, err := os.Stat(victim)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
check := target.ReadOnlyWriteAuthority(test.path(root, victim))
|
||||||
|
if check.OK || (!strings.Contains(check.Evidence, "symbolic link") && !strings.Contains(check.Evidence, "symlink")) {
|
||||||
|
t.Fatalf("symlink path reported writable: %#v", check)
|
||||||
|
}
|
||||||
|
after, err := os.Stat(victim)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(victim)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(beforeContent) != content || string(data) != content || !reflect.DeepEqual(before.Sys(), after.Sys()) {
|
||||||
|
t.Fatalf("victim changed: before=%#v after=%#v content=%q", before, after, data)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user