From dd3b613787f055665b1b60a51567d0f460dd3f2e Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Mon, 16 Mar 2026 12:04:59 +0100 Subject: [PATCH] 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 --- cmd/ssh-auth-none-demo/ssh-auth-none-demo.go | 26 +- cmd/tailscaled/depaware.txt | 4 +- flake.nix | 2 +- go.mod | 3 +- go.mod.sri | 2 +- go.sum | 2 + licenses/tailscale.md | 2 +- shell.nix | 2 +- ssh/tailssh/hostkeys.go | 16 +- ssh/tailssh/incubator.go | 122 ++++---- ssh/tailssh/tailssh.go | 142 ++++----- ssh/tailssh/tailssh_integration_test.go | 291 ++++++++++++++++++- ssh/tailssh/tailssh_test.go | 14 +- ssh/tailssh/testcontainers/Dockerfile | 4 + 14 files changed, 460 insertions(+), 172 deletions(-) diff --git a/cmd/ssh-auth-none-demo/ssh-auth-none-demo.go b/cmd/ssh-auth-none-demo/ssh-auth-none-demo.go index 3c3ade3cd..a2cd3acd2 100644 --- a/cmd/ssh-auth-none-demo/ssh-auth-none-demo.go +++ b/cmd/ssh-auth-none-demo/ssh-auth-none-demo.go @@ -28,8 +28,8 @@ "path/filepath" "time" - gossh "golang.org/x/crypto/ssh" - "tailscale.com/tempfork/gliderlabs/ssh" + gliderssh "github.com/tailscale/gliderssh" + "golang.org/x/crypto/ssh" ) // keyTypes are the SSH key types that we either try to read from the @@ -60,23 +60,23 @@ func main() { log.Fatal("no host keys") } - srv := &ssh.Server{ + srv := &gliderssh.Server{ Addr: *addr, Version: "Tailscale", Handler: handleSessionPostSSHAuth, - ServerConfigCallback: func(ctx ssh.Context) *gossh.ServerConfig { + ServerConfigCallback: func(ctx gliderssh.Context) *ssh.ServerConfig { start := time.Now() - var spac gossh.ServerPreAuthConn - return &gossh.ServerConfig{ - PreAuthConnCallback: func(conn gossh.ServerPreAuthConn) { + var spac ssh.ServerPreAuthConn + return &ssh.ServerConfig{ + PreAuthConnCallback: func(conn ssh.ServerPreAuthConn) { spac = conn }, NoClientAuth: true, // required for the NoClientAuthCallback to run - NoClientAuthCallback: func(cm gossh.ConnMetadata) (*gossh.Permissions, error) { + NoClientAuthCallback: func(cm ssh.ConnMetadata) (*ssh.Permissions, error) { spac.SendAuthBanner(fmt.Sprintf("# Banner: doing none auth at %v\r\n", time.Since(start))) if cm.User() == "denyme" { - return nil, &gossh.BannerError{ + return nil, &ssh.BannerError{ Err: errors.New("denying access"), Message: "denyme is not allowed to access this machine\n", } @@ -96,7 +96,7 @@ func main() { } return nil, nil }, - BannerCallback: func(cm gossh.ConnMetadata) string { + BannerCallback: func(cm ssh.ConnMetadata) string { log.Printf("Got connection from user %q, %q from %v", cm.User(), cm.ClientVersion(), cm.RemoteAddr()) return fmt.Sprintf("# Banner for user %q, %q\n", cm.User(), cm.ClientVersion()) }, @@ -115,7 +115,7 @@ func main() { log.Printf("done") } -func handleSessionPostSSHAuth(s ssh.Session) { +func handleSessionPostSSHAuth(s gliderssh.Session) { log.Printf("Started session from user %q", s.User()) fmt.Fprintf(s, "Hello user %q, it worked.\n", s.User()) @@ -143,13 +143,13 @@ func handleSessionPostSSHAuth(s ssh.Session) { s.Exit(0) } -func getHostKeys(dir string) (ret []ssh.Signer, err error) { +func getHostKeys(dir string) (ret []gliderssh.Signer, err error) { for _, typ := range keyTypes { hostKey, err := hostKeyFileOrCreate(dir, typ) if err != nil { return nil, err } - signer, err := gossh.ParsePrivateKey(hostKey) + signer, err := ssh.ParsePrivateKey(hostKey) if err != nil { return nil, err } diff --git a/cmd/tailscaled/depaware.txt b/cmd/tailscaled/depaware.txt index e0c72ad77..9c4186e4f 100644 --- a/cmd/tailscaled/depaware.txt +++ b/cmd/tailscaled/depaware.txt @@ -6,7 +6,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de W 💣 github.com/alexbrainman/sspi from github.com/alexbrainman/sspi/internal/common+ W github.com/alexbrainman/sspi/internal/common from github.com/alexbrainman/sspi/negotiate W 💣 github.com/alexbrainman/sspi/negotiate from tailscale.com/net/tshttpproxy - LD github.com/anmitsu/go-shlex from tailscale.com/tempfork/gliderlabs/ssh + LD github.com/anmitsu/go-shlex from github.com/tailscale/gliderssh L github.com/aws/aws-sdk-go-v2/aws from github.com/aws/aws-sdk-go-v2/aws/defaults+ L github.com/aws/aws-sdk-go-v2/aws/arn from tailscale.com/ipn/store/awsstore L github.com/aws/aws-sdk-go-v2/aws/defaults from github.com/aws/aws-sdk-go-v2/service/ssm+ @@ -176,6 +176,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de D github.com/prometheus-community/pro-bing from tailscale.com/wgengine/netstack L 💣 github.com/safchain/ethtool from tailscale.com/net/netkernelconf+ W 💣 github.com/tailscale/certstore from tailscale.com/control/controlclient + LD github.com/tailscale/gliderssh from tailscale.com/ssh/tailssh W 💣 github.com/tailscale/go-winio from tailscale.com/safesocket W 💣 github.com/tailscale/go-winio/internal/fs from github.com/tailscale/go-winio W 💣 github.com/tailscale/go-winio/internal/socket from github.com/tailscale/go-winio @@ -393,7 +394,6 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de tailscale.com/syncs from tailscale.com/cmd/tailscaled+ tailscale.com/tailcfg from tailscale.com/client/local+ tailscale.com/tempfork/acme from tailscale.com/ipn/ipnlocal - LD tailscale.com/tempfork/gliderlabs/ssh from tailscale.com/ssh/tailssh tailscale.com/tempfork/heap from tailscale.com/wgengine/magicsock tailscale.com/tempfork/httprec from tailscale.com/feature/c2n tailscale.com/tka from tailscale.com/client/local+ diff --git a/flake.nix b/flake.nix index 318dbc54b..acb8ea7be 100644 --- a/flake.nix +++ b/flake.nix @@ -163,4 +163,4 @@ }); }; } -# nix-direnv cache busting line: sha256-VsVMvTEblVx/HNbuCVxC9UgKpriRwixswUSKVGLMf3Q= +# nix-direnv cache busting line: sha256-PLt+IPqemF3agESg6jV8AzbiOpgL45mJ/AymcNUo7VU= diff --git a/go.mod b/go.mod index 8e31076a3..8e107f160 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,6 @@ require ( github.com/akutz/memconn v0.1.0 github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa github.com/andybalholm/brotli v1.1.0 - github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be github.com/atotto/clipboard v0.1.4 github.com/aws/aws-sdk-go-v2 v1.41.0 github.com/aws/aws-sdk-go-v2/config v1.29.5 @@ -90,6 +89,7 @@ require ( github.com/studio-b12/gowebdav v0.9.0 github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e github.com/tailscale/depaware v0.0.0-20251001183927-9c2ad255ef3f + github.com/tailscale/gliderssh v0.3.4-0.20260330083525-c1389c70ff89 github.com/tailscale/goexpect v0.0.0-20210902213824-6e8c725cea41 github.com/tailscale/gokrazy-kernel v0.0.0-20240728225134-3d23beabda2e github.com/tailscale/golang-x-crypto v0.0.0-20250404221719-a5573b049869 @@ -151,6 +151,7 @@ require ( github.com/OpenPeeDeeP/depguard/v2 v2.2.0 // indirect github.com/alecthomas/go-check-sumtype v0.1.4 // indirect github.com/alexkohler/nakedret/v2 v2.0.4 // indirect + github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect github.com/armon/go-metrics v0.4.1 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/beevik/ntp v0.3.0 // indirect diff --git a/go.mod.sri b/go.mod.sri index 452ea623e..d14a565a6 100644 --- a/go.mod.sri +++ b/go.mod.sri @@ -1 +1 @@ -sha256-VsVMvTEblVx/HNbuCVxC9UgKpriRwixswUSKVGLMf3Q= +sha256-PLt+IPqemF3agESg6jV8AzbiOpgL45mJ/AymcNUo7VU= diff --git a/go.sum b/go.sum index b09fcdc72..af3abb648 100644 --- a/go.sum +++ b/go.sum @@ -1130,6 +1130,8 @@ github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e h1:PtWT87weP github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e/go.mod h1:XrBNfAFN+pwoWuksbFS9Ccxnopa15zJGgXRFN90l3K4= github.com/tailscale/depaware v0.0.0-20251001183927-9c2ad255ef3f h1:PDPGJtm9PFBLNudHGwkfUGp/FWvP+kXXJ0D1pB35F40= github.com/tailscale/depaware v0.0.0-20251001183927-9c2ad255ef3f/go.mod h1:p9lPsd+cx33L3H9nNoecRRxPssFKUwwI50I3pZ0yT+8= +github.com/tailscale/gliderssh v0.3.4-0.20260330083525-c1389c70ff89 h1:glgVc1ZYMjwN1Q/ITWeuSQyl029uayagaR2sjsifehc= +github.com/tailscale/gliderssh v0.3.4-0.20260330083525-c1389c70ff89/go.mod h1:wn16Km1EZOX4UEAyaZa3dBwfFGOJ7neck40NcwosJUw= github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4= github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg= github.com/tailscale/goexpect v0.0.0-20210902213824-6e8c725cea41 h1:/V2rCMMWcsjYaYO2MeovLw+ClP63OtXgCF2Y1eb8+Ns= diff --git a/licenses/tailscale.md b/licenses/tailscale.md index f122b5243..d5e46e90f 100644 --- a/licenses/tailscale.md +++ b/licenses/tailscale.md @@ -103,5 +103,5 @@ Some packages may only be included on certain architectures or operating systems - [k8s.io/client-go/util/homedir](https://pkg.go.dev/k8s.io/client-go/util/homedir) ([Apache-2.0](https://github.com/kubernetes/client-go/blob/v0.34.0/LICENSE)) - [sigs.k8s.io/yaml](https://pkg.go.dev/sigs.k8s.io/yaml) ([Apache-2.0](https://github.com/kubernetes-sigs/yaml/blob/v1.6.0/LICENSE)) - [tailscale.com](https://pkg.go.dev/tailscale.com) ([BSD-3-Clause](https://github.com/tailscale/tailscale/blob/HEAD/LICENSE)) - - [tailscale.com/tempfork/gliderlabs/ssh](https://pkg.go.dev/tailscale.com/tempfork/gliderlabs/ssh) ([BSD-3-Clause](https://github.com/tailscale/tailscale/blob/HEAD/tempfork/gliderlabs/ssh/LICENSE)) + - [github.com/tailscale/gliderssh](https://pkg.go.dev/github.com/tailscale/gliderssh) ([BSD-3-Clause](https://github.com/tailscale/gliderssh/blob/HEAD/LICENSE)) - [tailscale.com/tempfork/spf13/cobra](https://pkg.go.dev/tailscale.com/tempfork/spf13/cobra) ([Apache-2.0](https://github.com/tailscale/tailscale/blob/HEAD/tempfork/spf13/cobra/LICENSE.txt)) diff --git a/shell.nix b/shell.nix index 51fe449d8..6f396d981 100644 --- a/shell.nix +++ b/shell.nix @@ -16,4 +16,4 @@ ) { src = ./.; }).shellNix -# nix-direnv cache busting line: sha256-VsVMvTEblVx/HNbuCVxC9UgKpriRwixswUSKVGLMf3Q= +# nix-direnv cache busting line: sha256-PLt+IPqemF3agESg6jV8AzbiOpgL45mJ/AymcNUo7VU= diff --git a/ssh/tailssh/hostkeys.go b/ssh/tailssh/hostkeys.go index f14d99c46..8046a021a 100644 --- a/ssh/tailssh/hostkeys.go +++ b/ssh/tailssh/hostkeys.go @@ -21,7 +21,7 @@ "strings" "sync" - gossh "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh" "tailscale.com/types/logger" "tailscale.com/util/mak" ) @@ -33,8 +33,8 @@ // getHostKeys returns the SSH host keys, using system keys when running as root // and generating Tailscale-specific keys as needed. -func getHostKeys(varRoot string, logf logger.Logf) ([]gossh.Signer, error) { - var existing map[string]gossh.Signer +func getHostKeys(varRoot string, logf logger.Logf) ([]ssh.Signer, error) { + var existing map[string]ssh.Signer if os.Geteuid() == 0 { existing = getSystemHostKeys(logf) } @@ -49,14 +49,14 @@ func getHostKeyPublicStrings(varRoot string, logf logger.Logf) ([]string, error) } var keyStrings []string for _, signer := range signers { - keyStrings = append(keyStrings, strings.TrimSpace(string(gossh.MarshalAuthorizedKey(signer.PublicKey())))) + keyStrings = append(keyStrings, strings.TrimSpace(string(ssh.MarshalAuthorizedKey(signer.PublicKey())))) } return keyStrings, nil } // getTailscaleHostKeys returns the three (rsa, ecdsa, ed25519) SSH host // keys, reusing the provided ones in existing if present in the map. -func getTailscaleHostKeys(varRoot string, existing map[string]gossh.Signer) (keys []gossh.Signer, err error) { +func getTailscaleHostKeys(varRoot string, existing map[string]ssh.Signer) (keys []ssh.Signer, err error) { var keyDir string // lazily initialized $TAILSCALE_VAR/ssh dir. for _, typ := range keyTypes { if s, ok := existing[typ]; ok { @@ -76,7 +76,7 @@ func getTailscaleHostKeys(varRoot string, existing map[string]gossh.Signer) (key if err != nil { return nil, fmt.Errorf("error creating SSH host key type %q in %q: %w", typ, keyDir, err) } - signer, err := gossh.ParsePrivateKey(hostKey) + signer, err := ssh.ParsePrivateKey(hostKey) if err != nil { return nil, fmt.Errorf("error parsing SSH host key type %q from %q: %w", typ, keyDir, err) } @@ -137,14 +137,14 @@ func hostKeyFileOrCreate(keyDir, typ string) ([]byte, error) { return pemGen, err } -func getSystemHostKeys(logf logger.Logf) (ret map[string]gossh.Signer) { +func getSystemHostKeys(logf logger.Logf) (ret map[string]ssh.Signer) { for _, typ := range keyTypes { filename := "/etc/ssh/ssh_host_" + typ + "_key" hostKey, err := os.ReadFile(filename) if err != nil || len(bytes.TrimSpace(hostKey)) == 0 { continue } - signer, err := gossh.ParsePrivateKey(hostKey) + signer, err := ssh.ParsePrivateKey(hostKey) if err != nil { logf("warning: error reading host key %s: %v (generating one instead)", filename, err) continue diff --git a/ssh/tailssh/incubator.go b/ssh/tailssh/incubator.go index 28316b04d..c20b18d3e 100644 --- a/ssh/tailssh/incubator.go +++ b/ssh/tailssh/incubator.go @@ -35,13 +35,13 @@ "github.com/creack/pty" "github.com/pkg/sftp" + gliderssh "github.com/tailscale/gliderssh" "github.com/u-root/u-root/pkg/termios" - gossh "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh" "golang.org/x/sys/unix" "tailscale.com/cmd/tailscaled/childproc" "tailscale.com/hostinfo" "tailscale.com/tailcfg" - "tailscale.com/tempfork/gliderlabs/ssh" "tailscale.com/types/logger" "tailscale.com/version/distro" ) @@ -897,7 +897,7 @@ func (ss *sshSession) launchProcess() error { return nil } -func resizeWindow(fd int, winCh <-chan ssh.Window) { +func resizeWindow(fd int, winCh <-chan gliderssh.Window) { for win := range winCh { unix.IoctlSetWinsize(fd, syscall.TIOCSWINSZ, &unix.Winsize{ Row: uint16(win.Height), @@ -912,62 +912,62 @@ func resizeWindow(fd int, winCh <-chan ssh.Window) { // to mnemonic names expected by the termios package. // These are meant to be platform independent. var opcodeShortName = map[uint8]string{ - gossh.VINTR: "intr", - gossh.VQUIT: "quit", - gossh.VERASE: "erase", - gossh.VKILL: "kill", - gossh.VEOF: "eof", - gossh.VEOL: "eol", - gossh.VEOL2: "eol2", - gossh.VSTART: "start", - gossh.VSTOP: "stop", - gossh.VSUSP: "susp", - gossh.VDSUSP: "dsusp", - gossh.VREPRINT: "rprnt", - gossh.VWERASE: "werase", - gossh.VLNEXT: "lnext", - gossh.VFLUSH: "flush", - gossh.VSWTCH: "swtch", - gossh.VSTATUS: "status", - gossh.VDISCARD: "discard", - gossh.IGNPAR: "ignpar", - gossh.PARMRK: "parmrk", - gossh.INPCK: "inpck", - gossh.ISTRIP: "istrip", - gossh.INLCR: "inlcr", - gossh.IGNCR: "igncr", - gossh.ICRNL: "icrnl", - gossh.IUCLC: "iuclc", - gossh.IXON: "ixon", - gossh.IXANY: "ixany", - gossh.IXOFF: "ixoff", - gossh.IMAXBEL: "imaxbel", - gossh.IUTF8: "iutf8", - gossh.ISIG: "isig", - gossh.ICANON: "icanon", - gossh.XCASE: "xcase", - gossh.ECHO: "echo", - gossh.ECHOE: "echoe", - gossh.ECHOK: "echok", - gossh.ECHONL: "echonl", - gossh.NOFLSH: "noflsh", - gossh.TOSTOP: "tostop", - gossh.IEXTEN: "iexten", - gossh.ECHOCTL: "echoctl", - gossh.ECHOKE: "echoke", - gossh.PENDIN: "pendin", - gossh.OPOST: "opost", - gossh.OLCUC: "olcuc", - gossh.ONLCR: "onlcr", - gossh.OCRNL: "ocrnl", - gossh.ONOCR: "onocr", - gossh.ONLRET: "onlret", - gossh.CS7: "cs7", - gossh.CS8: "cs8", - gossh.PARENB: "parenb", - gossh.PARODD: "parodd", - gossh.TTY_OP_ISPEED: "tty_op_ispeed", - gossh.TTY_OP_OSPEED: "tty_op_ospeed", + ssh.VINTR: "intr", + ssh.VQUIT: "quit", + ssh.VERASE: "erase", + ssh.VKILL: "kill", + ssh.VEOF: "eof", + ssh.VEOL: "eol", + ssh.VEOL2: "eol2", + ssh.VSTART: "start", + ssh.VSTOP: "stop", + ssh.VSUSP: "susp", + ssh.VDSUSP: "dsusp", + ssh.VREPRINT: "rprnt", + ssh.VWERASE: "werase", + ssh.VLNEXT: "lnext", + ssh.VFLUSH: "flush", + ssh.VSWTCH: "swtch", + ssh.VSTATUS: "status", + ssh.VDISCARD: "discard", + ssh.IGNPAR: "ignpar", + ssh.PARMRK: "parmrk", + ssh.INPCK: "inpck", + ssh.ISTRIP: "istrip", + ssh.INLCR: "inlcr", + ssh.IGNCR: "igncr", + ssh.ICRNL: "icrnl", + ssh.IUCLC: "iuclc", + ssh.IXON: "ixon", + ssh.IXANY: "ixany", + ssh.IXOFF: "ixoff", + ssh.IMAXBEL: "imaxbel", + ssh.IUTF8: "iutf8", + ssh.ISIG: "isig", + ssh.ICANON: "icanon", + ssh.XCASE: "xcase", + ssh.ECHO: "echo", + ssh.ECHOE: "echoe", + ssh.ECHOK: "echok", + ssh.ECHONL: "echonl", + ssh.NOFLSH: "noflsh", + ssh.TOSTOP: "tostop", + ssh.IEXTEN: "iexten", + ssh.ECHOCTL: "echoctl", + ssh.ECHOKE: "echoke", + ssh.PENDIN: "pendin", + ssh.OPOST: "opost", + ssh.OLCUC: "olcuc", + ssh.ONLCR: "onlcr", + ssh.OCRNL: "ocrnl", + ssh.ONOCR: "onocr", + ssh.ONLRET: "onlret", + ssh.CS7: "cs7", + ssh.CS8: "cs8", + ssh.PARENB: "parenb", + ssh.PARODD: "parodd", + ssh.TTY_OP_ISPEED: "tty_op_ispeed", + ssh.TTY_OP_OSPEED: "tty_op_ospeed", } // startWithPTY starts cmd with a pseudo-terminal attached to Stdin, Stdout and Stderr. @@ -1011,11 +1011,11 @@ func (ss *sshSession) startWithPTY() (ptyFile, tty *os.File, err error) { tios.Col = int(ptyReq.Window.Width) for c, v := range ptyReq.Modes { - if c == gossh.TTY_OP_ISPEED { + if c == ssh.TTY_OP_ISPEED { tios.Ispeed = int(v) continue } - if c == gossh.TTY_OP_OSPEED { + if c == ssh.TTY_OP_OSPEED { tios.Ospeed = int(v) continue } diff --git a/ssh/tailssh/tailssh.go b/ssh/tailssh/tailssh.go index 9eff62c6a..95cf771af 100644 --- a/ssh/tailssh/tailssh.go +++ b/ssh/tailssh/tailssh.go @@ -30,7 +30,8 @@ "syscall" "time" - gossh "golang.org/x/crypto/ssh" + gliderssh "github.com/tailscale/gliderssh" + "golang.org/x/crypto/ssh" "tailscale.com/envknob" "tailscale.com/feature" "tailscale.com/ipn/ipnlocal" @@ -38,7 +39,6 @@ "tailscale.com/net/tsdial" "tailscale.com/sessionrecording" "tailscale.com/tailcfg" - "tailscale.com/tempfork/gliderlabs/ssh" "tailscale.com/types/key" "tailscale.com/types/logger" "tailscale.com/types/netmap" @@ -54,10 +54,10 @@ sshDisableForwarding = envknob.RegisterBool("TS_SSH_DISABLE_FORWARDING") sshDisablePTY = envknob.RegisterBool("TS_SSH_DISABLE_PTY") - // errTerminal is an empty gossh.PartialSuccessError (with no 'Next' + // errTerminal is an empty ssh.PartialSuccessError (with no 'Next' // authentication methods that may proceed), which results in the SSH // server immediately disconnecting the client. - errTerminal = &gossh.PartialSuccessError{} + errTerminal = &ssh.PartialSuccessError{} // hookSSHLoginSuccess is called after successful SSH authentication. // It is set by platform-specific code (e.g., auditd_linux.go). @@ -204,7 +204,7 @@ func (srv *server) OnPolicyChange() { } // conn represents a single SSH connection and its associated -// ssh.Server. +// gliderssh.Server. // // During the lifecycle of a connection, the following are called in order: // Setup and discover server info @@ -220,9 +220,9 @@ func (srv *server) OnPolicyChange() { // channels concurrently. At which point any of the following can be called // in any order. // - c.handleSessionPostSSHAuth -// - c.mayForwardLocalPortTo followed by ssh.DirectTCPIPHandler +// - c.mayForwardLocalPortTo followed by gliderssh.DirectTCPIPHandler type conn struct { - *ssh.Server + *gliderssh.Server srv *server insecureSkipTailscaleAuth bool // used by tests. @@ -234,9 +234,9 @@ type conn struct { idH string connID string // ID that's shared with control - // spac is a [gossh.ServerPreAuthConn] used for sending auth banners. + // spac is a [ssh.ServerPreAuthConn] used for sending auth banners. // Banners cannot be sent after auth completes. - spac gossh.ServerPreAuthConn + spac ssh.ServerPreAuthConn // The following fields are set during clientAuth and are used for policy // evaluation and session management. They are immutable after clientAuth @@ -280,7 +280,7 @@ func (c *conn) vlogf(format string, args ...any) { // errDenied is returned by auth callbacks when a connection is denied by the // policy. It writes the message to an auth banner and then returns an empty -// gossh.PartialSuccessError in order to stop processing authentication +// ssh.PartialSuccessError in order to stop processing authentication // attempts and immediately disconnect the client. func (c *conn) errDenied(message string) error { if message == "" { @@ -293,7 +293,7 @@ func (c *conn) errDenied(message string) error { } // errBanner writes the given message to an auth banner and then returns an -// empty gossh.PartialSuccessError in order to stop processing authentication +// empty ssh.PartialSuccessError in order to stop processing authentication // attempts and immediately disconnect the client. The contents of err is not // leaked in the auth banner, but it is logged to the server's log. func (c *conn) errBanner(message string, err error) error { @@ -308,7 +308,7 @@ func (c *conn) errBanner(message string, err error) error { // errUnexpected is returned by auth callbacks that encounter an unexpected // error, such as being unable to send an auth banner. It sends an empty -// gossh.PartialSuccessError to tell gossh.Server to stop processing +// ssh.PartialSuccessError to tell ssh.Server to stop processing // authentication attempts and instead disconnect immediately. func (c *conn) errUnexpected(err error) error { c.logf("terminal error: %s", err) @@ -319,11 +319,11 @@ func (c *conn) errUnexpected(err error) error { // // If policy evaluation fails, it returns an error. // If access is denied, it returns an error. This must always be an empty -// gossh.PartialSuccessError to prevent further authentication methods from +// ssh.PartialSuccessError to prevent further authentication methods from // being tried. -func (c *conn) clientAuth(cm gossh.ConnMetadata) (perms *gossh.Permissions, retErr error) { +func (c *conn) clientAuth(cm ssh.ConnMetadata) (perms *ssh.Permissions, retErr error) { defer func() { - if pse, ok := retErr.(*gossh.PartialSuccessError); ok { + if pse, ok := retErr.(*ssh.PartialSuccessError); ok { if pse.Next.GSSAPIWithMICConfig != nil || pse.Next.KeyboardInteractiveCallback != nil || pse.Next.PasswordCallback != nil || @@ -336,7 +336,7 @@ func (c *conn) clientAuth(cm gossh.ConnMetadata) (perms *gossh.Permissions, retE }() if c.insecureSkipTailscaleAuth { - return &gossh.Permissions{}, nil + return &ssh.Permissions{}, nil } if err := c.setInfo(cm); err != nil { @@ -384,7 +384,7 @@ func (c *conn) clientAuth(cm gossh.ConnMetadata) (perms *gossh.Permissions, retE } c.finalAction = action c.authCompleted.Store(true) - return &gossh.Permissions{}, nil + return &ssh.Permissions{}, nil case action.Reject: metricTerminalReject.Add(1) c.finalAction = action @@ -417,14 +417,14 @@ func (c *conn) clientAuth(cm gossh.ConnMetadata) (perms *gossh.Permissions, retE } } -// ServerConfig implements ssh.ServerConfigCallback. -func (c *conn) ServerConfig(ctx ssh.Context) *gossh.ServerConfig { - return &gossh.ServerConfig{ - PreAuthConnCallback: func(spac gossh.ServerPreAuthConn) { +// ServerConfig implements gliderssh.ServerConfigCallback. +func (c *conn) ServerConfig(ctx gliderssh.Context) *ssh.ServerConfig { + return &ssh.ServerConfig{ + PreAuthConnCallback: func(spac ssh.ServerPreAuthConn) { c.spac = spac }, NoClientAuth: true, // required for the NoClientAuthCallback to run - NoClientAuthCallback: func(cm gossh.ConnMetadata) (*gossh.Permissions, error) { + NoClientAuthCallback: func(cm ssh.ConnMetadata) (*ssh.Permissions, error) { // First perform client authentication, which can potentially // involve multiple steps (for example prompting user to log in to // Tailscale admin panel to confirm identity). @@ -438,10 +438,10 @@ func (c *conn) ServerConfig(ctx ssh.Context) *gossh.ServerConfig { // specify a username ending in "+password" to force password auth. // The actual value of the password doesn't matter. if strings.HasSuffix(cm.User(), forcePasswordSuffix) { - return nil, &gossh.PartialSuccessError{ - Next: gossh.ServerAuthCallbacks{ - PasswordCallback: func(_ gossh.ConnMetadata, password []byte) (*gossh.Permissions, error) { - return &gossh.Permissions{}, nil + return nil, &ssh.PartialSuccessError{ + Next: ssh.ServerAuthCallbacks{ + PasswordCallback: func(_ ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) { + return &ssh.Permissions{}, nil }, }, } @@ -449,14 +449,14 @@ func (c *conn) ServerConfig(ctx ssh.Context) *gossh.ServerConfig { return perms, nil }, - PasswordCallback: func(cm gossh.ConnMetadata, pword []byte) (*gossh.Permissions, error) { + PasswordCallback: func(cm ssh.ConnMetadata, pword []byte) (*ssh.Permissions, error) { // Some clients don't request 'none' authentication. Instead, they // immediately supply a password. We humor them by accepting the // password, but authenticate as usual, ignoring the actual value of // the password. return c.clientAuth(cm) }, - PublicKeyCallback: func(cm gossh.ConnMetadata, key gossh.PublicKey) (*gossh.Permissions, error) { + PublicKeyCallback: func(cm ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { // Some clients don't request 'none' authentication. Instead, they // immediately supply a public key. We humor them by accepting the // key, but authenticate as usual, ignoring the actual content of @@ -479,9 +479,9 @@ func (srv *server) newConn() (*conn, error) { c := &conn{srv: srv} now := srv.now() c.connID = fmt.Sprintf("ssh-conn-%s-%02x", now.UTC().Format("20060102T150405"), randBytes(5)) - fwdHandler := &ssh.ForwardedTCPHandler{} - streamLocalFwdHandler := &ssh.ForwardedUnixHandler{} - c.Server = &ssh.Server{ + fwdHandler := &gliderssh.ForwardedTCPHandler{} + streamLocalFwdHandler := &gliderssh.ForwardedUnixHandler{} + c.Server = &gliderssh.Server{ Version: "Tailscale", ServerConfigCallback: c.ServerConfig, @@ -492,14 +492,14 @@ func (srv *server) newConn() (*conn, error) { LocalUnixForwardingCallback: c.mayForwardLocalUnixTo, ReverseUnixForwardingCallback: c.mayReverseUnixForwardTo, - SubsystemHandlers: map[string]ssh.SubsystemHandler{ + SubsystemHandlers: map[string]gliderssh.SubsystemHandler{ "sftp": c.handleSessionPostSSHAuth, }, - ChannelHandlers: map[string]ssh.ChannelHandler{ - "direct-tcpip": ssh.DirectTCPIPHandler, - "direct-streamlocal@openssh.com": ssh.DirectStreamLocalHandler, + ChannelHandlers: map[string]gliderssh.ChannelHandler{ + "direct-tcpip": gliderssh.DirectTCPIPHandler, + "direct-streamlocal@openssh.com": gliderssh.DirectStreamLocalHandler, }, - RequestHandlers: map[string]ssh.RequestHandler{ + RequestHandlers: map[string]gliderssh.RequestHandler{ "tcpip-forward": fwdHandler.HandleSSHRequest, "cancel-tcpip-forward": fwdHandler.HandleSSHRequest, "streamlocal-forward@openssh.com": streamLocalFwdHandler.HandleSSHRequest, @@ -507,9 +507,9 @@ func (srv *server) newConn() (*conn, error) { }, } ss := c.Server - maps.Copy(ss.RequestHandlers, ssh.DefaultRequestHandlers) - maps.Copy(ss.ChannelHandlers, ssh.DefaultChannelHandlers) - maps.Copy(ss.SubsystemHandlers, ssh.DefaultSubsystemHandlers) + maps.Copy(ss.RequestHandlers, gliderssh.DefaultRequestHandlers) + maps.Copy(ss.ChannelHandlers, gliderssh.DefaultChannelHandlers) + maps.Copy(ss.SubsystemHandlers, gliderssh.DefaultSubsystemHandlers) keys, err := getHostKeys(srv.lb.TailscaleVarRoot(), srv.logf) if err != nil { return nil, err @@ -523,7 +523,7 @@ func (srv *server) newConn() (*conn, error) { // mayReversePortPortForwardTo reports whether the ctx should be allowed to port forward // to the specified host and port. // TODO(bradfitz/maisem): should we have more checks on host/port? -func (c *conn) mayReversePortForwardTo(ctx ssh.Context, destinationHost string, destinationPort uint32) bool { +func (c *conn) mayReversePortForwardTo(ctx gliderssh.Context, destinationHost string, destinationPort uint32) bool { if sshDisableForwarding() { return false } @@ -537,7 +537,7 @@ func (c *conn) mayReversePortForwardTo(ctx ssh.Context, destinationHost string, // mayForwardLocalPortTo reports whether the ctx should be allowed to port forward // to the specified host and port. // TODO(bradfitz/maisem): should we have more checks on host/port? -func (c *conn) mayForwardLocalPortTo(ctx ssh.Context, destinationHost string, destinationPort uint32) bool { +func (c *conn) mayForwardLocalPortTo(ctx gliderssh.Context, destinationHost string, destinationPort uint32) bool { if sshDisableForwarding() { return false } @@ -548,42 +548,44 @@ func (c *conn) mayForwardLocalPortTo(ctx ssh.Context, destinationHost string, de return false } -// mayForwardLocalUnixTo reports whether the ctx should be allowed to forward -// to the specified Unix domain socket path. This is the server-side handler for -// direct-streamlocal@openssh.com (SSH -L with Unix sockets). -func (c *conn) mayForwardLocalUnixTo(ctx ssh.Context, socketPath string) (net.Conn, error) { +// mayForwardLocalUnixTo is the server-side handler for +// direct-streamlocal@openssh.com (SSH -L with Unix sockets). It returns a +// connection to the specified Unix domain socket path if forwarding is +// permitted, or an error if not. +func (c *conn) mayForwardLocalUnixTo(ctx gliderssh.Context, socketPath string) (net.Conn, error) { if sshDisableForwarding() { - return nil, ssh.ErrRejected + return nil, gliderssh.ErrRejected } if c.finalAction != nil && c.finalAction.AllowLocalPortForwarding { metricLocalPortForward.Add(1) - cb := ssh.NewLocalUnixForwardingCallback(c.unixForwardingOptions()) + cb := gliderssh.NewLocalUnixForwardingCallback(c.unixForwardingOptions()) return cb(ctx, socketPath) } - return nil, ssh.ErrRejected + return nil, gliderssh.ErrRejected } -// mayReverseUnixForwardTo reports whether the ctx should be allowed to create -// a reverse Unix domain socket forward. This is the server-side handler for -// streamlocal-forward@openssh.com (SSH -R with Unix sockets). -func (c *conn) mayReverseUnixForwardTo(ctx ssh.Context, socketPath string) (net.Listener, error) { +// mayReverseUnixForwardTo is the server-side handler for +// streamlocal-forward@openssh.com (SSH -R with Unix sockets). It returns a +// listener for the specified Unix domain socket path if reverse forwarding is +// permitted, or an error if not. +func (c *conn) mayReverseUnixForwardTo(ctx gliderssh.Context, socketPath string) (net.Listener, error) { if sshDisableForwarding() { - return nil, ssh.ErrRejected + return nil, gliderssh.ErrRejected } if c.finalAction != nil && c.finalAction.AllowRemotePortForwarding { metricRemotePortForward.Add(1) - cb := ssh.NewReverseUnixForwardingCallback(c.unixForwardingOptions()) + cb := gliderssh.NewReverseUnixForwardingCallback(c.unixForwardingOptions()) return cb(ctx, socketPath) } - return nil, ssh.ErrRejected + return nil, gliderssh.ErrRejected } // unixForwardingOptions returns the Unix forwarding options scoped to the // authenticated local user. Socket paths are restricted to the user's home // directory, /tmp, and /run/user/. -func (c *conn) unixForwardingOptions() ssh.UnixForwardingOptions { - return ssh.UnixForwardingOptions{ - AllowedDirectories: ssh.UserSocketDirectories(c.localUser.HomeDir, c.localUser.Uid), +func (c *conn) unixForwardingOptions() gliderssh.UnixForwardingOptions { + return gliderssh.UnixForwardingOptions{ + AllowedDirectories: gliderssh.UserSocketDirectories(c.localUser.HomeDir, c.localUser.Uid), BindUnlink: true, } } @@ -635,7 +637,7 @@ func toIPPort(a net.Addr) (ipp netip.AddrPort) { // connInfo populates the sshConnInfo from the provided arguments, // validating only that they represent a known Tailscale identity. -func (c *conn) setInfo(cm gossh.ConnMetadata) error { +func (c *conn) setInfo(cm ssh.ConnMetadata) error { if c.info != nil { return nil } @@ -685,7 +687,7 @@ func (c *conn) evaluatePolicy() (_ *tailcfg.SSHAction, localUser string, acceptE // handleSessionPostSSHAuth runs an SSH session after the SSH-level authentication, // but not necessarily before all the Tailscale-level extra verification has // completed. It also handles SFTP requests. -func (c *conn) handleSessionPostSSHAuth(s ssh.Session) { +func (c *conn) handleSessionPostSSHAuth(s gliderssh.Session) { // Do this check after auth, but before starting the session. switch s.Subsystem() { case "sftp": @@ -734,7 +736,7 @@ func (c *conn) expandDelegateURLLocked(actionURL string) string { // sshSession is an accepted Tailscale SSH session. type sshSession struct { - ssh.Session + gliderssh.Session sharedID string // ID that's shared with control logf logger.Logf @@ -747,8 +749,8 @@ type sshSession struct { cmd *exec.Cmd wrStdin io.WriteCloser rdStdout io.ReadCloser - rdStderr io.ReadCloser // rdStderr is nil for pty sessions - ptyReq *ssh.Pty // non-nil for pty sessions + rdStderr io.ReadCloser // rdStderr is nil for pty sessions + ptyReq *gliderssh.Pty // non-nil for pty sessions // childPipes is a list of pipes that need to be closed when the process exits. // For pty sessions, this is the tty fd. @@ -772,7 +774,7 @@ func (ss *sshSession) vlogf(format string, args ...any) { } } -func (c *conn) newSSHSession(s ssh.Session) *sshSession { +func (c *conn) newSSHSession(s gliderssh.Session) *sshSession { sharedID := fmt.Sprintf("sess-%s-%02x", c.srv.now().UTC().Format("20060102T150405"), randBytes(5)) c.logf("starting session: %v", sharedID) ctx, cancel := context.WithCancelCause(s.Context()) @@ -907,10 +909,10 @@ func (c *conn) detachSession(ss *sshSession) { var errSessionDone = errors.New("session is done") // handleSSHAgentForwarding starts a Unix socket listener and in the background -// forwards agent connections between the listener and the ssh.Session. +// forwards agent connections between the listener and the gliderssh.Session. // On success, it assigns ss.agentListener. -func (ss *sshSession) handleSSHAgentForwarding(s ssh.Session, lu *userMeta) error { - if !ssh.AgentRequested(ss) || !ss.conn.finalAction.AllowAgentForwarding { +func (ss *sshSession) handleSSHAgentForwarding(s gliderssh.Session, lu *userMeta) error { + if !gliderssh.AgentRequested(ss) || !ss.conn.finalAction.AllowAgentForwarding { return nil } if sshDisableForwarding() { @@ -920,7 +922,7 @@ func (ss *sshSession) handleSSHAgentForwarding(s ssh.Session, lu *userMeta) erro return nil } ss.logf("ssh: agent forwarding requested") - ln, err := ssh.NewAgentListener() + ln, err := gliderssh.NewAgentListener() if err != nil { return err } @@ -952,7 +954,7 @@ func (ss *sshSession) handleSSHAgentForwarding(s ssh.Session, lu *userMeta) erro return err } - go ssh.ForwardAgentConnections(ln, s) + go gliderssh.ForwardAgentConnections(ln, s) ss.agentListener = ln return nil } @@ -1325,7 +1327,7 @@ func (ss *sshSession) startNewRecording() (_ *recording, err error) { } } - var w ssh.Window + var w gliderssh.Window if ptyReq, _, isPtyReq := ss.Pty(); isPtyReq { w = ptyReq.Window } diff --git a/ssh/tailssh/tailssh_integration_test.go b/ssh/tailssh/tailssh_integration_test.go index 1eb79b37e..d49ca8eef 100644 --- a/ssh/tailssh/tailssh_integration_test.go +++ b/ssh/tailssh/tailssh_integration_test.go @@ -31,11 +31,11 @@ "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 @@ func TestIntegrationParamiko(t *testing.T) { } } +// 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//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/) 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"}, }, diff --git a/ssh/tailssh/tailssh_test.go b/ssh/tailssh/tailssh_test.go index 3bf6a72c3..5141209ec 100644 --- a/ssh/tailssh/tailssh_test.go +++ b/ssh/tailssh/tailssh_test.go @@ -33,6 +33,7 @@ "testing/synctest" "time" + gliderssh "github.com/tailscale/gliderssh" "golang.org/x/net/http2" "golang.org/x/net/http2/h2c" "tailscale.com/cmd/testwrapper/flakytest" @@ -42,7 +43,6 @@ "tailscale.com/net/tsdial" "tailscale.com/sessionrecording" "tailscale.com/tailcfg" - "tailscale.com/tempfork/gliderlabs/ssh" testssh "tailscale.com/tempfork/sshtest/ssh" "tailscale.com/tsd" "tailscale.com/tstest" @@ -688,9 +688,9 @@ func TestSSHRecordingNonInteractive(t *testing.T) { t.Skipf("skipping on %q; only runs on linux and darwin", runtime.GOOS) } var recording []byte - ctx, cancel := context.WithTimeout(context.Background(), time.Second) + done := make(chan struct{}) recordingServer := mockRecordingServer(t, func(w http.ResponseWriter, r *http.Request) { - defer cancel() + defer close(done) w.WriteHeader(http.StatusOK) w.(http.Flusher).Flush() @@ -758,7 +758,11 @@ func TestSSHRecordingNonInteractive(t *testing.T) { } wg.Wait() - <-ctx.Done() // wait for recording to finish + select { + case <-done: + case <-time.After(30 * time.Second): + t.Fatal("timed out waiting for recording") + } var ch sessionrecording.CastHeader if err := json.NewDecoder(bytes.NewReader(recording)).Decode(&ch); err != nil { t.Fatal(err) @@ -1094,7 +1098,7 @@ func TestSSH(t *testing.T) { sc.finalAction = sc.action0 sc.authCompleted.Store(true) - sc.Handler = func(s ssh.Session) { + sc.Handler = func(s gliderssh.Session) { sc.newSSHSession(s).run() } diff --git a/ssh/tailssh/testcontainers/Dockerfile b/ssh/tailssh/testcontainers/Dockerfile index 4ef1c1eb0..768791028 100644 --- a/ssh/tailssh/testcontainers/Dockerfile +++ b/ssh/tailssh/testcontainers/Dockerfile @@ -38,6 +38,10 @@ RUN if echo "$BASE" | grep "ubuntu:"; then rm -Rf /home/testuser; fi RUN TAILSCALED_PATH=`pwd`tailscaled ./tailssh.test -test.v -test.run TestIntegrationSSH RUN if echo "$BASE" | grep "ubuntu:"; then rm -Rf /home/testuser; fi RUN TAILSCALED_PATH=`pwd`tailscaled ./tailssh.test -test.v -test.run TestIntegrationParamiko +RUN TAILSCALED_PATH=`pwd`tailscaled ./tailssh.test -test.v -test.run TestLocalUnixForwarding +RUN TAILSCALED_PATH=`pwd`tailscaled ./tailssh.test -test.v -test.run TestReverseUnixForwarding +RUN TAILSCALED_PATH=`pwd`tailscaled ./tailssh.test -test.v -test.run TestUnixForwardingDenied +RUN TAILSCALED_PATH=`pwd`tailscaled ./tailssh.test -test.v -test.run TestUnixForwardingPathRestriction RUN echo "Then run tests as non-root user testuser and make sure tests still pass." RUN touch /tmp/tailscalessh.log