Skip to content

Commit b48ec24

Browse files
steipetecoygeek
andauthored
feat(preflight): add raw socket capability probe (#1387)
Co-authored-by: Coy Geek <coygeek@users.noreply.github.com>
1 parent 8296793 commit b48ec24

5 files changed

Lines changed: 623 additions & 50 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
### Added
66

7+
- Added an opt-in Linux/WSL2 `raw_socket` preflight probe that distinguishes direct, non-interactive-sudo, unavailable, and missing-interpreter states without sending packets or elevating workloads. Thanks @coygeek.
78
- Added checkpoint last-use tracking and composable `checkpoint prune --unused-for` cleanup for inactive local records and provider artifacts.
89
- Added provider-native create, verify, delete, and fork lifecycle for direct Hetzner project-snapshot checkpoints, including exact local-claim image deletion.
910
- Added `crabbox heartbeat` so external SSH drivers can refresh owned lease idle deadlines and optionally update the idle timeout.

docs/commands/run.md

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -401,8 +401,9 @@ or the command/script you run.
401401
By default it probes common language and infrastructure tools plus OS-specific
402402
basics. Default generic probes are `git`, `tar`, `node`, `npm`, `corepack`,
403403
`pnpm`, `yarn`, `bun`, and `docker`; `uv`, `python`, and `python3` are available
404-
as additional opt-in built-ins. POSIX/Linux/WSL probes also include `sudo`,
405-
`apt`, and `bubblewrap`; native Windows probes include `powershell`,
404+
as additional opt-in built-ins. Linux and WSL2 also support the opt-in
405+
`raw_socket` capability probe. POSIX/Linux/WSL probes include `sudo`, `apt`,
406+
and `bubblewrap`; native Windows probes include `powershell`,
406407
`execution_policy`, `longpaths`, `temp`, and `pwsh`.
407408

408409
Use `--preflight-tools` to replace the default tool list for one run:
@@ -411,6 +412,7 @@ Use `--preflight-tools` to replace the default tool list for one run:
411412
crabbox run --preflight --preflight-tools node,bun,docker -- bun test
412413
crabbox run --preflight --preflight-tools default,uv -- node --test
413414
crabbox run --preflight --preflight-tools python,python3 -- python3 -m pytest
415+
crabbox run --preflight --preflight-tools raw_socket -- ./packet-tests
414416
crabbox run --preflight --preflight-tools none -- ./smoke.sh
415417
```
416418

@@ -422,13 +424,21 @@ including `python` and `python3` on native Windows; Crabbox does not map either
422424
name to `py`. An unavailable literal command prints `<name>=missing` and the run
423425
continues.
424426

427+
`raw_socket` uses `python3`, then `python`, to open and immediately close
428+
`socket(AF_INET, SOCK_RAW, IPPROTO_RAW)` without binding, connecting, sending,
429+
or receiving. Its stable result is `direct`, `sudo`, `unavailable`, or
430+
`probe_missing`. `sudo` means only that the same bounded probe succeeded through
431+
non-interactive `sudo -n`; Crabbox never elevates the workload, grants
432+
capabilities, installs software, or changes an image or container. The probe is
433+
separate from Python, Scapy, tcpdump, libpcap, and packet-capture availability.
434+
Unsupported targets skip it through normal preflight target filtering.
435+
425436
Configure the default per repo:
426437

427438
```yaml
428439
run:
429440
preflightTools:
430-
- python
431-
- python3
441+
- raw_socket
432442
```
433443
434444
## Profiles, presets, and proof

docs/observability.md

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -300,20 +300,29 @@ script/command itself.
300300
The built-in probes cover common toolchains — `git`, `tar`, `node`, `npm`,
301301
`corepack`, `pnpm`, `yarn`, `bun`, `docker`, and opt-in `uv`, `python`, and
302302
`python3` — plus target-specific probes such as `sudo`, `apt`, `bubblewrap`,
303-
`powershell`, `execution_policy`, `longpaths`, `temp`, and `pwsh`. Override the
304-
probe list per run:
303+
`powershell`, `execution_policy`, `longpaths`, `temp`, and `pwsh`. Linux and
304+
WSL2 also support an opt-in `raw_socket` capability probe. Override the probe
305+
list per run:
305306

306307
```sh
307308
crabbox run --preflight --preflight-tools python,python3 -- python3 -m pytest
309+
crabbox run --preflight --preflight-tools raw_socket -- ./packet-tests
308310
```
309311

312+
`raw_socket` reports `direct` when the execution user can open and immediately
313+
close `socket(AF_INET, SOCK_RAW, IPPROTO_RAW)`, `sudo` when only the same
314+
bounded probe succeeds through `sudo -n`, `unavailable` otherwise, and
315+
`probe_missing` when neither `python3` nor `python` exists. It never sends a
316+
packet or elevates the workload. This kernel capability is distinct from
317+
Python, Scapy, tcpdump, libpcap, or packet-capture-tool availability, and
318+
unsupported targets skip the probe.
319+
310320
Or per repository:
311321

312322
```yaml
313323
run:
314324
preflightTools:
315-
- python
316-
- python3
325+
- raw_socket
317326
```
318327
319328
## Actions hydration

internal/cli/run_observability.go

Lines changed: 201 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -165,27 +165,42 @@ func printRemoteCapabilityPreflight(ctx context.Context, w io.Writer, cfg Config
165165
if len(tools) == 0 {
166166
return
167167
}
168-
var out string
169-
var err error
170-
if isWindowsNativeTarget(target) {
171-
out, err = runWindowsRemoteCapabilityPreflight(ctx, target, workdir, env, envFiles, tools)
172-
} else if isWindowsWSL2Target(target) {
173-
out, err = runWSL2RemoteCapabilityPreflight(ctx, target, workdir, env, envFiles, tools)
174-
} else {
175-
out, err = runSSHCombinedOutput(ctx, target, remoteCapabilityPreflightCommand(workdir, env, envFiles, tools))
176-
}
177-
if err != nil {
178-
fmt.Fprintf(w, "remote preflight failed: %v\n", err)
179-
if strings.TrimSpace(out) != "" {
180-
fmt.Fprintf(w, "remote preflight output: %s\n", strings.TrimSpace(out))
168+
baseTools := make([]string, 0, len(tools))
169+
rawSocketRequested := false
170+
for _, tool := range tools {
171+
if tool == rawSocketPreflightTool {
172+
rawSocketRequested = true
173+
continue
181174
}
182-
return
183-
}
184-
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
185-
if strings.TrimSpace(line) != "" {
186-
fmt.Fprintf(w, "remote preflight %s\n", strings.TrimSpace(line))
175+
baseTools = append(baseTools, tool)
176+
}
177+
if len(baseTools) > 0 {
178+
var out string
179+
var err error
180+
if isWindowsNativeTarget(target) {
181+
out, err = runWindowsRemoteCapabilityPreflight(ctx, target, workdir, env, envFiles, baseTools)
182+
} else if isWindowsWSL2Target(target) {
183+
out, err = runWSL2RemoteCapabilityPreflight(ctx, target, workdir, env, envFiles, baseTools)
184+
} else {
185+
out, err = runSSHCombinedOutput(ctx, target, remoteCapabilityPreflightCommand(workdir, env, envFiles, baseTools))
186+
}
187+
if err != nil {
188+
fmt.Fprintf(w, "remote preflight failed: %v\n", err)
189+
if strings.TrimSpace(out) != "" {
190+
fmt.Fprintf(w, "remote preflight output: %s\n", strings.TrimSpace(out))
191+
}
192+
} else {
193+
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
194+
if strings.TrimSpace(line) != "" {
195+
fmt.Fprintf(w, "remote preflight %s\n", strings.TrimSpace(line))
196+
}
197+
}
187198
}
188199
}
200+
if rawSocketRequested {
201+
state := runRawSocketCapabilityPreflight(ctx, target, workdir, env, envFiles)
202+
fmt.Fprintf(w, "remote preflight %s=%s\n", rawSocketPreflightTool, state)
203+
}
189204
}
190205

191206
func printDelegatedPreflightUnsupported(w io.Writer, provider string) {
@@ -501,31 +516,175 @@ type preflightToolSpec struct {
501516
OS map[string]bool
502517
}
503518

519+
const (
520+
rawSocketPreflightTool = "raw_socket"
521+
rawSocketPreflightTimeout = 30 * time.Second
522+
rawSocketProbePrefix = "__crabbox_raw_socket_v1__:"
523+
rawSocketProbeDirect = rawSocketProbePrefix + "direct"
524+
rawSocketProbeSudo = rawSocketProbePrefix + "sudo"
525+
rawSocketProbeUnavailable = rawSocketProbePrefix + "unavailable"
526+
rawSocketProbeMissing = rawSocketProbePrefix + "probe_missing"
527+
)
528+
529+
const rawSocketPythonProbe = `import sys
530+
sys.path = [entry for entry in sys.path if entry not in ("", ".")]
531+
import errno
532+
import socket
533+
try:
534+
probe = socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_RAW)
535+
probe.close()
536+
except socket.error as exc:
537+
if getattr(exc, "errno", None) in (errno.EPERM, errno.EACCES):
538+
sys.exit(77)
539+
sys.exit(78)
540+
except BaseException:
541+
sys.exit(78)`
542+
504543
var preflightToolRegistry = map[string]preflightToolSpec{
505-
"apt": {Posix: []string{"apt-get", "--version"}, OS: map[string]bool{"linux": true}},
506-
"bubblewrap": {Posix: []string{"bwrap", "--version"}, OS: map[string]bool{"linux": true}},
507-
"bun": {Posix: []string{"bun", "--version"}, Windows: []string{"bun", "--version"}},
508-
"bwrap": {Posix: []string{"bwrap", "--version"}, OS: map[string]bool{"linux": true}},
509-
"cargo": {Posix: []string{"cargo", "--version"}, Windows: []string{"cargo", "--version"}},
510-
"corepack": {Posix: []string{"corepack", "--version"}, Windows: []string{"corepack", "--version"}},
511-
"docker": {Posix: []string{"docker", "--version"}, Windows: []string{"docker", "--version"}},
512-
"execution_policy": {Windows: []string{"Get-ExecutionPolicy -Scope Process"}, OS: map[string]bool{"windows": true}},
513-
"git": {Posix: []string{"git", "--version"}, Windows: []string{"git", "--version"}},
514-
"go": {Posix: []string{"go", "version"}, Windows: []string{"go", "version"}},
515-
"longpaths": {Windows: []string{"git config --global --get core.longpaths"}, OS: map[string]bool{"windows": true}},
516-
"make": {Posix: []string{"make", "--version"}},
517-
"node": {Posix: []string{"node", "--version"}, Windows: []string{"node", "--version"}},
518-
"npm": {Posix: []string{"npm", "--version"}, Windows: []string{"npm", "--version"}},
519-
"pnpm": {Posix: []string{"pnpm", "--version"}, Windows: []string{"pnpm", "--version"}},
520-
"powershell": {Windows: []string{"$PSVersionTable.PSVersion.ToString()"}, OS: map[string]bool{"windows": true}},
521-
"python": {Posix: []string{"python", "--version"}, Windows: []string{"python", "--version"}},
522-
"python3": {Posix: []string{"python3", "--version"}, Windows: []string{"python3", "--version"}},
523-
"pwsh": {Windows: []string{"pwsh", "--version"}, OS: map[string]bool{"windows": true}},
524-
"sudo": {OS: map[string]bool{"linux": true, "macos": true}},
525-
"tar": {Posix: []string{"tar", "--version"}, Windows: []string{"tar", "--version"}},
526-
"temp": {Windows: []string{"$env:TEMP"}, OS: map[string]bool{"windows": true}},
527-
"uv": {Posix: []string{"uv", "--version"}, Windows: []string{"uv", "--version"}},
528-
"yarn": {Posix: []string{"yarn", "--version"}, Windows: []string{"yarn", "--version"}},
544+
"apt": {Posix: []string{"apt-get", "--version"}, OS: map[string]bool{"linux": true}},
545+
"bubblewrap": {Posix: []string{"bwrap", "--version"}, OS: map[string]bool{"linux": true}},
546+
"bun": {Posix: []string{"bun", "--version"}, Windows: []string{"bun", "--version"}},
547+
"bwrap": {Posix: []string{"bwrap", "--version"}, OS: map[string]bool{"linux": true}},
548+
"cargo": {Posix: []string{"cargo", "--version"}, Windows: []string{"cargo", "--version"}},
549+
"corepack": {Posix: []string{"corepack", "--version"}, Windows: []string{"corepack", "--version"}},
550+
"docker": {Posix: []string{"docker", "--version"}, Windows: []string{"docker", "--version"}},
551+
"execution_policy": {Windows: []string{"Get-ExecutionPolicy -Scope Process"}, OS: map[string]bool{"windows": true}},
552+
"git": {Posix: []string{"git", "--version"}, Windows: []string{"git", "--version"}},
553+
"go": {Posix: []string{"go", "version"}, Windows: []string{"go", "version"}},
554+
"longpaths": {Windows: []string{"git config --global --get core.longpaths"}, OS: map[string]bool{"windows": true}},
555+
"make": {Posix: []string{"make", "--version"}},
556+
"node": {Posix: []string{"node", "--version"}, Windows: []string{"node", "--version"}},
557+
"npm": {Posix: []string{"npm", "--version"}, Windows: []string{"npm", "--version"}},
558+
"pnpm": {Posix: []string{"pnpm", "--version"}, Windows: []string{"pnpm", "--version"}},
559+
"powershell": {Windows: []string{"$PSVersionTable.PSVersion.ToString()"}, OS: map[string]bool{"windows": true}},
560+
"python": {Posix: []string{"python", "--version"}, Windows: []string{"python", "--version"}},
561+
"python3": {Posix: []string{"python3", "--version"}, Windows: []string{"python3", "--version"}},
562+
"pwsh": {Windows: []string{"pwsh", "--version"}, OS: map[string]bool{"windows": true}},
563+
rawSocketPreflightTool: {OS: map[string]bool{"linux": true}},
564+
"sudo": {OS: map[string]bool{"linux": true, "macos": true}},
565+
"tar": {Posix: []string{"tar", "--version"}, Windows: []string{"tar", "--version"}},
566+
"temp": {Windows: []string{"$env:TEMP"}, OS: map[string]bool{"windows": true}},
567+
"uv": {Posix: []string{"uv", "--version"}, Windows: []string{"uv", "--version"}},
568+
"yarn": {Posix: []string{"yarn", "--version"}, Windows: []string{"yarn", "--version"}},
569+
}
570+
571+
const rawSocketSudoPATH = "/usr/local/bin:/usr/bin:/bin:/run/current-system/sw/bin:/nix/var/nix/profiles/default/bin:/run/current-system/profile/bin"
572+
573+
func rawSocketPreflightScript() string {
574+
return rawSocketPreflightScriptWithSudoEnvironment([]string{
575+
"/usr/bin/sudo",
576+
"/bin/sudo",
577+
"/usr/local/bin/sudo",
578+
"/run/wrappers/bin/sudo",
579+
"/run/setuid-programs/sudo",
580+
}, rawSocketSudoPATH)
581+
}
582+
583+
func rawSocketPreflightScriptWithSudoEnvironment(sudoExecutables []string, sudoPath string) string {
584+
probe := shellQuote(rawSocketPythonProbe)
585+
sudoCandidates := make([]string, 0, len(sudoExecutables))
586+
for _, sudo := range sudoExecutables {
587+
sudoCandidates = append(sudoCandidates, shellQuote(sudo))
588+
}
589+
if len(sudoCandidates) == 0 {
590+
sudoCandidates = append(sudoCandidates, "__crabbox_no_trusted_sudo__")
591+
}
592+
sudoProbe := shellQuote(`PATH=` + shellQuote(sudoPath) + `
593+
export PATH
594+
case "$1" in
595+
python3|python) ;;
596+
*) exit 79 ;;
597+
esac
598+
command -v "$1" >/dev/null 2>&1 || exit 79
599+
exec "$1" -B -E -S -c "$2"`)
600+
return `found_interpreter=0
601+
for interpreter_name in python3 python; do
602+
interpreter="$(command -v "$interpreter_name" 2>/dev/null || true)"
603+
if [ -z "$interpreter" ] || [ ! -x "$interpreter" ]; then
604+
continue
605+
fi
606+
found_interpreter=1
607+
direct_status=0
608+
"$interpreter" -B -E -S -c ` + probe + ` >/dev/null 2>&1 || direct_status=$?
609+
if [ "$direct_status" -eq 0 ]; then
610+
printf '` + rawSocketProbeDirect + `\n'
611+
exit 0
612+
fi
613+
if [ "$direct_status" -ne 77 ]; then
614+
continue
615+
fi
616+
# Resolve the same interpreter name only inside a fixed root-side PATH, never the workload PATH.
617+
for sudo_executable in ` + strings.Join(sudoCandidates, " ") + `; do
618+
if [ ! -x "$sudo_executable" ]; then
619+
continue
620+
fi
621+
if "$sudo_executable" -n -- /bin/sh -c ` + sudoProbe + ` crabbox-raw-socket "$interpreter_name" ` + probe + ` >/dev/null 2>&1; then
622+
printf '` + rawSocketProbeSudo + `\n'
623+
exit 0
624+
fi
625+
done
626+
done
627+
if [ "$found_interpreter" -eq 0 ]; then
628+
printf '` + rawSocketProbeMissing + `\n'
629+
else
630+
printf '` + rawSocketProbeUnavailable + `\n'
631+
fi
632+
`
633+
}
634+
635+
func runRawSocketCapabilityPreflight(ctx context.Context, target SSHTarget, workdir string, env map[string]string, envFiles []string) string {
636+
command := rawSocketCapabilityPreflightCommand(workdir, env, envFiles)
637+
return runRawSocketCapabilityPreflightWithRunner(ctx, rawSocketPreflightTimeout, func(probeCtx context.Context) (string, error) {
638+
if isWindowsWSL2Target(target) {
639+
return runWSL2ControlCombinedOutput(probeCtx, target, command)
640+
}
641+
return runSSHCombinedOutput(probeCtx, target, command)
642+
})
643+
}
644+
645+
func rawSocketCapabilityPreflightCommand(workdir string, env map[string]string, envFiles []string) string {
646+
return remoteShellCommandWithEnvFiles(workdir, env, envFiles, rawSocketPreflightScript())
647+
}
648+
649+
func runRawSocketCapabilityPreflightWithRunner(ctx context.Context, timeout time.Duration, runner func(context.Context) (string, error)) string {
650+
probeCtx, cancel := context.WithTimeout(ctx, timeout)
651+
defer cancel()
652+
out, err := runner(probeCtx)
653+
if err != nil || probeCtx.Err() != nil {
654+
return "unavailable"
655+
}
656+
return parseRawSocketProbeOutput(out)
657+
}
658+
659+
func parseRawSocketProbeOutput(out string) string {
660+
state := ""
661+
for _, line := range strings.Split(out, "\n") {
662+
line = strings.TrimSpace(line)
663+
if !strings.HasPrefix(line, rawSocketProbePrefix) {
664+
continue
665+
}
666+
var next string
667+
switch line {
668+
case rawSocketProbeDirect:
669+
next = "direct"
670+
case rawSocketProbeSudo:
671+
next = "sudo"
672+
case rawSocketProbeMissing:
673+
next = "probe_missing"
674+
case rawSocketProbeUnavailable:
675+
next = "unavailable"
676+
default:
677+
return "unavailable"
678+
}
679+
if state != "" {
680+
return "unavailable"
681+
}
682+
state = next
683+
}
684+
if state == "" {
685+
return "unavailable"
686+
}
687+
return state
529688
}
530689

531690
var defaultPreflightToolNames = []string{"git", "tar", "node", "npm", "corepack", "pnpm", "yarn", "bun", "docker", "sudo", "apt", "bubblewrap", "powershell", "execution_policy", "longpaths", "temp", "pwsh"}

0 commit comments

Comments
 (0)