|
| 1 | +//go:build darwin || linux || freebsd |
| 2 | + |
| 3 | +package runner |
| 4 | + |
| 5 | +import ( |
| 6 | + "fmt" |
| 7 | + "strings" |
| 8 | + |
| 9 | + "github.com/Use-Tusk/fence/pkg/fence" |
| 10 | + "github.com/Use-Tusk/tusk-cli/internal/utils" |
| 11 | +) |
| 12 | + |
| 13 | +// isSandboxSupported reports whether the current platform can actually |
| 14 | +// isolate replay service startup (i.e. fence is available). |
| 15 | +func isSandboxSupported() bool { |
| 16 | + return fence.IsSupported() |
| 17 | +} |
| 18 | + |
| 19 | +// fenceSandbox is the Unix-platform implementation of sandboxManager, |
| 20 | +// backed by github.com/Use-Tusk/fence. |
| 21 | +type fenceSandbox struct { |
| 22 | + mgr *fence.Manager |
| 23 | +} |
| 24 | + |
| 25 | +// WrapCommand delegates to the underlying fence.Manager. |
| 26 | +func (s *fenceSandbox) WrapCommand(command string) (string, error) { |
| 27 | + return s.mgr.WrapCommand(command) |
| 28 | +} |
| 29 | + |
| 30 | +// Cleanup releases fence's socat bridges, proxies, and temp sockets. |
| 31 | +func (s *fenceSandbox) Cleanup() { |
| 32 | + if s.mgr != nil { |
| 33 | + s.mgr.Cleanup() |
| 34 | + } |
| 35 | +} |
| 36 | + |
| 37 | +// newReplaySandboxManager builds the effective fence config for replay |
| 38 | +// mode, creates the fence.Manager, applies the requested service |
| 39 | +// execution model + exposed host paths, and initializes the manager. |
| 40 | +// On any error after fence.NewManager succeeds, the manager's Cleanup is |
| 41 | +// invoked before returning so no fence-allocated resources leak. |
| 42 | +func newReplaySandboxManager(opts replaySandboxOptions) (sandboxManager, error) { |
| 43 | + fenceCfg, err := createReplayFenceConfig(opts.UserConfigPath) |
| 44 | + if err != nil { |
| 45 | + // No prefix here: service.go adds the user-facing |
| 46 | + // "failed to prepare replay sandbox config:" framing. |
| 47 | + return nil, &sandboxConfigError{err: err} |
| 48 | + } |
| 49 | + |
| 50 | + mgr := fence.NewManager(fenceCfg, opts.Debug, false) |
| 51 | + // Defensive: Cleanup is idempotent and fence's Initialize already |
| 52 | + // unwinds its own partial state on failure, but this guards against |
| 53 | + // future fence changes that add allocating steps between NewManager |
| 54 | + // and Initialize (or between error returns inside Initialize). |
| 55 | + success := false |
| 56 | + defer func() { |
| 57 | + if !success { |
| 58 | + mgr.Cleanup() |
| 59 | + } |
| 60 | + }() |
| 61 | + |
| 62 | + executionModel := fence.ServiceBindsInSandbox |
| 63 | + if opts.BindsOnHost { |
| 64 | + executionModel = fence.ServiceBindsOnHost |
| 65 | + } |
| 66 | + mgr.SetService(fence.ServiceOptions{ |
| 67 | + ExposedPorts: []int{opts.ExposedPort}, |
| 68 | + ExecutionModel: executionModel, |
| 69 | + }) |
| 70 | + |
| 71 | + for _, ehp := range opts.ExposedHostPaths { |
| 72 | + if err := mgr.ExposeHostPath(ehp.Path, ehp.Writable); err != nil { |
| 73 | + return nil, fmt.Errorf("expose host path %q to sandbox: %w", ehp.Path, err) |
| 74 | + } |
| 75 | + } |
| 76 | + |
| 77 | + if err := mgr.Initialize(); err != nil { |
| 78 | + return nil, fmt.Errorf("initialize replay sandbox: %w", err) |
| 79 | + } |
| 80 | + |
| 81 | + success = true |
| 82 | + return &fenceSandbox{mgr: mgr}, nil |
| 83 | +} |
| 84 | + |
| 85 | +// createReplayFenceConfig creates the effective fence config for replay mode. |
| 86 | +// This blocks localhost outbound connections to force the service to use SDK |
| 87 | +// mocks. |
| 88 | +// |
| 89 | +// Exposed (lowercase) for the Unix-only service_test.go tests that verify |
| 90 | +// user-config merging behavior. Not part of the package's cross-platform |
| 91 | +// surface. |
| 92 | +func createReplayFenceConfig(userConfigPath string) (*fence.Config, error) { |
| 93 | + cfg := baseReplayFenceConfig() |
| 94 | + if userConfigPath == "" { |
| 95 | + return cfg, nil |
| 96 | + } |
| 97 | + |
| 98 | + resolvedPath := utils.ResolveTuskPath(userConfigPath) |
| 99 | + userCfg, err := fence.LoadConfigResolved(resolvedPath) |
| 100 | + if err != nil { |
| 101 | + return nil, fmt.Errorf("load custom fence config %q: %w", resolvedPath, err) |
| 102 | + } |
| 103 | + if userCfg == nil { |
| 104 | + return nil, fmt.Errorf("custom fence config not found: %s", resolvedPath) |
| 105 | + } |
| 106 | + if err := validateReplayFenceConfig(userCfg); err != nil { |
| 107 | + return nil, err |
| 108 | + } |
| 109 | + |
| 110 | + merged := fence.MergeConfigs(cfg, userCfg) |
| 111 | + applyReplayFenceInvariants(merged) |
| 112 | + return merged, nil |
| 113 | +} |
| 114 | + |
| 115 | +func baseReplayFenceConfig() *fence.Config { |
| 116 | + f := false |
| 117 | + return &fence.Config{ |
| 118 | + Network: fence.NetworkConfig{ |
| 119 | + AllowedDomains: []string{ |
| 120 | + // Allow localhost for the service's own health checks |
| 121 | + "localhost", |
| 122 | + "127.0.0.1", |
| 123 | + }, |
| 124 | + AllowLocalBinding: true, // Allow service to bind to its port |
| 125 | + AllowLocalOutbound: &f, // Block outbound to localhost (Postgres, Redis, etc.) |
| 126 | + AllowAllUnixSockets: true, // Allow SDK to connect to mock server via Unix socket |
| 127 | + }, |
| 128 | + Filesystem: fence.FilesystemConfig{ |
| 129 | + AllowWrite: getAllowedWriteDirs(), |
| 130 | + }, |
| 131 | + } |
| 132 | +} |
| 133 | + |
| 134 | +func validateReplayFenceConfig(cfg *fence.Config) error { |
| 135 | + if cfg == nil { |
| 136 | + return nil |
| 137 | + } |
| 138 | + |
| 139 | + requiredDomains := []string{"localhost", "127.0.0.1"} |
| 140 | + for _, deniedDomain := range cfg.Network.DeniedDomains { |
| 141 | + for _, requiredDomain := range requiredDomains { |
| 142 | + if strings.EqualFold(deniedDomain, requiredDomain) { |
| 143 | + return fmt.Errorf("custom replay fence config cannot deny %q because replay health checks require it", requiredDomain) |
| 144 | + } |
| 145 | + } |
| 146 | + } |
| 147 | + |
| 148 | + return nil |
| 149 | +} |
| 150 | + |
| 151 | +func applyReplayFenceInvariants(cfg *fence.Config) { |
| 152 | + if cfg == nil { |
| 153 | + return |
| 154 | + } |
| 155 | + |
| 156 | + f := false |
| 157 | + cfg.Network.AllowedDomains = mergeUniqueStrings( |
| 158 | + cfg.Network.AllowedDomains, |
| 159 | + []string{"localhost", "127.0.0.1"}, |
| 160 | + ) |
| 161 | + cfg.Network.AllowLocalBinding = true |
| 162 | + cfg.Network.AllowLocalOutbound = &f |
| 163 | + cfg.Network.AllowAllUnixSockets = true |
| 164 | + cfg.Filesystem.AllowWrite = mergeUniqueStrings(cfg.Filesystem.AllowWrite, getAllowedWriteDirs()) |
| 165 | +} |
| 166 | + |
| 167 | +func mergeUniqueStrings(existing, required []string) []string { |
| 168 | + if len(required) == 0 { |
| 169 | + return existing |
| 170 | + } |
| 171 | + |
| 172 | + seen := make(map[string]struct{}, len(existing)+len(required)) |
| 173 | + merged := make([]string, 0, len(existing)+len(required)) |
| 174 | + for _, value := range existing { |
| 175 | + if _, ok := seen[value]; ok { |
| 176 | + continue |
| 177 | + } |
| 178 | + seen[value] = struct{}{} |
| 179 | + merged = append(merged, value) |
| 180 | + } |
| 181 | + for _, value := range required { |
| 182 | + if _, ok := seen[value]; ok { |
| 183 | + continue |
| 184 | + } |
| 185 | + seen[value] = struct{}{} |
| 186 | + merged = append(merged, value) |
| 187 | + } |
| 188 | + return merged |
| 189 | +} |
| 190 | + |
| 191 | +// getAllowedWriteDirs returns the default writable paths for replay mode. |
| 192 | +// We allow broad local writes by default. Note that Fence still enforces |
| 193 | +// mandatory dangerous-path protections (see |
| 194 | +// https://github.com/Use-Tusk/fence/blob/main/internal/sandbox/dangerous.go). |
| 195 | +func getAllowedWriteDirs() []string { |
| 196 | + return []string{ |
| 197 | + "/", |
| 198 | + } |
| 199 | +} |
0 commit comments