Updates #5794 Change-Id: I7b05cd29ec02085cb503bbcd0beb61bf455002ac Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>main
parent
6f75647c0e
commit
b3953ce0c4
@ -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
|
||||
} |
||||
Loading…
Reference in new issue