ssh/tailssh: replace incubator process with su instead of running su as child

This allows the SSH_AUTH_SOCK environment variable to work inside of
su and agent forwarding to succeed.

Fixes #12467

Signed-off-by: Percy Wegmann <percy@tailscale.com>
pull/12562/head
Percy Wegmann 5 months ago committed by Percy Wegmann
parent 24976b5bfd
commit 730f0368d0

@ -0,0 +1,23 @@
# Run the ssh integration tests with `make sshintegrationtest`.
# These tests can also be running locally.
name: "ssh-integrationtest"
concurrency:
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
cancel-in-progress: true
on:
pull_request:
paths:
- "ssh/**"
- "tempfork/gliderlabs/ssh/**"
- ".github/workflows/ssh-integrationtest"
jobs:
ssh-integrationtest:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Run SSH integration tests
run: |
make sshintegrationtest

@ -110,8 +110,8 @@ publishdevnameserver: ## Build and publish k8s-nameserver image to location spec
.PHONY: sshintegrationtest .PHONY: sshintegrationtest
sshintegrationtest: ## Run the SSH integration tests in various Docker containers 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 ./tool/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 && \ GOOS=linux GOARCH=amd64 ./tool/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: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: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:mantic" && docker build --build-arg="BASE=ubuntu:mantic" -t ssh-ubuntu-mantic ssh/tailssh/testcontainers && \

@ -400,8 +400,12 @@ func tryExecLogin(dlogf logger.Logf, ia incubatorArgs) error {
} }
loginArgs := ia.loginArgs(loginCmdPath) loginArgs := ia.loginArgs(loginCmdPath)
dlogf("logging in with %s %+v", loginCmdPath, loginArgs) dlogf("logging in with %s %+v", loginCmdPath, loginArgs)
// replace the running process
return unix.Exec(loginCmdPath, loginArgs, os.Environ()) // If Exec works, the Go code will not proceed past this:
err = unix.Exec(loginCmdPath, loginArgs, os.Environ())
// If we made it here, Exec failed.
return err
} }
// trySU attempts to start a login shell using su. If su is available and // trySU attempts to start a login shell using su. If su is available and
@ -438,8 +442,12 @@ func trySU(dlogf logger.Logf, ia incubatorArgs) (handled bool, err error) {
} }
dlogf("logging in with %s %q", su, loginArgs) dlogf("logging in with %s %q", su, loginArgs)
cmd := newCommand(ia.hasTTY, su, loginArgs)
return true, cmd.Run() // If Exec works, the Go code will not proceed past this:
err = unix.Exec(su, loginArgs, os.Environ())
// If we made it here, Exec failed.
return true, err
} }
// findSU attempts to find an su command which supports the -l and -c flags. // findSU attempts to find an su command which supports the -l and -c flags.

@ -8,14 +8,13 @@ package tailssh
import ( import (
"bufio" "bufio"
"bytes"
"context" "context"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand" "crypto/rand"
"crypto/rsa" "crypto/rsa"
"crypto/x509" "crypto/x509"
"encoding/pem" "encoding/pem"
"errors"
"fmt" "fmt"
"io" "io"
"log" "log"
@ -24,6 +23,7 @@ import (
"net/netip" "net/netip"
"os" "os"
"os/exec" "os/exec"
"path/filepath"
"runtime" "runtime"
"strings" "strings"
"testing" "testing"
@ -34,8 +34,10 @@ import (
"github.com/pkg/sftp" "github.com/pkg/sftp"
gossh "github.com/tailscale/golang-x-crypto/ssh" gossh "github.com/tailscale/golang-x-crypto/ssh"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/agent"
"tailscale.com/net/tsdial" "tailscale.com/net/tsdial"
"tailscale.com/tailcfg" "tailscale.com/tailcfg"
glider "tailscale.com/tempfork/gliderlabs/ssh"
"tailscale.com/types/key" "tailscale.com/types/key"
"tailscale.com/types/netmap" "tailscale.com/types/netmap"
"tailscale.com/util/set" "tailscale.com/util/set"
@ -300,6 +302,95 @@ func TestIntegrationSCP(t *testing.T) {
} }
} }
func TestSSHAgentForwarding(t *testing.T) {
debugTest.Store(true)
t.Cleanup(func() {
debugTest.Store(false)
})
// Create a client SSH key
tmpDir, err := os.MkdirTemp("", "")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_ = os.RemoveAll(tmpDir)
})
pkFile := filepath.Join(tmpDir, "pk")
clientKey, clientKeyRSA := generateClientKey(t, pkFile)
// Start upstream SSH server
l, err := net.Listen("tcp", "127.0.0.1:")
if err != nil {
t.Fatalf("unable to listen for SSH: %s", err)
}
t.Cleanup(func() {
_ = l.Close()
})
// Run an SSH server that accepts connections from that client SSH key.
gs := glider.Server{
Handler: func(s glider.Session) {
io.WriteString(s, "Hello world\n")
},
PublicKeyHandler: func(ctx glider.Context, key glider.PublicKey) error {
// Note - this is not meant to be cryptographically secure, it's
// just checking that SSH agent forwarding is forwarding the right
// key.
a := key.Marshal()
b := clientKey.PublicKey().Marshal()
if !bytes.Equal(a, b) {
return errors.New("key mismatch")
}
return nil
},
}
go gs.Serve(l)
// Run tailscale SSH server and connect to it
username := "testuser"
tailscaleAddr := testServer(t, username, false) // TODO: make this false to use V2 behavior
tcl, err := ssh.Dial("tcp", tailscaleAddr, &ssh.ClientConfig{
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { tcl.Close() })
s, err := tcl.NewSession()
if err != nil {
t.Fatal(err)
}
// Set up SSH agent forwarding on the client
err = agent.RequestAgentForwarding(s)
if err != nil {
t.Fatal(err)
}
keyring := agent.NewKeyring()
keyring.Add(agent.AddedKey{
PrivateKey: clientKeyRSA,
})
err = agent.ForwardToAgent(tcl, keyring)
if err != nil {
t.Fatal(err)
}
// Attempt to SSH to the upstream test server using the forwarded SSH key
// and run the "true" command.
upstreamHost, upstreamPort, err := net.SplitHostPort(l.Addr().String())
if err != nil {
t.Fatal(err)
}
o, err := s.CombinedOutput(fmt.Sprintf(`ssh -T -o StrictHostKeyChecking=no -p %s upstreamuser@%s "true"`, upstreamPort, upstreamHost))
if err != nil {
t.Fatalf("unable to call true command: %s\n%s", err, o)
}
}
func fallbackToSUAvailable() bool { func fallbackToSUAvailable() bool {
if runtime.GOOS != "linux" { if runtime.GOOS != "linux" {
return false return false
@ -374,10 +465,25 @@ readLoop:
return string(_got) return string(_got)
} }
func testClient(t *testing.T, forceV1Behavior bool) *ssh.Client { func testClient(t *testing.T, forceV1Behavior bool, authMethods ...ssh.AuthMethod) *ssh.Client {
t.Helper() t.Helper()
username := "testuser" username := "testuser"
addr := testServer(t, username, forceV1Behavior)
cl, err := ssh.Dial("tcp", addr, &ssh.ClientConfig{
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Auth: authMethods,
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { cl.Close() })
return cl
}
func testServer(t *testing.T, username string, forceV1Behavior bool) string {
srv := &server{ srv := &server{
lb: &testBackend{localUser: username, forceV1Behavior: forceV1Behavior}, lb: &testBackend{localUser: username, forceV1Behavior: forceV1Behavior},
logf: log.Printf, logf: log.Printf,
@ -392,21 +498,15 @@ func testClient(t *testing.T, forceV1Behavior bool) *ssh.Client {
t.Cleanup(func() { l.Close() }) t.Cleanup(func() { l.Close() })
go func() { go func() {
conn, err := l.Accept() for {
if err == nil { conn, err := l.Accept()
go srv.HandleSSHConn(&addressFakingConn{conn}) if err == nil {
go srv.HandleSSHConn(&addressFakingConn{conn})
}
} }
}() }()
cl, err := ssh.Dial("tcp", l.Addr().String(), &ssh.ClientConfig{ return l.Addr().String()
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
})
if err != nil {
log.Fatal(err)
}
t.Cleanup(func() { cl.Close() })
return cl
} }
func testSession(t *testing.T, forceV1Behavior bool) *session { func testSession(t *testing.T, forceV1Behavior bool) *session {
@ -417,7 +517,7 @@ func testSession(t *testing.T, forceV1Behavior bool) *session {
func testSessionFor(t *testing.T, cl *ssh.Client) *session { func testSessionFor(t *testing.T, cl *ssh.Client) *session {
s, err := cl.NewSession() s, err := cl.NewSession()
if err != nil { if err != nil {
log.Fatal(err) t.Fatal(err)
} }
t.Cleanup(func() { s.Close() }) t.Cleanup(func() { s.Close() })
@ -435,6 +535,31 @@ func testSessionFor(t *testing.T, cl *ssh.Client) *session {
} }
} }
func generateClientKey(t *testing.T, privateKeyFile string) (ssh.Signer, *rsa.PrivateKey) {
t.Helper()
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
mk, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
t.Fatal(err)
}
privateKey := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: mk})
if privateKey == nil {
t.Fatal("failed to encoded private key")
}
err = os.WriteFile(privateKeyFile, privateKey, 0600)
if err != nil {
t.Fatal(err)
}
signer, err := ssh.ParsePrivateKey(privateKey)
if err != nil {
t.Fatal(err)
}
return signer, priv
}
// testBackend implements ipnLocalBackend // testBackend implements ipnLocalBackend
type testBackend struct { type testBackend struct {
localUser string localUser string
@ -443,33 +568,23 @@ type testBackend struct {
func (tb *testBackend) GetSSH_HostKeys() ([]gossh.Signer, error) { func (tb *testBackend) GetSSH_HostKeys() ([]gossh.Signer, error) {
var result []gossh.Signer var result []gossh.Signer
for _, typ := range []string{"ed25519", "ecdsa", "rsa"} { var priv any
var priv any var err error
var err error const keySize = 2048
switch typ { priv, err = rsa.GenerateKey(rand.Reader, keySize)
case "ed25519": if err != nil {
_, priv, err = ed25519.GenerateKey(rand.Reader) return nil, err
case "ecdsa": }
curve := elliptic.P256() mk, err := x509.MarshalPKCS8PrivateKey(priv)
priv, err = ecdsa.GenerateKey(curve, rand.Reader) if err != nil {
case "rsa": return nil, err
const keySize = 2048 }
priv, err = rsa.GenerateKey(rand.Reader, keySize) hostKey := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: mk})
} signer, err := gossh.ParsePrivateKey(hostKey)
if err != nil { if err != nil {
return nil, err 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)
} }
result = append(result, signer)
return result, nil return result, nil
} }
@ -487,7 +602,7 @@ func (tb *testBackend) NetMap() *netmap.NetworkMap {
Rules: []*tailcfg.SSHRule{ Rules: []*tailcfg.SSHRule{
{ {
Principals: []*tailcfg.SSHPrincipal{{Any: true}}, Principals: []*tailcfg.SSHPrincipal{{Any: true}},
Action: &tailcfg.SSHAction{Accept: true}, Action: &tailcfg.SSHAction{Accept: true, AllowAgentForwarding: true},
SSHUsers: map[string]string{"*": tb.localUser}, SSHUsers: map[string]string{"*": tb.localUser},
}, },
}, },

@ -20,7 +20,8 @@ COPY tailssh.test .
RUN chmod 755 tailscaled RUN chmod 755 tailscaled
RUN echo "First run tests normally." # RUN echo "First run tests normally."
RUN eval `ssh-agent -s` && TAILSCALED_PATH=`pwd`tailscaled ./tailssh.test -test.v -test.run TestSSHAgentForwarding
RUN rm -Rf /home/testuser RUN rm -Rf /home/testuser
RUN TAILSCALED_PATH=`pwd`tailscaled ./tailssh.test -test.v -test.run TestIntegrationSFTP RUN TAILSCALED_PATH=`pwd`tailscaled ./tailssh.test -test.v -test.run TestIntegrationSFTP
RUN rm -Rf /home/testuser RUN rm -Rf /home/testuser
@ -30,6 +31,7 @@ RUN TAILSCALED_PATH=`pwd`tailscaled ./tailssh.test -test.v -test.run TestIntegra
RUN echo "Then run tests as non-root user testuser and make sure tests still pass." RUN echo "Then run tests as non-root user testuser and make sure tests still pass."
RUN chown testuser:groupone /tmp/tailscalessh.log RUN chown testuser:groupone /tmp/tailscalessh.log
RUN TAILSCALED_PATH=`pwd`tailscaled eval `su -m testuser -c ssh-agent -s` && su -m testuser -c "./tailssh.test -test.v -test.run TestSSHAgentForwarding"
RUN TAILSCALED_PATH=`pwd`tailscaled su -m testuser -c "./tailssh.test -test.v -test.run TestIntegration TestDoDropPrivileges" RUN TAILSCALED_PATH=`pwd`tailscaled su -m testuser -c "./tailssh.test -test.v -test.run TestIntegration TestDoDropPrivileges"
RUN chown root:root /tmp/tailscalessh.log RUN chown root:root /tmp/tailscalessh.log
@ -42,12 +44,14 @@ RUN chmod 755 /usr/bin/login
# Simulate getenforce command # Simulate getenforce command
RUN printf "#!/bin/bash\necho 'Enforcing'" > /usr/bin/getenforce RUN printf "#!/bin/bash\necho 'Enforcing'" > /usr/bin/getenforce
RUN chmod 755 /usr/bin/getenforce RUN chmod 755 /usr/bin/getenforce
RUN eval `ssh-agent -s` && TAILSCALED_PATH=`pwd`tailscaled ./tailssh.test -test.v -test.run TestSSHAgentForwarding
RUN TAILSCALED_PATH=`pwd`tailscaled ./tailssh.test -test.v -test.run TestIntegration RUN TAILSCALED_PATH=`pwd`tailscaled ./tailssh.test -test.v -test.run TestIntegration
RUN mv /tmp/login_orig /usr/bin/login RUN mv /tmp/login_orig /usr/bin/login
RUN rm /usr/bin/getenforce RUN rm /usr/bin/getenforce
RUN echo "Then remove the login command and make sure tests still pass." RUN echo "Then remove the login command and make sure tests still pass."
RUN rm `which login` RUN rm `which login`
RUN eval `ssh-agent -s` && TAILSCALED_PATH=`pwd`tailscaled ./tailssh.test -test.v -test.run TestSSHAgentForwarding
RUN rm -Rf /home/testuser RUN rm -Rf /home/testuser
RUN TAILSCALED_PATH=`pwd`tailscaled ./tailssh.test -test.v -test.run TestIntegrationSFTP RUN TAILSCALED_PATH=`pwd`tailscaled ./tailssh.test -test.v -test.run TestIntegrationSFTP
RUN rm -Rf /home/testuser RUN rm -Rf /home/testuser
@ -58,6 +62,7 @@ RUN TAILSCALED_PATH=`pwd`tailscaled ./tailssh.test -test.v -test.run TestIntegra
RUN echo "Then remove the su command and make sure tests still pass." RUN echo "Then remove the su command and make sure tests still pass."
RUN chown root:root /tmp/tailscalessh.log RUN chown root:root /tmp/tailscalessh.log
RUN rm `which su` RUN rm `which su`
RUN eval `ssh-agent -s` && TAILSCALED_PATH=`pwd`tailscaled ./tailssh.test -test.v -test.run TestSSHAgentForwarding
RUN TAILSCALED_PATH=`pwd`tailscaled ./tailssh.test -test.v -test.run TestIntegration RUN TAILSCALED_PATH=`pwd`tailscaled ./tailssh.test -test.v -test.run TestIntegration
RUN echo "Test doDropPrivileges" RUN echo "Test doDropPrivileges"

Loading…
Cancel
Save