Compare commits

...

2 Commits

Author SHA1 Message Date
Percy Wegmann
62c8ed7d73 WIP
Signed-off-by: Percy Wegmann <percy@tailscale.com>
2024-04-28 20:57:00 -05:00
Percy Wegmann
6537f351a1 ssh/tailss: add integration test
Updates tailscale/corp#11854

Signed-off-by: Percy Wegmann <percy@tailscale.com>
2024-04-28 19:53:40 -05:00
5 changed files with 543 additions and 96 deletions

1
.gitignore vendored
View File

@@ -9,6 +9,7 @@
cmd/tailscale/tailscale
cmd/tailscaled/tailscaled
ssh/tailssh/testcontainers/tailscaled
# Test binary, built with `go test -c`
*.test

View File

@@ -100,6 +100,18 @@ 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: ## Run the SSH integration tests in various Docker containers
@GOOS=linux GOARCH=amd64 go test -tags integrationtest -c ./ssh/tailssh -o ssh/tailssh/testcontainers/tailssh.test && \
GOOS=linux GOARCH=amd64 go build -o ssh/tailssh/testcontainers/tailscaled ./cmd/tailscaled && \
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"
@@ -26,6 +27,7 @@ import (
"sort"
"strconv"
"strings"
"sync/atomic"
"syscall"
"github.com/creack/pty"
@@ -121,22 +123,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, "--")
@@ -146,7 +133,7 @@ func (ss *sshSession) newIncubatorCommand() (cmd *exec.Cmd) {
return exec.CommandContext(ss.ctx, ss.conn.srv.tailscaledPath, incubatorArgs...)
}
const debugIncubator = false
var debugIncubator atomic.Bool
type stdRWC struct{}
@@ -164,19 +151,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,22 +182,22 @@ 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
}
// 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.
// 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. 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`.
// Tailscaled launches the incubator as the same user as it was launched as.
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 +208,34 @@ 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 {
if debugIncubator.Load() {
// 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 {
logf = log.New(sl, "", 0).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 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.
@@ -250,6 +247,172 @@ func beIncubator(args []string) error {
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 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, such as using the su command.
func tryLoginCmd(logf logger.Logf, ia incubatorArgs) (bool, error) {
// Only the macOS version of the login command supports executing a
// command, all other versions only support launching a shell without
// taking any arguments.
if !ia.isShell && runtime.GOOS != "darwin" {
return false, nil
}
switch runtime.GOOS {
case "linux", "freebsd", "openbsd":
if !ia.hasTTY {
// We can only use the login command if a shell was requested with
// a TTY. If there is no TTY, login exits immediately, which
// breaks things like 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)
// replace the running process
return true, unix.Exec(loginCmdPath, loginArgs, ia.env)
}
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 {
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)
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, unix.Exec(su, loginArgs, ia.env)
}
// 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
for _, g := range strings.Split(ia.groups, ",") {
gid, err := strconv.ParseInt(g, 10, 32)
@@ -263,26 +426,12 @@ func beIncubator(args []string) error {
return err
}
if ia.isSFTP {
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
}
logf("running %s %+v", ia.cmdName, ia.cmdArgs)
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
@@ -296,7 +445,7 @@ func beIncubator(args []string) error {
Foreground: true,
}
}
err = cmd.Run()
err := cmd.Run()
if ee, ok := err.(*exec.ExitError); ok {
ps := ee.ProcessState
code := ps.ExitCode()
@@ -731,18 +880,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 +903,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,292 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build integrationtest
// +build integrationtest
package tailssh
import (
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"fmt"
"io"
"log"
"net"
"net/http"
"net/netip"
"os"
"runtime"
"strings"
"testing"
"time"
gossh "github.com/tailscale/golang-x-crypto/ssh"
"golang.org/x/crypto/ssh"
"tailscale.com/net/tsdial"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
"tailscale.com/types/netmap"
)
// This file contains integration tests of the SSH functionality. These tests
// exercise everything except for the authentication logic.
//
// The tests make the following assumptions about the environment:
//
// - OS is one of MacOS, Linux, FreeBSD or OpenBSD
// - Test is being run as root (e.g. go test -tags integrationtest -c . && sudo ./tailssh.test -test.run TestIntegration)
// - TAILSCALED_PATH environment variable points at tailscaled binary
// - User "testuser" exists
// - "testuser" is in groups "groupone" and "grouptwo"
func TestIntegration(t *testing.T) {
debugIncubator.Store(true)
t.Cleanup(func() {
debugIncubator.Store(false)
})
homeDir := "/home/testuser"
if runtime.GOOS == "darwin" {
homeDir = "/Users/testuser"
}
tests := []struct {
cmd string
want []string
}{
{
cmd: "id",
want: []string{"testuser", "groupone", "grouptwo"},
},
{
cmd: "pwd",
want: []string{homeDir},
},
{
cmd: "pwd",
want: []string{homeDir},
},
}
for _, test := range tests {
// run every test both without and with a shell
for _, shell := range []bool{false, true} {
shellQualifier := "no_shell"
if shell {
shellQualifier = "shell"
}
t.Run(fmt.Sprintf("%s_%s", test.cmd, shellQualifier), func(t *testing.T) {
s := testSession(t)
defer s.Close()
if shell {
err := s.RequestPty("xterm", 40, 80, ssh.TerminalModes{})
if err != nil {
t.Fatalf("unable to request shell: %s", err)
}
}
err := s.Start(test.cmd)
if err != nil {
t.Fatalf("unable to start command: %s", err)
}
ch := make(chan []byte)
go func() {
for {
b := make([]byte, 1)
n, err := s.stdout.Read(b)
if n > 0 {
ch <- b
}
if err == io.EOF {
return
}
}
}()
// Read first byte in blocking fashion.
_got := <-ch
// Read subsequent bytes in non-blocking fashion.
readLoop:
for {
select {
case b := <-ch:
_got = append(_got, b...)
case <-time.After(25 * time.Millisecond):
break readLoop
}
}
got := string(_got)
for _, want := range test.want {
if !strings.Contains(got, want) {
t.Errorf("%q does not contain %q", got, want)
}
}
})
}
}
}
type session struct {
*ssh.Session
stdin io.WriteCloser
stdout io.ReadCloser
stderr io.ReadCloser
}
func testSession(t *testing.T) *session {
t.Helper()
username := "testuser"
srv := &server{
lb: &testBackend{localUser: username},
logf: log.Printf,
tailscaledPath: os.Getenv("TAILSCALED_PATH"),
timeNow: time.Now,
}
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { l.Close() })
go func() {
conn, err := l.Accept()
if err == nil {
go srv.HandleSSHConn(&addressFakingConn{conn})
}
}()
cl, err := ssh.Dial("tcp", l.Addr().String(), &ssh.ClientConfig{
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
})
if err != nil {
log.Fatal(err)
}
t.Cleanup(func() { cl.Close() })
s, err := cl.NewSession()
if err != nil {
log.Fatal(err)
}
stdinReader, stdinWriter := io.Pipe()
stdoutReader, stdoutWriter := io.Pipe()
stderrReader, stderrWriter := io.Pipe()
s.Stdin = stdinReader
s.Stdout = io.MultiWriter(stdoutWriter, os.Stdout)
s.Stderr = io.MultiWriter(stderrWriter, os.Stderr)
return &session{
Session: s,
stdin: stdinWriter,
stdout: stdoutReader,
stderr: stderrReader,
}
}
// testBackend implements ipnLocalBackend
type testBackend struct {
localUser string
}
func (tb *testBackend) GetSSH_HostKeys() ([]gossh.Signer, error) {
var result []gossh.Signer
for _, typ := range []string{"ed25519", "ecdsa", "rsa"} {
var priv any
var err error
switch typ {
case "ed25519":
_, priv, err = ed25519.GenerateKey(rand.Reader)
case "ecdsa":
curve := elliptic.P256()
priv, err = ecdsa.GenerateKey(curve, rand.Reader)
case "rsa":
const keySize = 2048
priv, err = rsa.GenerateKey(rand.Reader, keySize)
}
if err != nil {
return nil, err
}
mk, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
return nil, err
}
hostKey := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: mk})
signer, err := gossh.ParsePrivateKey(hostKey)
if err != nil {
return nil, err
}
result = append(result, signer)
}
return result, nil
}
func (tb *testBackend) ShouldRunSSH() bool {
return true
}
func (tb *testBackend) NetMap() *netmap.NetworkMap {
return &netmap.NetworkMap{
SSHPolicy: &tailcfg.SSHPolicy{
Rules: []*tailcfg.SSHRule{
&tailcfg.SSHRule{
Principals: []*tailcfg.SSHPrincipal{{Any: true}},
Action: &tailcfg.SSHAction{Accept: true},
SSHUsers: map[string]string{"*": tb.localUser},
},
},
},
}
}
func (tb *testBackend) WhoIs(ipp netip.AddrPort) (n tailcfg.NodeView, u tailcfg.UserProfile, ok bool) {
return (&tailcfg.Node{}).View(), tailcfg.UserProfile{
LoginName: tb.localUser + "@example.com",
}, true
}
func (tb *testBackend) DoNoiseRequest(req *http.Request) (*http.Response, error) {
return nil, nil
}
func (tb *testBackend) Dialer() *tsdial.Dialer {
return nil
}
func (tb *testBackend) TailscaleVarRoot() string {
return ""
}
func (tb *testBackend) NodeKey() key.NodePublic {
return key.NodePublic{}
}
type addressFakingConn struct {
net.Conn
}
func (conn *addressFakingConn) LocalAddr() net.Addr {
return &net.TCPAddr{
IP: net.ParseIP("100.100.100.101"),
Port: 22,
}
}
func (conn *addressFakingConn) RemoteAddr() net.Addr {
return &net.TCPAddr{
IP: net.ParseIP("100.100.100.102"),
Port: 10002,
}
}

View File

@@ -0,0 +1,12 @@
ARG BASE
FROM ${BASE}
RUN groupadd -g 10000 groupone
RUN groupadd -g 10001 grouptwo
RUN useradd -g 10000 -G 10001 -u 10002 testuser
RUN sed -i -e 's/Default: no/Default: yes/g' /usr/share/pam-configs/mkhomedir || echo "might not be ubuntu"
RUN cat /usr/share/pam-configs/mkhomedir || echo "might not be ubuntu"
RUN pam-auth-update --enable mkhomedir || echo "might not be ubuntu"
RUN authconfig --enablemkhomedir --update || echo "might not be fedora"
COPY . .
RUN TAILSCALED_PATH=`pwd`tailscaled ./tailssh.test -test.run TestIntegration