diff --git a/ssh/tailssh/incubator.go b/ssh/tailssh/incubator.go index e192ea5a7..fda60936d 100644 --- a/ssh/tailssh/incubator.go +++ b/ssh/tailssh/incubator.go @@ -31,11 +31,16 @@ import ( "github.com/creack/pty" "github.com/pkg/sftp" "github.com/u-root/u-root/pkg/termios" + "go4.org/mem" gossh "golang.org/x/crypto/ssh" "golang.org/x/sys/unix" "tailscale.com/cmd/tailscaled/childproc" + "tailscale.com/hostinfo" "tailscale.com/tempfork/gliderlabs/ssh" "tailscale.com/types/logger" + "tailscale.com/util/lineread" + "tailscale.com/util/strs" + "tailscale.com/version/distro" ) func init() { @@ -59,7 +64,14 @@ var maybeStartLoginSession = func(logf logger.Logf, ia incubatorArgs) (close fun // // If ss.srv.tailscaledPath is empty, this method is equivalent to // exec.CommandContext. -func (ss *sshSession) newIncubatorCommand() *exec.Cmd { +// +// The returned Cmd.Env is guaranteed to be nil; the caller populates it. +func (ss *sshSession) newIncubatorCommand() (cmd *exec.Cmd) { + defer func() { + if cmd.Env != nil { + panic("internal error") + } + }() var ( name string args []string @@ -292,7 +304,7 @@ func (ss *sshSession) launchProcess() error { } else { return err } - cmd.Env = append(cmd.Env, envForUser(ss.conn.localUser)...) + cmd.Env = envForUser(ss.conn.localUser) for _, kv := range ss.Environ() { if acceptEnvPair(kv) { cmd.Env = append(cmd.Env, kv) @@ -567,9 +579,66 @@ func envForUser(u *user.User) []string { fmt.Sprintf("SHELL=" + loginShell(u.Uid)), fmt.Sprintf("USER=" + u.Username), fmt.Sprintf("HOME=" + u.HomeDir), + fmt.Sprintf("PATH=" + defaultPathForUser(u)), } } +func defaultPathForUser(u *user.User) string { + isRoot := u.Uid == "0" + switch distro.Get() { + case distro.Debian: + hi := hostinfo.New() + if hi.Distro == "ubuntu" { + // distro.Get's Debian includes Ubuntu. But see if it's actually Ubuntu. + // Ubuntu doesn't empirically seem to distinguish between root and non-root for the default. + // And it includes /snap/bin. + return "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin" + } + if isRoot { + return "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + } + return "/usr/local/bin:/usr/bin:/bin:/usr/bn/games" + case distro.NixOS: + return defaultPathForUserOnNixOS(u) + } + if isRoot { + return "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + } + return "/usr/local/bin:/usr/bin:/bin" +} + +func defaultPathForUserOnNixOS(u *user.User) string { + var path string + lineread.File("/etc/pam/environment", func(lineb []byte) error { + if v := pathFromPAMEnvLine(lineb, u); v != "" { + path = v + return io.EOF // stop iteration + } + return nil + }) + return path +} + +func pathFromPAMEnvLine(line []byte, u *user.User) (path string) { + if !mem.HasPrefix(mem.B(line), mem.S("PATH")) { + return "" + } + rest := strings.TrimSpace(strings.TrimPrefix(string(line), "PATH")) + if quoted, ok := strs.CutPrefix(rest, "DEFAULT="); ok { + if path, err := strconv.Unquote(quoted); err == nil { + path = strings.NewReplacer( + "@{HOME}", u.HomeDir, + "@{PAM_USER}", u.Username, + ).Replace(path) + if !strings.Contains(path, "@{") { + // If no more expansions, use it. Otherwise we fail closed. + return path + } + } + } + return "" +} + // updateStringInSlice mutates ss to change the first occurrence of a // to b. func updateStringInSlice(ss []string, a, b string) { diff --git a/ssh/tailssh/tailssh_test.go b/ssh/tailssh/tailssh_test.go index 91bc22016..5e8744b6f 100644 --- a/ssh/tailssh/tailssh_test.go +++ b/ssh/tailssh/tailssh_test.go @@ -44,6 +44,7 @@ import ( "tailscale.com/util/lineread" "tailscale.com/util/must" "tailscale.com/util/strs" + "tailscale.com/version/distro" "tailscale.com/wgengine" ) @@ -755,3 +756,43 @@ func TestAcceptEnvPair(t *testing.T) { } } } + +func TestPathFromPAMEnvLine(t *testing.T) { + u := &user.User{Username: "foo", HomeDir: "/Homes/Foo"} + tests := []struct { + line string + u *user.User + want string + }{ + {"", &user.User{}, ""}, + {`PATH DEFAULT="/run/wrappers/bin:@{HOME}/.nix-profile/bin:/etc/profiles/per-user/@{PAM_USER}/bin:/nix/var/nix/profiles/default/bin:/run/current-system/sw/bin"`, + u, "/run/wrappers/bin:/Homes/Foo/.nix-profile/bin:/etc/profiles/per-user/foo/bin:/nix/var/nix/profiles/default/bin:/run/current-system/sw/bin"}, + {`PATH DEFAULT="@{SOMETHING_ELSE}:nope:@{HOME}"`, + u, ""}, + } + for i, tt := range tests { + got := pathFromPAMEnvLine([]byte(tt.line), tt.u) + if got != tt.want { + t.Errorf("%d. got %q; want %q", i, got, tt.want) + } + } +} + +func TestPathFromPAMEnvLineOnNixOS(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("skipping on non-linux") + } + if distro.Get() != distro.NixOS { + t.Skip("skipping on non-NixOS") + } + u, err := user.Current() + if err != nil { + t.Fatal(err) + } + got := defaultPathForUserOnNixOS(u) + if got == "" { + x, err := os.ReadFile("/etc/pam/environment") + t.Fatalf("no result. file was: err=%v, contents=%s", err, x) + } + t.Logf("success; got=%q", got) +}