|
|
|
|
@ -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: "foo@bar.com"}}, |
|
|
|
|
SSHUsers: map[string]string{"*": "ubuntu"}, |
|
|
|
|
}, |
|
|
|
|
ci: &sshConnInfo{uprof: &tailcfg.UserProfile{LoginName: "foo@bar.com"}}, |
|
|
|
|
ci: &sshConnInfo{uprof: tailcfg.UserProfile{LoginName: "foo@bar.com"}}, |
|
|
|
|
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: "bar@baz.tld", |
|
|
|
|
}, |
|
|
|
|
}, |
|
|
|
|
|