fix: complete target diagnostics and reset inspection

This commit is contained in:
proxyctl coordinator
2026-07-17 10:29:41 +08:00
parent f6c6ead325
commit 0fbaa71e37
14 changed files with 366 additions and 10 deletions
+1 -1
View File
@@ -224,7 +224,7 @@ func runMaintenanceCommand(args []string, input io.Reader, output io.Writer, sto
continue continue
} }
if item.Err != nil { if item.Err != 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.Err.Error()), profile.RedactText(item.Inspection.Evidence))
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))
+3
View File
@@ -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, ",") {
+2 -2
View File
@@ -232,10 +232,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"))
+10 -5
View File
@@ -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)
@@ -126,17 +126,22 @@ 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)
if m.logger != nil { if m.logger != nil {
_ = m.logger.Write(LogEntry{Time: time.Now(), Operation: "reset", Target: adapter.ID(), Result: outcome, Detail: detail}) _ = m.logger.Write(LogEntry{Time: time.Now(), Operation: "reset", Target: adapter.ID(), Result: outcome, Detail: detail})
} }
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, Err: redactError(operation.Err)})
} }
return result return result
} }
+38
View File
@@ -43,6 +43,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) {
@@ -111,6 +133,22 @@ 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 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")
+28
View File
@@ -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() {
+24
View File
@@ -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()
+29 -1
View File
@@ -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,31 @@ 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 _, _, err := childObject(proxies, "default"); err != nil {
schemaCheck.OK = false
schemaCheck.Evidence = profile.RedactText(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"}
@@ -44,6 +44,29 @@ 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 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)
+38
View File
@@ -74,6 +74,8 @@ type Adapter struct {
runner CommandRunner runner CommandRunner
} }
var _ target.Diagnosable = (*Adapter)(nil)
func New(configuration Configuration, files FileSystem, runner CommandRunner) *Adapter { func New(configuration Configuration, files FileSystem, runner CommandRunner) *Adapter {
if configuration.DaemonConfigPath == "" { if configuration.DaemonConfigPath == "" {
configuration.DaemonConfigPath = "/etc/docker/daemon.json" configuration.DaemonConfigPath = "/etc/docker/daemon.json"
@@ -102,6 +104,42 @@ 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 := mergeConfig(data, profile.Profile{}); schemaErr != nil {
schemaCheck.OK = false
schemaCheck.Evidence = profile.RedactText("Docker daemon configuration cannot be safely merged: " + 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())
}
}
return []target.Check{
commandCheck,
schemaCheck,
target.ReadOnlyWriteAuthority(a.configuration.DaemonConfigPath, a.configuration.RecoveryPath),
}
}
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
@@ -102,6 +102,30 @@ 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)
} }
func TestDiagnosticChecksCoverSystemdSchemaAndAuthorityWithoutMutation(t *testing.T) {
a, record, daemonPath, recoveryPath, _ := fixture(t)
checks := a.DiagnosticChecks(context.Background(), activeProfile())
for _, name := range []string{"required command and service", "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)
}
}
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 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)
+69 -1
View File
@@ -34,6 +34,12 @@ const (
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 {
@@ -230,7 +236,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 +280,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 {
+26
View File
@@ -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,29 @@ 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 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{}
+51
View File
@@ -0,0 +1,51 @@
package target
import (
"errors"
"io/fs"
"os"
"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
}
func readOnlyWritablePath(path string) (bool, string) {
candidate := filepath.Clean(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
}
}