ssh: replace tempfork with tailscale/gliderssh

Brings in a newer version of Gliderlabs SSH with added socket forwarding support.

Fixes #12409
Fixes #5295

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
This commit is contained in:
Kristoffer Dalby
2026-03-16 12:04:59 +01:00
committed by Kristoffer Dalby
parent 82fa218c4a
commit dd3b613787
14 changed files with 460 additions and 172 deletions
+283 -8
View File
@@ -31,11 +31,11 @@ import (
"github.com/bramvdbogaerde/go-scp"
"github.com/google/go-cmp/cmp"
"github.com/pkg/sftp"
gliderssh "github.com/tailscale/gliderssh"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/agent"
"tailscale.com/net/tsdial"
"tailscale.com/tailcfg"
glider "tailscale.com/tempfork/gliderlabs/ssh"
"tailscale.com/types/key"
"tailscale.com/types/netmap"
"tailscale.com/util/set"
@@ -360,11 +360,11 @@ func TestSSHAgentForwarding(t *testing.T) {
})
// Run an SSH server that accepts connections from that client SSH key.
gs := glider.Server{
Handler: func(s glider.Session) {
gs := gliderssh.Server{
Handler: func(s gliderssh.Session) {
io.WriteString(s, "Hello world\n")
},
PublicKeyHandler: func(ctx glider.Context, key glider.PublicKey) error {
PublicKeyHandler: func(ctx gliderssh.Context, key gliderssh.PublicKey) error {
// Note - this is not meant to be cryptographically secure, it's
// just checking that SSH agent forwarding is forwarding the right
// key.
@@ -464,6 +464,233 @@ client.exec_command('pwd')
}
}
// TestLocalUnixForwarding tests direct-streamlocal@openssh.com, which is what
// podman remote (issue #12409) and VSCode Remote (issue #5295) use to reach
// Unix domain sockets on the remote host through SSH. The client opens a
// channel to a Unix socket path on the server, and data is proxied through.
func TestLocalUnixForwarding(t *testing.T) {
debugTest.Store(true)
t.Cleanup(func() {
debugTest.Store(false)
})
// Create a Unix socket server in /tmp that simulates a service like
// podman's API socket at /run/user/<uid>/podman/podman.sock.
socketDir, err := os.MkdirTemp("", "tailssh-test-")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.RemoveAll(socketDir) })
socketPath := filepath.Join(socketDir, "test-service.sock")
ul, err := net.Listen("unix", socketPath)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ul.Close() })
// The service echoes back whatever it receives, like an API server would.
go func() {
for {
conn, err := ul.Accept()
if err != nil {
return
}
go func() {
defer conn.Close()
io.Copy(conn, conn)
}()
}
}()
// Start Tailscale SSH server with local port forwarding enabled.
addr := testServerWithOpts(t, testServerOpts{
username: "testuser",
allowLocalPortForwarding: true,
})
// Connect to the Tailscale SSH server.
cl, err := ssh.Dial("tcp", addr, &ssh.ClientConfig{
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { cl.Close() })
// Open a direct-streamlocal@openssh.com channel to the Unix socket,
// exactly as podman remote does.
conn, err := cl.Dial("unix", socketPath)
if err != nil {
t.Fatalf("failed to dial unix socket through SSH: %s", err)
}
defer conn.Close()
// Send data through the tunnel and verify it echoes back.
want := "GET /_ping HTTP/1.1\r\nHost: d\r\n\r\n"
_, err = io.WriteString(conn, want)
if err != nil {
t.Fatalf("failed to write through tunnel: %s", err)
}
got := make([]byte, len(want))
_, err = io.ReadFull(conn, got)
if err != nil {
t.Fatalf("failed to read through tunnel: %s", err)
}
if string(got) != want {
t.Errorf("got %q, want %q", got, want)
}
}
// TestReverseUnixForwarding tests streamlocal-forward@openssh.com, which tools
// like VSCode Remote and Zed use to create Unix domain sockets on the remote
// host that forward connections back to the client through SSH.
func TestReverseUnixForwarding(t *testing.T) {
debugTest.Store(true)
t.Cleanup(func() {
debugTest.Store(false)
})
// Start Tailscale SSH server with remote port forwarding enabled.
addr := testServerWithOpts(t, testServerOpts{
username: "testuser",
allowRemotePortForwarding: true,
})
// Connect to the Tailscale SSH server.
cl, err := ssh.Dial("tcp", addr, &ssh.ClientConfig{
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { cl.Close() })
// Request reverse forwarding -- the server creates a Unix socket and
// forwards incoming connections back through the SSH tunnel.
socketDir, err := os.MkdirTemp("", "tailssh-test-")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.RemoveAll(socketDir) })
remoteSocketPath := filepath.Join(socketDir, "reverse.sock")
ln, err := cl.ListenUnix(remoteSocketPath)
if err != nil {
t.Fatalf("failed to request reverse unix forwarding: %s", err)
}
t.Cleanup(func() { ln.Close() })
// Verify the socket file was created on the server side.
if _, err := os.Stat(remoteSocketPath); err != nil {
t.Fatalf("reverse forwarded socket not created: %s", err)
}
// Accept a connection from the tunnel (client side) and write data.
want := "hello from reverse tunnel"
go func() {
conn, err := ln.Accept()
if err != nil {
return
}
defer conn.Close()
io.WriteString(conn, want)
}()
// Connect directly to the socket on the server side, simulating a
// local process connecting to the VSCode/Zed IPC socket.
conn, err := net.Dial("unix", remoteSocketPath)
if err != nil {
t.Fatalf("failed to connect to reverse forwarded socket: %s", err)
}
defer conn.Close()
got, err := io.ReadAll(conn)
if err != nil {
t.Fatalf("failed to read from reverse forwarded socket: %s", err)
}
if string(got) != want {
t.Errorf("got %q, want %q", got, want)
}
}
// TestUnixForwardingDenied verifies that Unix socket forwarding is rejected
// when the SSH policy does not permit port forwarding.
func TestUnixForwardingDenied(t *testing.T) {
debugTest.Store(true)
t.Cleanup(func() {
debugTest.Store(false)
})
// Start server with forwarding disabled (the default policy).
addr := testServerWithOpts(t, testServerOpts{
username: "testuser",
})
cl, err := ssh.Dial("tcp", addr, &ssh.ClientConfig{
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { cl.Close() })
// Direct Unix socket forwarding should be rejected.
_, err = cl.Dial("unix", "/tmp/anything.sock")
if err == nil {
t.Error("expected direct unix forwarding to be rejected, but it succeeded")
}
// Reverse Unix socket forwarding should also be rejected.
socketDir, err := os.MkdirTemp("", "tailssh-test-")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.RemoveAll(socketDir) })
_, err = cl.ListenUnix(filepath.Join(socketDir, "denied.sock"))
if err == nil {
t.Error("expected reverse unix forwarding to be rejected, but it succeeded")
}
}
// TestUnixForwardingPathRestriction verifies that socket paths outside the
// allowed directories (home, /tmp, /run/user/<uid>) are rejected even when
// forwarding is permitted by policy.
func TestUnixForwardingPathRestriction(t *testing.T) {
debugTest.Store(true)
t.Cleanup(func() {
debugTest.Store(false)
})
addr := testServerWithOpts(t, testServerOpts{
username: "testuser",
allowLocalPortForwarding: true,
allowRemotePortForwarding: true,
})
cl, err := ssh.Dial("tcp", addr, &ssh.ClientConfig{
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { cl.Close() })
// Paths outside allowed directories should be rejected.
restrictedPaths := []string{
"/var/run/docker.sock",
"/etc/evil.sock",
}
for _, path := range restrictedPaths {
_, err := cl.Dial("unix", path)
if err == nil {
t.Errorf("expected direct forwarding to %q to be rejected, but it succeeded", path)
}
}
}
func fallbackToSUAvailable() bool {
if runtime.GOOS != "linux" {
return false
@@ -582,6 +809,47 @@ func testServer(t *testing.T, username string, forceV1Behavior bool, allowSendEn
return l.Addr().String()
}
type testServerOpts struct {
username string
forceV1Behavior bool
allowSendEnv bool
allowLocalPortForwarding bool
allowRemotePortForwarding bool
}
func testServerWithOpts(t *testing.T, opts testServerOpts) string {
t.Helper()
srv := &server{
lb: &testBackend{
localUser: opts.username,
forceV1Behavior: opts.forceV1Behavior,
allowSendEnv: opts.allowSendEnv,
allowLocalPortForwarding: opts.allowLocalPortForwarding,
allowRemotePortForwarding: opts.allowRemotePortForwarding,
},
logf: log.Printf,
tailscaledPath: os.Getenv("TAILSCALED_PATH"),
timeNow: time.Now,
}
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { l.Close() })
go func() {
for {
conn, err := l.Accept()
if err == nil {
go srv.HandleSSHConn(&addressFakingConn{conn})
}
}
}()
return l.Addr().String()
}
func testSession(t *testing.T, forceV1Behavior bool, allowSendEnv bool, sendEnv map[string]string) *session {
cl := testClient(t, forceV1Behavior, allowSendEnv)
return testSessionFor(t, cl, sendEnv)
@@ -639,9 +907,11 @@ func generateClientKey(t *testing.T, privateKeyFile string) (ssh.Signer, *rsa.Pr
// testBackend implements ipnLocalBackend
type testBackend struct {
localUser string
forceV1Behavior bool
allowSendEnv bool
localUser string
forceV1Behavior bool
allowSendEnv bool
allowLocalPortForwarding bool
allowRemotePortForwarding bool
}
func (tb *testBackend) ShouldRunSSH() bool {
@@ -661,7 +931,12 @@ func (tb *testBackend) NetMap() *netmap.NetworkMap {
Rules: []*tailcfg.SSHRule{
{
Principals: []*tailcfg.SSHPrincipal{{Any: true}},
Action: &tailcfg.SSHAction{Accept: true, AllowAgentForwarding: true},
Action: &tailcfg.SSHAction{
Accept: true,
AllowAgentForwarding: true,
AllowLocalPortForwarding: tb.allowLocalPortForwarding,
AllowRemotePortForwarding: tb.allowRemotePortForwarding,
},
SSHUsers: map[string]string{"*": tb.localUser},
AcceptEnv: []string{"GIT_*", "EXACT_MATCH", "TEST?NG"},
},