refactor(sshstdio): delete the superseded direct-dial and remote-launch stack
BuildCommand/BuildCommandForMode/BuildCommandForObservation/ BuildCommandForRemoteLaunch(WithRoot)/BuildCommandForRemoteCleanup, isolatedLaunchScript/quotedWords/RunRemoteCommand, and the whole Dial/DialContext(WithLaunch|WithIsolatedLaunch|WithRuntimeObserver)/ dialContext(WithLaunch) family are all unreachable from production: the daemonmux SSH carriage (mux.go's BuildCommandForMux/DialMuxContext) replaced them. Dial/DialContext/DialContextWithLaunch were already dead on main; they are removed here too rather than left as orphaned callers of the functions this change deletes. BuildCommandForRemoteCommand and shellQuote stay: BuildCommandForMux still calls BuildCommandForRemoteCommand in production, so its own test (TestBuildCommandForRemoteCommandQuotesEveryWord) is kept. launch_test.go tested only the deleted isolated-launch cluster and is removed entirely.
- commit
- e312f754cde9f2b64fa47c92c43831c395fbedaa
- author
- brice · · 2026-09-23 01:55 UTC
- parent
- e374227
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +0 -467
D internal/adapters/sshstdio/launch_test.go +0 -98
| @@ -1,98 +0,0 @@ | |||
| 1 | - | package sshstdio | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "os" | |
| 5 | - | "os/exec" | |
| 6 | - | "path/filepath" | |
| 7 | - | "testing" | |
| 8 | - | ||
| 9 | - | "github.com/stretchr/testify/require" | |
| 10 | - | ) | |
| 11 | - | ||
| 12 | - | func lookupCommand(t *testing.T, name string) string { | |
| 13 | - | t.Helper() | |
| 14 | - | path, err := exec.LookPath(name) | |
| 15 | - | require.NoError(t, err) | |
| 16 | - | path, err = filepath.Abs(path) | |
| 17 | - | require.NoError(t, err) | |
| 18 | - | return path | |
| 19 | - | } | |
| 20 | - | ||
| 21 | - | func TestBuildCommandForRemoteLaunchQuotesBinaryAndEnvironment(t *testing.T) { | |
| 22 | - | spec := BuildCommandForRemoteLaunch("test@example.com", "/opt/vev with-space", []string{"HOME=/tmp/home", "VALUE=a;b'c"}, "_stdio") | |
| 23 | - | require.Equal(t, "ssh", spec.Path) | |
| 24 | - | require.Equal(t, []string{ | |
| 25 | - | "--", | |
| 26 | - | "test@example.com", | |
| 27 | - | "'env' '-i' 'HOME=/tmp/home' 'VALUE=a;b'\\''c' '/opt/vev with-space' '_stdio'", | |
| 28 | - | }, spec.Args) | |
| 29 | - | } | |
| 30 | - | ||
| 31 | - | func TestBuildCommandForIsolatedRemoteLaunchQuotesRootAndOwner(t *testing.T) { | |
| 32 | - | spec := BuildCommandForRemoteLaunchWithRoot("test@example.com", "/tmp/root with-space", "owner;token", "/opt/vev with-space", []string{"HOME=/tmp/home", "VALUE=a;b'c"}, "_stdio") | |
| 33 | - | require.Equal(t, "ssh", spec.Path) | |
| 34 | - | require.Equal(t, "test@example.com", spec.Args[1]) | |
| 35 | - | require.Contains(t, spec.Args[2], "'sh' '-c'") | |
| 36 | - | require.Contains(t, spec.Args[2], "'/tmp/root with-space'") | |
| 37 | - | require.Contains(t, spec.Args[2], "'owner;token'") | |
| 38 | - | require.NotContains(t, spec.Args[2], "owner;token' &&") | |
| 39 | - | require.Contains(t, spec.Args[2], "VALUE=a;b") | |
| 40 | - | } | |
| 41 | - | ||
| 42 | - | func TestBuildCommandForRemoteCleanupVerifiesOwnerBeforeRemoval(t *testing.T) { | |
| 43 | - | spec := BuildCommandForRemoteCleanup("test@example.com", "/tmp/root", "owner", "/opt/vev", []string{"HOME=/tmp/home"}, "_ui-cleanup") | |
| 44 | - | require.Contains(t, spec.Args[2], "/tmp/root/.vev-ui-driver-owner") | |
| 45 | - | require.Contains(t, spec.Args[2], "'_ui-cleanup'") | |
| 46 | - | require.Contains(t, spec.Args[2], "rm -rf \"$root\"") | |
| 47 | - | } | |
| 48 | - | ||
| 49 | - | func TestIsolatedLaunchScriptRemovesRootWhenCleanupCommandFails(t *testing.T) { | |
| 50 | - | truePath := lookupCommand(t, "true") | |
| 51 | - | falsePath := lookupCommand(t, "false") | |
| 52 | - | tests := []struct { | |
| 53 | - | name string | |
| 54 | - | mode string | |
| 55 | - | cleanup bool | |
| 56 | - | wantExists bool | |
| 57 | - | }{ | |
| 58 | - | {name: "launch", mode: "_stdio", wantExists: true}, | |
| 59 | - | {name: "cleanup", mode: "_ui-cleanup", cleanup: true, wantExists: false}, | |
| 60 | - | } | |
| 61 | - | for _, test := range tests { | |
| 62 | - | t.Run(test.name, func(t *testing.T) { | |
| 63 | - | root := filepath.Join(t.TempDir(), "root") | |
| 64 | - | if test.cleanup { | |
| 65 | - | require.NoError(t, exec.Command("sh", "-c", isolatedLaunchScript(root, "owner-token", truePath, nil, "_stdio", false)).Run()) | |
| 66 | - | } | |
| 67 | - | err := exec.Command("sh", "-c", isolatedLaunchScript(root, "owner-token", falsePath, nil, test.mode, test.cleanup)).Run() | |
| 68 | - | var exitErr *exec.ExitError | |
| 69 | - | require.ErrorAs(t, err, &exitErr) | |
| 70 | - | require.Equal(t, 1, exitErr.ExitCode()) | |
| 71 | - | _, statErr := os.Stat(root) | |
| 72 | - | if test.wantExists { | |
| 73 | - | require.NoError(t, statErr) | |
| 74 | - | } else { | |
| 75 | - | require.ErrorIs(t, statErr, os.ErrNotExist) | |
| 76 | - | } | |
| 77 | - | }) | |
| 78 | - | } | |
| 79 | - | } | |
| 80 | - | ||
| 81 | - | func TestIsolatedLaunchScriptOwnsAndCleansFreshRoot(t *testing.T) { | |
| 82 | - | truePath := lookupCommand(t, "true") | |
| 83 | - | root := filepath.Join(t.TempDir(), "root") | |
| 84 | - | launch := isolatedLaunchScript(root, "owner-token", truePath, nil, "_stdio", false) | |
| 85 | - | require.NoError(t, exec.Command("sh", "-c", launch).Run()) | |
| 86 | - | marker := filepath.Join(root, ".vev-ui-driver-owner") | |
| 87 | - | data, err := os.ReadFile(marker) | |
| 88 | - | require.NoError(t, err) | |
| 89 | - | require.Equal(t, []byte("owner-token"), data) | |
| 90 | - | ||
| 91 | - | // The same owner can reconnect to the endpoint, but another token cannot | |
| 92 | - | // reuse the root created by this invocation. | |
| 93 | - | require.NoError(t, exec.Command("sh", "-c", isolatedLaunchScript(root, "owner-token", truePath, nil, "_stdio", false)).Run()) | |
| 94 | - | require.Error(t, exec.Command("sh", "-c", isolatedLaunchScript(root, "other-owner", truePath, nil, "_stdio", false)).Run()) | |
| 95 | - | require.NoError(t, exec.Command("sh", "-c", isolatedLaunchScript(root, "owner-token", truePath, nil, "_ui-cleanup", true)).Run()) | |
| 96 | - | _, err = os.Stat(root) | |
| 97 | - | require.ErrorIs(t, err, os.ErrNotExist) | |
| 98 | - | } | |
| @@ -1,98 +0,0 @@ | |||||
| 1 | - | package sshstdio | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "os" | |||
| 5 | - | "os/exec" | |||
| 6 | - | "path/filepath" | |||
| 7 | - | "testing" | |||
| 8 | - | ||||
| 9 | - | "github.com/stretchr/testify/require" | |||
| 10 | - | ) | |||
| 11 | - | ||||
| 12 | - | func lookupCommand(t *testing.T, name string) string { | |||
| 13 | - | t.Helper() | |||
| 14 | - | path, err := exec.LookPath(name) | |||
| 15 | - | require.NoError(t, err) | |||
| 16 | - | path, err = filepath.Abs(path) | |||
| 17 | - | require.NoError(t, err) | |||
| 18 | - | return path | |||
| 19 | - | } | |||
| 20 | - | ||||
| 21 | - | func TestBuildCommandForRemoteLaunchQuotesBinaryAndEnvironment(t *testing.T) { | |||
| 22 | - | spec := BuildCommandForRemoteLaunch("test@example.com", "/opt/vev with-space", []string{"HOME=/tmp/home", "VALUE=a;b'c"}, "_stdio") | |||
| 23 | - | require.Equal(t, "ssh", spec.Path) | |||
| 24 | - | require.Equal(t, []string{ | |||
| 25 | - | "--", | |||
| 26 | - | "test@example.com", | |||
| 27 | - | "'env' '-i' 'HOME=/tmp/home' 'VALUE=a;b'\\''c' '/opt/vev with-space' '_stdio'", | |||
| 28 | - | }, spec.Args) | |||
| 29 | - | } | |||
| 30 | - | ||||
| 31 | - | func TestBuildCommandForIsolatedRemoteLaunchQuotesRootAndOwner(t *testing.T) { | |||
| 32 | - | spec := BuildCommandForRemoteLaunchWithRoot("test@example.com", "/tmp/root with-space", "owner;token", "/opt/vev with-space", []string{"HOME=/tmp/home", "VALUE=a;b'c"}, "_stdio") | |||
| 33 | - | require.Equal(t, "ssh", spec.Path) | |||
| 34 | - | require.Equal(t, "test@example.com", spec.Args[1]) | |||
| 35 | - | require.Contains(t, spec.Args[2], "'sh' '-c'") | |||
| 36 | - | require.Contains(t, spec.Args[2], "'/tmp/root with-space'") | |||
| 37 | - | require.Contains(t, spec.Args[2], "'owner;token'") | |||
| 38 | - | require.NotContains(t, spec.Args[2], "owner;token' &&") | |||
| 39 | - | require.Contains(t, spec.Args[2], "VALUE=a;b") | |||
| 40 | - | } | |||
| 41 | - | ||||
| 42 | - | func TestBuildCommandForRemoteCleanupVerifiesOwnerBeforeRemoval(t *testing.T) { | |||
| 43 | - | spec := BuildCommandForRemoteCleanup("test@example.com", "/tmp/root", "owner", "/opt/vev", []string{"HOME=/tmp/home"}, "_ui-cleanup") | |||
| 44 | - | require.Contains(t, spec.Args[2], "/tmp/root/.vev-ui-driver-owner") | |||
| 45 | - | require.Contains(t, spec.Args[2], "'_ui-cleanup'") | |||
| 46 | - | require.Contains(t, spec.Args[2], "rm -rf \"$root\"") | |||
| 47 | - | } | |||
| 48 | - | ||||
| 49 | - | func TestIsolatedLaunchScriptRemovesRootWhenCleanupCommandFails(t *testing.T) { | |||
| 50 | - | truePath := lookupCommand(t, "true") | |||
| 51 | - | falsePath := lookupCommand(t, "false") | |||
| 52 | - | tests := []struct { | |||
| 53 | - | name string | |||
| 54 | - | mode string | |||
| 55 | - | cleanup bool | |||
| 56 | - | wantExists bool | |||
| 57 | - | }{ | |||
| 58 | - | {name: "launch", mode: "_stdio", wantExists: true}, | |||
| 59 | - | {name: "cleanup", mode: "_ui-cleanup", cleanup: true, wantExists: false}, | |||
| 60 | - | } | |||
| 61 | - | for _, test := range tests { | |||
| 62 | - | t.Run(test.name, func(t *testing.T) { | |||
| 63 | - | root := filepath.Join(t.TempDir(), "root") | |||
| 64 | - | if test.cleanup { | |||
| 65 | - | require.NoError(t, exec.Command("sh", "-c", isolatedLaunchScript(root, "owner-token", truePath, nil, "_stdio", false)).Run()) | |||
| 66 | - | } | |||
| 67 | - | err := exec.Command("sh", "-c", isolatedLaunchScript(root, "owner-token", falsePath, nil, test.mode, test.cleanup)).Run() | |||
| 68 | - | var exitErr *exec.ExitError | |||
| 69 | - | require.ErrorAs(t, err, &exitErr) | |||
| 70 | - | require.Equal(t, 1, exitErr.ExitCode()) | |||
| 71 | - | _, statErr := os.Stat(root) | |||
| 72 | - | if test.wantExists { | |||
| 73 | - | require.NoError(t, statErr) | |||
| 74 | - | } else { | |||
| 75 | - | require.ErrorIs(t, statErr, os.ErrNotExist) | |||
| 76 | - | } | |||
| 77 | - | }) | |||
| 78 | - | } | |||
| 79 | - | } | |||
| 80 | - | ||||
| 81 | - | func TestIsolatedLaunchScriptOwnsAndCleansFreshRoot(t *testing.T) { | |||
| 82 | - | truePath := lookupCommand(t, "true") | |||
| 83 | - | root := filepath.Join(t.TempDir(), "root") | |||
| 84 | - | launch := isolatedLaunchScript(root, "owner-token", truePath, nil, "_stdio", false) | |||
| 85 | - | require.NoError(t, exec.Command("sh", "-c", launch).Run()) | |||
| 86 | - | marker := filepath.Join(root, ".vev-ui-driver-owner") | |||
| 87 | - | data, err := os.ReadFile(marker) | |||
| 88 | - | require.NoError(t, err) | |||
| 89 | - | require.Equal(t, []byte("owner-token"), data) | |||
| 90 | - | ||||
| 91 | - | // The same owner can reconnect to the endpoint, but another token cannot | |||
| 92 | - | // reuse the root created by this invocation. | |||
| 93 | - | require.NoError(t, exec.Command("sh", "-c", isolatedLaunchScript(root, "owner-token", truePath, nil, "_stdio", false)).Run()) | |||
| 94 | - | require.Error(t, exec.Command("sh", "-c", isolatedLaunchScript(root, "other-owner", truePath, nil, "_stdio", false)).Run()) | |||
| 95 | - | require.NoError(t, exec.Command("sh", "-c", isolatedLaunchScript(root, "owner-token", truePath, nil, "_ui-cleanup", true)).Run()) | |||
| 96 | - | _, err = os.Stat(root) | |||
| 97 | - | require.ErrorIs(t, err, os.ErrNotExist) | |||
| 98 | - | } | |||
M internal/adapters/sshstdio/transport.go +0 -227
| @@ -5,8 +5,6 @@ | |||
| 5 | 5 | package sshstdio | |
| 6 | 6 | ||
| 7 | 7 | import ( | |
| 8 | - | "bytes" | |
| 9 | - | "context" | |
| 10 | 8 | "errors" | |
| 11 | 9 | "fmt" | |
| 12 | 10 | "io" | |
| @@ -529,19 +527,6 @@ type CommandSpec struct { | |||
| 529 | 527 | Args []string | |
| 530 | 528 | } | |
| 531 | 529 | ||
| 532 | - | // BuildCommand constructs the local ssh subprocess argv without invoking a | |
| 533 | - | // local shell. OpenSSH sends the remote command as one string for the remote | |
| 534 | - | // user's shell to interpret, so every remote argv word is POSIX single-quoted. | |
| 535 | - | func BuildCommand(target, _ string) CommandSpec { | |
| 536 | - | return BuildCommandForMode(target, "_stdio", "") | |
| 537 | - | } | |
| 538 | - | ||
| 539 | - | // BuildCommandForMode constructs the local ssh subprocess argv for a hidden vev | |
| 540 | - | // remote mode such as _stdio or _quic-bootstrap. | |
| 541 | - | func BuildCommandForMode(target, mode, _ string) CommandSpec { | |
| 542 | - | return BuildCommandForRemoteCommand(target, "vev", mode) | |
| 543 | - | } | |
| 544 | - | ||
| 545 | 530 | // BuildCommandForRemoteCommand constructs ssh argv for an arbitrary remote | |
| 546 | 531 | // command. Every remote word is POSIX single-quoted; the target remains one | |
| 547 | 532 | // local argv word after the option terminator. | |
| @@ -554,218 +539,6 @@ func BuildCommandForRemoteCommand(target string, command ...string) CommandSpec | |||
| 554 | 539 | return CommandSpec{Path: "ssh", Args: args} | |
| 555 | 540 | } | |
| 556 | 541 | ||
| 557 | - | // BuildCommandForObservation constructs unattended ssh argv for remote | |
| 558 | - | // observation (catalogue checks). Authentication, proxying, and host-key trust | |
| 559 | - | // remain owned by the target's effective OpenSSH configuration so managed | |
| 560 | - | // transports such as NetBird keep their coherent policy. Vev only disables | |
| 561 | - | // TTY allocation and advertised host-key updates, and bounds connection time | |
| 562 | - | // and attempts. The caller leaves stdin detached and owns a whole-command | |
| 563 | - | // deadline, so background observation cannot wait indefinitely for input. | |
| 564 | - | func BuildCommandForObservation(target string, connectTimeout time.Duration, command ...string) CommandSpec { | |
| 565 | - | secs := int(connectTimeout / time.Second) | |
| 566 | - | if secs < 1 { | |
| 567 | - | secs = 1 | |
| 568 | - | } | |
| 569 | - | spec := BuildCommandForRemoteCommand(target, command...) | |
| 570 | - | opts := []string{ | |
| 571 | - | "-T", | |
| 572 | - | "-o", "UpdateHostKeys=no", | |
| 573 | - | "-o", fmt.Sprintf("ConnectTimeout=%d", secs), | |
| 574 | - | "-o", "ConnectionAttempts=1", | |
| 575 | - | } | |
| 576 | - | return CommandSpec{Path: spec.Path, Args: append(opts, spec.Args...)} | |
| 577 | - | } | |
| 578 | - | ||
| 579 | - | // BuildCommandForRemoteLaunch prefixes an explicit complete environment and | |
| 580 | - | // absolute executable to a hidden remote mode. Each value remains one quoted | |
| 581 | - | // remote-shell word; no caller input is concatenated into an unquoted command. | |
| 582 | - | func BuildCommandForRemoteLaunch(target, executable string, environment []string, mode string) CommandSpec { | |
| 583 | - | command := make([]string, 0, len(environment)+4) | |
| 584 | - | command = append(command, "env", "-i") | |
| 585 | - | command = append(command, environment...) | |
| 586 | - | command = append(command, executable, mode) | |
| 587 | - | return BuildCommandForRemoteCommand(target, command...) | |
| 588 | - | } | |
| 589 | - | ||
| 590 | - | const isolatedLaunchMarker = ".vev-ui-driver-owner" | |
| 591 | - | ||
| 592 | - | // BuildCommandForRemoteLaunchWithRoot creates a fresh private endpoint root on | |
| 593 | - | // the remote host before starting the selected binary. A stable owner token | |
| 594 | - | // lets later connections from the same driver reuse that root while rejecting | |
| 595 | - | // a root created by another invocation. | |
| 596 | - | func BuildCommandForRemoteLaunchWithRoot(target, root, ownerToken, executable string, environment []string, mode string) CommandSpec { | |
| 597 | - | if root == "" || ownerToken == "" { | |
| 598 | - | return BuildCommandForRemoteCommand(target, "sh", "-c", "exit 1") | |
| 599 | - | } | |
| 600 | - | script := isolatedLaunchScript(root, ownerToken, executable, environment, mode, false) | |
| 601 | - | return BuildCommandForRemoteCommand(target, "sh", "-c", script) | |
| 602 | - | } | |
| 603 | - | ||
| 604 | - | // BuildCommandForRemoteCleanup stops the daemon bound to an owned endpoint and | |
| 605 | - | // removes its root only after the owner token has been verified remotely. | |
| 606 | - | func BuildCommandForRemoteCleanup(target, root, ownerToken, executable string, environment []string, mode string) CommandSpec { | |
| 607 | - | if root == "" || ownerToken == "" { | |
| 608 | - | return BuildCommandForRemoteCommand(target, "sh", "-c", "exit 1") | |
| 609 | - | } | |
| 610 | - | script := isolatedLaunchScript(root, ownerToken, executable, environment, mode, true) | |
| 611 | - | return BuildCommandForRemoteCommand(target, "sh", "-c", script) | |
| 612 | - | } | |
| 613 | - | ||
| 614 | - | func isolatedLaunchScript(root, ownerToken, executable string, environment []string, mode string, cleanup bool) string { | |
| 615 | - | rootRef := shellQuote(root) | |
| 616 | - | markerRef := shellQuote(root + "/" + isolatedLaunchMarker) | |
| 617 | - | tokenRef := shellQuote(ownerToken) | |
| 618 | - | var script strings.Builder | |
| 619 | - | script.WriteString("set -eu; root=") | |
| 620 | - | script.WriteString(rootRef) | |
| 621 | - | script.WriteString("; marker=") | |
| 622 | - | script.WriteString(markerRef) | |
| 623 | - | script.WriteString("; token=") | |
| 624 | - | script.WriteString(tokenRef) | |
| 625 | - | script.WriteString("; if [ -L \"$root\" ]; then exit 1; fi; ") | |
| 626 | - | if cleanup { | |
| 627 | - | script.WriteString("if [ ! -e \"$root\" ]; then exit 0; fi; ") | |
| 628 | - | } | |
| 629 | - | script.WriteString("if [ -e \"$root\" ]; then ") | |
| 630 | - | script.WriteString("[ -d \"$root\" ] && [ ! -L \"$marker\" ] && [ -f \"$marker\" ] && [ \"$(cat \"$marker\")\" = \"$token\" ] || exit 1; ") | |
| 631 | - | if !cleanup { | |
| 632 | - | script.WriteString("else umask 077; mkdir -m 700 \"$root\"; created=1; ") | |
| 633 | - | script.WriteString("trap 'if [ \"$created\" = 1 ]; then rm -rf \"$root\"; fi' EXIT; ") | |
| 634 | - | for _, child := range []string{"config", "state", "runtime", "tmp"} { | |
| 635 | - | script.WriteString("mkdir -m 700 \"$root/") | |
| 636 | - | script.WriteString(child) | |
| 637 | - | script.WriteString("\"; ") | |
| 638 | - | } | |
| 639 | - | script.WriteString("printf '%s' \"$token\" > \"$marker\"; chmod 600 \"$marker\"; ") | |
| 640 | - | } | |
| 641 | - | script.WriteString("fi; ") | |
| 642 | - | command := make([]string, 0, len(environment)+4) | |
| 643 | - | command = append(command, "env", "-i") | |
| 644 | - | command = append(command, environment...) | |
| 645 | - | command = append(command, executable, mode) | |
| 646 | - | if cleanup { | |
| 647 | - | script.WriteString("command_status=0; if ") | |
| 648 | - | script.WriteString(quotedWords(command)) | |
| 649 | - | script.WriteString("; then :; else command_status=$?; fi; rm_status=0; rm -rf \"$root\" || rm_status=$?; if [ \"$command_status\" -ne 0 ]; then exit \"$command_status\"; fi; exit \"$rm_status\"") | |
| 650 | - | } else { | |
| 651 | - | script.WriteString("trap - EXIT; exec ") | |
| 652 | - | script.WriteString(quotedWords(command)) | |
| 653 | - | } | |
| 654 | - | return script.String() | |
| 655 | - | } | |
| 656 | - | ||
| 657 | - | func quotedWords(words []string) string { | |
| 658 | - | quoted := make([]string, 0, len(words)) | |
| 659 | - | for _, word := range words { | |
| 660 | - | quoted = append(quoted, shellQuote(word)) | |
| 661 | - | } | |
| 662 | - | return strings.Join(quoted, " ") | |
| 663 | - | } | |
| 664 | - | ||
| 665 | - | // RunRemoteCommand executes a command through the local ssh binary without | |
| 666 | - | // exposing its output, which may contain configured environment or endpoint | |
| 667 | - | // values. | |
| 668 | - | func RunRemoteCommand(ctx context.Context, spec CommandSpec) error { | |
| 669 | - | if err := ctx.Err(); err != nil { | |
| 670 | - | return err | |
| 671 | - | } | |
| 672 | - | cmd := exec.CommandContext(ctx, spec.Path, spec.Args...) | |
| 673 | - | cmd.Stdout = io.Discard | |
| 674 | - | cmd.Stderr = io.Discard | |
| 675 | - | return cmd.Run() | |
| 676 | - | } | |
| 677 | - | ||
| 678 | 542 | func shellQuote(s string) string { | |
| 679 | 543 | return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" | |
| 680 | 544 | } | |
| 681 | - | ||
| 682 | - | // Dial starts ssh target vev _stdio and returns a Transport over the child | |
| 683 | - | // process' stdio. Session selection is carried in Hello. The subprocess is | |
| 684 | - | // started with exec.Command argv, never through a shell. | |
| 685 | - | func Dial(target, session string) (wire.Transport, error) { | |
| 686 | - | return DialContext(context.Background(), target, session) | |
| 687 | - | } | |
| 688 | - | ||
| 689 | - | // DialContextWithRuntimeObserver is the option-bearing production constructor. | |
| 690 | - | func DialContextWithRuntimeObserver(ctx context.Context, target, session string, logger *slog.Logger, opts ...Option) (wire.Transport, error) { | |
| 691 | - | return dialContext(ctx, target, session, logger, opts...) | |
| 692 | - | } | |
| 693 | - | ||
| 694 | - | // DialContextWithLaunch is the explicit-environment variant used by isolated | |
| 695 | - | // driver endpoints. An empty executable preserves the normal `vev _stdio` | |
| 696 | - | // command. | |
| 697 | - | func DialContextWithLaunch(ctx context.Context, target, executable string, environment []string, logger *slog.Logger, opts ...Option) (wire.Transport, error) { | |
| 698 | - | return dialContextWithLaunch(ctx, target, "", "", executable, environment, logger, opts...) | |
| 699 | - | } | |
| 700 | - | ||
| 701 | - | // DialContextWithIsolatedLaunch starts a remote child after it has exclusively | |
| 702 | - | // created or revalidated its private endpoint root. | |
| 703 | - | func DialContextWithIsolatedLaunch(ctx context.Context, target, root, ownerToken, executable string, environment []string, logger *slog.Logger, opts ...Option) (wire.Transport, error) { | |
| 704 | - | return dialContextWithLaunch(ctx, target, root, ownerToken, executable, environment, logger, opts...) | |
| 705 | - | } | |
| 706 | - | ||
| 707 | - | // DialContext is like Dial, but the context gates ssh startup. Once the | |
| 708 | - | // transport is returned, its Close method owns the subprocess lifetime; the | |
| 709 | - | // handshake context must not kill an already-established carriage. Callers may | |
| 710 | - | // pass a logger to record ssh start failures and non-clean exits without logging | |
| 711 | - | // the generated command line. | |
| 712 | - | func DialContext(ctx context.Context, target, session string, logger ...*slog.Logger) (wire.Transport, error) { | |
| 713 | - | var log *slog.Logger | |
| 714 | - | if len(logger) > 0 { | |
| 715 | - | log = logger[0] | |
| 716 | - | } | |
| 717 | - | return dialContext(ctx, target, session, log) | |
| 718 | - | } | |
| 719 | - | ||
| 720 | - | func dialContext(ctx context.Context, target, session string, log *slog.Logger, opts ...Option) (wire.Transport, error) { | |
| 721 | - | return dialContextWithLaunch(ctx, target, "", "", "", nil, log, opts...) | |
| 722 | - | } | |
| 723 | - | ||
| 724 | - | func dialContextWithLaunch(ctx context.Context, target, root, ownerToken, executable string, environment []string, log *slog.Logger, opts ...Option) (wire.Transport, error) { | |
| 725 | - | if err := ctx.Err(); err != nil { | |
| 726 | - | return nil, err | |
| 727 | - | } | |
| 728 | - | spec := BuildCommand(target, "") | |
| 729 | - | if executable != "" { | |
| 730 | - | if root != "" && ownerToken != "" { | |
| 731 | - | spec = BuildCommandForRemoteLaunchWithRoot(target, root, ownerToken, executable, environment, "_stdio") | |
| 732 | - | } else { | |
| 733 | - | spec = BuildCommandForRemoteLaunch(target, executable, environment, "_stdio") | |
| 734 | - | } | |
| 735 | - | } | |
| 736 | - | // The caller's context is also the bounded protocol-handshake context and is | |
| 737 | - | // canceled after the first committed publication. Binding it to the command | |
| 738 | - | // would kill a healthy long-lived carriage at that boundary. Transport.Close | |
| 739 | - | // owns cancellation after Start; the preflight check above keeps canceled | |
| 740 | - | // attempts from starting a process. | |
| 741 | - | cmd := exec.Command(spec.Path, spec.Args...) | |
| 742 | - | stdin, err := cmd.StdinPipe() | |
| 743 | - | if err != nil { | |
| 744 | - | return nil, fmt.Errorf("sshstdio: stdin pipe: %w", err) | |
| 745 | - | } | |
| 746 | - | stdout, err := cmd.StdoutPipe() | |
| 747 | - | if err != nil { | |
| 748 | - | return nil, fmt.Errorf("sshstdio: stdout pipe: %w", err) | |
| 749 | - | } | |
| 750 | - | var stderr bytes.Buffer | |
| 751 | - | cmd.Stderr = &stderr | |
| 752 | - | if err := cmd.Start(); err != nil { | |
| 753 | - | if log != nil { | |
| 754 | - | log.Error("ssh start failed", "target", target, "session", "", "err", err) | |
| 755 | - | } | |
| 756 | - | return nil, fmt.Errorf("sshstdio: start ssh: %w", err) | |
| 757 | - | } | |
| 758 | - | ||
| 759 | - | waiter := newProcessWaiter(cmd, stdin, &stderr, sshCloseTimeout, log, target, "") | |
| 760 | - | transport := newTransport(stdout, stdin, waiter.close, waiter.eofErr) | |
| 761 | - | if err := ctx.Err(); err != nil { | |
| 762 | - | _ = transport.Close() | |
| 763 | - | return nil, err | |
| 764 | - | } | |
| 765 | - | for _, opt := range opts { | |
| 766 | - | if opt != nil { | |
| 767 | - | opt(transport) | |
| 768 | - | } | |
| 769 | - | } | |
| 770 | - | return transport, nil | |
| 771 | - | } | |
| @@ -5,8 +5,6 @@ | |||||
| 5 | package sshstdio | 5 | package sshstdio | ||
| 6 | 6 | ||||
| 7 | import ( | 7 | import ( | ||
| 8 | - | "bytes" | |||
| 9 | - | "context" | |||
| 10 | "errors" | 8 | "errors" | ||
| 11 | "fmt" | 9 | "fmt" | ||
| 12 | "io" | 10 | "io" | ||
| @@ -529,19 +527,6 @@ type CommandSpec struct { | |||||
| 529 | Args []string | 527 | Args []string | ||
| 530 | } | 528 | } | ||
| 531 | 529 | ||||
| 532 | - | // BuildCommand constructs the local ssh subprocess argv without invoking a | |||
| 533 | - | // local shell. OpenSSH sends the remote command as one string for the remote | |||
| 534 | - | // user's shell to interpret, so every remote argv word is POSIX single-quoted. | |||
| 535 | - | func BuildCommand(target, _ string) CommandSpec { | |||
| 536 | - | return BuildCommandForMode(target, "_stdio", "") | |||
| 537 | - | } | |||
| 538 | - | ||||
| 539 | - | // BuildCommandForMode constructs the local ssh subprocess argv for a hidden vev | |||
| 540 | - | // remote mode such as _stdio or _quic-bootstrap. | |||
| 541 | - | func BuildCommandForMode(target, mode, _ string) CommandSpec { | |||
| 542 | - | return BuildCommandForRemoteCommand(target, "vev", mode) | |||
| 543 | - | } | |||
| 544 | - | ||||
| 545 | // BuildCommandForRemoteCommand constructs ssh argv for an arbitrary remote | 530 | // BuildCommandForRemoteCommand constructs ssh argv for an arbitrary remote | ||
| 546 | // command. Every remote word is POSIX single-quoted; the target remains one | 531 | // command. Every remote word is POSIX single-quoted; the target remains one | ||
| 547 | // local argv word after the option terminator. | 532 | // local argv word after the option terminator. | ||
| @@ -554,218 +539,6 @@ func BuildCommandForRemoteCommand(target string, command ...string) CommandSpec | |||||
| 554 | return CommandSpec{Path: "ssh", Args: args} | 539 | return CommandSpec{Path: "ssh", Args: args} | ||
| 555 | } | 540 | } | ||
| 556 | 541 | ||||
| 557 | - | // BuildCommandForObservation constructs unattended ssh argv for remote | |||
| 558 | - | // observation (catalogue checks). Authentication, proxying, and host-key trust | |||
| 559 | - | // remain owned by the target's effective OpenSSH configuration so managed | |||
| 560 | - | // transports such as NetBird keep their coherent policy. Vev only disables | |||
| 561 | - | // TTY allocation and advertised host-key updates, and bounds connection time | |||
| 562 | - | // and attempts. The caller leaves stdin detached and owns a whole-command | |||
| 563 | - | // deadline, so background observation cannot wait indefinitely for input. | |||
| 564 | - | func BuildCommandForObservation(target string, connectTimeout time.Duration, command ...string) CommandSpec { | |||
| 565 | - | secs := int(connectTimeout / time.Second) | |||
| 566 | - | if secs < 1 { | |||
| 567 | - | secs = 1 | |||
| 568 | - | } | |||
| 569 | - | spec := BuildCommandForRemoteCommand(target, command...) | |||
| 570 | - | opts := []string{ | |||
| 571 | - | "-T", | |||
| 572 | - | "-o", "UpdateHostKeys=no", | |||
| 573 | - | "-o", fmt.Sprintf("ConnectTimeout=%d", secs), | |||
| 574 | - | "-o", "ConnectionAttempts=1", | |||
| 575 | - | } | |||
| 576 | - | return CommandSpec{Path: spec.Path, Args: append(opts, spec.Args...)} | |||
| 577 | - | } | |||
| 578 | - | ||||
| 579 | - | // BuildCommandForRemoteLaunch prefixes an explicit complete environment and | |||
| 580 | - | // absolute executable to a hidden remote mode. Each value remains one quoted | |||
| 581 | - | // remote-shell word; no caller input is concatenated into an unquoted command. | |||
| 582 | - | func BuildCommandForRemoteLaunch(target, executable string, environment []string, mode string) CommandSpec { | |||
| 583 | - | command := make([]string, 0, len(environment)+4) | |||
| 584 | - | command = append(command, "env", "-i") | |||
| 585 | - | command = append(command, environment...) | |||
| 586 | - | command = append(command, executable, mode) | |||
| 587 | - | return BuildCommandForRemoteCommand(target, command...) | |||
| 588 | - | } | |||
| 589 | - | ||||
| 590 | - | const isolatedLaunchMarker = ".vev-ui-driver-owner" | |||
| 591 | - | ||||
| 592 | - | // BuildCommandForRemoteLaunchWithRoot creates a fresh private endpoint root on | |||
| 593 | - | // the remote host before starting the selected binary. A stable owner token | |||
| 594 | - | // lets later connections from the same driver reuse that root while rejecting | |||
| 595 | - | // a root created by another invocation. | |||
| 596 | - | func BuildCommandForRemoteLaunchWithRoot(target, root, ownerToken, executable string, environment []string, mode string) CommandSpec { | |||
| 597 | - | if root == "" || ownerToken == "" { | |||
| 598 | - | return BuildCommandForRemoteCommand(target, "sh", "-c", "exit 1") | |||
| 599 | - | } | |||
| 600 | - | script := isolatedLaunchScript(root, ownerToken, executable, environment, mode, false) | |||
| 601 | - | return BuildCommandForRemoteCommand(target, "sh", "-c", script) | |||
| 602 | - | } | |||
| 603 | - | ||||
| 604 | - | // BuildCommandForRemoteCleanup stops the daemon bound to an owned endpoint and | |||
| 605 | - | // removes its root only after the owner token has been verified remotely. | |||
| 606 | - | func BuildCommandForRemoteCleanup(target, root, ownerToken, executable string, environment []string, mode string) CommandSpec { | |||
| 607 | - | if root == "" || ownerToken == "" { | |||
| 608 | - | return BuildCommandForRemoteCommand(target, "sh", "-c", "exit 1") | |||
| 609 | - | } | |||
| 610 | - | script := isolatedLaunchScript(root, ownerToken, executable, environment, mode, true) | |||
| 611 | - | return BuildCommandForRemoteCommand(target, "sh", "-c", script) | |||
| 612 | - | } | |||
| 613 | - | ||||
| 614 | - | func isolatedLaunchScript(root, ownerToken, executable string, environment []string, mode string, cleanup bool) string { | |||
| 615 | - | rootRef := shellQuote(root) | |||
| 616 | - | markerRef := shellQuote(root + "/" + isolatedLaunchMarker) | |||
| 617 | - | tokenRef := shellQuote(ownerToken) | |||
| 618 | - | var script strings.Builder | |||
| 619 | - | script.WriteString("set -eu; root=") | |||
| 620 | - | script.WriteString(rootRef) | |||
| 621 | - | script.WriteString("; marker=") | |||
| 622 | - | script.WriteString(markerRef) | |||
| 623 | - | script.WriteString("; token=") | |||
| 624 | - | script.WriteString(tokenRef) | |||
| 625 | - | script.WriteString("; if [ -L \"$root\" ]; then exit 1; fi; ") | |||
| 626 | - | if cleanup { | |||
| 627 | - | script.WriteString("if [ ! -e \"$root\" ]; then exit 0; fi; ") | |||
| 628 | - | } | |||
| 629 | - | script.WriteString("if [ -e \"$root\" ]; then ") | |||
| 630 | - | script.WriteString("[ -d \"$root\" ] && [ ! -L \"$marker\" ] && [ -f \"$marker\" ] && [ \"$(cat \"$marker\")\" = \"$token\" ] || exit 1; ") | |||
| 631 | - | if !cleanup { | |||
| 632 | - | script.WriteString("else umask 077; mkdir -m 700 \"$root\"; created=1; ") | |||
| 633 | - | script.WriteString("trap 'if [ \"$created\" = 1 ]; then rm -rf \"$root\"; fi' EXIT; ") | |||
| 634 | - | for _, child := range []string{"config", "state", "runtime", "tmp"} { | |||
| 635 | - | script.WriteString("mkdir -m 700 \"$root/") | |||
| 636 | - | script.WriteString(child) | |||
| 637 | - | script.WriteString("\"; ") | |||
| 638 | - | } | |||
| 639 | - | script.WriteString("printf '%s' \"$token\" > \"$marker\"; chmod 600 \"$marker\"; ") | |||
| 640 | - | } | |||
| 641 | - | script.WriteString("fi; ") | |||
| 642 | - | command := make([]string, 0, len(environment)+4) | |||
| 643 | - | command = append(command, "env", "-i") | |||
| 644 | - | command = append(command, environment...) | |||
| 645 | - | command = append(command, executable, mode) | |||
| 646 | - | if cleanup { | |||
| 647 | - | script.WriteString("command_status=0; if ") | |||
| 648 | - | script.WriteString(quotedWords(command)) | |||
| 649 | - | script.WriteString("; then :; else command_status=$?; fi; rm_status=0; rm -rf \"$root\" || rm_status=$?; if [ \"$command_status\" -ne 0 ]; then exit \"$command_status\"; fi; exit \"$rm_status\"") | |||
| 650 | - | } else { | |||
| 651 | - | script.WriteString("trap - EXIT; exec ") | |||
| 652 | - | script.WriteString(quotedWords(command)) | |||
| 653 | - | } | |||
| 654 | - | return script.String() | |||
| 655 | - | } | |||
| 656 | - | ||||
| 657 | - | func quotedWords(words []string) string { | |||
| 658 | - | quoted := make([]string, 0, len(words)) | |||
| 659 | - | for _, word := range words { | |||
| 660 | - | quoted = append(quoted, shellQuote(word)) | |||
| 661 | - | } | |||
| 662 | - | return strings.Join(quoted, " ") | |||
| 663 | - | } | |||
| 664 | - | ||||
| 665 | - | // RunRemoteCommand executes a command through the local ssh binary without | |||
| 666 | - | // exposing its output, which may contain configured environment or endpoint | |||
| 667 | - | // values. | |||
| 668 | - | func RunRemoteCommand(ctx context.Context, spec CommandSpec) error { | |||
| 669 | - | if err := ctx.Err(); err != nil { | |||
| 670 | - | return err | |||
| 671 | - | } | |||
| 672 | - | cmd := exec.CommandContext(ctx, spec.Path, spec.Args...) | |||
| 673 | - | cmd.Stdout = io.Discard | |||
| 674 | - | cmd.Stderr = io.Discard | |||
| 675 | - | return cmd.Run() | |||
| 676 | - | } | |||
| 677 | - | ||||
| 678 | func shellQuote(s string) string { | 542 | func shellQuote(s string) string { | ||
| 679 | return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" | 543 | return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" | ||
| 680 | } | 544 | } | ||
| 681 | - | ||||
| 682 | - | // Dial starts ssh target vev _stdio and returns a Transport over the child | |||
| 683 | - | // process' stdio. Session selection is carried in Hello. The subprocess is | |||
| 684 | - | // started with exec.Command argv, never through a shell. | |||
| 685 | - | func Dial(target, session string) (wire.Transport, error) { | |||
| 686 | - | return DialContext(context.Background(), target, session) | |||
| 687 | - | } | |||
| 688 | - | ||||
| 689 | - | // DialContextWithRuntimeObserver is the option-bearing production constructor. | |||
| 690 | - | func DialContextWithRuntimeObserver(ctx context.Context, target, session string, logger *slog.Logger, opts ...Option) (wire.Transport, error) { | |||
| 691 | - | return dialContext(ctx, target, session, logger, opts...) | |||
| 692 | - | } | |||
| 693 | - | ||||
| 694 | - | // DialContextWithLaunch is the explicit-environment variant used by isolated | |||
| 695 | - | // driver endpoints. An empty executable preserves the normal `vev _stdio` | |||
| 696 | - | // command. | |||
| 697 | - | func DialContextWithLaunch(ctx context.Context, target, executable string, environment []string, logger *slog.Logger, opts ...Option) (wire.Transport, error) { | |||
| 698 | - | return dialContextWithLaunch(ctx, target, "", "", executable, environment, logger, opts...) | |||
| 699 | - | } | |||
| 700 | - | ||||
| 701 | - | // DialContextWithIsolatedLaunch starts a remote child after it has exclusively | |||
| 702 | - | // created or revalidated its private endpoint root. | |||
| 703 | - | func DialContextWithIsolatedLaunch(ctx context.Context, target, root, ownerToken, executable string, environment []string, logger *slog.Logger, opts ...Option) (wire.Transport, error) { | |||
| 704 | - | return dialContextWithLaunch(ctx, target, root, ownerToken, executable, environment, logger, opts...) | |||
| 705 | - | } | |||
| 706 | - | ||||
| 707 | - | // DialContext is like Dial, but the context gates ssh startup. Once the | |||
| 708 | - | // transport is returned, its Close method owns the subprocess lifetime; the | |||
| 709 | - | // handshake context must not kill an already-established carriage. Callers may | |||
| 710 | - | // pass a logger to record ssh start failures and non-clean exits without logging | |||
| 711 | - | // the generated command line. | |||
| 712 | - | func DialContext(ctx context.Context, target, session string, logger ...*slog.Logger) (wire.Transport, error) { | |||
| 713 | - | var log *slog.Logger | |||
| 714 | - | if len(logger) > 0 { | |||
| 715 | - | log = logger[0] | |||
| 716 | - | } | |||
| 717 | - | return dialContext(ctx, target, session, log) | |||
| 718 | - | } | |||
| 719 | - | ||||
| 720 | - | func dialContext(ctx context.Context, target, session string, log *slog.Logger, opts ...Option) (wire.Transport, error) { | |||
| 721 | - | return dialContextWithLaunch(ctx, target, "", "", "", nil, log, opts...) | |||
| 722 | - | } | |||
| 723 | - | ||||
| 724 | - | func dialContextWithLaunch(ctx context.Context, target, root, ownerToken, executable string, environment []string, log *slog.Logger, opts ...Option) (wire.Transport, error) { | |||
| 725 | - | if err := ctx.Err(); err != nil { | |||
| 726 | - | return nil, err | |||
| 727 | - | } | |||
| 728 | - | spec := BuildCommand(target, "") | |||
| 729 | - | if executable != "" { | |||
| 730 | - | if root != "" && ownerToken != "" { | |||
| 731 | - | spec = BuildCommandForRemoteLaunchWithRoot(target, root, ownerToken, executable, environment, "_stdio") | |||
| 732 | - | } else { | |||
| 733 | - | spec = BuildCommandForRemoteLaunch(target, executable, environment, "_stdio") | |||
| 734 | - | } | |||
| 735 | - | } | |||
| 736 | - | // The caller's context is also the bounded protocol-handshake context and is | |||
| 737 | - | // canceled after the first committed publication. Binding it to the command | |||
| 738 | - | // would kill a healthy long-lived carriage at that boundary. Transport.Close | |||
| 739 | - | // owns cancellation after Start; the preflight check above keeps canceled | |||
| 740 | - | // attempts from starting a process. | |||
| 741 | - | cmd := exec.Command(spec.Path, spec.Args...) | |||
| 742 | - | stdin, err := cmd.StdinPipe() | |||
| 743 | - | if err != nil { | |||
| 744 | - | return nil, fmt.Errorf("sshstdio: stdin pipe: %w", err) | |||
| 745 | - | } | |||
| 746 | - | stdout, err := cmd.StdoutPipe() | |||
| 747 | - | if err != nil { | |||
| 748 | - | return nil, fmt.Errorf("sshstdio: stdout pipe: %w", err) | |||
| 749 | - | } | |||
| 750 | - | var stderr bytes.Buffer | |||
| 751 | - | cmd.Stderr = &stderr | |||
| 752 | - | if err := cmd.Start(); err != nil { | |||
| 753 | - | if log != nil { | |||
| 754 | - | log.Error("ssh start failed", "target", target, "session", "", "err", err) | |||
| 755 | - | } | |||
| 756 | - | return nil, fmt.Errorf("sshstdio: start ssh: %w", err) | |||
| 757 | - | } | |||
| 758 | - | ||||
| 759 | - | waiter := newProcessWaiter(cmd, stdin, &stderr, sshCloseTimeout, log, target, "") | |||
| 760 | - | transport := newTransport(stdout, stdin, waiter.close, waiter.eofErr) | |||
| 761 | - | if err := ctx.Err(); err != nil { | |||
| 762 | - | _ = transport.Close() | |||
| 763 | - | return nil, err | |||
| 764 | - | } | |||
| 765 | - | for _, opt := range opts { | |||
| 766 | - | if opt != nil { | |||
| 767 | - | opt(transport) | |||
| 768 | - | } | |||
| 769 | - | } | |||
| 770 | - | return transport, nil | |||
| 771 | - | } | |||
M internal/adapters/sshstdio/transport_test.go +0 -142
| @@ -7,7 +7,6 @@ import ( | |||
| 7 | 7 | "errors" | |
| 8 | 8 | "io" | |
| 9 | 9 | "log/slog" | |
| 10 | - | "os" | |
| 11 | 10 | "os/exec" | |
| 12 | 11 | "strings" | |
| 13 | 12 | "sync" | |
| @@ -24,52 +23,6 @@ import ( | |||
| 24 | 23 | // legacy type byte. | |
| 25 | 24 | const frameHeaderLen = 4 | |
| 26 | 25 | ||
| 27 | - | func TestBuildCommandUsesExecArgs(t *testing.T) { | |
| 28 | - | // BuildCommand deliberately starts `ssh -- target 'vev' '_stdio'`. | |
| 29 | - | // Session selection and terminal color capability are carried in vev's Hello | |
| 30 | - | // message, not in ssh env. | |
| 31 | - | tests := []struct { | |
| 32 | - | name string | |
| 33 | - | target string | |
| 34 | - | session string | |
| 35 | - | want []string | |
| 36 | - | }{ | |
| 37 | - | {name: "no session", target: "user@example.com", want: []string{"--", "user@example.com", "'vev' '_stdio'"}}, | |
| 38 | - | {name: "session is carried by Hello rather than ssh command", target: "user@example.com", session: "work; rm -rf /", want: []string{"--", "user@example.com", "'vev' '_stdio'"}}, | |
| 39 | - | {name: "target kept as single local arg after option terminator", target: "user@host; touch /tmp/pwn", session: "work", want: []string{"--", "user@host; touch /tmp/pwn", "'vev' '_stdio'"}}, | |
| 40 | - | } | |
| 41 | - | ||
| 42 | - | for _, tt := range tests { | |
| 43 | - | t.Run(tt.name, func(t *testing.T) { | |
| 44 | - | got := BuildCommand(tt.target, tt.session) | |
| 45 | - | if got.Path != "ssh" { | |
| 46 | - | t.Fatalf("Path = %q, want ssh", got.Path) | |
| 47 | - | } | |
| 48 | - | if len(got.Args) != len(tt.want) { | |
| 49 | - | t.Fatalf("Args len = %d, want %d (%q)", len(got.Args), len(tt.want), got.Args) | |
| 50 | - | } | |
| 51 | - | for i := range tt.want { | |
| 52 | - | if got.Args[i] != tt.want[i] { | |
| 53 | - | t.Fatalf("Args[%d] = %q, want %q (all args %q)", i, got.Args[i], tt.want[i], got.Args) | |
| 54 | - | } | |
| 55 | - | } | |
| 56 | - | }) | |
| 57 | - | } | |
| 58 | - | } | |
| 59 | - | ||
| 60 | - | func TestBuildCommandForModeUsesCanonicalSSHArgs(t *testing.T) { | |
| 61 | - | got := BuildCommandForMode("user@example.com", "_quic-bootstrap", "work") | |
| 62 | - | want := []string{"--", "user@example.com", "'vev' '_quic-bootstrap'"} | |
| 63 | - | if got.Path != "ssh" { | |
| 64 | - | t.Fatalf("Path = %q, want ssh", got.Path) | |
| 65 | - | } | |
| 66 | - | for i := range want { | |
| 67 | - | if got.Args[i] != want[i] { | |
| 68 | - | t.Fatalf("Args[%d] = %q, want %q (all args %q)", i, got.Args[i], want[i], got.Args) | |
| 69 | - | } | |
| 70 | - | } | |
| 71 | - | } | |
| 72 | - | ||
| 73 | 26 | func TestBuildCommandForRemoteCommandQuotesEveryWord(t *testing.T) { | |
| 74 | 27 | tests := []struct { | |
| 75 | 28 | name string | |
| @@ -115,92 +68,6 @@ func TestBuildCommandForRemoteCommandQuotesEveryWord(t *testing.T) { | |||
| 115 | 68 | } | |
| 116 | 69 | } | |
| 117 | 70 | ||
| 118 | - | func TestBuildCommandForObservationPreservesHostTrustAndAuthenticationPolicy(t *testing.T) { | |
| 119 | - | spec := BuildCommandForObservation("user@example.com", 5*time.Second, "vev", "cmd", "remote-catalog", "--json") | |
| 120 | - | if spec.Path != "ssh" { | |
| 121 | - | t.Fatalf("Path = %q, want ssh", spec.Path) | |
| 122 | - | } | |
| 123 | - | flat := strings.Join(spec.Args, " ") | |
| 124 | - | for _, want := range []string{"-T", "UpdateHostKeys=no", "ConnectTimeout=5", "ConnectionAttempts=1"} { | |
| 125 | - | if !strings.Contains(flat, want) { | |
| 126 | - | t.Fatalf("observation argv %q missing %q", spec.Args, want) | |
| 127 | - | } | |
| 128 | - | } | |
| 129 | - | for _, forbidden := range []string{"BatchMode=", "StrictHostKeyChecking=", "UserKnownHostsFile=", "ProxyCommand="} { | |
| 130 | - | if strings.Contains(flat, forbidden) { | |
| 131 | - | t.Fatalf("observation argv %q overrides host policy %q", spec.Args, forbidden) | |
| 132 | - | } | |
| 133 | - | } | |
| 134 | - | ||
| 135 | - | // Effective behavior: host-specific authentication and trust policy stays | |
| 136 | - | // intact while vev still disables TTY allocation, host-key updates, and | |
| 137 | - | // unbounded connection attempts. | |
| 138 | - | ssh, err := exec.LookPath("ssh") | |
| 139 | - | if err != nil { | |
| 140 | - | t.Skip("ssh binary not available for effective-config probe") | |
| 141 | - | } | |
| 142 | - | conflict := "Host *\n RequestTTY yes\n UpdateHostKeys yes\n BatchMode no\n StrictHostKeyChecking no\n UserKnownHostsFile /dev/null\n ProxyCommand fake-proxy %h %p\n" | |
| 143 | - | confPath := t.TempDir() + "/ssh_config" | |
| 144 | - | if err := os.WriteFile(confPath, []byte(conflict), 0o600); err != nil { | |
| 145 | - | t.Fatalf("write conflicting ssh config: %v", err) | |
| 146 | - | } | |
| 147 | - | sep := -1 | |
| 148 | - | for i, arg := range spec.Args { | |
| 149 | - | if arg == "--" { | |
| 150 | - | sep = i | |
| 151 | - | break | |
| 152 | - | } | |
| 153 | - | } | |
| 154 | - | if sep < 0 { | |
| 155 | - | t.Fatalf("observation argv %q has no option terminator", spec.Args) | |
| 156 | - | } | |
| 157 | - | probe := append(append([]string{}, spec.Args[:sep]...), "-F", confPath, "-G", "user@example.com") | |
| 158 | - | out, err := exec.Command(ssh, probe...).Output() | |
| 159 | - | if err != nil { | |
| 160 | - | t.Fatalf("ssh -G probe: %v", err) | |
| 161 | - | } | |
| 162 | - | effective := map[string]string{} | |
| 163 | - | for _, line := range strings.Split(string(out), "\n") { | |
| 164 | - | key, value, ok := strings.Cut(line, " ") | |
| 165 | - | if ok { | |
| 166 | - | effective[strings.ToLower(key)] = strings.ToLower(value) | |
| 167 | - | } | |
| 168 | - | } | |
| 169 | - | for key, want := range map[string]string{ | |
| 170 | - | "connecttimeout": "5", "connectionattempts": "1", | |
| 171 | - | } { | |
| 172 | - | if effective[key] != want { | |
| 173 | - | t.Fatalf("effective ssh %s = %q, want %q (config must not override observation argv)", key, effective[key], want) | |
| 174 | - | } | |
| 175 | - | } | |
| 176 | - | // ssh -G spells boolean keywords true/false where the configuration | |
| 177 | - | // spells yes/no; normalize both spellings before comparing. | |
| 178 | - | normalize := func(value string) string { | |
| 179 | - | switch value { | |
| 180 | - | case "yes", "true": | |
| 181 | - | return "yes" | |
| 182 | - | case "no", "false": | |
| 183 | - | return "no" | |
| 184 | - | default: | |
| 185 | - | return value | |
| 186 | - | } | |
| 187 | - | } | |
| 188 | - | for key, want := range map[string]string{ | |
| 189 | - | "batchmode": "no", "stricthostkeychecking": "no", | |
| 190 | - | "requesttty": "no", "updatehostkeys": "no", | |
| 191 | - | } { | |
| 192 | - | if got := normalize(effective[key]); got != want { | |
| 193 | - | t.Fatalf("effective ssh %s = %q, want %q", key, effective[key], want) | |
| 194 | - | } | |
| 195 | - | } | |
| 196 | - | if effective["userknownhostsfile"] != "/dev/null" { | |
| 197 | - | t.Fatalf("effective ssh userknownhostsfile = %q, want /dev/null", effective["userknownhostsfile"]) | |
| 198 | - | } | |
| 199 | - | if !strings.Contains(effective["proxycommand"], "fake-proxy") { | |
| 200 | - | t.Fatalf("effective ssh proxycommand = %q, want configured proxy", effective["proxycommand"]) | |
| 201 | - | } | |
| 202 | - | } | |
| 203 | - | ||
| 204 | 71 | func TestCloseInterruptsBlockedSend(t *testing.T) { | |
| 205 | 72 | writer := &blockedWriter{started: make(chan struct{}), released: make(chan struct{})} | |
| 206 | 73 | transport := NewTransport(nil, writer, nil) | |
| @@ -348,15 +215,6 @@ func TestTransportUsesCanonicalFrameMaximum(t *testing.T) { | |||
| 348 | 215 | } | |
| 349 | 216 | } | |
| 350 | 217 | ||
| 351 | - | func TestDialContextCanceledBeforeStart(t *testing.T) { | |
| 352 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 353 | - | cancel() | |
| 354 | - | _, err := DialContext(ctx, "example.com", "work") | |
| 355 | - | if !errors.Is(err, context.Canceled) { | |
| 356 | - | t.Fatalf("DialContext error = %v, want context.Canceled", err) | |
| 357 | - | } | |
| 358 | - | } | |
| 359 | - | ||
| 360 | 218 | func TestRecvMapsUnexpectedEOFDuringHeaderToSSHExit(t *testing.T) { | |
| 361 | 219 | sshErr := errors.New("ssh exited with status 255") | |
| 362 | 220 | tr := newTransport(strings.NewReader("\x00"), io.Discard, nil, func() error { return sshErr }) | |
| @@ -7,7 +7,6 @@ import ( | |||||
| 7 | "errors" | 7 | "errors" | ||
| 8 | "io" | 8 | "io" | ||
| 9 | "log/slog" | 9 | "log/slog" | ||
| 10 | - | "os" | |||
| 11 | "os/exec" | 10 | "os/exec" | ||
| 12 | "strings" | 11 | "strings" | ||
| 13 | "sync" | 12 | "sync" | ||
| @@ -24,52 +23,6 @@ import ( | |||||
| 24 | // legacy type byte. | 23 | // legacy type byte. | ||
| 25 | const frameHeaderLen = 4 | 24 | const frameHeaderLen = 4 | ||
| 26 | 25 | ||||
| 27 | - | func TestBuildCommandUsesExecArgs(t *testing.T) { | |||
| 28 | - | // BuildCommand deliberately starts `ssh -- target 'vev' '_stdio'`. | |||
| 29 | - | // Session selection and terminal color capability are carried in vev's Hello | |||
| 30 | - | // message, not in ssh env. | |||
| 31 | - | tests := []struct { | |||
| 32 | - | name string | |||
| 33 | - | target string | |||
| 34 | - | session string | |||
| 35 | - | want []string | |||
| 36 | - | }{ | |||
| 37 | - | {name: "no session", target: "user@example.com", want: []string{"--", "user@example.com", "'vev' '_stdio'"}}, | |||
| 38 | - | {name: "session is carried by Hello rather than ssh command", target: "user@example.com", session: "work; rm -rf /", want: []string{"--", "user@example.com", "'vev' '_stdio'"}}, | |||
| 39 | - | {name: "target kept as single local arg after option terminator", target: "user@host; touch /tmp/pwn", session: "work", want: []string{"--", "user@host; touch /tmp/pwn", "'vev' '_stdio'"}}, | |||
| 40 | - | } | |||
| 41 | - | ||||
| 42 | - | for _, tt := range tests { | |||
| 43 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 44 | - | got := BuildCommand(tt.target, tt.session) | |||
| 45 | - | if got.Path != "ssh" { | |||
| 46 | - | t.Fatalf("Path = %q, want ssh", got.Path) | |||
| 47 | - | } | |||
| 48 | - | if len(got.Args) != len(tt.want) { | |||
| 49 | - | t.Fatalf("Args len = %d, want %d (%q)", len(got.Args), len(tt.want), got.Args) | |||
| 50 | - | } | |||
| 51 | - | for i := range tt.want { | |||
| 52 | - | if got.Args[i] != tt.want[i] { | |||
| 53 | - | t.Fatalf("Args[%d] = %q, want %q (all args %q)", i, got.Args[i], tt.want[i], got.Args) | |||
| 54 | - | } | |||
| 55 | - | } | |||
| 56 | - | }) | |||
| 57 | - | } | |||
| 58 | - | } | |||
| 59 | - | ||||
| 60 | - | func TestBuildCommandForModeUsesCanonicalSSHArgs(t *testing.T) { | |||
| 61 | - | got := BuildCommandForMode("user@example.com", "_quic-bootstrap", "work") | |||
| 62 | - | want := []string{"--", "user@example.com", "'vev' '_quic-bootstrap'"} | |||
| 63 | - | if got.Path != "ssh" { | |||
| 64 | - | t.Fatalf("Path = %q, want ssh", got.Path) | |||
| 65 | - | } | |||
| 66 | - | for i := range want { | |||
| 67 | - | if got.Args[i] != want[i] { | |||
| 68 | - | t.Fatalf("Args[%d] = %q, want %q (all args %q)", i, got.Args[i], want[i], got.Args) | |||
| 69 | - | } | |||
| 70 | - | } | |||
| 71 | - | } | |||
| 72 | - | ||||
| 73 | func TestBuildCommandForRemoteCommandQuotesEveryWord(t *testing.T) { | 26 | func TestBuildCommandForRemoteCommandQuotesEveryWord(t *testing.T) { | ||
| 74 | tests := []struct { | 27 | tests := []struct { | ||
| 75 | name string | 28 | name string | ||
| @@ -115,92 +68,6 @@ func TestBuildCommandForRemoteCommandQuotesEveryWord(t *testing.T) { | |||||
| 115 | } | 68 | } | ||
| 116 | } | 69 | } | ||
| 117 | 70 | ||||
| 118 | - | func TestBuildCommandForObservationPreservesHostTrustAndAuthenticationPolicy(t *testing.T) { | |||
| 119 | - | spec := BuildCommandForObservation("user@example.com", 5*time.Second, "vev", "cmd", "remote-catalog", "--json") | |||
| 120 | - | if spec.Path != "ssh" { | |||
| 121 | - | t.Fatalf("Path = %q, want ssh", spec.Path) | |||
| 122 | - | } | |||
| 123 | - | flat := strings.Join(spec.Args, " ") | |||
| 124 | - | for _, want := range []string{"-T", "UpdateHostKeys=no", "ConnectTimeout=5", "ConnectionAttempts=1"} { | |||
| 125 | - | if !strings.Contains(flat, want) { | |||
| 126 | - | t.Fatalf("observation argv %q missing %q", spec.Args, want) | |||
| 127 | - | } | |||
| 128 | - | } | |||
| 129 | - | for _, forbidden := range []string{"BatchMode=", "StrictHostKeyChecking=", "UserKnownHostsFile=", "ProxyCommand="} { | |||
| 130 | - | if strings.Contains(flat, forbidden) { | |||
| 131 | - | t.Fatalf("observation argv %q overrides host policy %q", spec.Args, forbidden) | |||
| 132 | - | } | |||
| 133 | - | } | |||
| 134 | - | ||||
| 135 | - | // Effective behavior: host-specific authentication and trust policy stays | |||
| 136 | - | // intact while vev still disables TTY allocation, host-key updates, and | |||
| 137 | - | // unbounded connection attempts. | |||
| 138 | - | ssh, err := exec.LookPath("ssh") | |||
| 139 | - | if err != nil { | |||
| 140 | - | t.Skip("ssh binary not available for effective-config probe") | |||
| 141 | - | } | |||
| 142 | - | conflict := "Host *\n RequestTTY yes\n UpdateHostKeys yes\n BatchMode no\n StrictHostKeyChecking no\n UserKnownHostsFile /dev/null\n ProxyCommand fake-proxy %h %p\n" | |||
| 143 | - | confPath := t.TempDir() + "/ssh_config" | |||
| 144 | - | if err := os.WriteFile(confPath, []byte(conflict), 0o600); err != nil { | |||
| 145 | - | t.Fatalf("write conflicting ssh config: %v", err) | |||
| 146 | - | } | |||
| 147 | - | sep := -1 | |||
| 148 | - | for i, arg := range spec.Args { | |||
| 149 | - | if arg == "--" { | |||
| 150 | - | sep = i | |||
| 151 | - | break | |||
| 152 | - | } | |||
| 153 | - | } | |||
| 154 | - | if sep < 0 { | |||
| 155 | - | t.Fatalf("observation argv %q has no option terminator", spec.Args) | |||
| 156 | - | } | |||
| 157 | - | probe := append(append([]string{}, spec.Args[:sep]...), "-F", confPath, "-G", "user@example.com") | |||
| 158 | - | out, err := exec.Command(ssh, probe...).Output() | |||
| 159 | - | if err != nil { | |||
| 160 | - | t.Fatalf("ssh -G probe: %v", err) | |||
| 161 | - | } | |||
| 162 | - | effective := map[string]string{} | |||
| 163 | - | for _, line := range strings.Split(string(out), "\n") { | |||
| 164 | - | key, value, ok := strings.Cut(line, " ") | |||
| 165 | - | if ok { | |||
| 166 | - | effective[strings.ToLower(key)] = strings.ToLower(value) | |||
| 167 | - | } | |||
| 168 | - | } | |||
| 169 | - | for key, want := range map[string]string{ | |||
| 170 | - | "connecttimeout": "5", "connectionattempts": "1", | |||
| 171 | - | } { | |||
| 172 | - | if effective[key] != want { | |||
| 173 | - | t.Fatalf("effective ssh %s = %q, want %q (config must not override observation argv)", key, effective[key], want) | |||
| 174 | - | } | |||
| 175 | - | } | |||
| 176 | - | // ssh -G spells boolean keywords true/false where the configuration | |||
| 177 | - | // spells yes/no; normalize both spellings before comparing. | |||
| 178 | - | normalize := func(value string) string { | |||
| 179 | - | switch value { | |||
| 180 | - | case "yes", "true": | |||
| 181 | - | return "yes" | |||
| 182 | - | case "no", "false": | |||
| 183 | - | return "no" | |||
| 184 | - | default: | |||
| 185 | - | return value | |||
| 186 | - | } | |||
| 187 | - | } | |||
| 188 | - | for key, want := range map[string]string{ | |||
| 189 | - | "batchmode": "no", "stricthostkeychecking": "no", | |||
| 190 | - | "requesttty": "no", "updatehostkeys": "no", | |||
| 191 | - | } { | |||
| 192 | - | if got := normalize(effective[key]); got != want { | |||
| 193 | - | t.Fatalf("effective ssh %s = %q, want %q", key, effective[key], want) | |||
| 194 | - | } | |||
| 195 | - | } | |||
| 196 | - | if effective["userknownhostsfile"] != "/dev/null" { | |||
| 197 | - | t.Fatalf("effective ssh userknownhostsfile = %q, want /dev/null", effective["userknownhostsfile"]) | |||
| 198 | - | } | |||
| 199 | - | if !strings.Contains(effective["proxycommand"], "fake-proxy") { | |||
| 200 | - | t.Fatalf("effective ssh proxycommand = %q, want configured proxy", effective["proxycommand"]) | |||
| 201 | - | } | |||
| 202 | - | } | |||
| 203 | - | ||||
| 204 | func TestCloseInterruptsBlockedSend(t *testing.T) { | 71 | func TestCloseInterruptsBlockedSend(t *testing.T) { | ||
| 205 | writer := &blockedWriter{started: make(chan struct{}), released: make(chan struct{})} | 72 | writer := &blockedWriter{started: make(chan struct{}), released: make(chan struct{})} | ||
| 206 | transport := NewTransport(nil, writer, nil) | 73 | transport := NewTransport(nil, writer, nil) | ||
| @@ -348,15 +215,6 @@ func TestTransportUsesCanonicalFrameMaximum(t *testing.T) { | |||||
| 348 | } | 215 | } | ||
| 349 | } | 216 | } | ||
| 350 | 217 | ||||
| 351 | - | func TestDialContextCanceledBeforeStart(t *testing.T) { | |||
| 352 | - | ctx, cancel := context.WithCancel(context.Background()) | |||
| 353 | - | cancel() | |||
| 354 | - | _, err := DialContext(ctx, "example.com", "work") | |||
| 355 | - | if !errors.Is(err, context.Canceled) { | |||
| 356 | - | t.Fatalf("DialContext error = %v, want context.Canceled", err) | |||
| 357 | - | } | |||
| 358 | - | } | |||
| 359 | - | ||||
| 360 | func TestRecvMapsUnexpectedEOFDuringHeaderToSSHExit(t *testing.T) { | 218 | func TestRecvMapsUnexpectedEOFDuringHeaderToSSHExit(t *testing.T) { | ||
| 361 | sshErr := errors.New("ssh exited with status 255") | 219 | sshErr := errors.New("ssh exited with status 255") | ||
| 362 | tr := newTransport(strings.NewReader("\x00"), io.Discard, nil, func() error { return sshErr }) | 220 | tr := newTransport(strings.NewReader("\x00"), io.Discard, nil, func() error { return sshErr }) | ||