package remote
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"syscall"
"testing"
"time"
"github.com/creack/pty"
)
// a batch ssh run must not reach the terminal: tssh ignores BatchMode and
// reads host key answers from /dev/tty, stealing keys typed into the picker.
// the check re-runs this test under a pty it owns, with a fake tssh that
// reports whether it could open /dev/tty
func TestBatchSSHHasNoTerminal(t *testing.T) {
if os.Getenv("TOBI_TEST_TTY_PROBE") == "1" {
_, err := os.Open("/dev/tty")
out, _ := sshRun(context.Background(), "host", nil, sshOpts{cmd: "true", batch: true, timeout: "1"})
fmt.Printf("helper:%v ssh:%s", err == nil, strings.TrimSpace(out))
return
}
dir := t.TempDir()
fake := "#!/bin/sh\n(exec 3</dev/tty) 2>/dev/null && echo tty || echo notty\n"
if err := os.WriteFile(filepath.Join(dir, "tssh"), []byte(fake), 0755); err != nil {
t.Fatal(err)
}
master, tty, err := pty.Open()
if err != nil {
t.Fatal(err)
}
defer master.Close()
defer tty.Close()
var out bytes.Buffer
c := exec.Command(os.Args[0], "-test.run=^TestBatchSSHHasNoTerminal$")
c.Env = append(os.Environ(), "TOBI_TEST_TTY_PROBE=1", "PATH="+dir+":/usr/bin:/bin")
c.Stdin, c.Stdout, c.Stderr = tty, &out, &out
c.SysProcAttr = &syscall.SysProcAttr{Setsid: true, Setctty: true}
if err := c.Run(); err != nil {
t.Fatalf("%v: %s", err, out.String())
}
if !strings.Contains(out.String(), "helper:true ssh:notty") {
t.Fatalf("want the helper on a terminal and ssh off it, got %q", out.String())
}
}
// a probe that times out takes its ProxyCommand with it: killing only ssh
// orphaned a tailscale nc per unreachable host
func TestBatchSSHTimeoutKillsItsChildren(t *testing.T) {
dir := t.TempDir()
pidFile := filepath.Join(dir, "child.pid")
fake := "#!/bin/sh\nsleep 60 &\necho $! > " + pidFile + "\nwait\n"
if err := os.WriteFile(filepath.Join(dir, "tssh"), []byte(fake), 0755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir+":/usr/bin:/bin")
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
defer cancel()
if _, err := sshRun(ctx, "host", nil, sshOpts{cmd: "true", batch: true, timeout: "1"}); !errors.Is(err, ErrUnreachable) {
t.Fatalf("a timed out run should be unreachable, got %v", err)
}
data, err := os.ReadFile(pidFile)
if err != nil {
t.Fatal(err)
}
pid, _ := strconv.Atoi(strings.TrimSpace(string(data)))
for deadline := time.Now().Add(time.Second); syscall.Kill(pid, 0) == nil; time.Sleep(10 * time.Millisecond) {
if time.Now().After(deadline) {
_ = syscall.Kill(pid, syscall.SIGKILL)
t.Fatalf("the fake ssh's child %d outlived the timeout", pid)
}
}
}