This commit is contained in:
Percy Wegmann 2024-04-27 23:43:18 +08:00 committed by GitHub
commit af5c93dfd3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 390 additions and 95 deletions

View File

@ -575,6 +575,16 @@ jobs:
env: env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
ssh:
runs-on: ubuntu-22.04
steps:
- name: checkout
uses: actions/checkout@v4
- name: run ssh tests
run: |
export PATH=$(./tool/go env GOROOT)/bin:$PATH
make sshintegrationtest
check_mergeability: check_mergeability:
if: always() if: always()
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04

View File

@ -100,6 +100,17 @@ publishdevoperator: ## Build and publish k8s-operator image to location specifie
@test "${REPO}" != "ghcr.io/tailscale/k8s-operator" || (echo "REPO=... must not be ghcr.io/tailscale/k8s-operator" && exit 1) @test "${REPO}" != "ghcr.io/tailscale/k8s-operator" || (echo "REPO=... must not be ghcr.io/tailscale/k8s-operator" && exit 1)
TAGS="${TAGS}" REPOS=${REPO} PLATFORM=${PLATFORM} PUSH=true TARGET=operator ./build_docker.sh TAGS="${TAGS}" REPOS=${REPO} PLATFORM=${PLATFORM} PUSH=true TARGET=operator ./build_docker.sh
.PHONY: sshintegrationtest
sshintegrationtest:
GOOS=linux GOARCH=amd64 go test -tags integrationtest -c ./ssh/tailssh -o ssh/tailssh/testcontainers/tailssh.test && \
echo "Testing on ubuntu:focal" && docker build --build-arg="BASE=ubuntu:focal" -t ssh-ubuntu-focal ssh/tailssh/testcontainers && \
echo "Testing on ubuntu:jammy" && docker build --build-arg="BASE=ubuntu:jammy" -t ssh-ubuntu-jammy ssh/tailssh/testcontainers && \
echo "Testing on ubuntu:mantic" && docker build --build-arg="BASE=ubuntu:mantic" -t ssh-ubuntu-mantic ssh/tailssh/testcontainers && \
echo "Testing on ubuntu:noble" && docker build --build-arg="BASE=ubuntu:noble" -t ssh-ubuntu-noble ssh/tailssh/testcontainers && \
echo "Testing on fedora:38" && docker build --build-arg="BASE=dokken/fedora-38" -t ssh-fedora-38 ssh/tailssh/testcontainers && \
echo "Testing on fedora:39" && docker build --build-arg="BASE=dokken/fedora-39" -t ssh-fedora-39 ssh/tailssh/testcontainers && \
echo "Testing on fedora:40" && docker build --build-arg="BASE=dokken/fedora-40" -t ssh-fedora-40 ssh/tailssh/testcontainers
help: ## Show this help help: ## Show this help
@echo "\nSpecify a command. The choices are:\n" @echo "\nSpecify a command. The choices are:\n"
@grep -hE '^[0-9a-zA-Z_-]+:.*?## .*$$' ${MAKEFILE_LIST} | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[0;36m%-20s\033[m %s\n", $$1, $$2}' @grep -hE '^[0-9a-zA-Z_-]+:.*?## .*$$' ${MAKEFILE_LIST} | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[0;36m%-20s\033[m %s\n", $$1, $$2}'

View File

@ -12,6 +12,7 @@
package tailssh package tailssh
import ( import (
"bytes"
"errors" "errors"
"flag" "flag"
"fmt" "fmt"
@ -121,22 +122,7 @@ func (ss *sshSession) newIncubatorCommand() (cmd *exec.Cmd) {
if isShell { if isShell {
incubatorArgs = append(incubatorArgs, "--shell") incubatorArgs = append(incubatorArgs, "--shell")
} }
// Only the macOS version of the login command supports executing a
// command, all other versions only support launching a shell
// without taking any arguments.
shouldUseLoginCmd := isShell || runtime.GOOS == "darwin"
if hostinfo.IsSELinuxEnforcing() {
// If we're running on a SELinux-enabled system, the login
// command will be unable to set the correct context for the
// shell. Fall back to using the incubator to launch the shell.
// See http://github.com/tailscale/tailscale/issues/4908.
shouldUseLoginCmd = false
}
if shouldUseLoginCmd {
if lp, err := exec.LookPath("login"); err == nil {
incubatorArgs = append(incubatorArgs, "--login-cmd="+lp)
}
}
incubatorArgs = append(incubatorArgs, "--cmd="+name) incubatorArgs = append(incubatorArgs, "--cmd="+name)
if len(args) > 0 { if len(args) > 0 {
incubatorArgs = append(incubatorArgs, "--") incubatorArgs = append(incubatorArgs, "--")
@ -146,7 +132,9 @@ func (ss *sshSession) newIncubatorCommand() (cmd *exec.Cmd) {
return exec.CommandContext(ss.ctx, ss.conn.srv.tailscaledPath, incubatorArgs...) return exec.CommandContext(ss.ctx, ss.conn.srv.tailscaledPath, incubatorArgs...)
} }
const debugIncubator = false const debugIncubator = true
var runningInTest = false
type stdRWC struct{} type stdRWC struct{}
@ -164,19 +152,22 @@ func (stdRWC) Close() error {
} }
type incubatorArgs struct { type incubatorArgs struct {
uid int uid int
gid int gid int
groups string groups string
localUser string localUser string
remoteUser string remoteUser string
remoteIP string remoteIP string
ttyName string ttyName string
hasTTY bool hasTTY bool
cmdName string cmdName string
isSFTP bool isSFTP bool
isShell bool isShell bool
loginCmdPath string cmdArgs []string
cmdArgs []string env []string
stdin io.ReadCloser
stdout io.WriteCloser
stderr io.WriteCloser
} }
func parseIncubatorArgs(args []string) (a incubatorArgs) { func parseIncubatorArgs(args []string) (a incubatorArgs) {
@ -192,22 +183,22 @@ func parseIncubatorArgs(args []string) (a incubatorArgs) {
flags.StringVar(&a.cmdName, "cmd", "", "the cmd to launch (ignored in sftp mode)") flags.StringVar(&a.cmdName, "cmd", "", "the cmd to launch (ignored in sftp mode)")
flags.BoolVar(&a.isShell, "shell", false, "is launching a shell (with no cmds)") flags.BoolVar(&a.isShell, "shell", false, "is launching a shell (with no cmds)")
flags.BoolVar(&a.isSFTP, "sftp", false, "run sftp server (cmd is ignored)") flags.BoolVar(&a.isSFTP, "sftp", false, "run sftp server (cmd is ignored)")
flags.StringVar(&a.loginCmdPath, "login-cmd", "", "the path to `login` cmd")
flags.Parse(args) flags.Parse(args)
a.cmdArgs = flags.Args() a.cmdArgs = flags.Args()
return a return a
} }
// beIncubator is the entrypoint to the `tailscaled be-child ssh` subcommand. // 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. // It is responsible for informing the system of a new login session for the
// This is sometimes necessary for mounting home directories and decrypting file // user. This is sometimes necessary for mounting home directories and
// systems. // decrypting file systems.
// //
// Tailscaled launches the incubator as the same user as it was // Tailscaled launches the incubator as the same user as it was launched as.
// 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`.
func beIncubator(args []string) error { func beIncubator(args []string) error {
return doBeIncubator(args, os.Environ(), os.Stdin, os.Stdout, os.Stderr)
}
func doBeIncubator(args []string, env []string, stdin io.ReadCloser, stdout, stderr io.WriteCloser) error {
// To defend against issues like https://golang.org/issue/1435, // To defend against issues like https://golang.org/issue/1435,
// defensively lock our current goroutine's thread to the current // defensively lock our current goroutine's thread to the current
// system thread before we start making any UID/GID/group changes. // system thread before we start making any UID/GID/group changes.
@ -218,27 +209,38 @@ func beIncubator(args []string) error {
runtime.LockOSThread() runtime.LockOSThread()
defer runtime.UnlockOSThread() defer runtime.UnlockOSThread()
ia := parseIncubatorArgs(args)
if ia.isSFTP && ia.isShell {
return fmt.Errorf("--sftp and --shell are mutually exclusive")
}
logf := logger.Discard logf := logger.Discard
if debugIncubator { if debugIncubator {
// We don't own stdout or stderr, so the only place we can log is syslog. // We don't own stdout or stderr, so the only place we can log is syslog.
if sl, err := syslog.New(syslog.LOG_INFO|syslog.LOG_DAEMON, "tailscaled-ssh"); err == nil { if sl, err := syslog.New(syslog.LOG_INFO|syslog.LOG_DAEMON, "tailscaled-ssh"); err == nil {
log.Println("Logging to syslog")
logf = log.New(sl, "", 0).Printf logf = log.New(sl, "", 0).Printf
} }
} else if runningInTest {
// We can log to stdout during testing
logf = log.Printf
} }
euid := os.Geteuid() ia := parseIncubatorArgs(args)
runningAsRoot := euid == 0 ia.env = env
if runningAsRoot && ia.loginCmdPath != "" { ia.stdin = stdin
// Check if we can exec into the login command instead of trying to ia.stdout = stdout
// incubate ourselves. ia.stderr = stderr
if la := ia.loginArgs(); la != nil { if ia.isSFTP && ia.isShell {
return unix.Exec(ia.loginCmdPath, la, os.Environ()) return fmt.Errorf("--sftp and --shell are mutually exclusive")
} }
if ia.isSFTP {
return handleFTP(logf)
}
attemptLoginShell := shouldAttemptLoginShell()
if !attemptLoginShell {
logf("not attempting login shell")
} else if handled, err := tryLoginCmd(logf, ia); handled {
return err
} else {
logf("not attempting login command")
} }
// Inform the system that we are about to log someone in. // Inform the system that we are about to log someone in.
@ -250,6 +252,170 @@ func beIncubator(args []string) error {
defer sessionCloser() defer sessionCloser()
} }
if attemptLoginShell {
// We weren't able to use login, maybe we can use su.
if handled, err := tryLoginWithSU(logf, ia); handled {
return err
} else {
logf("not attempting su")
}
}
// We couldn't use su either, fall back to just dropping privileges.
return handleDropPrivileges(logf, ia)
}
// shouldAttemptLoginShell decides whether we should attempt to get a full
// login shell with the login or su commands.
func shouldAttemptLoginShell() bool {
euid := os.Geteuid()
runningAsRoot := euid == 0
if !runningAsRoot {
// We have to be root in order to create a login shell.
return false
}
if hostinfo.IsSELinuxEnforcing() {
// If we're running on a SELinux-enabled system, neiher login nor su
// will be able to set the correct context for the shell. So, we don't
// bother trying to run them and instead fall back to using the
// incubator to launch the shell.
// See http://github.com/tailscale/tailscale/issues/4908.
return false
}
return true
}
// tryLoginCmd attempts to handle the ssh session by creating a full login
// shell using the login command. If it was able to do so, it returns true,
// plus any error from running that shell. If it was unable to do so, it
// returns false, nil.
//
// Creating a login shell in this way allows us to trigger register the remote
// IP of the login session, trigger PAM authentication, and get the "remote"
// PAM profile.
//
// However, login is subject to some limitations.
//
// 1. login cannot be used to execute commands except on macOS.
// 2. On Linux and BSD, login requires a TTY to keep running.
//
// In these cases, tryLoginCmd returns false, nil to indicate that processing
// should fall through to other methods.
func tryLoginCmd(logf logger.Logf, ia incubatorArgs) (bool, error) {
if !ia.isShell && runtime.GOOS != "darwin" {
return false, nil
}
switch runtime.GOOS {
case "linux", "freebsd", "openbsd":
if !ia.hasTTY {
// We can only use login command if a shell was requested with a TTY. If
// there is no TTY, login exits immediately, which breaks things likes
// mosh and VSCode.
return false, nil
}
}
if loginCmdPath, err := exec.LookPath("login"); err == nil {
loginArgs := ia.loginArgs(loginCmdPath)
logf("logging in with %s %+v", loginCmdPath, loginArgs)
return true, execReplacing(ia, loginCmdPath, loginArgs)
}
return false, nil
}
// tryLoginWithSU attempts to start a login shell using su. If su is available
// and supports the necessary arguments, this returns true, plus the result of
// executing su. Otherwise, it returns false, nil.
//
// Creating a login shell in this way allows us to trigger PAM authentication
// and get the "login" PAM profile.
//
// Unlike login, su often does not require a TTY, so on Linux hosts that have
// an su command which accepts the right flags, we'll use su instead of login
// when no TTY is available.
func tryLoginWithSU(logf logger.Logf, ia incubatorArgs) (bool, error) {
// Currently, we only support falling back to su on Linux. This
// potentially could work on BSDs as well, but requires testing.
if runtime.GOOS != "linux" {
return false, nil
}
su, err := exec.LookPath("su")
if err != nil {
// Can't find su, don't bother trying.
logf("can't find su command")
return false, nil
}
// Get help text to inspect supported flags.
out, err := exec.Command(su, "-h").CombinedOutput()
if err != nil {
logf("%s doesn't support -h, don't use", su)
// Can't even call su -h, don't bother trying.
return false, nil
}
supportsFlag := func(flag string) bool {
return bytes.Contains(out, []byte(flag))
}
// Make sure su supports the necessary flags.
if !supportsFlag("--login") {
logf("%s doesn't support --login, don't use", su)
return false, nil
}
if !supportsFlag("--command") {
logf("%s doesn't support --command, don't use", su)
return false, nil
}
loginArgs := []string{
"--login",
}
if ia.hasTTY && supportsFlag("--pty") {
// Allocate a pseudo terminal for improved security. In particular,
// this can help avoid TIOCSTI ioctl terminal injection.
loginArgs = append(loginArgs, "--pty")
}
loginArgs = append(loginArgs, ia.localUser)
if !ia.isShell && ia.cmdName != "" {
// We only execute the requested command if we're not requesting a
// shell. When requesting a shell, the command is the requested shell,
// which is redundant because `su -l` will give the user their default
// shell.
loginArgs = append(loginArgs, "--command", ia.cmdName)
loginArgs = append(loginArgs, ia.cmdArgs...)
}
logf("logging in with %s %+v", su, loginArgs)
return true, execReplacing(ia, su, loginArgs)
}
// handleFTP serves FTP connections.
func handleFTP(logf logger.Logf) error {
logf("handling sftp")
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
}
// handleDropPrivileges 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`.
func handleDropPrivileges(logf logger.Logf, ia incubatorArgs) error {
var groupIDs []int var groupIDs []int
for _, g := range strings.Split(ia.groups, ",") { for _, g := range strings.Split(ia.groups, ",") {
gid, err := strconv.ParseInt(g, 10, 32) gid, err := strconv.ParseInt(g, 10, 32)
@ -263,26 +429,12 @@ func beIncubator(args []string) error {
return err return err
} }
if ia.isSFTP { logf("running %s %+v", ia.cmdName, ia.cmdArgs)
logf("handling sftp")
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
}
cmd := exec.Command(ia.cmdName, ia.cmdArgs...) cmd := exec.Command(ia.cmdName, ia.cmdArgs...)
cmd.Stdin = os.Stdin cmd.Stdin = ia.stdin
cmd.Stdout = os.Stdout cmd.Stdout = ia.stdout
cmd.Stderr = os.Stderr cmd.Stderr = ia.stderr
cmd.Env = os.Environ() cmd.Env = ia.env
if ia.hasTTY { if ia.hasTTY {
// If we were launched with a tty then we should // If we were launched with a tty then we should
@ -296,7 +448,7 @@ func beIncubator(args []string) error {
Foreground: true, Foreground: true,
} }
} }
err = cmd.Run() err := cmd.Run()
if ee, ok := err.(*exec.ExitError); ok { if ee, ok := err.(*exec.ExitError); ok {
ps := ee.ProcessState ps := ee.ProcessState
code := ps.ExitCode() code := ps.ExitCode()
@ -312,6 +464,24 @@ func beIncubator(args []string) error {
return err return err
} }
// execReplacing executes the given cmdPath replacing the current process.
// When testing on macOS, it does not replace the current process.
func execReplacing(ia incubatorArgs, cmdPath string, args []string) error {
if runningInTest && runtime.GOOS == "darwin" {
// replacing the running process doesn't work when integration testing
// on macOS, so we use exec.Cmd instead.
cmd := exec.Command(cmdPath, args[1:]...)
cmd.Stdin = ia.stdin
cmd.Stdout = ia.stdout
cmd.Stderr = ia.stderr
cmd.Env = ia.env
return cmd.Run()
}
// replace the running process
return unix.Exec(cmdPath, args, ia.env)
}
const ( const (
// This controls whether we assert that our privileges were dropped // This controls whether we assert that our privileges were dropped
// using geteuid/getegid; it's a const and not an envknob because the // using geteuid/getegid; it's a const and not an envknob because the
@ -731,18 +901,11 @@ func fileExists(path string) bool {
} }
// loginArgs returns the arguments to use to exec the login binary. // loginArgs returns the arguments to use to exec the login binary.
// It returns nil if the login binary should not be used. func (ia *incubatorArgs) loginArgs(loginCmdPath string) []string {
// The login binary is only used:
// - on darwin, if the client is requesting a shell or a command.
// - on linux and BSD, if the client is requesting a shell with a TTY.
func (ia *incubatorArgs) loginArgs() []string {
if ia.isSFTP {
return nil
}
switch runtime.GOOS { switch runtime.GOOS {
case "darwin": case "darwin":
args := []string{ args := []string{
ia.loginCmdPath, loginCmdPath,
"-f", // already authenticated "-f", // already authenticated
// login typically discards the previous environment, but we want to // login typically discards the previous environment, but we want to
@ -761,29 +924,17 @@ func (ia *incubatorArgs) loginArgs() []string {
} }
return args return args
case "linux": case "linux":
if !ia.isShell || !ia.hasTTY {
// We can only use login command if a shell was requested with a TTY. If
// there is no TTY, login exits immediately, which breaks things likes
// mosh and VSCode.
return nil
}
if distro.Get() == distro.Arch && !fileExists("/etc/pam.d/remote") { if distro.Get() == distro.Arch && !fileExists("/etc/pam.d/remote") {
// See https://github.com/tailscale/tailscale/issues/4924 // See https://github.com/tailscale/tailscale/issues/4924
// //
// Arch uses a different login binary that makes the -h flag set the PAM // Arch uses a different login binary that makes the -h flag set the PAM
// service to "remote". So if they don't have that configured, don't // service to "remote". So if they don't have that configured, don't
// pass -h. // pass -h.
return []string{ia.loginCmdPath, "-f", ia.localUser, "-p"} return []string{loginCmdPath, "-f", ia.localUser, "-p"}
} }
return []string{ia.loginCmdPath, "-f", ia.localUser, "-h", ia.remoteIP, "-p"} return []string{loginCmdPath, "-f", ia.localUser, "-h", ia.remoteIP, "-p"}
case "freebsd", "openbsd": case "freebsd", "openbsd":
if !ia.isShell || !ia.hasTTY { return []string{loginCmdPath, "-fp", "-h", ia.remoteIP, ia.localUser}
// We can only use login command if a shell was requested with a TTY. If
// there is no TTY, login exits immediately, which breaks things likes
// mosh and VSCode.
return nil
}
return []string{ia.loginCmdPath, "-fp", "-h", ia.remoteIP, ia.localUser}
} }
panic("unimplemented") panic("unimplemented")
} }

View File

@ -0,0 +1,112 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build integrationtest
// +build integrationtest
package tailssh
import (
"bufio"
"io"
"log"
"os"
"os/exec"
"os/user"
"runtime"
"strings"
"testing"
"github.com/google/go-cmp/cmp"
)
// TestBeIncubator runs an integration test of the beIncubator function. It
// expects an execution environment that meets the following requirements:
//
// - OS is one of MacOS, Linux, FreeBSD or OpenBSD
// - User "testuser" exists
// - "theuser" is in groups "groupone" and "grouptwo"
func TestIntegrationBeIncubator(t *testing.T) {
runningInTest = true
testuser, err := user.Lookup("testuser")
if err != nil {
t.Fatal(err)
}
groupone, err := user.LookupGroup("groupone")
if err != nil {
t.Fatal(err)
}
grouptwo, err := user.LookupGroup("grouptwo")
if err != nil {
t.Fatal(err)
}
runCmd := func(cmd string) string {
errCh := make(chan error, 1)
defer func() {
select {
case err := <-errCh:
if err != nil {
t.Fatal(err)
}
}
}()
args := []string{
"--uid", testuser.Uid,
"--gid", testuser.Gid,
"--groups", groupone.Gid + "," + grouptwo.Gid,
"--local-user", "testuser",
"--remote-user", "remoteuser",
"--remote-ip", "192.168.1.180",
"--cmd", cmd,
}
log.Printf("Testing with args %+v", args)
stdinReader, stdin := io.Pipe()
stdoutReader, stdoutWriter := io.Pipe()
stderrReader, stderrWriter := io.Pipe()
defer stdin.Close()
defer stdoutReader.Close()
defer stderrReader.Close()
go func() {
errCh <- doBeIncubator(args, os.Environ(), stdinReader, stdoutWriter, stderrWriter)
}()
stdout := bufio.NewReader(stdoutReader)
go io.Copy(os.Stderr, stderrReader)
result, err := stdout.ReadString('\n')
if err != nil {
t.Fatal(err)
}
return strings.TrimSpace(result)
}
gotId := runCmd("id")
if !strings.Contains(gotId, "testuserd") {
t.Logf("id output %q missing testuser", gotId)
}
if !strings.Contains(gotId, "groupone") {
t.Logf("id output %q missing groupone", gotId)
}
if !strings.Contains(gotId, "grouptwo") {
t.Logf("id output %q missing grouptwo", gotId)
}
_, err = exec.LookPath("su")
if err == nil {
// If su command is present, make sure that pwd without TTY shows the
// correct directory.
gotPwd := runCmd("pwd")
wantPwd := "/home/testuserd"
if runtime.GOOS == "darwin" {
wantPwd = "/Users/testuser"
}
if diff := cmp.Diff(gotPwd, wantPwd); diff != "" {
t.Fatalf("unexpected pwd output (-got +want):\n%s", diff)
}
}
}

View File

@ -0,0 +1,11 @@
ARG BASE
FROM ${BASE}
RUN groupadd -g 10000 groupone
RUN groupadd -g 10001 grouptwo
RUN useradd -g 10000 -G 10001 -u 10002 -m testuser
COPY . .
RUN ./tailssh.test -test.run TestIntegration
# Remove the su command and run the test again to make sure it works without su
RUN rm `which su`
RUN ./tailssh.test -test.run TestIntegration