Browse Source

ssh/tailssh: add Plan 9 support for Tailscale SSH

Updates #5794

Change-Id: I7b05cd29ec02085cb503bbcd0beb61bf455002ac
Signed-off-by: Brad Fitzpatrick <[email protected]>
Brad Fitzpatrick 11 months ago
parent
commit
b3953ce0c4

+ 1 - 1
cmd/tailscaled/ssh.go

@@ -1,7 +1,7 @@
 // Copyright (c) Tailscale Inc & AUTHORS
 // SPDX-License-Identifier: BSD-3-Clause
 
-//go:build (linux || darwin || freebsd || openbsd) && !ts_omit_ssh
+//go:build (linux || darwin || freebsd || openbsd || plan9) && !ts_omit_ssh
 
 package main
 

+ 4 - 0
cmd/tailscaled/tailscaled.go

@@ -200,6 +200,10 @@ func main() {
 	flag.BoolVar(&args.disableLogs, "no-logs-no-support", false, "disable log uploads; this also disables any technical support")
 	flag.StringVar(&args.confFile, "config", "", "path to config file, or 'vm:user-data' to use the VM's user-data (EC2)")
 
+	if runtime.GOOS == "plan9" && os.Getenv("_NETSHELL_CHILD_") != "" {
+		os.Args = []string{"tailscaled", "be-child", "plan9-netshell"}
+	}
+
 	if len(os.Args) > 1 {
 		sub := os.Args[1]
 		if fp, ok := subCommands[sub]; ok {

+ 1 - 1
envknob/featureknob/featureknob.go

@@ -40,7 +40,7 @@ func CanRunTailscaleSSH() error {
 		if version.IsSandboxedMacOS() {
 			return errors.New("The Tailscale SSH server does not run in sandboxed Tailscale GUI builds.")
 		}
-	case "freebsd", "openbsd":
+	case "freebsd", "openbsd", "plan9":
 	default:
 		return errors.New("The Tailscale SSH server is not supported on " + runtime.GOOS)
 	}

+ 3 - 1
go.mod

@@ -37,6 +37,7 @@ require (
 	github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874
 	github.com/go-logr/zapr v1.3.0
 	github.com/go-ole/go-ole v1.3.0
+	github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737
 	github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466
 	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da
 	github.com/golang/snappy v0.0.4
@@ -90,7 +91,7 @@ require (
 	github.com/tc-hib/winres v0.2.1
 	github.com/tcnksm/go-httpstat v0.2.0
 	github.com/toqueteos/webbrowser v1.2.0
-	github.com/u-root/u-root v0.12.0
+	github.com/u-root/u-root v0.14.0
 	github.com/vishvananda/netns v0.0.4
 	go.uber.org/zap v1.27.0
 	go4.org/mem v0.0.0-20240501181205-ae6ca9944745
@@ -121,6 +122,7 @@ require (
 )
 
 require (
+	9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f // indirect
 	github.com/4meepo/tagalign v1.3.3 // indirect
 	github.com/Antonboom/testifylint v1.2.0 // indirect
 	github.com/GaijinEntertainment/go-exhaustruct/v3 v3.2.0 // indirect

+ 10 - 6
go.sum

@@ -2,6 +2,8 @@
 4d63.com/gocheckcompilerdirectives v1.2.1/go.mod h1:yjDJSxmDTtIHHCqX0ufRYZDL6vQtMG7tJdKVeWwsqvs=
 4d63.com/gochecknoglobals v0.2.1 h1:1eiorGsgHOFOuoOiJDy2psSrQbRdIHrlge0IJIkUgDc=
 4d63.com/gochecknoglobals v0.2.1/go.mod h1:KRE8wtJB3CXCsb1xy421JfTHIIbmT3U5ruxw2Qu8fSU=
+9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f h1:1C7nZuxUMNz7eiQALRfiqNOm04+m3edWlRff/BYHf0Q=
+9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f/go.mod h1:hHyrZRryGqVdqrknjq5OWDLGCTJ2NeEvtrpR96mjraM=
 cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
 cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
 cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
@@ -391,6 +393,8 @@ github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1 h1:TQcrn6Wq+sKGkpyPvppOz99zsM
 github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
 github.com/go-xmlfmt/xmlfmt v1.1.2 h1:Nea7b4icn8s57fTx1M5AI4qQT5HEM3rVUO8MuE6g80U=
 github.com/go-xmlfmt/xmlfmt v1.1.2/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM=
+github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737 h1:cf60tHxREO3g1nroKr2osU3JWZsJzkfi7rEg+oAB0Lo=
+github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737/go.mod h1:MIS0jDzbU/vuM9MC4YnBITCv+RYuTRq8dJzmCrFsK9g=
 github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4=
 github.com/gobuffalo/flect v1.0.3/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs=
 github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
@@ -547,8 +551,8 @@ github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSo
 github.com/huandu/xstrings v1.3.3/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
 github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
 github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
-github.com/hugelgupf/vmtest v0.0.0-20240102225328-693afabdd27f h1:ov45/OzrJG8EKbGjn7jJZQJTN7Z1t73sFYNIRd64YlI=
-github.com/hugelgupf/vmtest v0.0.0-20240102225328-693afabdd27f/go.mod h1:JoDrYMZpDPYo6uH9/f6Peqms3zNNWT2XiGgioMOIGuI=
+github.com/hugelgupf/vmtest v0.0.0-20240216064925-0561770280a1 h1:jWoR2Yqg8tzM0v6LAiP7i1bikZJu3gxpgvu3g1Lw+a0=
+github.com/hugelgupf/vmtest v0.0.0-20240216064925-0561770280a1/go.mod h1:B63hDJMhTupLWCHwopAyEo7wRFowx9kOc8m8j1sfOqE=
 github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
 github.com/illarion/gonotify/v3 v3.0.2 h1:O7S6vcopHexutmpObkeWsnzMJt/r1hONIEogeVNmJMk=
 github.com/illarion/gonotify/v3 v3.0.2/go.mod h1:HWGPdPe817GfvY3w7cx6zkbzNZfi3QjcBm/wgVvEL1U=
@@ -952,10 +956,10 @@ github.com/tommy-muehle/go-mnd/v2 v2.5.1 h1:NowYhSdyE/1zwK9QCLeRb6USWdoif80Ie+v+
 github.com/tommy-muehle/go-mnd/v2 v2.5.1/go.mod h1:WsUAkMJMYww6l/ufffCD3m+P7LEvr8TnZn9lwVDlgzw=
 github.com/toqueteos/webbrowser v1.2.0 h1:tVP/gpK69Fx+qMJKsLE7TD8LuGWPnEV71wBN9rrstGQ=
 github.com/toqueteos/webbrowser v1.2.0/go.mod h1:XWoZq4cyp9WeUeak7w7LXRUQf1F1ATJMir8RTqb4ayM=
-github.com/u-root/gobusybox/src v0.0.0-20231228173702-b69f654846aa h1:unMPGGK/CRzfg923allsikmvk2l7beBeFPUNC4RVX/8=
-github.com/u-root/gobusybox/src v0.0.0-20231228173702-b69f654846aa/go.mod h1:Zj4Tt22fJVn/nz/y6Ergm1SahR9dio1Zm/D2/S0TmXM=
-github.com/u-root/u-root v0.12.0 h1:K0AuBFriwr0w/PGS3HawiAw89e3+MU7ks80GpghAsNs=
-github.com/u-root/u-root v0.12.0/go.mod h1:FYjTOh4IkIZHhjsd17lb8nYW6udgXdJhG1c0r6u0arI=
+github.com/u-root/gobusybox/src v0.0.0-20240225013946-a274a8d5d83a h1:eg5FkNoQp76ZsswyGZ+TjYqA/rhKefxK8BW7XOlQsxo=
+github.com/u-root/gobusybox/src v0.0.0-20240225013946-a274a8d5d83a/go.mod h1:e/8TmrdreH0sZOw2DFKBaUV7bvDWRq6SeM9PzkuVM68=
+github.com/u-root/u-root v0.14.0 h1:Ka4T10EEML7dQ5XDvO9c3MBN8z4nuSnGjcd1jmU2ivg=
+github.com/u-root/u-root v0.14.0/go.mod h1:hAyZorapJe4qzbLWlAkmSVCJGbfoU9Pu4jpJ1WMluqE=
 github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM=
 github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA=
 github.com/ulikunitz/xz v0.5.11 h1:kpFauv27b6ynzBNT/Xy+1k+fK4WswhN/6PN5WhFAGw8=

+ 1 - 1
ipn/ipnlocal/ssh.go

@@ -1,7 +1,7 @@
 // Copyright (c) Tailscale Inc & AUTHORS
 // SPDX-License-Identifier: BSD-3-Clause
 
-//go:build linux || (darwin && !ios) || freebsd || openbsd
+//go:build linux || (darwin && !ios) || freebsd || openbsd || plan9
 
 package ipnlocal
 

+ 1 - 1
ipn/ipnlocal/ssh_stub.go

@@ -1,7 +1,7 @@
 // Copyright (c) Tailscale Inc & AUTHORS
 // SPDX-License-Identifier: BSD-3-Clause
 
-//go:build ios || (!linux && !darwin && !freebsd && !openbsd)
+//go:build ios || (!linux && !darwin && !freebsd && !openbsd && !plan9)
 
 package ipnlocal
 

+ 421 - 0
ssh/tailssh/incubator_plan9.go

@@ -0,0 +1,421 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+// This file contains the plan9-specific version of the incubator. Tailscaled
+// launches the incubator as the same user as it was launched as. The
+// incubator then registers a new session with the OS, sets its UID
+// and groups to the specified `--uid`, `--gid` and `--groups`, and
+// then launches the requested `--cmd`.
+
+package tailssh
+
+import (
+	"encoding/json"
+	"errors"
+	"flag"
+	"fmt"
+	"io"
+	"log"
+	"os"
+	"os/exec"
+	"runtime"
+	"strconv"
+	"strings"
+	"sync/atomic"
+
+	"github.com/go4org/plan9netshell"
+	"github.com/pkg/sftp"
+	"tailscale.com/cmd/tailscaled/childproc"
+	"tailscale.com/tailcfg"
+	"tailscale.com/types/logger"
+)
+
+func init() {
+	childproc.Add("ssh", beIncubator)
+	childproc.Add("sftp", beSFTP)
+	childproc.Add("plan9-netshell", beNetshell)
+}
+
+// newIncubatorCommand returns a new exec.Cmd configured with
+// `tailscaled be-child ssh` as the entrypoint.
+//
+// If ss.srv.tailscaledPath is empty, this method is equivalent to
+// exec.CommandContext.
+//
+// The returned Cmd.Env is guaranteed to be nil; the caller populates it.
+func (ss *sshSession) newIncubatorCommand(logf logger.Logf) (cmd *exec.Cmd, err error) {
+	defer func() {
+		if cmd.Env != nil {
+			panic("internal error")
+		}
+	}()
+
+	var isSFTP, isShell bool
+	switch ss.Subsystem() {
+	case "sftp":
+		isSFTP = true
+	case "":
+		isShell = ss.RawCommand() == ""
+	default:
+		panic(fmt.Sprintf("unexpected subsystem: %v", ss.Subsystem()))
+	}
+
+	if ss.conn.srv.tailscaledPath == "" {
+		if isSFTP {
+			// SFTP relies on the embedded Go-based SFTP server in tailscaled,
+			// so without tailscaled, we can't serve SFTP.
+			return nil, errors.New("no tailscaled found on path, can't serve SFTP")
+		}
+
+		loginShell := ss.conn.localUser.LoginShell()
+		logf("directly running /bin/rc -c %q", ss.RawCommand())
+		return exec.CommandContext(ss.ctx, loginShell, "-c", ss.RawCommand()), nil
+	}
+
+	lu := ss.conn.localUser
+	ci := ss.conn.info
+	remoteUser := ci.uprof.LoginName
+	if ci.node.IsTagged() {
+		remoteUser = strings.Join(ci.node.Tags().AsSlice(), ",")
+	}
+
+	incubatorArgs := []string{
+		"be-child",
+		"ssh",
+		// TODO: "--uid=" + lu.Uid,
+		// TODO: "--gid=" + lu.Gid,
+		"--local-user=" + lu.Username,
+		"--home-dir=" + lu.HomeDir,
+		"--remote-user=" + remoteUser,
+		"--remote-ip=" + ci.src.Addr().String(),
+		"--has-tty=false", // updated in-place by startWithPTY
+		"--tty-name=",     // updated in-place by startWithPTY
+	}
+
+	nm := ss.conn.srv.lb.NetMap()
+	forceV1Behavior := nm.HasCap(tailcfg.NodeAttrSSHBehaviorV1) && !nm.HasCap(tailcfg.NodeAttrSSHBehaviorV2)
+	if forceV1Behavior {
+		incubatorArgs = append(incubatorArgs, "--force-v1-behavior")
+	}
+
+	if debugTest.Load() {
+		incubatorArgs = append(incubatorArgs, "--debug-test")
+	}
+
+	switch {
+	case isSFTP:
+		// Note that we include both the `--sftp` flag and a command to launch
+		// tailscaled as `be-child sftp`. If login or su is available, and
+		// we're not running with tailcfg.NodeAttrSSHBehaviorV1, this will
+		// result in serving SFTP within a login shell, with full PAM
+		// integration. Otherwise, we'll serve SFTP in the incubator process
+		// with no PAM integration.
+		incubatorArgs = append(incubatorArgs, "--sftp", fmt.Sprintf("--cmd=%s be-child sftp", ss.conn.srv.tailscaledPath))
+	case isShell:
+		incubatorArgs = append(incubatorArgs, "--shell")
+	default:
+		incubatorArgs = append(incubatorArgs, "--cmd="+ss.RawCommand())
+	}
+
+	allowSendEnv := nm.HasCap(tailcfg.NodeAttrSSHEnvironmentVariables)
+	if allowSendEnv {
+		env, err := filterEnv(ss.conn.acceptEnv, ss.Session.Environ())
+		if err != nil {
+			return nil, err
+		}
+
+		if len(env) > 0 {
+			encoded, err := json.Marshal(env)
+			if err != nil {
+				return nil, fmt.Errorf("failed to encode environment: %w", err)
+			}
+			incubatorArgs = append(incubatorArgs, fmt.Sprintf("--encoded-env=%q", encoded))
+		}
+	}
+
+	return exec.CommandContext(ss.ctx, ss.conn.srv.tailscaledPath, incubatorArgs...), nil
+}
+
+var debugTest atomic.Bool
+
+type stdRWC struct{}
+
+func (stdRWC) Read(p []byte) (n int, err error) {
+	return os.Stdin.Read(p)
+}
+
+func (stdRWC) Write(b []byte) (n int, err error) {
+	return os.Stdout.Write(b)
+}
+
+func (stdRWC) Close() error {
+	os.Exit(0)
+	return nil
+}
+
+type incubatorArgs struct {
+	localUser          string
+	homeDir            string
+	remoteUser         string
+	remoteIP           string
+	ttyName            string
+	hasTTY             bool
+	cmd                string
+	isSFTP             bool
+	isShell            bool
+	forceV1Behavior    bool
+	debugTest          bool
+	isSELinuxEnforcing bool
+	encodedEnv         string
+}
+
+func parseIncubatorArgs(args []string) (incubatorArgs, error) {
+	var ia incubatorArgs
+
+	flags := flag.NewFlagSet("", flag.ExitOnError)
+	flags.StringVar(&ia.localUser, "local-user", "", "the user to run as")
+	flags.StringVar(&ia.homeDir, "home-dir", "/", "the user's home directory")
+	flags.StringVar(&ia.remoteUser, "remote-user", "", "the remote user/tags")
+	flags.StringVar(&ia.remoteIP, "remote-ip", "", "the remote Tailscale IP")
+	flags.StringVar(&ia.ttyName, "tty-name", "", "the tty name (pts/3)")
+	flags.BoolVar(&ia.hasTTY, "has-tty", false, "is the output attached to a tty")
+	flags.StringVar(&ia.cmd, "cmd", "", "the cmd to launch, including all arguments (ignored in sftp mode)")
+	flags.BoolVar(&ia.isShell, "shell", false, "is launching a shell (with no cmds)")
+	flags.BoolVar(&ia.isSFTP, "sftp", false, "run sftp server (cmd is ignored)")
+	flags.BoolVar(&ia.forceV1Behavior, "force-v1-behavior", false, "allow falling back to the su command if login is unavailable")
+	flags.BoolVar(&ia.debugTest, "debug-test", false, "should debug in test mode")
+	flags.BoolVar(&ia.isSELinuxEnforcing, "is-selinux-enforcing", false, "whether SELinux is in enforcing mode")
+	flags.StringVar(&ia.encodedEnv, "encoded-env", "", "JSON encoded array of environment variables in '['key=value']' format")
+	flags.Parse(args)
+	return ia, nil
+}
+
+func (ia incubatorArgs) forwardedEnviron() ([]string, string, error) {
+	environ := os.Environ()
+	// pass through SSH_AUTH_SOCK environment variable to support ssh agent forwarding
+	allowListKeys := "SSH_AUTH_SOCK"
+
+	if ia.encodedEnv != "" {
+		unquoted, err := strconv.Unquote(ia.encodedEnv)
+		if err != nil {
+			return nil, "", fmt.Errorf("unable to parse encodedEnv %q: %w", ia.encodedEnv, err)
+		}
+
+		var extraEnviron []string
+
+		err = json.Unmarshal([]byte(unquoted), &extraEnviron)
+		if err != nil {
+			return nil, "", fmt.Errorf("unable to parse encodedEnv %q: %w", ia.encodedEnv, err)
+		}
+
+		environ = append(environ, extraEnviron...)
+
+		for _, v := range extraEnviron {
+			allowListKeys = fmt.Sprintf("%s,%s", allowListKeys, strings.Split(v, "=")[0])
+		}
+	}
+
+	return environ, allowListKeys, nil
+}
+
+func beNetshell(args []string) error {
+	plan9netshell.Main()
+	return nil
+}
+
+// beIncubator is the entrypoint to the `tailscaled be-child ssh` subcommand.
+// It is responsible for informing the system of a new login session for the
+// user. This is sometimes necessary for mounting home directories and
+// decrypting file systems.
+//
+// Tailscaled launches the incubator as the same user as it was launched as.
+func beIncubator(args []string) error {
+	// To defend against issues like https://golang.org/issue/1435,
+	// defensively lock our current goroutine's thread to the current
+	// system thread before we start making any UID/GID/group changes.
+	//
+	// This shouldn't matter on Linux because syscall.AllThreadsSyscall is
+	// used to invoke syscalls on all OS threads, but (as of 2023-03-23)
+	// that function is not implemented on all platforms.
+	runtime.LockOSThread()
+	defer runtime.UnlockOSThread()
+
+	ia, err := parseIncubatorArgs(args)
+	if err != nil {
+		return err
+	}
+	if ia.isSFTP && ia.isShell {
+		return fmt.Errorf("--sftp and --shell are mutually exclusive")
+	}
+
+	if ia.isShell {
+		plan9netshell.Main()
+		return nil
+	}
+
+	dlogf := logger.Discard
+	if ia.debugTest {
+		// In testing, we don't always have syslog, so log to a temp file.
+		if logFile, err := os.OpenFile("/tmp/tailscalessh.log", os.O_APPEND|os.O_WRONLY, 0666); err == nil {
+			lf := log.New(logFile, "", 0)
+			dlogf = func(msg string, args ...any) {
+				lf.Printf(msg, args...)
+				logFile.Sync()
+			}
+			defer logFile.Close()
+		}
+	}
+
+	return handleInProcess(dlogf, ia)
+}
+
+func handleInProcess(dlogf logger.Logf, ia incubatorArgs) error {
+	if ia.isSFTP {
+		return handleSFTPInProcess(dlogf, ia)
+	}
+	return handleSSHInProcess(dlogf, ia)
+}
+
+func handleSFTPInProcess(dlogf logger.Logf, ia incubatorArgs) error {
+	dlogf("handling sftp")
+
+	return serveSFTP()
+}
+
+// beSFTP serves SFTP in-process.
+func beSFTP(args []string) error {
+	return serveSFTP()
+}
+
+func serveSFTP() error {
+	server, err := sftp.NewServer(stdRWC{})
+	if err != nil {
+		return err
+	}
+	// TODO(https://github.com/pkg/sftp/pull/554): Revert the check for io.EOF,
+	// when sftp is patched to report clean termination.
+	if err := server.Serve(); err != nil && err != io.EOF {
+		return err
+	}
+	return nil
+}
+
+// handleSSHInProcess is a last resort if we couldn't use login or su. It
+// registers a new session with the OS, sets its UID, GID and groups to the
+// specified values, and then launches the requested `--cmd` in the user's
+// login shell.
+func handleSSHInProcess(dlogf logger.Logf, ia incubatorArgs) error {
+
+	environ, _, err := ia.forwardedEnviron()
+	if err != nil {
+		return err
+	}
+
+	dlogf("running /bin/rc -c %q", ia.cmd)
+	cmd := newCommand("/bin/rc", environ, []string{"-c", ia.cmd})
+	err = cmd.Run()
+	if ee, ok := err.(*exec.ExitError); ok {
+		ps := ee.ProcessState
+		code := ps.ExitCode()
+		if code < 0 {
+			// TODO(bradfitz): do we need to also check the syscall.WaitStatus
+			// and make our process look like it also died by signal/same signal
+			// as our child process? For now we just do the exit code.
+			fmt.Fprintf(os.Stderr, "[tailscale-ssh: process died: %v]\n", ps.String())
+			code = 1 // for now. so we don't exit with negative
+		}
+		os.Exit(code)
+	}
+	return err
+}
+
+func newCommand(cmdPath string, cmdEnviron []string, cmdArgs []string) *exec.Cmd {
+	cmd := exec.Command(cmdPath, cmdArgs...)
+	cmd.Stdin = os.Stdin
+	cmd.Stdout = os.Stdout
+	cmd.Stderr = os.Stderr
+	cmd.Env = cmdEnviron
+
+	return cmd
+}
+
+// launchProcess launches an incubator process for the provided session.
+// It is responsible for configuring the process execution environment.
+// The caller can wait for the process to exit by calling cmd.Wait().
+//
+// It sets ss.cmd, stdin, stdout, and stderr.
+func (ss *sshSession) launchProcess() error {
+	var err error
+	ss.cmd, err = ss.newIncubatorCommand(ss.logf)
+	if err != nil {
+		return err
+	}
+
+	cmd := ss.cmd
+	cmd.Dir = "/"
+	cmd.Env = append(os.Environ(), envForUser(ss.conn.localUser)...)
+	for _, kv := range ss.Environ() {
+		if acceptEnvPair(kv) {
+			cmd.Env = append(cmd.Env, kv)
+		}
+	}
+
+	ci := ss.conn.info
+	cmd.Env = append(cmd.Env,
+		fmt.Sprintf("SSH_CLIENT=%s %d %d", ci.src.Addr(), ci.src.Port(), ci.dst.Port()),
+		fmt.Sprintf("SSH_CONNECTION=%s %d %s %d", ci.src.Addr(), ci.src.Port(), ci.dst.Addr(), ci.dst.Port()),
+	)
+
+	if ss.agentListener != nil {
+		cmd.Env = append(cmd.Env, fmt.Sprintf("SSH_AUTH_SOCK=%s", ss.agentListener.Addr()))
+	}
+
+	return ss.startWithStdPipes()
+}
+
+// startWithStdPipes starts cmd with os.Pipe for Stdin, Stdout and Stderr.
+func (ss *sshSession) startWithStdPipes() (err error) {
+	var rdStdin, wrStdout, wrStderr io.ReadWriteCloser
+	defer func() {
+		if err != nil {
+			closeAll(rdStdin, ss.wrStdin, ss.rdStdout, wrStdout, ss.rdStderr, wrStderr)
+		}
+	}()
+	if ss.cmd == nil {
+		return errors.New("nil cmd")
+	}
+	if rdStdin, ss.wrStdin, err = os.Pipe(); err != nil {
+		return err
+	}
+	if ss.rdStdout, wrStdout, err = os.Pipe(); err != nil {
+		return err
+	}
+	if ss.rdStderr, wrStderr, err = os.Pipe(); err != nil {
+		return err
+	}
+	ss.cmd.Stdin = rdStdin
+	ss.cmd.Stdout = wrStdout
+	ss.cmd.Stderr = wrStderr
+	ss.childPipes = []io.Closer{rdStdin, wrStdout, wrStderr}
+	return ss.cmd.Start()
+}
+
+func envForUser(u *userMeta) []string {
+	return []string{
+		fmt.Sprintf("user=%s", u.Username),
+		fmt.Sprintf("home=%s", u.HomeDir),
+		fmt.Sprintf("path=%s", defaultPathForUser(&u.User)),
+	}
+}
+
+// acceptEnvPair reports whether the environment variable key=value pair
+// should be accepted from the client. It uses the same default as OpenSSH
+// AcceptEnv.
+func acceptEnvPair(kv string) bool {
+	k, _, ok := strings.Cut(kv, "=")
+	if !ok {
+		return false
+	}
+	_ = k
+	return true // permit anything on plan9 during bringup, for debugging at least
+}

+ 2 - 2
ssh/tailssh/tailssh.go

@@ -1,7 +1,7 @@
 // Copyright (c) Tailscale Inc & AUTHORS
 // SPDX-License-Identifier: BSD-3-Clause
 
-//go:build linux || (darwin && !ios) || freebsd || openbsd
+//go:build linux || (darwin && !ios) || freebsd || openbsd || plan9
 
 // Package tailssh is an SSH server integrated into Tailscale.
 package tailssh
@@ -903,7 +903,7 @@ func (ss *sshSession) run() {
 		defer t.Stop()
 	}
 
-	if euid := os.Geteuid(); euid != 0 {
+	if euid := os.Geteuid(); euid != 0 && runtime.GOOS != "plan9" {
 		if lu.Uid != fmt.Sprint(euid) {
 			ss.logf("can't switch to user %q from process euid %v", lu.Username, euid)
 			fmt.Fprintf(ss, "can't switch user\r\n")

+ 7 - 1
ssh/tailssh/user.go

@@ -1,7 +1,7 @@
 // Copyright (c) Tailscale Inc & AUTHORS
 // SPDX-License-Identifier: BSD-3-Clause
 
-//go:build linux || (darwin && !ios) || freebsd || openbsd
+//go:build linux || (darwin && !ios) || freebsd || openbsd || plan9
 
 package tailssh
 
@@ -48,6 +48,9 @@ func userLookup(username string) (*userMeta, error) {
 }
 
 func (u *userMeta) LoginShell() string {
+	if runtime.GOOS == "plan9" {
+		return "/bin/rc"
+	}
 	if u.loginShellCached != "" {
 		// This field should be populated on Linux, at least, because
 		// func userLookup on Linux uses "getent" to look up the user
@@ -85,6 +88,9 @@ func defaultPathForUser(u *user.User) string {
 	if s := defaultPathTmpl(); s != "" {
 		return expandDefaultPathTmpl(s, u)
 	}
+	if runtime.GOOS == "plan9" {
+		return "/bin"
+	}
 	isRoot := u.Uid == "0"
 	switch distro.Get() {
 	case distro.Debian:

+ 4 - 0
util/osuser/group_ids.go

@@ -19,6 +19,10 @@ import (
 // an error. It will first try to use the 'id' command to get the group IDs,
 // and if that fails, it will fall back to the user.GroupIds method.
 func GetGroupIds(user *user.User) ([]string, error) {
+	if runtime.GOOS == "plan9" {
+		return nil, nil
+	}
+
 	if runtime.GOOS != "linux" {
 		return user.GroupIds()
 	}

+ 21 - 2
util/osuser/user.go

@@ -54,9 +54,18 @@ func lookup(usernameOrUID string, std lookupStd, wantShell bool) (*user.User, st
 	// Skip getent entirely on Non-Unix platforms that won't ever have it.
 	// (Using HasPrefix for "wasip1", anticipating that WASI support will
 	// move beyond "preview 1" some day.)
-	if runtime.GOOS == "windows" || runtime.GOOS == "js" || runtime.GOARCH == "wasm" {
+	if runtime.GOOS == "windows" || runtime.GOOS == "js" || runtime.GOARCH == "wasm" || runtime.GOOS == "plan9" {
+		var shell string
+		if wantShell && runtime.GOOS == "plan9" {
+			shell = "/bin/rc"
+		}
+		if runtime.GOOS == "plan9" {
+			if u, err := user.Current(); err == nil {
+				return u, shell, nil
+			}
+		}
 		u, err := std(usernameOrUID)
-		return u, "", err
+		return u, shell, err
 	}
 
 	// No getent on Gokrazy. So hard-code the login shell.
@@ -78,6 +87,16 @@ func lookup(usernameOrUID string, std lookupStd, wantShell bool) (*user.User, st
 		return u, shell, nil
 	}
 
+	if runtime.GOOS == "plan9" {
+		return &user.User{
+			Uid:      "0",
+			Gid:      "0",
+			Username: "glenda",
+			Name:     "Glenda",
+			HomeDir:  "/",
+		}, "/bin/rc", nil
+	}
+
 	// Start with getent if caller wants to get the user shell.
 	if wantShell {
 		return userLookupGetent(usernameOrUID, std)