mirror of
https://github.com/tailscale/tailscale.git
synced 2026-10-29 14:39:16 -04:00
Keep the dialed service socket in the credentialed forwarding child and return a private socketpair endpoint instead. Passing the service socket to tailscaled preserves connect-time peer credentials but causes payload SCM_CREDENTIALS to identify the root daemon. Keep dial children alive for bidirectional forwarding. Return a lifetime pipe alongside the data socket so full closure or a discarded dial reply ends the relay independently of data EOF. Preserve half-closes and the monitor's descriptor acknowledgment/teardown ownership for both fds. Listener children retain their existing descriptor handoff behavior. Handle local Unix forwarding channel closure independently of the SSH connection: the channel request stream closes on CHANNEL_CLOSE, not CHANNEL_EOF. Stop waiting for service output while giving queued client payload a bounded drain in each forwarding stage, so a silent or non-reading service cannot retain the relay indefinitely. Test relay exit/reaping with silent services, discarded replies, queued payload and backpressure, including end-to-end SSH channel closure while the transport stays connected. Fix a canary test's unsynchronized marker assertion to wait for the reporting goroutine. Add a root-run cross-UID SO_PASSCRED regression test and lifecycle coverage for both half-close directions and monitor shutdown with an idle relay. Updates tailscale/corp#50380 Signed-off-by: James Tucker <james@tailscale.com> Change-Id: I54b8ddf02b9d82434e474fea2ec283c0e39fab3e
254 lines
6.9 KiB
Go
254 lines
6.9 KiB
Go
// Copyright (c) Tailscale Inc & contributors
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
//go:build linux && !android
|
|
|
|
package tailssh
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/netip"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
|
|
"golang.org/x/sys/unix"
|
|
"tailscale.com/net/memnet"
|
|
"tailscale.com/tailcfg"
|
|
testssh "tailscale.com/tempfork/sshtest/ssh"
|
|
"tailscale.com/tstest"
|
|
)
|
|
|
|
// TestFwdFullStackLocalUnixPayloadCredentials checks the credentials on
|
|
// payload writes, not just the peer credentials established at dial time.
|
|
func TestFwdFullStackLocalUnixPayloadCredentials(t *testing.T) {
|
|
if os.Getuid() != 0 {
|
|
t.Skip("cross-UID payload credentials require a root-run server; same-UID forwarding is tested by TestFwdFullStackLocalUnix")
|
|
}
|
|
lu := fwdTestIdentity(t)
|
|
boundUID, err := strconv.Atoi(lu.Uid)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
boundGID, err := strconv.Atoi(lu.Gid)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if boundUID == os.Getuid() {
|
|
t.Fatal("test identity must differ from server identity")
|
|
}
|
|
|
|
exe := makeExecutableReachable(t)
|
|
t.Setenv("TS_TEST_SSH_FWD_PREFIX", strings.Join(testFwdPrefix, " "))
|
|
tstest.Replace(t, &monitorSpawnPrefix, testMonitorPrefix)
|
|
|
|
// A 0700 directory owned by the mapped user and a 0600 socket keep
|
|
// this canary private to the mapped user (and the root test process).
|
|
// Use /tmp explicitly: the full-stack forwarding allowlist rejects
|
|
// arbitrary TMPDIR values.
|
|
dir, err := os.MkdirTemp("/tmp", "tailssh-payload-creds-")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { os.RemoveAll(dir) })
|
|
if err := os.Chown(dir, boundUID, boundGID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
socketPath := filepath.Join(dir, "payload-creds.sock")
|
|
// Arm SO_PASSCRED before accept so credentials on the first payload
|
|
// are preserved even if the sender writes before the server receives.
|
|
lc := net.ListenConfig{Control: func(_, _ string, c syscall.RawConn) error {
|
|
var sockErr error
|
|
if err := c.Control(func(fd uintptr) {
|
|
sockErr = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_PASSCRED, 1)
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
return sockErr
|
|
}}
|
|
ln, err := lc.Listen(context.Background(), "unix", socketPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { ln.Close() })
|
|
if err := os.Chown(socketPath, boundUID, boundGID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Chmod(socketPath, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
marker := []byte("ssh-unix-payload-marker")
|
|
type result struct {
|
|
uid int
|
|
err error
|
|
}
|
|
received := make(chan result, 2)
|
|
go func() {
|
|
for range 2 {
|
|
sc, err := ln.Accept()
|
|
if err != nil {
|
|
received <- result{err: err}
|
|
return
|
|
}
|
|
func() {
|
|
defer sc.Close()
|
|
uid, err := recvFwdPayloadCredentials(sc.(*net.UnixConn), marker)
|
|
if err == nil {
|
|
_, err = sc.Write(marker)
|
|
}
|
|
received <- result{uid: uid, err: err}
|
|
}()
|
|
}
|
|
}()
|
|
|
|
// The direct connection is a same-user control for SO_PASSCRED and
|
|
// recvmsg on this listener, independent of SSH forwarding.
|
|
direct, err := net.Dial("unix", socketPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := direct.SetDeadline(time.Now().Add(10 * time.Second)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := direct.Write(marker); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := make([]byte, len(marker))
|
|
if _, err := io.ReadFull(direct, got); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
direct.Close()
|
|
if !bytes.Equal(got, marker) {
|
|
t.Fatalf("direct echo = %q; want %q", got, marker)
|
|
}
|
|
select {
|
|
case r := <-received:
|
|
if r.err != nil || r.uid != os.Getuid() {
|
|
t.Fatalf("direct payload credentials = uid %d, err %v; want uid %d", r.uid, r.err, os.Getuid())
|
|
}
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatal("direct service receive timed out")
|
|
}
|
|
|
|
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,
|
|
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)
|
|
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: %v", err)
|
|
return
|
|
}
|
|
defer uc.Close()
|
|
if _, err := uc.Write(marker); err != nil {
|
|
t.Errorf("forwarded write: %v", err)
|
|
return
|
|
}
|
|
got := make([]byte, len(marker))
|
|
if _, err := io.ReadFull(uc, got); err != nil {
|
|
t.Errorf("forwarded read: %v", err)
|
|
return
|
|
}
|
|
if !bytes.Equal(got, marker) {
|
|
t.Errorf("forwarded echo = %q; want %q", got, marker)
|
|
}
|
|
})
|
|
if err := s.HandleSSHConn(serverConn); err != nil {
|
|
t.Errorf("HandleSSHConn: %v", err)
|
|
}
|
|
wg.Wait()
|
|
select {
|
|
case r := <-received:
|
|
if r.err != nil {
|
|
t.Fatalf("forwarded service receive: %v", r.err)
|
|
}
|
|
if r.uid != boundUID {
|
|
t.Errorf("forwarded payload SCM_CREDENTIALS uid = %d; want mapped uid %d (server uid %d)", r.uid, boundUID, os.Getuid())
|
|
}
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatal("forwarded service receive timed out")
|
|
}
|
|
}
|
|
|
|
// recvFwdPayloadCredentials receives the first payload bytes with recvmsg
|
|
// to inspect SCM_CREDENTIALS enabled on the listener before accept.
|
|
func recvFwdPayloadCredentials(sc *net.UnixConn, marker []byte) (int, error) {
|
|
f, err := sc.File()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer f.Close()
|
|
fd := int(f.Fd())
|
|
if err := unix.SetsockoptTimeval(fd, unix.SOL_SOCKET, unix.SO_RCVTIMEO, &unix.Timeval{Sec: 10}); err != nil {
|
|
return 0, err
|
|
}
|
|
got := make([]byte, len(marker))
|
|
oob := make([]byte, unix.CmsgSpace(unix.SizeofUcred))
|
|
n, oobn, flags, _, err := unix.Recvmsg(fd, got, oob, 0)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if n == 0 || flags&unix.MSG_CTRUNC != 0 {
|
|
return 0, fmt.Errorf("recvmsg: n=%d flags=%d", n, flags)
|
|
}
|
|
if _, err := io.ReadFull(sc, got[n:]); err != nil {
|
|
return 0, err
|
|
}
|
|
if !bytes.Equal(got, marker) {
|
|
return 0, fmt.Errorf("payload = %q; want %q", got, marker)
|
|
}
|
|
msgs, err := unix.ParseSocketControlMessage(oob[:oobn])
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
for _, msg := range msgs {
|
|
if msg.Header.Level == unix.SOL_SOCKET && msg.Header.Type == unix.SCM_CREDENTIALS {
|
|
cred, err := unix.ParseUnixCredentials(&msg)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return int(cred.Uid), nil
|
|
}
|
|
}
|
|
return 0, fmt.Errorf("recvmsg: no SCM_CREDENTIALS in %d control messages", len(msgs))
|
|
}
|