merge: integrate proxyctl ticket 06
This commit is contained in:
+17
-2
@@ -9,6 +9,7 @@ import (
|
|||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
"proxyctl/internal/app"
|
"proxyctl/internal/app"
|
||||||
|
"proxyctl/internal/dockerdaemon"
|
||||||
"proxyctl/internal/profile"
|
"proxyctl/internal/profile"
|
||||||
"proxyctl/internal/shell"
|
"proxyctl/internal/shell"
|
||||||
"proxyctl/internal/target"
|
"proxyctl/internal/target"
|
||||||
@@ -23,11 +24,17 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func run(args []string, input io.Reader, output io.Writer) error {
|
func run(args []string, input io.Reader, output io.Writer) error {
|
||||||
|
return runWithSystemTargets(args, input, output, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runWithSystemTargets keeps process smoke tests hermetic. A nil slice selects
|
||||||
|
// production system Targets; a non-nil slice supplies fakes (or no Targets).
|
||||||
|
func runWithSystemTargets(args []string, input io.Reader, output io.Writer, systemTargets []target.Target) error {
|
||||||
executable, err := os.Executable()
|
executable, err := os.Executable()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("locate proxyctl executable: %w", err)
|
return fmt.Errorf("locate proxyctl executable: %w", err)
|
||||||
}
|
}
|
||||||
return runWithEnvironment(args, input, output, processEnvironment{}, nil, executable)
|
return runWithEnvironmentAndTargets(args, input, output, processEnvironment{}, nil, executable, systemTargets)
|
||||||
}
|
}
|
||||||
|
|
||||||
type processEnvironment struct{}
|
type processEnvironment struct{}
|
||||||
@@ -41,6 +48,10 @@ func (processEnvironment) LookupUser(name string) (profile.UserIdentity, error)
|
|||||||
func (processEnvironment) LookupEnv(name string) (string, bool) { return os.LookupEnv(name) }
|
func (processEnvironment) LookupEnv(name string) (string, bool) { return os.LookupEnv(name) }
|
||||||
|
|
||||||
func runWithEnvironment(args []string, input io.Reader, output io.Writer, environment profile.IdentityEnvironment, files shell.FileSystem, executable string) error {
|
func runWithEnvironment(args []string, input io.Reader, output io.Writer, environment profile.IdentityEnvironment, files shell.FileSystem, executable string) error {
|
||||||
|
return runWithEnvironmentAndTargets(args, input, output, environment, files, executable, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runWithEnvironmentAndTargets(args []string, input io.Reader, output io.Writer, environment profile.IdentityEnvironment, files shell.FileSystem, executable string, systemTargets []target.Target) error {
|
||||||
profileConfiguration, err := profile.ResolveConfiguration(environment)
|
profileConfiguration, err := profile.ResolveConfiguration(environment)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -74,7 +85,11 @@ func runWithEnvironment(args []string, input io.Reader, output io.Writer, enviro
|
|||||||
}
|
}
|
||||||
|
|
||||||
store := profile.NewOwnedFileStoreAt(profileConfiguration.Path, profileConfiguration.Owner, profileConfiguration.TrustedRoot)
|
store := profile.NewOwnedFileStoreAt(profileConfiguration.Path, profileConfiguration.Owner, profileConfiguration.TrustedRoot)
|
||||||
application, err := app.NewWithTargets(store, []target.Target{shellTarget})
|
if systemTargets == nil {
|
||||||
|
systemTargets = []target.Target{dockerdaemon.New(dockerdaemon.DefaultConfiguration(), nil, nil)}
|
||||||
|
}
|
||||||
|
targets := append([]target.Target{shellTarget}, systemTargets...)
|
||||||
|
application, err := app.NewWithTargets(store, targets)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"proxyctl/internal/target"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDefaultInvocationStartsTUIWithNoProfileState(t *testing.T) {
|
func TestDefaultInvocationStartsTUIWithNoProfileState(t *testing.T) {
|
||||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||||
var output bytes.Buffer
|
var output bytes.Buffer
|
||||||
if err := run(nil, strings.NewReader("q"), &output); err != nil {
|
if err := runWithSystemTargets(nil, strings.NewReader("q"), &output, []target.Target{}); err != nil {
|
||||||
t.Fatalf("run proxyctl: %v", err)
|
t.Fatalf("run proxyctl: %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(output.String(), "No Active Profile") {
|
if !strings.Contains(output.String(), "No Active Profile") {
|
||||||
@@ -27,7 +29,7 @@ func TestDefaultInvocationLoadsPersistedActiveProfile(t *testing.T) {
|
|||||||
"activeProfile": "office"
|
"activeProfile": "office"
|
||||||
}`)
|
}`)
|
||||||
var output bytes.Buffer
|
var output bytes.Buffer
|
||||||
if err := run(nil, strings.NewReader("q"), &output); err != nil {
|
if err := runWithSystemTargets(nil, strings.NewReader("q"), &output, []target.Target{}); err != nil {
|
||||||
t.Fatalf("run proxyctl: %v", err)
|
t.Fatalf("run proxyctl: %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(output.String(), "Active Profile: office") {
|
if !strings.Contains(output.String(), "Active Profile: office") {
|
||||||
|
|||||||
+7
-3
@@ -44,9 +44,10 @@ type Snapshot struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Result struct {
|
type Result struct {
|
||||||
Snapshot Snapshot
|
Snapshot Snapshot
|
||||||
Notice string
|
Notice string
|
||||||
Err error
|
Confirmation string
|
||||||
|
Err error
|
||||||
}
|
}
|
||||||
|
|
||||||
type Module interface {
|
type Module interface {
|
||||||
@@ -159,6 +160,9 @@ func (a *Application) setTargetDesired(intent Intent) Result {
|
|||||||
if intent.Enabled && active == nil {
|
if intent.Enabled && active == nil {
|
||||||
return Result{Snapshot: a.Snapshot(), Err: errors.New("no Active Profile; open Profile management before enabling a Target")}
|
return Result{Snapshot: a.Snapshot(), Err: errors.New("no Active Profile; open Profile management before enabling a Target")}
|
||||||
}
|
}
|
||||||
|
if warning := selected.Confirmation(intent.Enabled); warning != "" && !intent.Confirm {
|
||||||
|
return Result{Snapshot: a.Snapshot(), Confirmation: warning}
|
||||||
|
}
|
||||||
result := selected.SetDesired(context.Background(), intent.Enabled, active)
|
result := selected.SetDesired(context.Background(), intent.Enabled, active)
|
||||||
a.inspections[selected.ID()] = result.Inspection
|
a.inspections[selected.ID()] = result.Inspection
|
||||||
notice := ""
|
notice := ""
|
||||||
|
|||||||
@@ -13,10 +13,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type fakeTarget struct {
|
type fakeTarget struct {
|
||||||
available bool
|
available bool
|
||||||
state target.State
|
state target.State
|
||||||
setErr error
|
setErr error
|
||||||
calls []bool
|
confirmation string
|
||||||
|
calls []bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeTarget) ID() string { return "shell" }
|
func (f *fakeTarget) ID() string { return "shell" }
|
||||||
@@ -29,6 +30,7 @@ func (f *fakeTarget) Availability(context.Context) target.Availability {
|
|||||||
func (f *fakeTarget) Inspect(context.Context, *profile.Profile) target.Inspection {
|
func (f *fakeTarget) Inspect(context.Context, *profile.Profile) target.Inspection {
|
||||||
return target.Inspection{State: f.state, Evidence: "actual state"}
|
return target.Inspection{State: f.state, Evidence: "actual state"}
|
||||||
}
|
}
|
||||||
|
func (f *fakeTarget) Confirmation(bool) string { return f.confirmation }
|
||||||
func (f *fakeTarget) SetDesired(_ context.Context, enabled bool, _ *profile.Profile) target.OperationResult {
|
func (f *fakeTarget) SetDesired(_ context.Context, enabled bool, _ *profile.Profile) target.OperationResult {
|
||||||
f.calls = append(f.calls, enabled)
|
f.calls = append(f.calls, enabled)
|
||||||
if f.setErr == nil {
|
if f.setErr == nil {
|
||||||
@@ -71,6 +73,31 @@ func TestApplicationTargetSeamRequiresActiveProfileAndReturnsTruthfulResults(t *
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestApplicationRequiresTargetConfirmationBeforeMutation(t *testing.T) {
|
||||||
|
adapter := &fakeTarget{available: true, state: target.Disabled, confirmation: "Docker will restart immediately and may disrupt running work."}
|
||||||
|
application, err := app.NewWithTargets(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json")), []target.Target{targetWithID{fakeTarget: adapter, id: "docker-daemon"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
createAndActivate(t, application)
|
||||||
|
|
||||||
|
warning := application.Dispatch(app.Intent{Kind: app.SetTargetDesired, TargetID: "docker-daemon", Enabled: true})
|
||||||
|
if warning.Confirmation == "" || warning.Err != nil || len(adapter.calls) != 0 || warning.Snapshot.Targets[0].State != target.Disabled {
|
||||||
|
t.Fatalf("unconfirmed result = %#v, calls=%v", warning, adapter.calls)
|
||||||
|
}
|
||||||
|
confirmed := application.Dispatch(app.Intent{Kind: app.SetTargetDesired, TargetID: "docker-daemon", Enabled: true, Confirm: true})
|
||||||
|
if confirmed.Err != nil || len(adapter.calls) != 1 || confirmed.Snapshot.Targets[0].State != target.Enabled {
|
||||||
|
t.Fatalf("confirmed result = %#v, calls=%v", confirmed, adapter.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type targetWithID struct {
|
||||||
|
*fakeTarget
|
||||||
|
id string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t targetWithID) ID() string { return t.id }
|
||||||
|
|
||||||
func TestApplicationHidesUnavailableTargetButKeepsExplanation(t *testing.T) {
|
func TestApplicationHidesUnavailableTargetButKeepsExplanation(t *testing.T) {
|
||||||
adapter := &fakeTarget{available: false, state: target.Disabled}
|
adapter := &fakeTarget{available: false, state: target.Disabled}
|
||||||
application, err := app.NewWithTargets(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json")), []target.Target{adapter})
|
application, err := app.NewWithTargets(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json")), []target.Target{adapter})
|
||||||
|
|||||||
@@ -0,0 +1,434 @@
|
|||||||
|
// Package dockerdaemon implements the systemd-managed Docker daemon Target.
|
||||||
|
package dockerdaemon
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"proxyctl/internal/profile"
|
||||||
|
"proxyctl/internal/securefs"
|
||||||
|
"proxyctl/internal/target"
|
||||||
|
)
|
||||||
|
|
||||||
|
const service = "docker.service"
|
||||||
|
|
||||||
|
type Configuration struct {
|
||||||
|
DaemonConfigPath string
|
||||||
|
RecoveryPath string
|
||||||
|
TrustedRoot string
|
||||||
|
}
|
||||||
|
|
||||||
|
func DefaultConfiguration() Configuration {
|
||||||
|
return Configuration{DaemonConfigPath: "/etc/docker/daemon.json", RecoveryPath: "/var/lib/proxyctl/docker-daemon.json", TrustedRoot: "/"}
|
||||||
|
}
|
||||||
|
|
||||||
|
type FileSystem interface {
|
||||||
|
ReadFile(string) ([]byte, error)
|
||||||
|
FileMode(string) (os.FileMode, error)
|
||||||
|
AtomicWrite(string, []byte, os.FileMode) error
|
||||||
|
Remove(string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type OSFileSystem struct{ store securefs.Store }
|
||||||
|
|
||||||
|
func NewOSFileSystem(configuration Configuration) OSFileSystem {
|
||||||
|
root := configuration.TrustedRoot
|
||||||
|
if root == "" {
|
||||||
|
root = "/"
|
||||||
|
}
|
||||||
|
return OSFileSystem{store: securefs.Store{TrustedRoot: root}}
|
||||||
|
}
|
||||||
|
func (f OSFileSystem) ReadFile(path string) ([]byte, error) { return f.store.ReadFile(path) }
|
||||||
|
func (f OSFileSystem) FileMode(path string) (os.FileMode, error) { return f.store.FileMode(path) }
|
||||||
|
func (f OSFileSystem) AtomicWrite(path string, data []byte, mode os.FileMode) error {
|
||||||
|
return f.store.AtomicWrite(path, data, mode)
|
||||||
|
}
|
||||||
|
func (f OSFileSystem) Remove(path string) error { return f.store.Remove(path) }
|
||||||
|
|
||||||
|
type CommandRunner interface {
|
||||||
|
Run(context.Context, string, ...string) ([]byte, error)
|
||||||
|
}
|
||||||
|
type ExecRunner struct{}
|
||||||
|
|
||||||
|
func (ExecRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) {
|
||||||
|
output, err := exec.CommandContext(ctx, name, args...).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return output, fmt.Errorf("%s: %s: %w", name, strings.TrimSpace(string(output)), err)
|
||||||
|
}
|
||||||
|
return output, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Adapter struct {
|
||||||
|
configuration Configuration
|
||||||
|
files FileSystem
|
||||||
|
runner CommandRunner
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(configuration Configuration, files FileSystem, runner CommandRunner) *Adapter {
|
||||||
|
if configuration.DaemonConfigPath == "" {
|
||||||
|
configuration.DaemonConfigPath = "/etc/docker/daemon.json"
|
||||||
|
}
|
||||||
|
if configuration.RecoveryPath == "" {
|
||||||
|
configuration.RecoveryPath = "/var/lib/proxyctl/docker-daemon.json"
|
||||||
|
}
|
||||||
|
if files == nil {
|
||||||
|
value := NewOSFileSystem(configuration)
|
||||||
|
files = value
|
||||||
|
}
|
||||||
|
if runner == nil {
|
||||||
|
runner = ExecRunner{}
|
||||||
|
}
|
||||||
|
return &Adapter{configuration: configuration, files: files, runner: runner}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Adapter) ID() string { return "docker-daemon" }
|
||||||
|
func (a *Adapter) Description() target.Description {
|
||||||
|
return target.Description{
|
||||||
|
Name: "Docker daemon",
|
||||||
|
Summary: "Configures proxying for Docker daemon operations such as registry access, not container network traffic or container/build defaults.",
|
||||||
|
Details: "Uses managed Docker daemon configuration. Every change reloads systemd and immediately restarts Docker, which may disrupt running work. System-file and service permissions are required; proxyctl reports permission errors and never initiates privilege escalation.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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."
|
||||||
|
}
|
||||||
|
func (a *Adapter) Availability(ctx context.Context) target.Availability {
|
||||||
|
availability, _ := a.detect(ctx)
|
||||||
|
return availability
|
||||||
|
}
|
||||||
|
func (a *Adapter) detect(ctx context.Context) (target.Availability, error) {
|
||||||
|
output, err := a.runner.Run(ctx, "systemctl", "show", service, "--property=LoadState", "--property=FragmentPath")
|
||||||
|
if err != nil {
|
||||||
|
return target.Availability{Evidence: "A supported systemd-managed Docker daemon was not detected"}, err
|
||||||
|
}
|
||||||
|
values := parseProperties(string(output))
|
||||||
|
if values["LoadState"] != "loaded" || strings.TrimSpace(values["FragmentPath"]) == "" {
|
||||||
|
return target.Availability{Evidence: "A supported systemd-managed Docker daemon was not detected"}, nil
|
||||||
|
}
|
||||||
|
return target.Availability{Available: true, Evidence: "Docker daemon service is loaded and managed by systemd at " + values["FragmentPath"]}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseProperties(output string) map[string]string {
|
||||||
|
result := map[string]string{}
|
||||||
|
for _, line := range strings.Split(output, "\n") {
|
||||||
|
key, value, ok := strings.Cut(line, "=")
|
||||||
|
if ok {
|
||||||
|
result[strings.TrimSpace(key)] = strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
type recovery struct {
|
||||||
|
Original []byte `json:"original,omitempty"`
|
||||||
|
OriginalExists bool `json:"originalExists"`
|
||||||
|
OriginalMode uint32 `json:"originalMode,omitempty"`
|
||||||
|
ConfigFingerprint string `json:"configFingerprint"`
|
||||||
|
RestartedFingerprint string `json:"restartedFingerprint,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Adapter) Inspect(_ context.Context, active *profile.Profile) target.Inspection {
|
||||||
|
metadata, managed, metadataErr := a.readRecovery()
|
||||||
|
data, configErr := a.files.ReadFile(a.configuration.DaemonConfigPath)
|
||||||
|
if managed || (metadataErr != nil && !errors.Is(metadataErr, fs.ErrNotExist)) {
|
||||||
|
if metadataErr != nil {
|
||||||
|
return target.Inspection{State: target.Drifted, Evidence: "managed Docker daemon recovery metadata cannot be read"}
|
||||||
|
}
|
||||||
|
if configErr != nil {
|
||||||
|
return target.Inspection{State: target.Drifted, Evidence: "managed Docker daemon configuration is missing or unreadable"}
|
||||||
|
}
|
||||||
|
if fingerprint(data) == metadata.ConfigFingerprint && active != nil {
|
||||||
|
normalized, err := compatible(*active)
|
||||||
|
if err == nil && configMatchesProfile(data, normalized) {
|
||||||
|
return target.Inspection{State: target.Enabled, Evidence: "managed Docker daemon configuration matches the Active Profile"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return target.Inspection{State: target.Drifted, Evidence: "managed Docker daemon configuration does not match the Active Profile"}
|
||||||
|
}
|
||||||
|
if configErr == nil && recognizesProxy(data) {
|
||||||
|
return target.Inspection{State: target.External, Evidence: "recognized proxy settings exist in Docker daemon configuration"}
|
||||||
|
}
|
||||||
|
return target.Inspection{State: target.Disabled, Evidence: "no recognized Docker daemon proxy configuration detected"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Adapter) SetDesired(ctx context.Context, enabled bool, active *profile.Profile) target.OperationResult {
|
||||||
|
if enabled && active == nil {
|
||||||
|
return target.OperationResult{Inspection: a.Inspect(ctx, active), Err: errors.New("no Active Profile; open Profile management to create and activate one")}
|
||||||
|
}
|
||||||
|
var normalized profile.Profile
|
||||||
|
var err error
|
||||||
|
if enabled {
|
||||||
|
normalized, err = compatible(*active)
|
||||||
|
if err != nil {
|
||||||
|
return target.OperationResult{Inspection: a.Inspect(ctx, active), Err: redact(err, active)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
availability, detectionErr := a.detect(ctx)
|
||||||
|
if detectionErr != nil {
|
||||||
|
return target.OperationResult{Inspection: a.Inspect(ctx, active), Err: redact(fmt.Errorf("detect systemd-managed Docker daemon: %w", detectionErr), active)}
|
||||||
|
}
|
||||||
|
if !availability.Available {
|
||||||
|
return target.OperationResult{Inspection: a.Inspect(ctx, active), Err: errors.New("Docker daemon Target is unavailable: a supported systemd-managed Docker daemon was not detected")}
|
||||||
|
}
|
||||||
|
if enabled {
|
||||||
|
err = a.enable(ctx, normalized)
|
||||||
|
} else {
|
||||||
|
err = a.disable(ctx)
|
||||||
|
}
|
||||||
|
inspection := a.Inspect(ctx, active)
|
||||||
|
return target.OperationResult{Inspection: inspection, Err: redact(err, active)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func compatible(input profile.Profile) (profile.Profile, error) {
|
||||||
|
p, err := profile.ValidateAndNormalize(input)
|
||||||
|
if err != nil {
|
||||||
|
return profile.Profile{}, err
|
||||||
|
}
|
||||||
|
if p.Mode == profile.AdvancedMode && p.AllURL != "" {
|
||||||
|
return profile.Profile{}, errors.New("Docker daemon Target does not support an ALL/SOCKS proxy; remove it before applying")
|
||||||
|
}
|
||||||
|
for name, raw := range map[string]string{"HTTP": p.HTTPURL, "HTTPS": p.HTTPSURL} {
|
||||||
|
if raw == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
u, _ := url.Parse(raw)
|
||||||
|
if u.Scheme != "http" && u.Scheme != "https" {
|
||||||
|
return profile.Profile{}, fmt.Errorf("Docker daemon Target does not support %s proxy scheme %q", name, u.Scheme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Adapter) enable(ctx context.Context, p profile.Profile) error {
|
||||||
|
metadata, managed, err := a.readRecovery()
|
||||||
|
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return fmt.Errorf("read Docker daemon recovery metadata: %w", err)
|
||||||
|
}
|
||||||
|
current, readErr := a.files.ReadFile(a.configuration.DaemonConfigPath)
|
||||||
|
if readErr != nil && !errors.Is(readErr, fs.ErrNotExist) {
|
||||||
|
return fmt.Errorf("read Docker daemon configuration: %w", readErr)
|
||||||
|
}
|
||||||
|
if !managed {
|
||||||
|
metadata.OriginalExists = readErr == nil
|
||||||
|
if metadata.OriginalExists {
|
||||||
|
metadata.Original = append([]byte(nil), current...)
|
||||||
|
if mode, modeErr := a.files.FileMode(a.configuration.DaemonConfigPath); modeErr == nil {
|
||||||
|
metadata.OriginalMode = uint32(mode.Perm())
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("inspect Docker daemon configuration permissions: %w", modeErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
base := current
|
||||||
|
if errors.Is(readErr, fs.ErrNotExist) {
|
||||||
|
base = []byte("{}")
|
||||||
|
}
|
||||||
|
desired, err := mergeConfig(base, p)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parse Docker daemon configuration before changing it: %w", err)
|
||||||
|
}
|
||||||
|
desiredFingerprint := fingerprint(desired)
|
||||||
|
if managed && fingerprint(current) == metadata.ConfigFingerprint && metadata.ConfigFingerprint == desiredFingerprint && metadata.RestartedFingerprint == desiredFingerprint {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
metadata.ConfigFingerprint = desiredFingerprint
|
||||||
|
metadata.RestartedFingerprint = ""
|
||||||
|
if err := a.writeRecovery(metadata); err != nil {
|
||||||
|
return fmt.Errorf("persist Docker daemon recovery metadata before changing configuration: %w", err)
|
||||||
|
}
|
||||||
|
if fingerprint(current) != desiredFingerprint {
|
||||||
|
mode := os.FileMode(0o600)
|
||||||
|
if metadata.OriginalExists {
|
||||||
|
mode = os.FileMode(metadata.OriginalMode)
|
||||||
|
}
|
||||||
|
if err := a.files.AtomicWrite(a.configuration.DaemonConfigPath, desired, mode); err != nil {
|
||||||
|
return fmt.Errorf("write Docker daemon configuration after recovery metadata persisted: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := a.reloadRestartInspect(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
metadata.RestartedFingerprint = desiredFingerprint
|
||||||
|
if err := a.writeRecovery(metadata); err != nil {
|
||||||
|
return fmt.Errorf("record completed Docker daemon restart: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Adapter) disable(ctx context.Context) error {
|
||||||
|
metadata, managed, err := a.readRecovery()
|
||||||
|
if errors.Is(err, fs.ErrNotExist) || !managed {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read Docker daemon recovery metadata: %w", err)
|
||||||
|
}
|
||||||
|
if metadata.OriginalExists {
|
||||||
|
current, _ := a.files.ReadFile(a.configuration.DaemonConfigPath)
|
||||||
|
if fingerprint(current) != fingerprint(metadata.Original) {
|
||||||
|
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) {
|
||||||
|
return fmt.Errorf("remove managed Docker daemon configuration: %w", err)
|
||||||
|
}
|
||||||
|
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("remove Docker daemon recovery metadata after restart: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Adapter) reloadRestartInspect(ctx context.Context) error {
|
||||||
|
if _, err := a.runner.Run(ctx, "systemctl", "daemon-reload"); err != nil {
|
||||||
|
return fmt.Errorf("reload systemd manager configuration: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := a.runner.Run(ctx, "systemctl", "restart", service); err != nil {
|
||||||
|
return fmt.Errorf("restart Docker daemon: %w", err)
|
||||||
|
}
|
||||||
|
output, err := a.runner.Run(ctx, "systemctl", "is-active", service)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("inspect Docker daemon after restart: %w", err)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(string(output)) != "active" {
|
||||||
|
return fmt.Errorf("inspect Docker daemon after restart: service state is %q", strings.TrimSpace(string(output)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Adapter) readRecovery() (recovery, bool, error) {
|
||||||
|
data, err := a.files.ReadFile(a.configuration.RecoveryPath)
|
||||||
|
if err != nil {
|
||||||
|
return recovery{}, false, err
|
||||||
|
}
|
||||||
|
var value recovery
|
||||||
|
if err := json.Unmarshal(data, &value); err != nil {
|
||||||
|
return recovery{}, true, err
|
||||||
|
}
|
||||||
|
return value, true, nil
|
||||||
|
}
|
||||||
|
func (a *Adapter) writeRecovery(value recovery) error {
|
||||||
|
data, err := json.MarshalIndent(value, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return a.files.AtomicWrite(a.configuration.RecoveryPath, append(data, '\n'), 0o600)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeConfig(data []byte, p profile.Profile) ([]byte, error) {
|
||||||
|
var document map[string]any
|
||||||
|
if len(strings.TrimSpace(string(data))) == 0 {
|
||||||
|
data = []byte("{}")
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &document); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if document == nil {
|
||||||
|
document = map[string]any{}
|
||||||
|
}
|
||||||
|
proxies := map[string]any{}
|
||||||
|
if existing, ok := document["proxies"].(map[string]any); ok {
|
||||||
|
for key, value := range existing {
|
||||||
|
proxies[key] = value
|
||||||
|
}
|
||||||
|
} else if _, exists := document["proxies"]; exists {
|
||||||
|
return nil, errors.New("the existing proxies field is not an object")
|
||||||
|
}
|
||||||
|
setOrDelete(proxies, "http-proxy", p.HTTPURL)
|
||||||
|
setOrDelete(proxies, "https-proxy", p.HTTPSURL)
|
||||||
|
setOrDelete(proxies, "no-proxy", strings.Join(p.Bypass, ","))
|
||||||
|
document["proxies"] = proxies
|
||||||
|
output, err := json.MarshalIndent(document, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return append(output, '\n'), nil
|
||||||
|
}
|
||||||
|
func setOrDelete(values map[string]any, key, value string) {
|
||||||
|
if value == "" {
|
||||||
|
delete(values, key)
|
||||||
|
} else {
|
||||||
|
values[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func configMatchesProfile(data []byte, p profile.Profile) bool {
|
||||||
|
var document map[string]any
|
||||||
|
if json.Unmarshal(data, &document) != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
proxies, ok := document["proxies"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return stringValue(proxies["http-proxy"]) == p.HTTPURL && stringValue(proxies["https-proxy"]) == p.HTTPSURL && stringValue(proxies["no-proxy"]) == strings.Join(p.Bypass, ",")
|
||||||
|
}
|
||||||
|
func recognizesProxy(data []byte) bool {
|
||||||
|
var document map[string]any
|
||||||
|
if json.Unmarshal(data, &document) != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
proxies, ok := document["proxies"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, key := range []string{"http-proxy", "https-proxy", "no-proxy"} {
|
||||||
|
if stringValue(proxies[key]) != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
func stringValue(value any) string { result, _ := value.(string); return result }
|
||||||
|
func fingerprint(data []byte) string { sum := sha256.Sum256(data); return hex.EncodeToString(sum[:]) }
|
||||||
|
|
||||||
|
type redactedError struct {
|
||||||
|
message string
|
||||||
|
class error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *redactedError) Error() string { return e.message }
|
||||||
|
func (e *redactedError) Is(other error) bool { return e.class != nil && errors.Is(e.class, other) }
|
||||||
|
func redact(err error, active *profile.Profile) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
message := profile.RedactText(err.Error())
|
||||||
|
if active != nil {
|
||||||
|
for _, raw := range []string{active.SingleURL, active.HTTPURL, active.HTTPSURL, active.AllURL} {
|
||||||
|
if raw == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
message = strings.ReplaceAll(message, raw, profile.ObscureURL(raw))
|
||||||
|
if parsed, parseErr := url.Parse(raw); parseErr == nil && parsed.User != nil {
|
||||||
|
if password, ok := parsed.User.Password(); ok && password != "" {
|
||||||
|
message = strings.ReplaceAll(message, password, "REDACTED")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var class error
|
||||||
|
for _, safe := range []error{os.ErrPermission, fs.ErrNotExist, fs.ErrExist, context.Canceled, context.DeadlineExceeded} {
|
||||||
|
if errors.Is(err, safe) {
|
||||||
|
class = safe
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &redactedError{message: message, class: class}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paths expose configured effects for diagnostics and hermetic tests.
|
||||||
|
func (a *Adapter) DaemonConfigPath() string { return filepath.Clean(a.configuration.DaemonConfigPath) }
|
||||||
|
func (a *Adapter) RecoveryPath() string { return filepath.Clean(a.configuration.RecoveryPath) }
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
package dockerdaemon_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"proxyctl/internal/dockerdaemon"
|
||||||
|
"proxyctl/internal/profile"
|
||||||
|
"proxyctl/internal/target"
|
||||||
|
"proxyctl/internal/target/targettest"
|
||||||
|
)
|
||||||
|
|
||||||
|
type recorder struct{ events []string }
|
||||||
|
|
||||||
|
type fakeRunner struct {
|
||||||
|
recorder *recorder
|
||||||
|
show string
|
||||||
|
fail map[string]error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRunner) Run(_ context.Context, name string, args ...string) ([]byte, error) {
|
||||||
|
command := strings.Join(append([]string{name}, args...), " ")
|
||||||
|
r.recorder.events = append(r.recorder.events, command)
|
||||||
|
if err := r.fail[command]; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if strings.Contains(command, " show ") {
|
||||||
|
return []byte(r.show), nil
|
||||||
|
}
|
||||||
|
if strings.Contains(command, " is-active ") {
|
||||||
|
return []byte("active\n"), nil
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type recordingFS struct{ recorder *recorder }
|
||||||
|
|
||||||
|
func (f recordingFS) ReadFile(path string) ([]byte, error) { return os.ReadFile(path) }
|
||||||
|
func (f recordingFS) FileMode(path string) (os.FileMode, error) {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return info.Mode().Perm(), nil
|
||||||
|
}
|
||||||
|
func (f recordingFS) AtomicWrite(path string, data []byte, mode os.FileMode) error {
|
||||||
|
f.recorder.events = append(f.recorder.events, "write "+filepath.Base(path))
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
temporary := path + ".tmp"
|
||||||
|
if err := os.WriteFile(temporary, data, mode); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(temporary, path)
|
||||||
|
}
|
||||||
|
func (f recordingFS) Remove(path string) error {
|
||||||
|
f.recorder.events = append(f.recorder.events, "remove "+filepath.Base(path))
|
||||||
|
return os.Remove(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fixture(t *testing.T) (*dockerdaemon.Adapter, *recorder, string, string, *fakeRunner) {
|
||||||
|
t.Helper()
|
||||||
|
directory := t.TempDir()
|
||||||
|
daemonPath := filepath.Join(directory, "etc", "docker", "daemon.json")
|
||||||
|
recoveryPath := filepath.Join(directory, "state", "docker-daemon.json")
|
||||||
|
record := &recorder{}
|
||||||
|
runner := &fakeRunner{recorder: record, show: "LoadState=loaded\nFragmentPath=/lib/systemd/system/docker.service\n", fail: map[string]error{}}
|
||||||
|
adapter := dockerdaemon.New(dockerdaemon.Configuration{DaemonConfigPath: daemonPath, RecoveryPath: recoveryPath, TrustedRoot: directory}, recordingFS{recorder: record}, runner)
|
||||||
|
return adapter, record, daemonPath, recoveryPath, runner
|
||||||
|
}
|
||||||
|
|
||||||
|
func activeProfile() *profile.Profile {
|
||||||
|
return &profile.Profile{Name: "office", Mode: profile.AdvancedMode, HTTPURL: "http://alice:secret@proxy.example:8080", HTTPSURL: "https://proxy.example:8443", Bypass: []string{"localhost", ".example.com", "10.0.0.0/8", "2001:db8::1"}}
|
||||||
|
}
|
||||||
|
|
||||||
|
type faultFS struct {
|
||||||
|
recordingFS
|
||||||
|
permission bool
|
||||||
|
failDaemon bool
|
||||||
|
failRecoveryOnce bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *faultFS) AtomicWrite(path string, data []byte, mode os.FileMode) error {
|
||||||
|
if f.permission {
|
||||||
|
return os.ErrPermission
|
||||||
|
}
|
||||||
|
if f.failDaemon && filepath.Base(path) == "daemon.json" {
|
||||||
|
return errors.New("write failed")
|
||||||
|
}
|
||||||
|
if f.failRecoveryOnce && filepath.Base(path) == "docker-daemon.json" {
|
||||||
|
f.failRecoveryOnce = false
|
||||||
|
return errors.New("secret recovery failure")
|
||||||
|
}
|
||||||
|
return f.recordingFS.AtomicWrite(path, data, mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDockerDaemonRunsReusableTargetContract(t *testing.T) {
|
||||||
|
targettest.Run(t, func(t *testing.T) targettest.Fixture {
|
||||||
|
a, _, daemonPath, _, _ := fixture(t)
|
||||||
|
return targettest.Fixture{
|
||||||
|
Target: a, Active: activeProfile(), Secret: "secret",
|
||||||
|
MakeExternal: func(t *testing.T) {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(daemonPath), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(daemonPath, []byte(`{"proxies":{"http-proxy":"http://external.example:8080"}}`), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
MakeDrifted: func(t *testing.T) {
|
||||||
|
if err := os.WriteFile(daemonPath, []byte(`{"proxies":{"http-proxy":"http://changed.example:8080"}}`), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
PermissionFailure: func(t *testing.T) target.OperationResult {
|
||||||
|
failed, record, _, _, failedRunner := fixture(t)
|
||||||
|
files := &faultFS{recordingFS: recordingFS{recorder: record}, permission: true}
|
||||||
|
failed = dockerdaemon.New(dockerdaemon.Configuration{DaemonConfigPath: failed.DaemonConfigPath(), RecoveryPath: failed.RecoveryPath(), TrustedRoot: filepath.Dir(filepath.Dir(failed.DaemonConfigPath()))}, files, failedRunner)
|
||||||
|
return failed.SetDesired(context.Background(), true, activeProfile())
|
||||||
|
},
|
||||||
|
PartialFailure: func(t *testing.T) target.OperationResult {
|
||||||
|
failed, record, _, _, failedRunner := fixture(t)
|
||||||
|
files := &faultFS{recordingFS: recordingFS{recorder: record}, failDaemon: true}
|
||||||
|
failed = dockerdaemon.New(dockerdaemon.Configuration{DaemonConfigPath: failed.DaemonConfigPath(), RecoveryPath: failed.RecoveryPath()}, files, failedRunner)
|
||||||
|
return failed.SetDesired(context.Background(), true, activeProfile())
|
||||||
|
},
|
||||||
|
RedactionFailure: func(t *testing.T) target.OperationResult {
|
||||||
|
failed, _, _, _, failedRunner := fixture(t)
|
||||||
|
failedRunner.fail["systemctl daemon-reload"] = errors.New("http://alice:secret@proxy.example:8080")
|
||||||
|
return failed.SetDesired(context.Background(), true, activeProfile())
|
||||||
|
},
|
||||||
|
RecoveryRetry: func(t *testing.T) (target.OperationResult, target.OperationResult, target.OperationResult) {
|
||||||
|
failed, record, daemon, recovery, failedRunner := fixture(t)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(daemon), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(daemon, []byte(`{"proxies":{"http-proxy":"http://external.example:8080"}}`), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
files := &faultFS{recordingFS: recordingFS{recorder: record}, failRecoveryOnce: true}
|
||||||
|
failed = dockerdaemon.New(dockerdaemon.Configuration{DaemonConfigPath: daemon, RecoveryPath: recovery}, files, failedRunner)
|
||||||
|
first := failed.SetDesired(context.Background(), true, activeProfile())
|
||||||
|
retry := failed.SetDesired(context.Background(), true, activeProfile())
|
||||||
|
disabled := failed.SetDesired(context.Background(), false, nil)
|
||||||
|
return first, retry, disabled
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDockerDaemonAvailabilityDescriptionAndConfirmation(t *testing.T) {
|
||||||
|
a, _, _, _, runner := fixture(t)
|
||||||
|
if got := a.Availability(context.Background()); !got.Available || !strings.Contains(got.Evidence, "systemd") {
|
||||||
|
t.Fatalf("availability = %#v", got)
|
||||||
|
}
|
||||||
|
description := a.Description()
|
||||||
|
for _, phrase := range []string{"registry", "not container network traffic", "restart", "permission"} {
|
||||||
|
if !strings.Contains(strings.ToLower(description.Summary+" "+description.Details), phrase) {
|
||||||
|
t.Errorf("description misses %q: %#v", phrase, description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if warning := a.Confirmation(true); !strings.Contains(strings.ToLower(warning), "restart") || !strings.Contains(strings.ToLower(warning), "running") {
|
||||||
|
t.Fatalf("confirmation = %q", warning)
|
||||||
|
}
|
||||||
|
runner.show = "LoadState=not-found\nFragmentPath=\n"
|
||||||
|
if got := a.Availability(context.Background()); got.Available || !strings.Contains(got.Evidence, "not detected") {
|
||||||
|
t.Fatalf("unsupported availability = %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDockerDaemonEnableOrdersManagedWriteReloadRestartAndPostInspect(t *testing.T) {
|
||||||
|
a, record, daemonPath, _, _ := fixture(t)
|
||||||
|
result := a.SetDesired(context.Background(), true, activeProfile())
|
||||||
|
if result.Err != nil || result.Inspection.State != target.Enabled {
|
||||||
|
t.Fatalf("enable = %#v", result)
|
||||||
|
}
|
||||||
|
wantOrder := []string{"write docker-daemon.json", "write daemon.json", "systemctl daemon-reload", "systemctl restart docker.service", "systemctl is-active docker.service", "write docker-daemon.json"}
|
||||||
|
assertSubsequence(t, record.events, wantOrder)
|
||||||
|
var config map[string]any
|
||||||
|
data, err := os.ReadFile(daemonPath)
|
||||||
|
if err != nil || json.Unmarshal(data, &config) != nil {
|
||||||
|
t.Fatalf("daemon config: %v %s", err, data)
|
||||||
|
}
|
||||||
|
proxies := config["proxies"].(map[string]any)
|
||||||
|
if proxies["http-proxy"] != activeProfile().HTTPURL || proxies["https-proxy"] != activeProfile().HTTPSURL || !strings.Contains(proxies["no-proxy"].(string), "10.0.0.0/8") {
|
||||||
|
t.Fatalf("managed proxies = %#v", proxies)
|
||||||
|
}
|
||||||
|
|
||||||
|
record.events = nil
|
||||||
|
second := a.SetDesired(context.Background(), true, activeProfile())
|
||||||
|
if second.Err != nil || second.Inspection.State != target.Enabled || len(mutationEvents(record.events)) != 0 {
|
||||||
|
t.Fatalf("idempotent enable = %#v, events=%v", second, record.events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDockerDaemonPreservesAndRestoresExistingConfiguration(t *testing.T) {
|
||||||
|
a, _, daemonPath, recoveryPath, _ := fixture(t)
|
||||||
|
original := []byte("{\n \"log-level\": \"debug\",\n \"proxies\": {\"http-proxy\": \"http://prior.example:3128\"}\n}\n")
|
||||||
|
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 got := a.Inspect(context.Background(), activeProfile()).State; got != target.External {
|
||||||
|
t.Fatalf("initial = %s", got)
|
||||||
|
}
|
||||||
|
if got := a.SetDesired(context.Background(), true, activeProfile()); got.Err != nil {
|
||||||
|
t.Fatal(got.Err)
|
||||||
|
}
|
||||||
|
if got := a.SetDesired(context.Background(), false, nil); got.Err != nil || got.Inspection.State != target.External {
|
||||||
|
t.Fatalf("disable = %#v", got)
|
||||||
|
}
|
||||||
|
restored, _ := os.ReadFile(daemonPath)
|
||||||
|
if !reflect.DeepEqual(restored, original) {
|
||||||
|
t.Fatalf("restored:\n%s\nwant:\n%s", restored, original)
|
||||||
|
}
|
||||||
|
if info, err := os.Stat(daemonPath); err != nil || info.Mode().Perm() != 0o640 {
|
||||||
|
t.Fatalf("restored mode = %v, %v", info, err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(recoveryPath); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatalf("recovery remains: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDockerDaemonRestartFailureIsRedactedTruthfulAndRetryable(t *testing.T) {
|
||||||
|
a, record, _, _, runner := fixture(t)
|
||||||
|
restart := "systemctl restart docker.service"
|
||||||
|
runner.fail[restart] = fmt.Errorf("restart exposed http://alice:secret@proxy.example:8080")
|
||||||
|
first := a.SetDesired(context.Background(), true, activeProfile())
|
||||||
|
if first.Err == nil || strings.Contains(first.Err.Error(), "secret") || first.Inspection.State != target.Enabled {
|
||||||
|
t.Fatalf("restart failure = %#v", first)
|
||||||
|
}
|
||||||
|
delete(runner.fail, restart)
|
||||||
|
record.events = nil
|
||||||
|
retry := a.SetDesired(context.Background(), true, activeProfile())
|
||||||
|
if retry.Err != nil || retry.Inspection.State != target.Enabled {
|
||||||
|
t.Fatalf("retry = %#v", retry)
|
||||||
|
}
|
||||||
|
if contains(record.events, "write daemon.json") || !contains(record.events, restart) {
|
||||||
|
t.Fatalf("retry events = %v", record.events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDockerDaemonRejectsIncompatibleProfileBeforeMutation(t *testing.T) {
|
||||||
|
for _, p := range []*profile.Profile{
|
||||||
|
{Name: "socks", Mode: profile.SingleMode, SingleURL: "socks5://proxy.example:1080"},
|
||||||
|
{Name: "all", Mode: profile.AdvancedMode, HTTPURL: "http://proxy.example:8080", AllURL: "http://proxy.example:8080"},
|
||||||
|
{Name: "bypass", Mode: profile.AdvancedMode, HTTPURL: "http://proxy.example:8080", Bypass: []string{"*"}},
|
||||||
|
} {
|
||||||
|
t.Run(p.Name, func(t *testing.T) {
|
||||||
|
a, record, _, _, _ := fixture(t)
|
||||||
|
result := a.SetDesired(context.Background(), true, p)
|
||||||
|
if result.Err == nil || len(mutationEvents(record.events)) != 0 || result.Inspection.State != target.Disabled {
|
||||||
|
t.Fatalf("incompatible result = %#v events=%v", result, record.events)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDockerDaemonNeverEscalatesAndReportsPermissionNormally(t *testing.T) {
|
||||||
|
a, record, _, _, runner := fixture(t)
|
||||||
|
runner.fail["systemctl daemon-reload"] = os.ErrPermission
|
||||||
|
got := a.SetDesired(context.Background(), true, activeProfile())
|
||||||
|
if got.Err == nil || !errors.Is(got.Err, os.ErrPermission) || got.Inspection.State != target.Enabled {
|
||||||
|
t.Fatalf("permission = %#v", got)
|
||||||
|
}
|
||||||
|
for _, event := range record.events {
|
||||||
|
if strings.Contains(event, "sudo") || strings.Contains(event, "pkexec") {
|
||||||
|
t.Fatalf("privilege escalation: %v", record.events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDockerDaemonInspectionHasAllFourStates(t *testing.T) {
|
||||||
|
a, _, daemonPath, _, _ := fixture(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
if got := a.Inspect(ctx, activeProfile()).State; got != target.Disabled {
|
||||||
|
t.Fatalf("disabled = %s", got)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(daemonPath), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(daemonPath, []byte(`{"proxies":{"http-proxy":"http://external.example:8080"}}`), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := a.Inspect(ctx, activeProfile()).State; got != target.External {
|
||||||
|
t.Fatalf("external = %s", got)
|
||||||
|
}
|
||||||
|
if got := a.SetDesired(ctx, true, activeProfile()); got.Err != nil {
|
||||||
|
t.Fatal(got.Err)
|
||||||
|
}
|
||||||
|
if got := a.Inspect(ctx, activeProfile()).State; got != target.Enabled {
|
||||||
|
t.Fatalf("enabled = %s", got)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(daemonPath, []byte(`{"proxies":{"http-proxy":"http://changed.example:8080"}}`), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := a.Inspect(ctx, activeProfile()).State; got != target.Drifted {
|
||||||
|
t.Fatalf("drifted = %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertSubsequence(t *testing.T, got, want []string) {
|
||||||
|
t.Helper()
|
||||||
|
index := 0
|
||||||
|
for _, event := range got {
|
||||||
|
if index < len(want) && event == want[index] {
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if index != len(want) {
|
||||||
|
t.Fatalf("events %v do not contain ordered %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func mutationEvents(events []string) []string {
|
||||||
|
var result []string
|
||||||
|
for _, event := range events {
|
||||||
|
if strings.HasPrefix(event, "write ") || strings.HasPrefix(event, "remove ") || strings.Contains(event, "daemon-reload") || strings.Contains(event, " restart ") {
|
||||||
|
result = append(result, event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
func contains(values []string, want string) bool {
|
||||||
|
for _, value := range values {
|
||||||
|
if value == want {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -183,9 +183,10 @@ func (a *Adapter) Availability(context.Context) target.Availability {
|
|||||||
return target.Availability{Evidence: "Shell Target requires Bash or Zsh; neither was detected"}
|
return target.Availability{Evidence: "Shell Target requires Bash or Zsh; neither was detected"}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
func (a *Adapter) EnvironmentPath() string { return a.configuration.EnvironmentFile }
|
func (a *Adapter) Confirmation(bool) string { return "" }
|
||||||
func (a *Adapter) RecoveryPath() string { return a.configuration.RecoveryFile }
|
func (a *Adapter) EnvironmentPath() string { return a.configuration.EnvironmentFile }
|
||||||
func (a *Adapter) StartupPath() string { return a.configuration.StartupFile }
|
func (a *Adapter) RecoveryPath() string { return a.configuration.RecoveryFile }
|
||||||
|
func (a *Adapter) StartupPath() string { return a.configuration.StartupFile }
|
||||||
|
|
||||||
func (a *Adapter) Inspect(_ context.Context, active *profile.Profile) target.Inspection {
|
func (a *Adapter) Inspect(_ context.Context, active *profile.Profile) target.Inspection {
|
||||||
metadata, managed, metadataErr := a.readRecovery()
|
metadata, managed, metadataErr := a.readRecovery()
|
||||||
|
|||||||
@@ -44,5 +44,8 @@ type Target interface {
|
|||||||
Description() Description
|
Description() Description
|
||||||
Availability(context.Context) Availability
|
Availability(context.Context) Availability
|
||||||
Inspect(context.Context, *profile.Profile) Inspection
|
Inspect(context.Context, *profile.Profile) Inspection
|
||||||
|
// Confirmation returns a warning that must be accepted before SetDesired.
|
||||||
|
// An empty string means the operation needs no confirmation.
|
||||||
|
Confirmation(enabled bool) string
|
||||||
SetDesired(context.Context, bool, *profile.Profile) OperationResult
|
SetDesired(context.Context, bool, *profile.Profile) OperationResult
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ func (seamTarget) Availability(context.Context) target.Availability {
|
|||||||
func (seamTarget) Inspect(context.Context, *profile.Profile) target.Inspection {
|
func (seamTarget) Inspect(context.Context, *profile.Profile) target.Inspection {
|
||||||
return target.Inspection{State: target.Disabled, Evidence: "absent"}
|
return target.Inspection{State: target.Disabled, Evidence: "absent"}
|
||||||
}
|
}
|
||||||
|
func (seamTarget) Confirmation(bool) string { return "" }
|
||||||
func (seamTarget) SetDesired(context.Context, bool, *profile.Profile) target.OperationResult {
|
func (seamTarget) SetDesired(context.Context, bool, *profile.Profile) target.OperationResult {
|
||||||
return target.OperationResult{Inspection: target.Inspection{State: target.Enabled}}
|
return target.OperationResult{Inspection: target.Inspection{State: target.Enabled}}
|
||||||
}
|
}
|
||||||
|
|||||||
+42
-11
@@ -26,14 +26,15 @@ type formState struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Model struct {
|
type Model struct {
|
||||||
application app.Module
|
application app.Module
|
||||||
snapshot app.Snapshot
|
snapshot app.Snapshot
|
||||||
page page
|
page page
|
||||||
selected int
|
selected int
|
||||||
form formState
|
form formState
|
||||||
confirm bool
|
confirm bool
|
||||||
status string
|
targetConfirm *app.Intent
|
||||||
applying bool
|
status string
|
||||||
|
applying bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type targetOperationDone struct {
|
type targetOperationDone struct {
|
||||||
@@ -50,9 +51,18 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if completed, ok := message.(targetOperationDone); ok {
|
if completed, ok := message.(targetOperationDone); ok {
|
||||||
m.applying = false
|
m.applying = false
|
||||||
m.snapshot = completed.result.Snapshot
|
m.snapshot = completed.result.Snapshot
|
||||||
if completed.result.Err != nil {
|
if completed.result.Confirmation != "" {
|
||||||
|
intent := app.Intent{Kind: app.SetTargetDesired}
|
||||||
|
if m.targetConfirm != nil {
|
||||||
|
intent = *m.targetConfirm
|
||||||
|
}
|
||||||
|
m.targetConfirm = &intent
|
||||||
|
m.status = completed.result.Confirmation
|
||||||
|
} else if completed.result.Err != nil {
|
||||||
|
m.targetConfirm = nil
|
||||||
m.status = "\x1b[31mError: " + completed.result.Err.Error() + "\x1b[0m"
|
m.status = "\x1b[31mError: " + completed.result.Err.Error() + "\x1b[0m"
|
||||||
} else {
|
} else {
|
||||||
|
m.targetConfirm = nil
|
||||||
m.status = completed.result.Notice
|
m.status = completed.result.Notice
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -74,6 +84,21 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
if m.targetConfirm != nil && !m.applying {
|
||||||
|
switch key.String() {
|
||||||
|
case "y":
|
||||||
|
intent := *m.targetConfirm
|
||||||
|
intent.Confirm = true
|
||||||
|
m.targetConfirm = nil
|
||||||
|
m.applying = true
|
||||||
|
m.status = ""
|
||||||
|
return m, func() tea.Msg { return targetOperationDone{result: m.application.Dispatch(intent)} }
|
||||||
|
case "n", "esc", "q":
|
||||||
|
m.targetConfirm = nil
|
||||||
|
m.status = ""
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
targets := m.availableTargets()
|
targets := m.availableTargets()
|
||||||
switch key.String() {
|
switch key.String() {
|
||||||
case "p":
|
case "p":
|
||||||
@@ -93,8 +118,10 @@ func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
desired := selected.State != target.Enabled
|
desired := selected.State != target.Enabled
|
||||||
m.applying = true
|
m.applying = true
|
||||||
m.status = ""
|
m.status = ""
|
||||||
|
intent := app.Intent{Kind: app.SetTargetDesired, TargetID: selected.ID, Enabled: desired}
|
||||||
|
m.targetConfirm = &intent
|
||||||
return m, func() tea.Msg {
|
return m, func() tea.Msg {
|
||||||
return targetOperationDone{result: m.application.Dispatch(app.Intent{Kind: app.SetTargetDesired, TargetID: selected.ID, Enabled: desired})}
|
return targetOperationDone{result: m.application.Dispatch(intent)}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "q", "esc", "ctrl+c":
|
case "q", "esc", "ctrl+c":
|
||||||
@@ -288,7 +315,11 @@ func (m Model) mainView() string {
|
|||||||
fmt.Fprintf(&b, "\nUnavailable %s: %s\n", item.Description.Name, item.AvailabilityEvidence)
|
fmt.Fprintf(&b, "\nUnavailable %s: %s\n", item.Description.Name, item.AvailabilityEvidence)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
b.WriteString("\nSpace: toggle ↑/k ↓/j: select p: manage Proxy Profiles q/Esc: quit\n")
|
if m.targetConfirm != nil && !m.applying {
|
||||||
|
b.WriteString("\ny: confirm n/Esc: cancel\n")
|
||||||
|
} else {
|
||||||
|
b.WriteString("\nSpace: toggle ↑/k ↓/j: select p: manage Proxy Profiles q/Esc: quit\n")
|
||||||
|
}
|
||||||
if m.status != "" {
|
if m.status != "" {
|
||||||
fmt.Fprintf(&b, "\n%s\n", m.status)
|
fmt.Fprintf(&b, "\n%s\n", m.status)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,42 @@ func TestTargetPanelShowsOnlyAvailableTargetsAndAppliesAsynchronously(t *testing
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTargetRestartConfirmationCancelsWithoutConfirmedMutation(t *testing.T) {
|
||||||
|
initial := app.Snapshot{ActiveProfile: "office", Targets: []app.TargetSnapshot{{ID: "docker-daemon", Description: target.Description{Name: "Docker daemon"}, Available: true, State: target.Disabled}}}
|
||||||
|
module := &targetModule{snapshot: initial, result: app.Result{Snapshot: initial, Confirmation: "Docker will restart immediately and may disrupt running work."}}
|
||||||
|
model := NewModel(module)
|
||||||
|
updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
|
||||||
|
model = updated.(Model)
|
||||||
|
updated, _ = model.Update(command())
|
||||||
|
model = updated.(Model)
|
||||||
|
if !strings.Contains(model.View(), "restart immediately") || len(module.intents) != 1 || module.intents[0].Confirm {
|
||||||
|
t.Fatalf("warning = intents %#v\n%s", module.intents, model.View())
|
||||||
|
}
|
||||||
|
updated, command = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}})
|
||||||
|
model = updated.(Model)
|
||||||
|
if command != nil || len(module.intents) != 1 || strings.Contains(model.View(), "restart immediately") {
|
||||||
|
t.Fatalf("cancel mutated = intents %#v\n%s", module.intents, model.View())
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, command = model.Update(tea.KeyMsg{Type: tea.KeySpace})
|
||||||
|
model = updated.(Model)
|
||||||
|
updated, _ = model.Update(command())
|
||||||
|
model = updated.(Model)
|
||||||
|
module.result = app.Result{
|
||||||
|
Snapshot: app.Snapshot{ActiveProfile: "office", Targets: []app.TargetSnapshot{{ID: "docker-daemon", Description: target.Description{Name: "Docker daemon"}, Available: true, State: target.Enabled}}},
|
||||||
|
Notice: "Docker daemon Target is now Enabled",
|
||||||
|
}
|
||||||
|
updated, command = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'y'}})
|
||||||
|
model = updated.(Model)
|
||||||
|
if command == nil {
|
||||||
|
t.Fatal("confirmation did not start operation")
|
||||||
|
}
|
||||||
|
_ = command()
|
||||||
|
if len(module.intents) != 3 || !module.intents[2].Confirm {
|
||||||
|
t.Fatalf("confirmed intents = %#v", module.intents)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUnavailableShellIsOutsideMainListWithDetectionExplanation(t *testing.T) {
|
func TestUnavailableShellIsOutsideMainListWithDetectionExplanation(t *testing.T) {
|
||||||
module := &targetModule{snapshot: app.Snapshot{Targets: []app.TargetSnapshot{
|
module := &targetModule{snapshot: app.Snapshot{Targets: []app.TargetSnapshot{
|
||||||
{ID: "shell", Description: target.Description{Name: "Shell"}, Available: false, AvailabilityEvidence: "Shell Target requires Bash or Zsh; neither was detected"},
|
{ID: "shell", Description: target.Description{Name: "Shell"}, Available: false, AvailabilityEvidence: "Shell Target requires Bash or Zsh; neither was detected"},
|
||||||
|
|||||||
Reference in New Issue
Block a user