Files
James Tucker 055c809d5f ssh/tailssh: release reverse-forward cancellation callbacks
Close the specific forwarded listener on connection cancellation and stop its callback when the accept loop exits. Canceling a forward no longer retains a connection-lifetime waiter or looks up a replacement listener by address.

Test repeated cancel and rebind operations on one connection, along with callback cleanup when the listener closes independently. Make the existing connection-cancellation test fail if its listener stays open.

Updates tailscale/corp#50380

Change-Id: Ife7fb0acde012b0abf1a8d804b9c6a8994ee5703
Signed-off-by: James Tucker <james@tailscale.com>
2026-10-26 17:42:07 -07:00

1313 lines
44 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build (linux && !android) || (darwin && !ios) || freebsd || openbsd
// Conn-level tests for the monitor-backed forwarding paths: unix -L,
// unix -R, TCP -R, and agent forwarding. The tested entry points are
// mayForwardLocalUnixTo, mayReverseUnixForwardTo, fwdTCPHandler, and
// handleSSHAgentForwarding. The forwarding-path tests drive a real conn
// with a real monitor and real one-shot fwd children through the
// self-exec seams in fwdchild_test.go. TestFwdAllowedSocketPathCaps and
// TestConnMonitorClosedRefusesRespawn exercise the lexical policy and
// the closed flag directly. Under root the conn binds to the non-root
// test user, so the cross-uid closures are pinned: socket ownership,
// permission-denied dials, and kernel privileged-port refusal. Non-root
// runs bind to the current user.
package tailssh
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net"
"net/netip"
"os"
"os/user"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"sync/atomic"
"syscall"
"testing"
"time"
gliderssh "github.com/tailscale/gliderssh"
gossh "golang.org/x/crypto/ssh"
"tailscale.com/envknob"
"tailscale.com/net/memnet"
"tailscale.com/tailcfg"
testssh "tailscale.com/tempfork/sshtest/ssh"
)
// fwdTestIdentity returns the local user the conn binds its monitor to.
// Under root it is the non-root test user, so sockets created by the
// forwarding children are owned by the session user. Otherwise it is the
// current user, where a non-root tailscaled binds the monitor to itself.
func fwdTestIdentity(t *testing.T) *userMeta {
t.Helper()
if os.Getuid() == 0 {
uid, gid := ensureFwdTestUser(t)
return &userMeta{User: user.User{
Username: fwdTestUser,
Uid: strconv.Itoa(uid),
Gid: strconv.Itoa(gid),
HomeDir: "/nonexistent", // no home needed. Tests use /tmp paths.
}}
}
u, err := user.Current()
if err != nil {
t.Fatalf("user.Current: %v", err)
}
return &userMeta{User: *u}
}
// newFwdTestConn returns a conn whose forwarding paths go through a real
// monitor (spawned lazily on first use), bound to lu's identity, with the
// given final action. It registers the monitor self-exec seams and closes
// the monitor on cleanup.
func newFwdTestConn(t *testing.T, lu *userMeta, final *tailcfg.SSHAction) *conn {
t.Helper()
exe := makeExecutableReachable(t)
// The override must reach the monitor process, which spawns the fwd
// children. It therefore travels through the environment.
t.Setenv("TS_TEST_SSH_FWD_PREFIX", strings.Join(testFwdPrefix, " "))
old := monitorSpawnPrefix
monitorSpawnPrefix = testMonitorPrefix
t.Cleanup(func() { monitorSpawnPrefix = old })
groups := []string{}
if g, err := os.Getgroups(); err == nil {
for _, v := range g {
groups = append(groups, strconv.Itoa(v))
}
} else {
t.Fatalf("os.Getgroups: %v", err)
}
// Install a matching policy and authenticated state for forwarding rechecks.
rule := &tailcfg.SSHRule{
SSHUsers: map[string]string{lu.Username: lu.Username},
Action: final,
Principals: []*tailcfg.SSHPrincipal{{Any: true}},
}
c := &conn{
// t.Logf so bind/spawn failures are visible in test output.
// tailscaledPath is the reachable copy of the test binary: the
// monitor exec'd from it spawns the fwd children from the same
// path, and under a root run the bound user can exec the copy
// but not the original.
srv: &server{logf: t.Logf, tailscaledPath: exe, lb: &localState{
sshEnabled: true,
matchingRule: rule,
}},
localUser: lu,
mappedLocalUser: lu.Username,
userGroupIDs: groups,
action0: final,
finalAction: final,
info: &sshConnInfo{
sshUser: lu.Username,
src: netip.MustParseAddrPort("100.100.100.101:2231"),
dst: netip.MustParseAddrPort("100.100.100.102:22"),
},
}
c.authCompleted.Store(true)
t.Cleanup(c.closeConnMonitor)
return c
}
// fwdTestCtx returns a gliderssh.Context for driving the conn callbacks and
// handlers directly, whose ContextKeyConn value is sshConn and which is
// canceled by the returned cancel func. Every other Value lookup returns
// true, matching process_unix_test.go's agentFwdContext.
func fwdTestCtx(sshConn gossh.Conn) (gliderssh.Context, context.CancelFunc) {
ctx, cancel := context.WithCancel(context.Background())
return fwdTestContext{Context: ctx, Mutex: &sync.Mutex{}, conn: sshConn}, cancel
}
// fwdTestContext implements gliderssh.Context. Value serves ContextKeyConn
// and returns true otherwise. Everything else is inert. The embedded mutex
// satisfies the interface's Locker requirement, like process_unix_test.go's
// agentFwdContext.
type fwdTestContext struct {
context.Context
*sync.Mutex
conn gossh.Conn
}
func (c fwdTestContext) Value(key any) any {
if key == gliderssh.ContextKeyConn {
return c.conn
}
return true
}
func (fwdTestContext) User() string { return "test" }
func (fwdTestContext) SessionID() string { return "test" }
func (fwdTestContext) ClientVersion() string { return "2.0" }
func (fwdTestContext) ServerVersion() string { return "2.0" }
func (fwdTestContext) RemoteAddr() net.Addr { return nil }
func (fwdTestContext) LocalAddr() net.Addr { return nil }
func (fwdTestContext) Permissions() *gliderssh.Permissions { return nil }
func (fwdTestContext) SetValue(key, value any) {}
// fwdTestSSHConn is a gossh.ServerConn stub for the TCP -R handler.
// OpenChannel returns an echo channel. What the client writes through the
// forwarded TCP connection comes back, so bicopy is exercised end to end.
type fwdTestSSHConn struct{ gossh.Conn }
// OpenChannel returns an echo channel so the accept loop and bicopy are
// exercised end to end. The handler type-asserts the ContextKeyConn value
// to gossh.Conn. A real server hands it a *gossh.ServerConn.
func (fwdTestSSHConn) OpenChannel(name string, data []byte) (gossh.Channel, <-chan *gossh.Request, error) {
if name != "forwarded-tcpip" {
return nil, nil, fmt.Errorf("unexpected channel type %q", name)
}
return newEchoChannel(), nil, nil
}
// echoChannel is a gossh.Channel that echoes its Write payload back out of
// Read. bicopy(ch, nc) then makes the TCP peer see its own writes.
type echoChannel struct {
buf chan []byte
closed chan struct{}
closeMu sync.Mutex
}
func newEchoChannel() *echoChannel {
return &echoChannel{buf: make(chan []byte, 16), closed: make(chan struct{})}
}
func (e *echoChannel) Read(p []byte) (int, error) {
select {
case b := <-e.buf:
return copy(p, b), nil
case <-e.closed:
return 0, io.EOF
}
}
func (e *echoChannel) Write(p []byte) (int, error) {
select {
case e.buf <- bytes.Clone(p):
return len(p), nil
case <-e.closed:
return 0, errors.New("channel closed")
}
}
func (e *echoChannel) Close() error {
e.closeMu.Lock()
defer e.closeMu.Unlock()
select {
case <-e.closed:
default:
close(e.closed)
}
return nil
}
func (e *echoChannel) CloseWrite() error { return nil }
func (e *echoChannel) SendRequest(string, bool, []byte) (bool, error) {
return false, nil
}
func (e *echoChannel) Stderr() io.ReadWriter { return e }
// fwdTmpDir creates a directory under /tmp, inside the socket-path
// allowlist, that the monitor's bound identity can use. Root runs create
// it as root and chmod it 0777 for the non-root bound user.
func fwdTmpDir(t *testing.T) string {
t.Helper()
dir, err := os.MkdirTemp("/tmp", "tailssh-fwd-test-")
if err != nil {
t.Fatal(err)
}
if err := os.Chmod(dir, 0o777); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.RemoveAll(dir) })
return dir
}
// statOwnerMode returns the owner uid and permission bits of path.
func statOwnerMode(t *testing.T, path string) (int, os.FileMode) {
t.Helper()
fi, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
st, ok := fi.Sys().(*syscall.Stat_t)
if !ok {
t.Fatalf("stat %q: unsupported syscall.Stat_t type %T", path, fi.Sys())
}
return int(st.Uid), fi.Mode().Perm()
}
// setDeadlineConn gives every blocking read/write in these tests a
// deadline so nothing can wedge the suite.
func setDeadlineConn(t *testing.T, c net.Conn) {
t.Helper()
dl := time.Now().Add(10 * time.Second)
if err := c.SetDeadline(dl); err != nil {
t.Fatal(err)
}
}
// TestFwdConnLocalUnixDial drives mayForwardLocalUnixTo (unix -L) through
// a real monitor: a dial to an accessible socket in /tmp returns a live
// connection dialed as the bound identity, and paths outside the
// allowlist (or relative/abstract) are rejected.
func TestFwdConnLocalUnixDial(t *testing.T) {
lu := fwdTestIdentity(t)
c := newFwdTestConn(t, lu, &tailcfg.SSHAction{
Accept: true,
AllowLocalPortForwarding: true,
})
socketPath := filepath.Join(fwdTmpDir(t), "service.sock")
ln, err := net.Listen("unix", socketPath)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ln.Close() })
// The test process owns the socket and may be root while the conn
// binds to the non-root test user, so make it connectable by the
// bound identity. TestFwdDialRoundTrip and TestFwdDialCrossUser pin
// the permission-denied direction; here we assert the conn-level path
// returns a live conn.
if err := os.Chmod(socketPath, 0o666); err != nil {
t.Fatal(err)
}
go func() {
for {
sc, err := ln.Accept()
if err != nil {
return
}
go func() {
defer sc.Close()
io.Copy(sc, sc)
}()
}
}()
ctx, _ := fwdTestCtx(nil)
uc, err := c.mayForwardLocalUnixTo(ctx, socketPath)
if err != nil {
t.Fatalf("mayForwardLocalUnixTo(%q): %v", socketPath, err)
}
defer uc.Close()
setDeadlineConn(t, uc)
want := "GET /_ping HTTP/1.1\r\n\r\n"
if _, err := uc.Write([]byte(want)); err != nil {
t.Fatalf("write through forwarded conn: %v", err)
}
got := make([]byte, len(want))
if _, err := io.ReadFull(uc, got); err != nil {
t.Fatalf("read through forwarded conn: %v", err)
}
if string(got) != want {
t.Errorf("echo = %q; want %q", got, want)
}
// Paths outside the allowed directories are rejected.
for _, bad := range []string{"/var/run/docker.sock", "/etc/evil.sock"} {
_, err := c.mayForwardLocalUnixTo(ctx, bad)
if err == nil {
t.Errorf("dial %q: unexpectedly allowed", bad)
continue
}
if !errors.Is(err, gliderssh.ErrRejected) || !strings.Contains(err.Error(), "not in an allowed directory") {
t.Errorf("dial %q error = %v; want an ErrRejected \"not in an allowed directory\"", bad, err)
}
}
// Relative and abstract paths are rejected.
for _, bad := range []string{"relative.sock", "@/tmp/abstract.sock"} {
_, err := c.mayForwardLocalUnixTo(ctx, bad)
if !errors.Is(err, gliderssh.ErrRejected) {
t.Errorf("dial %q error = %v; want ErrRejected", bad, err)
}
}
}
// TestFwdConnReverseUnixListen drives mayReverseUnixForwardTo (unix -R):
// the returned listener's socket is 0600 and owned by the bound identity.
// Data flows through it, and a foreign-owned live socket in a sticky
// directory is not unlinked in the root run.
func TestFwdConnReverseUnixListen(t *testing.T) {
lu := fwdTestIdentity(t)
uid, _ := strconv.Atoi(lu.Uid)
c := newFwdTestConn(t, lu, &tailcfg.SSHAction{
Accept: true,
AllowLocalPortForwarding: true, // for the dial-side half of the round trip
AllowRemotePortForwarding: true,
})
socketPath := filepath.Join(fwdTmpDir(t), "reverse.sock")
ctx, _ := fwdTestCtx(nil)
ln, err := c.mayReverseUnixForwardTo(ctx, socketPath)
if err != nil {
t.Fatalf("mayReverseUnixForwardTo(%q): %v", socketPath, err)
}
t.Cleanup(func() { ln.Close() })
// Socket created by the monitor's child: 0600, owned by the bound
// identity (the non-root test user under root runs).
gotUid, mode := statOwnerMode(t, socketPath)
if mode != 0o600 {
t.Errorf("socket mode = %o; want 0600", mode)
}
if gotUid != uid {
t.Errorf("socket uid = %d; want %d (the session user)", gotUid, uid)
}
// Round trip: accept on the reverse listener, dial it through the
// conn's own -L path, which is self-consistent because the same user
// can reach its own 0600 socket.
go func() {
sc, err := ln.Accept()
if err != nil {
return
}
defer sc.Close()
io.Copy(sc, sc)
}()
uc, err := c.mayForwardLocalUnixTo(ctx, socketPath)
if err != nil {
t.Fatalf("dial reverse socket through -L path: %v", err)
}
defer uc.Close()
setDeadlineConn(t, uc)
want := "hello reverse"
if _, err := uc.Write([]byte(want)); err != nil {
t.Fatalf("write: %v", err)
}
got := make([]byte, len(want))
if _, err := io.ReadFull(uc, got); err != nil {
t.Fatalf("read: %v", err)
}
if string(got) != want {
t.Errorf("echo = %q; want %q", got, want)
}
// Root run: a foreign root-owned live socket in sticky /tmp is not
// unlinked by a bind from the non-root session user. The bind fails
// EADDRINUSE and the original socket still accepts. Subtest so the
// non-root round trip above passes on its own and this guard's skip
// cannot erase it.
t.Run("foreign-socket-not-unlinked", func(t *testing.T) {
if os.Getuid() != 0 {
t.Skipf("non-root run: bound identity is the current user, so the foreign-socket case is same-uid; TestFwdListenUnlinkIfOwned pins it at monitor level")
}
foreignPath := filepath.Join(fwdTmpDir(t), "foreign.sock")
foreignLn, err := net.Listen("unix", foreignPath)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { foreignLn.Close() })
go func() {
for {
sc, err := foreignLn.Accept()
if err != nil {
return
}
sc.Close()
}
}()
if _, err := c.mayReverseUnixForwardTo(ctx, foreignPath); err == nil {
t.Fatal("bind over foreign-owned socket: unexpectedly allowed")
} else if !strings.Contains(err.Error(), "address already in use") {
t.Errorf("bind over foreign socket error = %v; want EADDRINUSE", err)
}
// The foreign socket was not unlinked and still accepts.
if _, _, err := statOwnerModeOK(foreignPath); err != nil {
t.Fatalf("foreign socket unlinked by refused bind: %v", err)
}
if _, err := net.Dial("unix", foreignPath); err != nil {
t.Errorf("foreign socket no longer accepts: %v", err)
}
})
}
func statOwnerModeOK(path string) (int, os.FileMode, error) {
fi, err := os.Stat(path)
if err != nil {
return 0, 0, err
}
st, ok := fi.Sys().(*syscall.Stat_t)
if !ok {
return 0, 0, fmt.Errorf("unsupported Stat_t %T", fi.Sys())
}
return int(st.Uid), fi.Mode().Perm(), nil
}
// TestFwdTCPHandler drives the tailssh-owned tcpip-forward handler: a
// loopback bind goes through the monitor's user-cred listener child and
// accepts with data echoing through the SSH channel stub. Non-loopback
// binds are rejected unless TS_SSH_FORWARD_GATEWAY_PORTS is set, and a
// privileged port is refused for a non-root bound identity.
func TestFwdTCPHandler(t *testing.T) {
lu := fwdTestIdentity(t)
c := newFwdTestConn(t, lu, &tailcfg.SSHAction{
Accept: true,
AllowRemotePortForwarding: true,
})
ctx, cancel := fwdTestCtx(fwdTestSSHConn{})
t.Cleanup(cancel)
h := &fwdTCPHandler{conn: c}
tcpReq := func(addr string, port uint32) *gossh.Request {
return &gossh.Request{
Type: "tcpip-forward",
WantReply: true,
Payload: gossh.Marshal(fwdRemoteForwardRequest{BindAddr: addr, BindPort: port}),
}
}
// 127.0.0.1:0 binds via the monitor's child and accepts. Data written
// by a local dialer echoes back through the SSH channel stub.
ok, payload := h.HandleSSHRequest(ctx, nil, tcpReq("127.0.0.1", 0))
if !ok {
t.Fatalf("tcpip-forward 127.0.0.1:0 rejected: %s", payload)
}
var succ fwdRemoteForwardSuccess
if err := gossh.Unmarshal(payload, &succ); err != nil {
t.Fatal(err)
}
if succ.BindPort == 0 {
t.Fatal("reply carried no concrete port")
}
fwdAddr := net.JoinHostPort("127.0.0.1", strconv.Itoa(int(succ.BindPort)))
tc, err := net.DialTimeout("tcp", fwdAddr, 5*time.Second)
if err != nil {
t.Fatalf("dial forwarded listener %s: %v", fwdAddr, err)
}
defer tc.Close()
setDeadlineConn(t, tc)
want := "ping through reverse tcp forward"
if _, err := tc.Write([]byte(want)); err != nil {
t.Fatalf("write to forwarded listener: %v", err)
}
got := make([]byte, len(want))
if _, err := io.ReadFull(tc, got); err != nil {
t.Fatalf("read from forwarded listener: %v", err)
}
if string(got) != want {
t.Errorf("echo = %q; want %q", got, want)
}
// Connection cancellation must close the forwarded listener.
cancel()
eventually(t, 5*time.Second, "forwarded listener remained open after cancellation", func() bool {
conn, err := net.DialTimeout("tcp", fwdAddr, time.Second)
if err != nil {
return true
}
conn.Close()
return false
})
// Non-loopback binds are rejected without the gateway-ports knob…
ctx2, cancel2 := fwdTestCtx(fwdTestSSHConn{})
t.Cleanup(cancel2)
if ok, msg := h.HandleSSHRequest(ctx2, nil, tcpReq("0.0.0.0", 0)); ok {
t.Fatal("tcpip-forward 0.0.0.0:0 allowed without TS_SSH_FORWARD_GATEWAY_PORTS")
} else if !bytes.Contains(msg, []byte("non-loopback")) {
t.Errorf("rejection message = %q; want non-loopback notice", msg)
}
// …and allowed with it. The knob widens address policy only, and the
// bind still runs with the user's credentials.
envknob.SetenvForTest(t, "TS_SSH_FORWARD_GATEWAY_PORTS", "1")
if ok, msg := h.HandleSSHRequest(ctx2, nil, tcpReq("0.0.0.0", 0)); !ok {
t.Fatalf("tcpip-forward 0.0.0.0:0 rejected with TS_SSH_FORWARD_GATEWAY_PORTS: %s", msg)
}
cancel2()
// Privileged ports fail at the kernel for the non-root bound
// identity, matching OpenSSH's rule that only root binds privileged
// ports. The port-80 refusal above pins that the bind runs as the
// bound user. Only where the kernel enforces the restriction:
// privileged CI containers often set ip_unprivileged_port_start to
// 0, which lets any uid bind any port, and darwin does not restrict
// the range at all.
if unprivilegedPrivilegedBinds() {
t.Log("skipping the privileged-port refusal: the kernel allows unprivileged privileged binds")
} else {
ctx3, cancel3 := fwdTestCtx(fwdTestSSHConn{})
t.Cleanup(cancel3)
if ok, msg := h.HandleSSHRequest(ctx3, nil, tcpReq("127.0.0.1", 80)); ok {
t.Fatalf("tcpip-forward 127.0.0.1:80 allowed for non-root bound identity %q", lu.Username)
} else {
t.Logf("privileged bind rejected: %s", msg)
}
cancel3()
}
}
// TestFwdTCPHandlerPolicyGate pins the handler's policy gates: the final
// action's AllowRemotePortForwarding and TS_SSH_DISABLE_FORWARDING.
func TestFwdTCPHandlerPolicyGate(t *testing.T) {
lu := fwdTestIdentity(t)
denied := newFwdTestConn(t, lu, &tailcfg.SSHAction{
Accept: true,
AllowRemotePortForwarding: false,
})
ctx, cancel := fwdTestCtx(fwdTestSSHConn{})
t.Cleanup(cancel)
h := &fwdTCPHandler{conn: denied}
req := &gossh.Request{
Type: "tcpip-forward",
WantReply: true,
Payload: gossh.Marshal(fwdRemoteForwardRequest{BindAddr: "127.0.0.1", BindPort: 0}),
}
if ok, msg := h.HandleSSHRequest(ctx, nil, req); ok {
t.Fatal("tcpip-forward allowed without AllowRemotePortForwarding")
} else if !bytes.Contains(msg, []byte("port forwarding is disabled")) {
t.Errorf("rejection message = %q", msg)
}
envknob.SetenvForTest(t, "TS_SSH_DISABLE_FORWARDING", "1")
if ok, msg := h.HandleSSHRequest(ctx, nil, req); ok {
t.Fatal("tcpip-forward allowed with TS_SSH_DISABLE_FORWARDING")
} else if !bytes.Contains(msg, []byte("port forwarding is disabled")) {
t.Errorf("rejection message = %q", msg)
}
cancel()
}
// TestFwdDisableForwarding pins that TS_SSH_DISABLE_FORWARDING rejects all
// four forwarding paths.
func TestFwdDisableForwarding(t *testing.T) {
envknob.SetenvForTest(t, "TS_SSH_DISABLE_FORWARDING", "1")
lu := fwdTestIdentity(t)
c := newFwdTestConn(t, lu, &tailcfg.SSHAction{
Accept: true,
AllowLocalPortForwarding: true,
AllowRemotePortForwarding: true,
AllowAgentForwarding: true,
})
ctx, cancel := fwdTestCtx(fwdTestSSHConn{})
t.Cleanup(cancel)
if _, err := c.mayForwardLocalUnixTo(ctx, "/tmp/anything.sock"); !errors.Is(err, gliderssh.ErrRejected) {
t.Errorf("unix -L error = %v; want ErrRejected", err)
}
if _, err := c.mayReverseUnixForwardTo(ctx, "/tmp/anything.sock"); !errors.Is(err, gliderssh.ErrRejected) {
t.Errorf("unix -R error = %v; want ErrRejected", err)
}
h := &fwdTCPHandler{conn: c}
req := &gossh.Request{
Type: "tcpip-forward",
WantReply: true,
Payload: gossh.Marshal(fwdRemoteForwardRequest{BindAddr: "127.0.0.1", BindPort: 0}),
}
if ok, msg := h.HandleSSHRequest(ctx, nil, req); ok {
t.Error("TCP -R allowed with TS_SSH_DISABLE_FORWARDING")
} else if !bytes.Contains(msg, []byte("port forwarding is disabled")) {
t.Errorf("TCP -R rejection message = %q", msg)
}
// Agent forwarding silently does not start, as before.
ss, s := newAgentTestSession(t, c)
if err := ss.handleSSHAgentForwarding(s, lu); err != nil {
t.Errorf("handleSSHAgentForwarding: %v", err)
}
if ss.agentListener != nil {
t.Error("agent listener started with TS_SSH_DISABLE_FORWARDING")
}
cancel()
}
// TestFwdFullStackLocalUnix is the full-stack -L test. A real SSH
// connection from an sshtest client over memnet reaches a real tailssh
// server with a real policy, with SSHUsers mapping alice to the bound
// user. The direct-streamlocal dial goes through the conn's lazily
// spawned real monitor and its one-shot fwd child. The service reads a
// fixed-length payload under a deadline, echoes it, and on linux reports
// the dialer's peer uid, which must be the monitor's bound uid.
func TestFwdFullStackLocalUnix(t *testing.T) {
lu := fwdTestIdentity(t)
boundUID, err := strconv.Atoi(lu.Uid)
if err != nil {
t.Fatalf("parse bound uid %q: %v", lu.Uid, err)
}
// The monitor seam overrides must be in place before the conn lazily
// spawns its monitor on the -L dial.
exe := makeExecutableReachable(t)
t.Setenv("TS_TEST_SSH_FWD_PREFIX", strings.Join(testFwdPrefix, " "))
old := monitorSpawnPrefix
monitorSpawnPrefix = testMonitorPrefix
t.Cleanup(func() { monitorSpawnPrefix = old })
// The service socket is in the allowlist's /tmp and is created by the
// test process, which may be root while the conn binds to the
// non-root test user, so it must be reachable by the bound identity.
socketPath := filepath.Join(fwdTmpDir(t), "svc.sock")
ln, err := net.Listen("unix", socketPath)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ln.Close() })
if err := os.Chmod(socketPath, 0o666); err != nil {
t.Fatal(err)
}
payload := []byte("GET /_ping HTTP/1.1\r\n\r\n") // fixed, known length
type serviceResult struct {
peerUID int
err error
}
svc := make(chan serviceResult, 1)
go func() {
sc, err := ln.Accept()
if err != nil {
svc <- serviceResult{err: err}
return
}
defer sc.Close()
if err := sc.SetDeadline(time.Now().Add(10 * time.Second)); err != nil {
svc <- serviceResult{err: err}
return
}
got := make([]byte, len(payload))
if _, err := io.ReadFull(sc, got); err != nil {
svc <- serviceResult{err: fmt.Errorf("service read: %w", err)}
return
}
if !bytes.Equal(got, payload) {
svc <- serviceResult{err: fmt.Errorf("service got %q, want %q", got, payload)}
return
}
// The dialer is the monitor's fwd child, so its credentials are
// the conn's bound identity. peerCredUID is linux-only.
peerUID := -1
if runtime.GOOS == "linux" {
peerUID, err = peerCredUID(sc)
if err != nil {
svc <- serviceResult{err: fmt.Errorf("service peer cred: %w", err)}
return
}
}
if _, err := sc.Write(payload); err != nil {
svc <- serviceResult{err: fmt.Errorf("service echo: %w", err)}
return
}
svc <- serviceResult{peerUID: peerUID}
}()
// The server runs a real auth flow with alice mapped to the bound
// local user and -L forwarding allowed.
rule := &tailcfg.SSHRule{
SSHUsers: map[string]string{"alice": lu.Username},
Action: &tailcfg.SSHAction{Accept: true, AllowLocalPortForwarding: true},
Principals: []*tailcfg.SSHPrincipal{
{Any: true},
},
}
s := &server{
logf: t.Logf,
tailscaledPath: exe, // the reachable copy; see makeExecutableReachable
lb: &localState{
sshEnabled: true,
varRoot: t.TempDir(),
matchingRule: rule,
},
}
src, dst := netip.MustParseAddrPort("100.100.100.101:2231"), netip.MustParseAddrPort("100.100.100.102:22")
clientConn, serverConn := memnet.NewTCPConn(src, dst, 1024)
// Deadline on the client side of the memnet transport, so nothing can
// wedge the test waiting on a transport that stopped making progress.
if err := clientConn.SetDeadline(time.Now().Add(30 * time.Second)); err != nil {
t.Fatal(err)
}
var wg sync.WaitGroup
wg.Go(func() {
cl, chans, reqs, err := testssh.NewClientConn(clientConn, clientConn.RemoteAddr().String(), &testssh.ClientConfig{
User: "alice",
HostKeyCallback: testssh.InsecureIgnoreHostKey(),
Timeout: 15 * time.Second,
})
if err != nil {
t.Errorf("client handshake: %v", err)
return
}
client := testssh.NewClient(cl, chans, reqs)
defer client.Close()
uc, err := client.Dial("unix", socketPath)
if err != nil {
t.Errorf("client -L dial %q: %v", socketPath, err)
return
}
defer uc.Close()
// No deadline on the channel-backed conn because x/crypto channels
// do not support deadlines. The memnet transport deadline above
// and the fixed-length reads bound this side instead.
if _, err := uc.Write(payload); err != nil {
t.Errorf("write through forwarded conn: %v", err)
return
}
got := make([]byte, len(payload))
if _, err := io.ReadFull(uc, got); err != nil {
t.Errorf("read through forwarded conn: %v", err)
return
}
if !bytes.Equal(got, payload) {
t.Errorf("echo = %q; want %q", got, payload)
}
})
if err := s.HandleSSHConn(serverConn); err != nil {
t.Errorf("HandleSSHConn: %v", err)
}
wg.Wait()
select {
case r := <-svc:
if r.err != nil {
t.Fatalf("service: %v", r.err)
}
if runtime.GOOS == "linux" && r.peerUID != boundUID {
t.Errorf("service observed peer uid %d; want %d (the conn's bound identity, not root tailscaled)", r.peerUID, boundUID)
}
case <-time.After(10 * time.Second):
t.Fatal("service never saw the forwarded connection")
}
}
// fwdTestCtxTimeout is fwdTestCtx with a deadline. The race test's dial
// attempts each carry a short budget so nothing can wedge the loop.
func fwdTestCtxTimeout(timeout time.Duration) (gliderssh.Context, context.CancelFunc) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
return fwdTestContext{Context: ctx, Mutex: &sync.Mutex{}, conn: nil}, cancel
}
// canaryListener is a marker-emitting canary unix socket for the
// root-dial-TOCTOU regression tests. Every accepted connection is served
// exactly one marker write and closed. markers is buffered so the accept
// loop never blocks. The test drains it to assert which side ever got
// served.
type canaryListener struct {
ln *net.UnixListener
markers chan string
done chan struct{} // closed when the accept loop exits
}
// listenCanary binds the canary socket at path with a umask-at-bind mode
// and starts its marker-emitting accept loop. See canaryListener for the
// markers channel semantics. The mode 0o600 comes from umask 0o177 and
// 0o666 from umask 0o111, so the socket is created directly at the
// requested mode.
func listenCanary(t *testing.T, path string, mode int, marker string) *canaryListener {
t.Helper()
old := syscall.Umask(0o777 &^ mode)
ln, err := net.Listen("unix", path)
syscall.Umask(old)
if err != nil {
t.Fatalf("listen canary %q: %v", path, err)
}
c := &canaryListener{
ln: ln.(*net.UnixListener),
markers: make(chan string, 64),
done: make(chan struct{}),
}
go func() {
defer close(c.done)
for {
sc, err := ln.Accept()
if err != nil {
return
}
sc.(*net.UnixConn).SetDeadline(time.Now().Add(5 * time.Second))
sc.Write([]byte(marker + "\n"))
sc.Close()
select {
case c.markers <- marker:
default:
}
}
}()
return c
}
// close stops the canary's accept loop and closes the socket.
// Closing the listener also unlinks the socket file.
func (c *canaryListener) close() {
c.ln.Close()
<-c.done
}
// TestFwdConnLocalUnixSymlinkToProtectedSocket pins the static-symlink
// variant of the root-dial race: a symlink inside an allowed directory
// may resolve anywhere, and the dial runs with the bound user's own
// permissions.
//
// The reachable half runs everywhere: a symlink inside the allowed /tmp
// dir to a socket the bound identity can open is served. The protected
// half is root-only: a root-owned 0600 canary outside the allowed
// directories, behind an in-allowlist symlink, must fail permission
// denied for the bound user. TestFwdDialCrossUser pins the same check
// at monitor level.
func TestFwdConnLocalUnixSymlinkToProtectedSocket(t *testing.T) {
lu := fwdTestIdentity(t)
c := newFwdTestConn(t, lu, &tailcfg.SSHAction{
Accept: true,
AllowLocalPortForwarding: true,
})
dir := fwdTmpDir(t)
ctx, cancel := fwdTestCtx(nil)
t.Cleanup(cancel)
// Reachable half, in every run. The allowlist does not resolve
// symlinks, so the user's own connect permission is the boundary.
reachable := filepath.Join(dir, "reachable.sock")
rl, err := net.Listen("unix", reachable)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { rl.Close() })
if err := os.Chmod(reachable, 0o666); err != nil {
t.Fatal(err)
}
go func() {
for {
sc, err := rl.Accept()
if err != nil {
return
}
go func() {
defer sc.Close()
sc.SetDeadline(time.Now().Add(10 * time.Second))
io.Copy(sc, sc)
}()
}
}()
okLink := filepath.Join(dir, "ok-link.sock")
if err := os.Symlink(reachable, okLink); err != nil {
t.Fatal(err)
}
uc, err := c.mayForwardLocalUnixTo(ctx, okLink)
if err != nil {
t.Fatalf("dial through in-allowlist symlink to reachable socket: %v", err)
}
setDeadlineConn(t, uc)
want := "symlink-dac"
if _, err := uc.Write([]byte(want)); err != nil {
t.Fatalf("write through symlinked forward: %v", err)
}
got := make([]byte, len(want))
if _, err := io.ReadFull(uc, got); err != nil {
t.Fatalf("read through symlinked forward: %v", err)
}
if string(got) != want {
t.Errorf("echo through symlinked forward = %q, want %q", got, want)
}
uc.Close()
// Protected half (root runs only): a root-owned 0600 canary outside
// the allowed directories, behind an in-allowlist symlink. Subtest so
// the reachable half above passes on its own and this guard's skip
// cannot erase it.
t.Run("protected-socket-dac", func(t *testing.T) {
if os.Getuid() != 0 {
t.Skipf("protected half is root-only: creating a socket protected against the bound user %q requires another uid; a root run of the suite exercises it (monitor-level DAC pin: TestFwdDialCrossUser)", lu.Username)
}
escape := filepath.Join("/var/run", fmt.Sprintf("tailssh-fwd-symlink-%d", os.Getpid()))
if err := os.MkdirAll(escape, 0o755); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.RemoveAll(escape) })
protected := filepath.Join(escape, "canary.sock")
canary := listenCanary(t, protected, 0o600, "PROTECTED")
t.Cleanup(canary.close)
evilLink := filepath.Join(dir, "evil-link.sock")
if err := os.Symlink(protected, evilLink); err != nil {
t.Fatal(err)
}
// Every dial attempt through the symlink must fail with the kernel's
// EACCES as the bound user. None may reach the canary.
for i := 0; i < 5; i++ {
uc, err := c.mayForwardLocalUnixTo(ctx, evilLink)
if err == nil {
uc.Close()
t.Fatalf("root-dial regression: dial through in-allowlist symlink to root-owned 0600 socket SUCCEEDED as bound user %q", lu.Username)
}
if errors.Is(err, gliderssh.ErrRejected) {
t.Errorf("dial %q: allowlist rejected a lexically-allowed symlink path (%v); the boundary must be DAC, not path strings", evilLink, err)
}
if !strings.Contains(err.Error(), "permission denied") {
t.Errorf("dial %q error = %v, want permission denied (the bound user's DAC)", evilLink, err)
}
}
// Control dial: the canary is served directly, without the forward,
// proving it is alive.
ctrl, err := net.Dial("unix", protected)
if err != nil {
t.Fatalf("control dial of canary: %v", err)
}
if err := ctrl.SetDeadline(time.Now().Add(5 * time.Second)); err != nil {
t.Fatal(err)
}
buf := make([]byte, 64)
n, err := io.ReadFull(ctrl, buf[:len("PROTECTED\n")])
if err != nil {
t.Fatalf("control read of canary marker: %v", err)
}
if string(buf[:n]) != "PROTECTED\n" {
t.Fatalf("canary control marker = %q", buf[:n])
}
ctrl.Close()
select {
case m := <-canary.markers:
if m != "PROTECTED" {
t.Fatalf("unexpected canary marker %q", m)
}
case <-time.After(5 * time.Second):
// Reading the socket marker does not synchronize with the
// canary goroutine's subsequent channel send.
t.Fatal("control marker not recorded by canary")
}
// The five forwarded attempts through the symlink were all refused.
if got := len(canary.markers); got != 0 {
t.Errorf("canary served %d extra connections after the control dial; the forwarded attempts reached the protected socket (root-dial race regression)", got)
}
})
}
// TestFwdConnLocalUnixDirSwapRaceNeverServesProtectedSocket is the
// direct pin of the race variant of the root-dial race: a parent
// directory is concurrently switched between the real directory and a
// symlink to an escape directory while forwarded dial attempts run. The
// dial runs as the connection's bound user, so a root-owned 0600 canary
// behind the raced path must never be served, and EACCES-classified
// attempts prove the symlink windows were hit. Root runs only.
func TestFwdConnLocalUnixDirSwapRaceNeverServesProtectedSocket(t *testing.T) {
if os.Getuid() != 0 {
t.Skip("root-only test: the race pin needs a protected (root-owned 0600) canary facing a non-root bound user; run under sudo")
}
lu := fwdTestIdentity(t)
c := newFwdTestConn(t, lu, &tailcfg.SSHAction{
Accept: true,
AllowLocalPortForwarding: true,
})
base := fwdTmpDir(t) // 0777, inside the allowlist
owned := filepath.Join(base, "owned")
aside := filepath.Join(base, "aside")
for _, d := range []string{owned, aside} {
if err := os.Mkdir(d, 0o755); err != nil {
t.Fatal(err)
}
}
// The escape directory is outside the tested path's own tree, like
// /var/run/docker.sock. It is traversable by every user, and its
// canary is protected by socket mode alone.
escape := filepath.Join("/var/run", fmt.Sprintf("tailssh-fwd-race-%d", os.Getpid()))
if err := os.MkdirAll(escape, 0o755); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.RemoveAll(escape) })
canaryPath := filepath.Join(escape, "canary.sock")
dialPath := filepath.Join(owned, "canary.sock")
// The swap loop alternates <owned> between the real empty directory
// and a symlink to the escape directory. It holds each state about
// 3ms so both are well represented across the dial attempts. A
// failed step restores the real-dir state, so the loop self-heals.
stop := make(chan struct{})
swapsDone := make(chan struct{})
var swaps, attempts, dials, eacces, enoent atomic.Int64
hold := func() {
select {
case <-stop:
case <-time.After(3 * time.Millisecond):
}
}
go func() {
defer close(swapsDone)
for {
select {
case <-stop:
return
default:
}
os.Remove(aside)
if err := os.Rename(owned, aside); err != nil {
continue
}
if err := os.Symlink(escape, owned); err != nil {
os.Rename(aside, owned)
continue
}
swaps.Add(1)
hold() // symlink state is in place
os.Remove(owned)
if err := os.Rename(aside, owned); err != nil {
os.Mkdir(owned, 0o755)
}
hold()
}
}()
t.Cleanup(func() {
close(stop)
<-swapsDone
})
// One forwarded dial attempt for the raced path, classified.
probe := func() (servedMarker string, class string) {
gctx, cancel := fwdTestCtxTimeout(8 * time.Second)
defer cancel()
attempts.Add(1)
uc, err := c.mayForwardLocalUnixTo(gctx, dialPath)
if err != nil {
switch s := err.Error(); {
case strings.Contains(s, "permission denied"):
eacces.Add(1)
return "", "eacces"
case strings.Contains(s, "no such file"):
enoent.Add(1)
return "", "enoent"
default:
return "", "err"
}
}
dials.Add(1)
defer uc.Close()
if err := uc.SetDeadline(time.Now().Add(3 * time.Second)); err != nil {
t.Errorf("SetDeadline on forward conn: %v", err)
return "", "ok"
}
buf := make([]byte, 128)
n, _ := uc.Read(buf)
return strings.TrimSpace(string(buf[:n])), "ok"
}
const controlMarker = "CTL-CANARY"
const protectedMarker = "PRT-CANARY"
// In the control phase, an accessible 0666 canary proves the harness
// delivers markers through raced forwards when the target is
// user-accessible.
control := listenCanary(t, canaryPath, 0o666, controlMarker)
served := 0
for i := 0; i < 60; i++ {
if marker, class := probe(); class == "ok" {
if marker != controlMarker {
t.Fatalf("control phase served unexpected marker %q", marker)
}
served++
break
}
}
if served == 0 {
t.Fatalf("control configuration never delivered a marker; cannot attest the race harness (swaps so far: %d, attempts: %d)", swaps.Load(), attempts.Load())
}
t.Logf("control configuration: marker delivered (attempts so far: %d, swaps: %d)", attempts.Load(), swaps.Load())
control.close()
// The stdlib unix listener's Close already unlinked the socket, so
// the removal tolerates ENOENT. The path must be gone before the
// protected canary rebinds it.
if err := os.Remove(canaryPath); err != nil && !os.IsNotExist(err) {
t.Fatal(err)
}
// In the protected phase, a root-owned 0600 canary behind the very same
// raced path. It must never serve.
protected := listenCanary(t, canaryPath, 0o600, protectedMarker)
t.Cleanup(protected.close)
const raceAttempts = 200
for i := 0; i < raceAttempts; i++ {
marker, class := probe()
if class == "ok" {
t.Fatalf("root-dial race regression: attempt %d reached the protected canary through the raced forward (marker %q)", i, marker)
}
if marker != "" {
t.Fatalf("root-dial regression: attempt %d received %q from beyond the forward; the protected canary was served", i, marker)
}
}
if got := len(protected.markers); got != 0 {
t.Fatalf("REGRESSION: protected canary served %d connections through the raced forward", got)
}
if got := eacces.Load(); got == 0 {
t.Fatalf("no dial attempt hit the protected canary through the swapped symlink (%d attempts, %d swaps); the race harness did not exercise the symlink state", attempts.Load(), swaps.Load())
}
if got := enoent.Load(); got == 0 {
t.Logf("note: no ENOENT-classified attempt observed (%d attempts)", attempts.Load())
}
t.Logf("protected canary never served: %d total attempts, %d succeeded, %d EACCES (symlink state hit, DAC-refused), %d ENOENT, %d swaps",
attempts.Load(), dials.Load(), eacces.Load(), enoent.Load(), swaps.Load())
}
// TestMonExitRegistryPIDReuse checks both exit-before-registration and
// registration-before-exit when two child generations share a PID.
func TestMonExitRegistryPIDReuse(t *testing.T) {
var r monExitRegistry
old := monitorProcess{PID: 42, ID: 1}
fresh := monitorProcess{PID: 42, ID: 2}
ch, ch2 := newMonExitStatus(), newMonExitStatus()
r.dispatchExit(old, 7)
r.registerSessionExit(fresh, ch2)
r.registerSessionExit(old, ch)
<-ch.done
if ch.code != 7 {
t.Fatalf("old exit = %d; want 7", ch.code)
}
select {
case <-ch2.done:
t.Fatal("old notification completed the new child")
default:
}
r.dispatchExit(fresh, 9)
<-ch2.done
if ch2.code != 9 || ch.code != 7 {
t.Fatalf("exit codes = %d, %d; want 7, 9", ch.code, ch2.code)
}
}
// TestFwdAllowedSocketPathCaps pins the allowlist's two lexical caps:
// a home directory that cleans to "/" must not collapse the allowlist
// to "every absolute path", and over-long paths beyond the sun_path
// capacity reject lexically.
func TestFwdAllowedSocketPathCaps(t *testing.T) {
for _, home := range []string{"/", "//", ""} {
c := &conn{localUser: &userMeta{User: user.User{Username: "u", Uid: "1000", Gid: "1000", HomeDir: home}}}
for _, bad := range []string{"/var/run/docker.sock", "/etc/passwd", "/root/x.sock", "/home"} {
if _, err := c.fwdAllowedSocketPath(bad); err == nil {
t.Errorf("fwdAllowedSocketPath(%q) with HomeDir %q = nil error; want rejection (a /-home must not match every path)", bad, home)
}
}
// The unconditional entries survive a collapsed home.
for _, ok := range []string{"/tmp/x.sock", "/run/user/1000/x.sock"} {
if _, err := c.fwdAllowedSocketPath(ok); err != nil {
t.Errorf("fwdAllowedSocketPath(%q) with HomeDir %q: %v; want allowed", ok, home, err)
}
}
}
// The sun_path length cap rejects at and above the cap and passes
// below, still inside an allowed dir.
c := &conn{localUser: &userMeta{User: user.User{Username: "u", Uid: "1000", Gid: "1000", HomeDir: "/home/u"}}}
if _, err := c.fwdAllowedSocketPath("/tmp/" + strings.Repeat("a", fwdMaxSunPathLen)); err == nil {
t.Errorf("path of length %d accepted; want the sun_path cap to reject", len("/tmp/")+fwdMaxSunPathLen)
}
if _, err := c.fwdAllowedSocketPath("/tmp/" + strings.Repeat("a", fwdMaxSunPathLen-6)); err != nil {
t.Errorf("path below the sun_path cap rejected: %v", err)
}
}
// TestConnMonitorClosedRefusesRespawn pins the closed flag on connMonitorState.
// See connMonitorState.closed. Once the conn's teardown has run, connMonitor must
// refuse to spawn for every late caller, including session launch, unix
// and TCP forwarding, and agent forwarding.
func TestConnMonitorClosedRefusesRespawn(t *testing.T) {
lu := fwdTestIdentity(t)
c := newFwdTestConn(t, lu, &tailcfg.SSHAction{Accept: true})
if _, err := c.connMonitor(); err != nil {
t.Fatalf("spawning monitor: %v", err)
}
c.closeConnMonitor()
if _, err := c.connMonitor(); err == nil {
t.Fatal("connMonitor spawned a monitor after closeConnMonitor; want refusal (nobody would close it)")
}
if mc := c.connMonitorIfAny(); mc != nil {
t.Fatal("connMonitor left a monitor in monitor.mon after closeConnMonitor")
}
// Closing again stays a no-op.
c.closeConnMonitor()
}
// TestRemoveAgentForwardingDirRefuses pins the session-end removal guard
// for the agent-forwarding directory. The directory was created by a
// child running as the connection's user and the removal runs with the
// daemon's credentials, so only the shape the child was supposed to
// produce — a real directory (never a symlink) named auth-agent*,
// directly inside the root-owned agentDirRoot, owned by the connection's
// user with no group or other access — is removed. A symlink entry, a
// directory outside agentDirRoot, and a loosened mode are all refused
// and left in place.
func TestRemoveAgentForwardingDirRefuses(t *testing.T) {
if fi, err := os.Stat(agentDirRoot); err != nil || !fi.Mode().IsDir() {
t.Skipf("agentDirRoot %s unavailable: %v", agentDirRoot, err)
}
if st, ok := fi2stat(t, agentDirRoot); !ok || st.Uid != 0 {
t.Skipf("agentDirRoot %s is not root-owned; the pin needs the standard temp root", agentDirRoot)
}
lu := &userMeta{User: user.User{Uid: strconv.Itoa(os.Getuid()), Username: "self"}}
// Positive control: the real shape is removed.
dir, err := os.MkdirTemp(agentDirRoot, "auth-agent")
if err != nil {
t.Fatal(err)
}
removeAgentForwardingDir(dir, lu, t.Logf)
if _, err := os.Stat(dir); !os.IsNotExist(err) {
t.Fatalf("agent dir %q still exists after removal (err=%v)", dir, err)
}
// A symlink entry is never followed: the symlink is refused and its
// target untouched.
d, err := os.MkdirTemp(agentDirRoot, "auth-agent")
if err != nil {
t.Fatal(err)
}
os.Remove(d)
target := t.TempDir()
if err := os.Symlink(target, d); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.Remove(d) })
removeAgentForwardingDir(d, lu, t.Logf)
if _, err := os.Stat(d); err != nil {
t.Fatalf("steered symlink entry %q was removed", d)
}
if _, err := os.Stat(target); err != nil {
t.Fatalf("symlink target %q removed through the steered entry", target)
}
// A directory outside agentDirRoot is refused, whatever its name.
outside := filepath.Join(t.TempDir(), "auth-agent-outside")
if err := os.Mkdir(outside, 0o700); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.RemoveAll(outside) })
removeAgentForwardingDir(outside, lu, t.Logf)
if _, err := os.Stat(outside); err != nil {
t.Fatalf("outside dir %q was removed", outside)
}
// A loosened mode is refused: the dir stays behind.
loose, err := os.MkdirTemp(agentDirRoot, "auth-agent")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.RemoveAll(loose) })
if err := os.Chmod(loose, 0o711); err != nil {
t.Fatal(err)
}
removeAgentForwardingDir(loose, lu, t.Logf)
if _, err := os.Stat(loose); err != nil {
t.Fatalf("loose-mode dir %q was removed", loose)
}
}
// fi2stat returns the syscall.Stat_t of path, reporting ok=false when
// the platform does not expose one.
func fi2stat(t *testing.T, path string) (syscall.Stat_t, bool) {
t.Helper()
fi, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
st, ok := fi.Sys().(*syscall.Stat_t)
return *st, ok
}