Something went wrong. Try again.
forked from tailscale/tailscale
Something went wrong. Try again.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313// 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}