This commit is contained in:
Percy Wegmann 2024-04-27 03:04:42 +00:00 committed by GitHub
commit 573af95d07
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 324 additions and 71 deletions

View File

@ -575,6 +575,16 @@ jobs:
env:
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:
if: always()
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)
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
@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}'

View File

@ -12,6 +12,7 @@
package tailssh
import (
"bytes"
"errors"
"flag"
"fmt"
@ -121,22 +122,7 @@ func (ss *sshSession) newIncubatorCommand() (cmd *exec.Cmd) {
if isShell {
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)
if len(args) > 0 {
incubatorArgs = append(incubatorArgs, "--")
@ -148,6 +134,8 @@ func (ss *sshSession) newIncubatorCommand() (cmd *exec.Cmd) {
const debugIncubator = false
var runningInTest = false
type stdRWC struct{}
func (stdRWC) Read(p []byte) (n int, err error) {
@ -164,19 +152,22 @@ func (stdRWC) Close() error {
}
type incubatorArgs struct {
uid int
gid int
groups string
localUser string
remoteUser string
remoteIP string
ttyName string
hasTTY bool
cmdName string
isSFTP bool
isShell bool
loginCmdPath string
cmdArgs []string
uid int
gid int
groups string
localUser string
remoteUser string
remoteIP string
ttyName string
hasTTY bool
cmdName string
isSFTP bool
isShell bool
cmdArgs []string
env []string
stdin io.ReadCloser
stdout io.WriteCloser
stderr io.WriteCloser
}
func parseIncubatorArgs(args []string) (a incubatorArgs) {
@ -192,7 +183,6 @@ func parseIncubatorArgs(args []string) (a incubatorArgs) {
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.isSFTP, "sftp", false, "run sftp server (cmd is ignored)")
flags.StringVar(&a.loginCmdPath, "login-cmd", "", "the path to `login` cmd")
flags.Parse(args)
a.cmdArgs = flags.Args()
return a
@ -208,6 +198,10 @@ func parseIncubatorArgs(args []string) (a incubatorArgs) {
// OS, sets its UID and groups to the specified `--uid`, `--gid` and
// `--groups` and then launches the requested `--cmd`.
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,
// defensively lock our current goroutine's thread to the current
// system thread before we start making any UID/GID/group changes.
@ -218,27 +212,29 @@ func beIncubator(args []string) error {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
ia := parseIncubatorArgs(args)
if ia.isSFTP && ia.isShell {
return fmt.Errorf("--sftp and --shell are mutually exclusive")
}
logf := logger.Discard
if debugIncubator {
// 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 {
log.Println("Logging to syslog")
logf = log.New(sl, "", 0).Printf
}
} else if runningInTest {
// We can log to stdout during testing
logf = log.Printf
}
euid := os.Geteuid()
runningAsRoot := euid == 0
if runningAsRoot && ia.loginCmdPath != "" {
// Check if we can exec into the login command instead of trying to
// incubate ourselves.
if la := ia.loginArgs(); la != nil {
return unix.Exec(ia.loginCmdPath, la, os.Environ())
}
ia := parseIncubatorArgs(args)
ia.env = env
ia.stdin = stdin
ia.stdout = stdout
ia.stderr = stderr
if ia.isSFTP && ia.isShell {
return fmt.Errorf("--sftp and --shell are mutually exclusive")
}
if handled, err := tryLoginShell(logf, ia); handled {
return err
}
// Inform the system that we are about to log someone in.
@ -250,6 +246,16 @@ func beIncubator(args []string) error {
defer sessionCloser()
}
// We weren't able to use login, maybe we can use su.
// Currently, we only support falling back to su on Linux. This
// potentially could work on BSDs as well, but requires testing.
canUseSU := runtime.GOOS == "linux"
if canUseSU {
if handled, err := tryLoginWithSU(logf, ia); handled {
return err
}
}
var groupIDs []int
for _, g := range strings.Split(ia.groups, ",") {
gid, err := strconv.ParseInt(g, 10, 32)
@ -279,10 +285,10 @@ func beIncubator(args []string) error {
}
cmd := exec.Command(ia.cmdName, ia.cmdArgs...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = os.Environ()
cmd.Stdin = ia.stdin
cmd.Stdout = ia.stdout
cmd.Stderr = ia.stderr
cmd.Env = ia.env
if ia.hasTTY {
// If we were launched with a tty then we should
@ -312,6 +318,136 @@ func beIncubator(args []string) error {
return err
}
// tryLoginShell attempts to handle the ssh session by creating a full login
// shell. 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.
//
// We prefer to create a login shell using either the login command
// (e.g. /usr/bin/login) or using the su command (e.g. /usr/bin/su). A login
// shell has the advantage of running PAM authentication, which will set up the
// connected user's environment. See https://github.com/tailscale/tailscale/issues/11854.
//
// login is preferred over su because it supports the `-h` option, allowing the
// system to record the remote IP associated with the login.
//
// 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.
//
// Unlike login, su often does not require a TTY, so on Linux hosts that have
// an su command which accepts the right flags, we fall back to using that when
// no TTY is available.
//
// Note - one nuance of this is that when we use login with the -h option, the
// shell will use the "remote" PAM profile. When we fall back to using "su",
// the shell will use the "login" PAM profile.
func tryLoginShell(logf logger.Logf, ia incubatorArgs) (bool, error) {
euid := os.Geteuid()
runningAsRoot := euid == 0
// Decide whether we should attempt to get a full login shell using either
// the login or su commands.
attemptLoginShell := true
switch {
case ia.isSFTP:
// If we're going to run an sFTP server, we don't want a shell
attemptLoginShell = false
case !runningAsRoot:
// We have to be root in order to create a login shell.
attemptLoginShell = false
case 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.
attemptLoginShell = false
}
if !attemptLoginShell {
logf("not attempting login shell")
return false, nil
}
shouldUseLoginCmd := ia.isShell || runtime.GOOS == "darwin"
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.
shouldUseLoginCmd = false
}
}
if shouldUseLoginCmd {
if loginCmdPath, err := exec.LookPath("login"); err == nil {
loginArgs := ia.loginArgs(loginCmdPath)
logf("running %s %+v", loginCmdPath, loginArgs)
return true, unix.Exec(loginCmdPath, loginArgs, os.Environ())
}
}
return false, nil
}
// tryLoginWithSU attempts to start a login shell using su instead of login. If
// su is available and supports the necessary arguments, this returns true,
// plus the result of executing su. Otherwise, it returns false, nil.
func tryLoginWithSU(logf logger.Logf, ia incubatorArgs) (bool, error) {
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("-l") {
logf("%s doesn't support -l, don't use", su)
return false, nil
}
if !supportsFlag("-c") {
logf("%s doesn't support -c, don't use", su)
return false, nil
}
loginArgs := []string{
"-l",
}
if ia.hasTTY && supportsFlag("-P") {
// Allocate a pseudo terminal for improved security. In particular,
// this can help avoid TIOCSTI ioctl terminal injection.
loginArgs = append(loginArgs, "-P")
}
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, "-c", ia.cmdName)
loginArgs = append(loginArgs, ia.cmdArgs...)
}
logf("logging in with su %+v", loginArgs)
return true, unix.Exec(su, loginArgs, os.Environ())
}
const (
// This controls whether we assert that our privileges were dropped
// using geteuid/getegid; it's a const and not an envknob because the
@ -731,18 +867,11 @@ func fileExists(path string) bool {
}
// loginArgs returns the arguments to use to exec the login binary.
// It returns nil if the login binary should not be used.
// 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
}
func (ia *incubatorArgs) loginArgs(loginCmdPath string) []string {
switch runtime.GOOS {
case "darwin":
args := []string{
ia.loginCmdPath,
loginCmdPath,
"-f", // already authenticated
// login typically discards the previous environment, but we want to
@ -761,29 +890,17 @@ func (ia *incubatorArgs) loginArgs() []string {
}
return args
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") {
// See https://github.com/tailscale/tailscale/issues/4924
//
// 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
// 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":
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
}
return []string{ia.loginCmdPath, "-fp", "-h", ia.remoteIP, ia.localUser}
return []string{loginCmdPath, "-fp", "-h", ia.remoteIP, ia.localUser}
}
panic("unimplemented")
}

View File

@ -0,0 +1,106 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build integrationtest
// +build integrationtest
package tailssh
import (
"bufio"
"io"
"log"
"os"
"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 TestIntegrationTestBeIncubator(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)
}
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)
}
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)
}
}

View File

@ -0,0 +1,9 @@
ARG BASE
FROM ${BASE}
RUN groupadd -g 10000 groupone
RUN groupadd -g 10001 grouptwo
RUN useradd -g 10000 -G 10001 -u 10002 -m testuser
# RUN dnf install -y util-linux || echo "not fedora"
COPY . .
RUN ./tailssh.test -test.run TestIntegrationTest