|
|
@@ -9,7 +9,10 @@ package tailssh
|
|
|
|
|
|
import (
|
|
|
"bytes"
|
|
|
+ "crypto/ed25519"
|
|
|
+ "crypto/rand"
|
|
|
"crypto/sha256"
|
|
|
+ "encoding/json"
|
|
|
"errors"
|
|
|
"fmt"
|
|
|
"io"
|
|
|
@@ -21,20 +24,27 @@ import (
|
|
|
"os/exec"
|
|
|
"os/user"
|
|
|
"reflect"
|
|
|
+ "runtime"
|
|
|
"strings"
|
|
|
+ "sync"
|
|
|
"sync/atomic"
|
|
|
"testing"
|
|
|
"time"
|
|
|
|
|
|
+ gossh "github.com/tailscale/golang-x-crypto/ssh"
|
|
|
"tailscale.com/ipn/ipnlocal"
|
|
|
"tailscale.com/ipn/store/mem"
|
|
|
+ "tailscale.com/net/nettest"
|
|
|
"tailscale.com/net/tsdial"
|
|
|
"tailscale.com/tailcfg"
|
|
|
"tailscale.com/tempfork/gliderlabs/ssh"
|
|
|
"tailscale.com/tstest"
|
|
|
"tailscale.com/types/logger"
|
|
|
+ "tailscale.com/types/netmap"
|
|
|
"tailscale.com/util/cibuild"
|
|
|
"tailscale.com/util/lineread"
|
|
|
+ "tailscale.com/util/must"
|
|
|
+ "tailscale.com/util/strs"
|
|
|
"tailscale.com/wgengine"
|
|
|
)
|
|
|
|
|
|
@@ -173,7 +183,7 @@ func TestMatchRule(t *testing.T) {
|
|
|
Principals: []*tailcfg.SSHPrincipal{{UserLogin: "[email protected]"}},
|
|
|
SSHUsers: map[string]string{"*": "ubuntu"},
|
|
|
},
|
|
|
- ci: &sshConnInfo{uprof: &tailcfg.UserProfile{LoginName: "[email protected]"}},
|
|
|
+ ci: &sshConnInfo{uprof: tailcfg.UserProfile{LoginName: "[email protected]"}},
|
|
|
wantUser: "ubuntu",
|
|
|
},
|
|
|
{
|
|
|
@@ -211,6 +221,250 @@ func TestMatchRule(t *testing.T) {
|
|
|
|
|
|
func timePtr(t time.Time) *time.Time { return &t }
|
|
|
|
|
|
+// localState implements ipnLocalBackend for testing.
|
|
|
+type localState struct {
|
|
|
+ sshEnabled bool
|
|
|
+ matchingRule *tailcfg.SSHRule
|
|
|
+
|
|
|
+ // serverActions is a map of the action name to the action.
|
|
|
+ // It is served for paths like https://unused/ssh-action/<action-name>.
|
|
|
+ // The action name is the last part of the action URL.
|
|
|
+ serverActions map[string]*tailcfg.SSHAction
|
|
|
+}
|
|
|
+
|
|
|
+var (
|
|
|
+ currentUser = os.Getenv("USER") // Use the current user for the test.
|
|
|
+ testSigner gossh.Signer
|
|
|
+ testSignerOnce sync.Once
|
|
|
+)
|
|
|
+
|
|
|
+func (ts *localState) GetSSH_HostKeys() ([]gossh.Signer, error) {
|
|
|
+ testSignerOnce.Do(func() {
|
|
|
+ _, priv, err := ed25519.GenerateKey(rand.Reader)
|
|
|
+ if err != nil {
|
|
|
+ panic(err)
|
|
|
+ }
|
|
|
+ s, err := gossh.NewSignerFromSigner(priv)
|
|
|
+ if err != nil {
|
|
|
+ panic(err)
|
|
|
+ }
|
|
|
+ testSigner = s
|
|
|
+ })
|
|
|
+ return []gossh.Signer{testSigner}, nil
|
|
|
+}
|
|
|
+
|
|
|
+func (ts *localState) ShouldRunSSH() bool {
|
|
|
+ return ts.sshEnabled
|
|
|
+}
|
|
|
+
|
|
|
+func (ts *localState) NetMap() *netmap.NetworkMap {
|
|
|
+ var policy *tailcfg.SSHPolicy
|
|
|
+ if ts.matchingRule != nil {
|
|
|
+ policy = &tailcfg.SSHPolicy{
|
|
|
+ Rules: []*tailcfg.SSHRule{
|
|
|
+ ts.matchingRule,
|
|
|
+ },
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return &netmap.NetworkMap{
|
|
|
+ SelfNode: &tailcfg.Node{
|
|
|
+ ID: 1,
|
|
|
+ },
|
|
|
+ SSHPolicy: policy,
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func (ts *localState) WhoIs(ipp netip.AddrPort) (n *tailcfg.Node, u tailcfg.UserProfile, ok bool) {
|
|
|
+ return &tailcfg.Node{
|
|
|
+ ID: 2,
|
|
|
+ StableID: "peer-id",
|
|
|
+ }, tailcfg.UserProfile{
|
|
|
+ LoginName: "peer",
|
|
|
+ }, true
|
|
|
+
|
|
|
+}
|
|
|
+
|
|
|
+func (ts *localState) DoNoiseRequest(req *http.Request) (*http.Response, error) {
|
|
|
+ rec := httptest.NewRecorder()
|
|
|
+ k, ok := strs.CutPrefix(req.URL.Path, "/ssh-action/")
|
|
|
+ if !ok {
|
|
|
+ rec.WriteHeader(http.StatusNotFound)
|
|
|
+ }
|
|
|
+ a, ok := ts.serverActions[k]
|
|
|
+ if !ok {
|
|
|
+ rec.WriteHeader(http.StatusNotFound)
|
|
|
+ return rec.Result(), nil
|
|
|
+ }
|
|
|
+ rec.WriteHeader(http.StatusOK)
|
|
|
+ if err := json.NewEncoder(rec).Encode(a); err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ return rec.Result(), nil
|
|
|
+}
|
|
|
+
|
|
|
+func (ts *localState) TailscaleVarRoot() string {
|
|
|
+ return ""
|
|
|
+}
|
|
|
+
|
|
|
+func newSSHRule(action *tailcfg.SSHAction) *tailcfg.SSHRule {
|
|
|
+ return &tailcfg.SSHRule{
|
|
|
+ SSHUsers: map[string]string{
|
|
|
+ "*": currentUser,
|
|
|
+ },
|
|
|
+ Action: action,
|
|
|
+ Principals: []*tailcfg.SSHPrincipal{
|
|
|
+ {
|
|
|
+ Any: true,
|
|
|
+ },
|
|
|
+ },
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func TestSSHAuthFlow(t *testing.T) {
|
|
|
+ if runtime.GOOS != "linux" {
|
|
|
+ t.Skip("Not running on Linux, skipping")
|
|
|
+ }
|
|
|
+ acceptRule := newSSHRule(&tailcfg.SSHAction{
|
|
|
+ Accept: true,
|
|
|
+ Message: "Welcome to Tailscale SSH!",
|
|
|
+ })
|
|
|
+ rejectRule := newSSHRule(&tailcfg.SSHAction{
|
|
|
+ Reject: true,
|
|
|
+ Message: "Go Away!",
|
|
|
+ })
|
|
|
+
|
|
|
+ tests := []struct {
|
|
|
+ name string
|
|
|
+ state *localState
|
|
|
+ wantBanner string
|
|
|
+ authErr bool
|
|
|
+ }{
|
|
|
+ {
|
|
|
+ name: "no-policy",
|
|
|
+ state: &localState{
|
|
|
+ sshEnabled: true,
|
|
|
+ },
|
|
|
+ authErr: true,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "accept",
|
|
|
+ state: &localState{
|
|
|
+ sshEnabled: true,
|
|
|
+ matchingRule: acceptRule,
|
|
|
+ },
|
|
|
+ wantBanner: "Welcome to Tailscale SSH!",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "reject",
|
|
|
+ state: &localState{
|
|
|
+ sshEnabled: true,
|
|
|
+ matchingRule: rejectRule,
|
|
|
+ },
|
|
|
+ wantBanner: "Go Away!",
|
|
|
+ authErr: true,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "simple-check",
|
|
|
+ state: &localState{
|
|
|
+ sshEnabled: true,
|
|
|
+ matchingRule: newSSHRule(&tailcfg.SSHAction{
|
|
|
+ HoldAndDelegate: "https://unused/ssh-action/accept",
|
|
|
+ }),
|
|
|
+ serverActions: map[string]*tailcfg.SSHAction{
|
|
|
+ "accept": acceptRule.Action,
|
|
|
+ },
|
|
|
+ },
|
|
|
+ wantBanner: "Welcome to Tailscale SSH!",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "multi-check",
|
|
|
+ state: &localState{
|
|
|
+ sshEnabled: true,
|
|
|
+ matchingRule: newSSHRule(&tailcfg.SSHAction{
|
|
|
+ HoldAndDelegate: "https://unused/ssh-action/check1",
|
|
|
+ }),
|
|
|
+ serverActions: map[string]*tailcfg.SSHAction{
|
|
|
+ "check1": {
|
|
|
+ Message: "url-here",
|
|
|
+ HoldAndDelegate: "https://unused/ssh-action/check2",
|
|
|
+ },
|
|
|
+ "check2": acceptRule.Action,
|
|
|
+ },
|
|
|
+ },
|
|
|
+ wantBanner: "url-here",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "check-reject",
|
|
|
+ state: &localState{
|
|
|
+ sshEnabled: true,
|
|
|
+ matchingRule: newSSHRule(&tailcfg.SSHAction{
|
|
|
+ HoldAndDelegate: "https://unused/ssh-action/reject",
|
|
|
+ }),
|
|
|
+ serverActions: map[string]*tailcfg.SSHAction{
|
|
|
+ "reject": rejectRule.Action,
|
|
|
+ },
|
|
|
+ },
|
|
|
+ wantBanner: "Go Away!",
|
|
|
+ authErr: true,
|
|
|
+ },
|
|
|
+ }
|
|
|
+ s := &server{
|
|
|
+ logf: logger.Discard,
|
|
|
+ }
|
|
|
+ defer s.Shutdown()
|
|
|
+ src, dst := must.Get(netip.ParseAddrPort("100.100.100.101:2231")), must.Get(netip.ParseAddrPort("100.100.100.102:22"))
|
|
|
+ for _, tc := range tests {
|
|
|
+ t.Run(tc.name, func(t *testing.T) {
|
|
|
+ sc, dc := nettest.NewTCPConn(src, dst, 1024)
|
|
|
+ s.lb = tc.state
|
|
|
+ cfg := &gossh.ClientConfig{
|
|
|
+ User: "alice",
|
|
|
+ HostKeyCallback: gossh.InsecureIgnoreHostKey(),
|
|
|
+ BannerCallback: func(message string) error {
|
|
|
+ if message != tc.wantBanner {
|
|
|
+ t.Errorf("BannerCallback = %q; want %q", message, tc.wantBanner)
|
|
|
+ }
|
|
|
+ return nil
|
|
|
+ },
|
|
|
+ }
|
|
|
+ var wg sync.WaitGroup
|
|
|
+ wg.Add(1)
|
|
|
+ go func() {
|
|
|
+ defer wg.Done()
|
|
|
+ c, chans, reqs, err := gossh.NewClientConn(sc, sc.RemoteAddr().String(), cfg)
|
|
|
+ if err != nil {
|
|
|
+ if !tc.authErr {
|
|
|
+ t.Errorf("client: %v", err)
|
|
|
+ }
|
|
|
+ return
|
|
|
+ } else if tc.authErr {
|
|
|
+ c.Close()
|
|
|
+ t.Errorf("client: expected error, got nil")
|
|
|
+ return
|
|
|
+ }
|
|
|
+ client := gossh.NewClient(c, chans, reqs)
|
|
|
+ defer client.Close()
|
|
|
+ session, err := client.NewSession()
|
|
|
+ if err != nil {
|
|
|
+ t.Errorf("client: %v", err)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ defer session.Close()
|
|
|
+ o, err := session.CombinedOutput("echo Ran echo!")
|
|
|
+ if err != nil {
|
|
|
+ t.Errorf("client: %v", err)
|
|
|
+ }
|
|
|
+ t.Logf("output: %s", o)
|
|
|
+ }()
|
|
|
+ if err := s.HandleSSHConn(dc); err != nil {
|
|
|
+ t.Errorf("unexpected error: %v", err)
|
|
|
+ }
|
|
|
+ wg.Wait()
|
|
|
+ })
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
func TestSSH(t *testing.T) {
|
|
|
var logf logger.Logf = t.Logf
|
|
|
eng, err := wgengine.NewFakeUserspaceEngine(logf, 0)
|
|
|
@@ -249,7 +503,7 @@ func TestSSH(t *testing.T) {
|
|
|
src: netip.MustParseAddrPort("1.2.3.4:32342"),
|
|
|
dst: netip.MustParseAddrPort("1.2.3.5:22"),
|
|
|
node: &tailcfg.Node{},
|
|
|
- uprof: &tailcfg.UserProfile{},
|
|
|
+ uprof: tailcfg.UserProfile{},
|
|
|
}
|
|
|
sc.finalAction = &tailcfg.SSHAction{Accept: true}
|
|
|
|
|
|
@@ -428,7 +682,7 @@ func TestPublicKeyFetching(t *testing.T) {
|
|
|
func TestExpandPublicKeyURL(t *testing.T) {
|
|
|
c := &conn{
|
|
|
info: &sshConnInfo{
|
|
|
- uprof: &tailcfg.UserProfile{
|
|
|
+ uprof: tailcfg.UserProfile{
|
|
|
LoginName: "[email protected]",
|
|
|
},
|
|
|
},
|