Something went wrong. Try again.
forked from tailscale/tailscale
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240// Copyright (c) Tailscale Inc & contributors// SPDX-License-Identifier: BSD-3-Clause
//go:build linux || darwin
package tailssh
import ( "bytes" "errors" "fmt" "net" "net/netip" "os/exec" "os/user" "strings" "testing" "time"
gliderssh "github.com/tailscale/gliderssh" "golang.org/x/crypto/ssh" "tailscale.com/ipn/ipnlocal" "tailscale.com/ipn/store/mem" "tailscale.com/tailcfg" "tailscale.com/tsd" "tailscale.com/tstest" "tailscale.com/types/logid" "tailscale.com/wgengine")
// testSSHHarness is the in-process SSH server harness shared by TestSSH// and TestExitCodePassthrough: a tailssh.server on a local listener// with auth bypassed, plus an execSSH closure that talks to it via// the system ssh client.type testSSHHarness struct { user *user.User addr string // host:port of the local listener execSSH func(args ...string) *exec.Cmd}
func newTestSSHHarness(t *testing.T) *testSSHHarness { t.Helper() return newTestSSHHarnessWithShell(t, "")}
// newTestSSHHarnessWithShell is newTestSSHHarness with the session// user's login shell pinned to shell (empty = the real login shell).// Tests that depend on exact process/fd structure use /bin/sh: some// real login shells (fish) fork -c commands and stay resident, which// hides pipe EOFs and changes who the direct child is.func newTestSSHHarnessWithShell(t *testing.T, shell string) *testSSHHarness { t.Helper() logf := tstest.WhileTestRunningLogger(t) sys := tsd.NewSystem() eng, err := wgengine.NewFakeUserspaceEngine(logf, sys.Set, sys.HealthTracker.Get(), sys.UserMetricsRegistry(), sys.Bus.Get()) if err != nil { t.Fatal(err) } sys.Set(eng) sys.Set(new(mem.Store)) lb, err := ipnlocal.NewLocalBackend(logf, logid.PublicID{}, sys, 0) if err != nil { t.Fatal(err) } t.Cleanup(func() { lb.Shutdown() }) lb.SetVarRoot(t.TempDir())
srv := &server{lb: lb, logf: logf} sc, err := srv.newConn() if err != nil { t.Fatal(err) } sc.insecureSkipTailscaleAuth = true
u, err := user.Current() if err != nil { t.Fatal(err) } um, err := userLookup(u.Username) if err != nil { t.Fatal(err) } if shell != "" { um.loginShellCached = shell } sc.localUser = um sc.info = &sshConnInfo{ sshUser: "test", src: netip.MustParseAddrPort("1.2.3.4:32342"), dst: netip.MustParseAddrPort("1.2.3.5:22"), node: (&tailcfg.Node{}).View(), uprof: tailcfg.UserProfile{}, } sc.action0 = &tailcfg.SSHAction{Accept: true} sc.finalAction = sc.action0 sc.authCompleted.Store(true) sc.Handler = func(s gliderssh.Session) { sc.newSSHSession(s).run() }
ln, err := net.Listen("tcp4", "127.0.0.1:0") if err != nil { t.Fatal(err) } t.Cleanup(func() { ln.Close() }) port := ln.Addr().(*net.TCPAddr).Port
go func() { for { c, err := ln.Accept() if err != nil { if !errors.Is(err, net.ErrClosed) { t.Errorf("Accept: %v", err) } return } go sc.HandleConn(c) } }()
execSSH := func(args ...string) *exec.Cmd { cmd := exec.Command("ssh", "-F", "none", "-v", "-p", fmt.Sprint(port), "-o", "StrictHostKeyChecking=no", "user@127.0.0.1") cmd.Args = append(cmd.Args, args...) return cmd }
return &testSSHHarness{user: u, addr: ln.Addr().String(), execSSH: execSSH}}
// gatedWriter blocks its first Write until gate is closed, then// writes everything to buf. Plugged in as the SSH client's stderr// sink it stops the client from consuming extended data, which stops// window adjustments, which exhausts the server's 2 MiB send window:// real-network backpressure, produced deterministically.type gatedWriter struct { gate <-chan struct{} buf bytes.Buffer}
func (w *gatedWriter) Write(p []byte) (int, error) { <-w.gate return w.buf.Write(p)}
// TestStderrTailNotTruncated asserts the full stderr stream reaches// the client when the process exits while stderr is still in flight.// The client withholds window credit (gatedWriter) so the server's// stderr copier is stuck mid-backlog when the process exits and the// stdout pipe EOFs. A CHANNEL_EOF sent at stdout-EOF, while stderr is// still draining, makes every later stderr write fail and silently// drops the tail; CHANNEL_EOF must wait for both streams.func TestStderrTailNotTruncated(t *testing.T) { h := newTestSSHHarnessWithShell(t, "/bin/sh")
cl, err := ssh.Dial("tcp", h.addr, &ssh.ClientConfig{ HostKeyCallback: ssh.InsecureIgnoreHostKey(), Timeout: 15 * time.Second, }) if err != nil { t.Fatal(err) } defer cl.Close() s, err := cl.NewSession() if err != nil { t.Fatal(err) } defer s.Close()
gate := make(chan struct{}) stderr := &gatedWriter{gate: gate} var stdout bytes.Buffer s.Stdout = &stdout s.Stderr = stderr
// 33 * 64 KiB = the client's 2 MiB channel window plus one pipe // buffer: enough that the window runs dry and the final stderr // chunks (and TAIL) are still server-side when the shell exits, // yet little enough that the shell doesn't block before exiting. const cmd = `dd if=/dev/zero bs=65536 count=33 1>&2 2>/dev/null; echo TAIL >&2; exit 7`
// Release the client's stderr sink only after the exit (and, with // the bug, the premature CHANNEL_EOF) has happened server-side. timer := time.AfterFunc(750*time.Millisecond, func() { close(gate) }) defer timer.Stop()
err = s.Run(cmd) var ee *ssh.ExitError if !errors.As(err, &ee) { t.Fatalf("want *ssh.ExitError, got %T: %v", err, err) } if got := ee.ExitStatus(); got != 7 { t.Errorf("exit status = %d, want 7", got) } if got := stderr.buf.String(); !strings.Contains(got, "TAIL") { t.Errorf("stderr tail dropped: got %d of %d bytes, missing final TAIL marker", len(got), 33*65536+len("TAIL\n")) }}
// TestExitCodePassthrough is the in-process, fast-feedback companion// to TestIntegrationExitCodes: 0 / 42 / 127 via the system ssh client// to an in-process tailssh.server. 127 = POSIX command-not-found.// https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html#tag_18_08_02func TestExitCodePassthrough(t *testing.T) { h := newTestSSHHarness(t)
tests := []struct { name string cmd string want int }{ {"zero", "true", 0}, {"passthrough", "exit 42", 42}, {"command_not_found", "/nonexistent/binary", 127}, }
for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := h.execSSH(tt.cmd).Run() if tt.want == 0 { if err != nil { t.Fatalf("want exit 0, got error: %v", err) } return } var ee *exec.ExitError if !errors.As(err, &ee) { t.Fatalf("want *exec.ExitError, got %T: %v", err, err) } if got := ee.ExitCode(); got != tt.want { t.Errorf("exit code = %d, want %d", got, tt.want) } }) }}