Files
James Tucker 6072693e91 ssh/tailssh: relay Unix forward payloads as the bound user
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
2026-10-26 11:34:49 -07:00

95 lines
2.5 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build (linux && !android) || (darwin && !ios) || freebsd || openbsd
package tailssh
import (
"io"
"net"
"os"
"path/filepath"
"testing"
"time"
)
// TestFwdDialRelayLifecycle checks that the extra socketpair preserves
// half-closes in both directions, and that monitor teardown closes an idle
// service connection even while the relay is waiting for traffic.
func TestFwdDialRelayLifecycle(t *testing.T) {
for _, mode := range []string{"client-half-close", "service-half-close", "monitor-close"} {
t.Run(mode, func(t *testing.T) {
mc := startFwdTestMonitor(t, os.Getuid(), os.Getgid(), testGroups(t))
dir := fwdWritableDir(t, os.Getuid(), os.Getgid())
path := filepath.Join(dir, "relay.sock")
ln, err := net.ListenUnix("unix", &net.UnixAddr{Name: path, Net: "unix"})
if err != nil {
t.Fatal(err)
}
defer ln.Close()
if err := ln.SetDeadline(time.Now().Add(5 * time.Second)); err != nil {
t.Fatal(err)
}
ctx, cancel := testCtx()
defer cancel()
client, err := mc.Dial(ctx, path)
if err != nil {
t.Fatal(err)
}
defer client.Close()
service, err := ln.AcceptUnix()
if err != nil {
t.Fatal(err)
}
defer service.Close()
for _, c := range []net.Conn{client, service} {
if err := c.SetDeadline(time.Now().Add(5 * time.Second)); err != nil {
t.Fatal(err)
}
}
readEOF := func(c net.Conn, want string) {
t.Helper()
got, err := io.ReadAll(c)
if err != nil || string(got) != want {
t.Fatalf("read to EOF = %q, %v; want %q, nil", got, err, want)
}
}
if mode == "monitor-close" {
if err := mc.Close(); err != nil {
t.Fatal(err)
}
readEOF(service, "")
readEOF(client, "")
return
}
var first, second interface {
net.Conn
CloseWrite() error
} = client, service
if mode == "service-half-close" {
first, second = second, first
}
if _, err := io.WriteString(first, "request"); err != nil {
t.Fatal(err)
}
if err := first.CloseWrite(); err != nil {
t.Fatal(err)
}
readEOF(second, "request")
// A write EOF must not prevent a response in the other direction.
if _, err := io.WriteString(second, "response"); err != nil {
t.Fatal(err)
}
if err := second.CloseWrite(); err != nil {
t.Fatal(err)
}
readEOF(first, "response")
if err := mc.Ping(ctx); err != nil {
t.Fatalf("monitor after relay completed: %v", err)
}
})
}
}