tailssh_test.go 31 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235
  1. // Copyright (c) Tailscale Inc & AUTHORS
  2. // SPDX-License-Identifier: BSD-3-Clause
  3. //go:build linux || darwin
  4. package tailssh
  5. import (
  6. "bytes"
  7. "context"
  8. "crypto/ed25519"
  9. "crypto/rand"
  10. "encoding/json"
  11. "errors"
  12. "fmt"
  13. "io"
  14. "net"
  15. "net/http"
  16. "net/http/httptest"
  17. "net/netip"
  18. "os"
  19. "os/exec"
  20. "os/user"
  21. "reflect"
  22. "runtime"
  23. "slices"
  24. "strconv"
  25. "strings"
  26. "sync"
  27. "sync/atomic"
  28. "testing"
  29. "time"
  30. gossh "golang.org/x/crypto/ssh"
  31. "golang.org/x/net/http2"
  32. "golang.org/x/net/http2/h2c"
  33. "tailscale.com/ipn/ipnlocal"
  34. "tailscale.com/ipn/store/mem"
  35. "tailscale.com/net/memnet"
  36. "tailscale.com/net/tsdial"
  37. "tailscale.com/sessionrecording"
  38. "tailscale.com/tailcfg"
  39. "tailscale.com/tempfork/gliderlabs/ssh"
  40. "tailscale.com/tsd"
  41. "tailscale.com/tstest"
  42. "tailscale.com/types/key"
  43. "tailscale.com/types/logger"
  44. "tailscale.com/types/logid"
  45. "tailscale.com/types/netmap"
  46. "tailscale.com/types/ptr"
  47. "tailscale.com/util/cibuild"
  48. "tailscale.com/util/lineiter"
  49. "tailscale.com/util/must"
  50. "tailscale.com/version/distro"
  51. "tailscale.com/wgengine"
  52. )
  53. func TestMatchRule(t *testing.T) {
  54. someAction := new(tailcfg.SSHAction)
  55. tests := []struct {
  56. name string
  57. rule *tailcfg.SSHRule
  58. ci *sshConnInfo
  59. wantErr error
  60. wantUser string
  61. wantAcceptEnv []string
  62. }{
  63. {
  64. name: "invalid-conn",
  65. rule: &tailcfg.SSHRule{
  66. Action: someAction,
  67. Principals: []*tailcfg.SSHPrincipal{{Any: true}},
  68. SSHUsers: map[string]string{
  69. "*": "ubuntu",
  70. },
  71. },
  72. wantErr: errInvalidConn,
  73. },
  74. {
  75. name: "nil-rule",
  76. ci: &sshConnInfo{},
  77. rule: nil,
  78. wantErr: errNilRule,
  79. },
  80. {
  81. name: "nil-action",
  82. ci: &sshConnInfo{},
  83. rule: &tailcfg.SSHRule{},
  84. wantErr: errNilAction,
  85. },
  86. {
  87. name: "expired",
  88. rule: &tailcfg.SSHRule{
  89. Action: someAction,
  90. RuleExpires: ptr.To(time.Unix(100, 0)),
  91. },
  92. ci: &sshConnInfo{},
  93. wantErr: errRuleExpired,
  94. },
  95. {
  96. name: "no-principal",
  97. rule: &tailcfg.SSHRule{
  98. Action: someAction,
  99. SSHUsers: map[string]string{
  100. "*": "ubuntu",
  101. }},
  102. ci: &sshConnInfo{},
  103. wantErr: errPrincipalMatch,
  104. },
  105. {
  106. name: "no-user-match",
  107. rule: &tailcfg.SSHRule{
  108. Action: someAction,
  109. Principals: []*tailcfg.SSHPrincipal{{Any: true}},
  110. },
  111. ci: &sshConnInfo{sshUser: "alice"},
  112. wantErr: errUserMatch,
  113. },
  114. {
  115. name: "ok-wildcard",
  116. rule: &tailcfg.SSHRule{
  117. Action: someAction,
  118. Principals: []*tailcfg.SSHPrincipal{{Any: true}},
  119. SSHUsers: map[string]string{
  120. "*": "ubuntu",
  121. },
  122. },
  123. ci: &sshConnInfo{sshUser: "alice"},
  124. wantUser: "ubuntu",
  125. },
  126. {
  127. name: "ok-wildcard-and-nil-principal",
  128. rule: &tailcfg.SSHRule{
  129. Action: someAction,
  130. Principals: []*tailcfg.SSHPrincipal{
  131. nil, // don't crash on this
  132. {Any: true},
  133. },
  134. SSHUsers: map[string]string{
  135. "*": "ubuntu",
  136. },
  137. },
  138. ci: &sshConnInfo{sshUser: "alice"},
  139. wantUser: "ubuntu",
  140. },
  141. {
  142. name: "ok-exact",
  143. rule: &tailcfg.SSHRule{
  144. Action: someAction,
  145. Principals: []*tailcfg.SSHPrincipal{{Any: true}},
  146. SSHUsers: map[string]string{
  147. "*": "ubuntu",
  148. "alice": "thealice",
  149. },
  150. },
  151. ci: &sshConnInfo{sshUser: "alice"},
  152. wantUser: "thealice",
  153. },
  154. {
  155. name: "ok-with-accept-env",
  156. rule: &tailcfg.SSHRule{
  157. Action: someAction,
  158. Principals: []*tailcfg.SSHPrincipal{{Any: true}},
  159. SSHUsers: map[string]string{
  160. "*": "ubuntu",
  161. "alice": "thealice",
  162. },
  163. AcceptEnv: []string{"EXAMPLE", "?_?", "TEST_*"},
  164. },
  165. ci: &sshConnInfo{sshUser: "alice"},
  166. wantUser: "thealice",
  167. wantAcceptEnv: []string{"EXAMPLE", "?_?", "TEST_*"},
  168. },
  169. {
  170. name: "no-users-for-reject",
  171. rule: &tailcfg.SSHRule{
  172. Principals: []*tailcfg.SSHPrincipal{{Any: true}},
  173. Action: &tailcfg.SSHAction{Reject: true},
  174. },
  175. ci: &sshConnInfo{sshUser: "alice"},
  176. },
  177. {
  178. name: "match-principal-node-ip",
  179. rule: &tailcfg.SSHRule{
  180. Action: someAction,
  181. Principals: []*tailcfg.SSHPrincipal{{NodeIP: "1.2.3.4"}},
  182. SSHUsers: map[string]string{"*": "ubuntu"},
  183. },
  184. ci: &sshConnInfo{src: netip.MustParseAddrPort("1.2.3.4:30343")},
  185. wantUser: "ubuntu",
  186. },
  187. {
  188. name: "match-principal-node-id",
  189. rule: &tailcfg.SSHRule{
  190. Action: someAction,
  191. Principals: []*tailcfg.SSHPrincipal{{Node: "some-node-ID"}},
  192. SSHUsers: map[string]string{"*": "ubuntu"},
  193. },
  194. ci: &sshConnInfo{node: (&tailcfg.Node{StableID: "some-node-ID"}).View()},
  195. wantUser: "ubuntu",
  196. },
  197. {
  198. name: "match-principal-userlogin",
  199. rule: &tailcfg.SSHRule{
  200. Action: someAction,
  201. Principals: []*tailcfg.SSHPrincipal{{UserLogin: "[email protected]"}},
  202. SSHUsers: map[string]string{"*": "ubuntu"},
  203. },
  204. ci: &sshConnInfo{uprof: tailcfg.UserProfile{LoginName: "[email protected]"}},
  205. wantUser: "ubuntu",
  206. },
  207. {
  208. name: "ssh-user-equal",
  209. rule: &tailcfg.SSHRule{
  210. Action: someAction,
  211. Principals: []*tailcfg.SSHPrincipal{{Any: true}},
  212. SSHUsers: map[string]string{
  213. "*": "=",
  214. },
  215. },
  216. ci: &sshConnInfo{sshUser: "alice"},
  217. wantUser: "alice",
  218. },
  219. }
  220. for _, tt := range tests {
  221. t.Run(tt.name, func(t *testing.T) {
  222. c := &conn{
  223. info: tt.ci,
  224. srv: &server{logf: t.Logf},
  225. }
  226. got, gotUser, gotAcceptEnv, err := c.matchRule(tt.rule)
  227. if err != tt.wantErr {
  228. t.Errorf("err = %v; want %v", err, tt.wantErr)
  229. }
  230. if gotUser != tt.wantUser {
  231. t.Errorf("user = %q; want %q", gotUser, tt.wantUser)
  232. }
  233. if err == nil && got == nil {
  234. t.Errorf("expected non-nil action on success")
  235. }
  236. if !slices.Equal(gotAcceptEnv, tt.wantAcceptEnv) {
  237. t.Errorf("acceptEnv = %v; want %v", gotAcceptEnv, tt.wantAcceptEnv)
  238. }
  239. })
  240. }
  241. }
  242. func TestEvalSSHPolicy(t *testing.T) {
  243. someAction := new(tailcfg.SSHAction)
  244. tests := []struct {
  245. name string
  246. policy *tailcfg.SSHPolicy
  247. ci *sshConnInfo
  248. wantMatch bool
  249. wantUser string
  250. wantAcceptEnv []string
  251. }{
  252. {
  253. name: "multiple-matches-picks-first-match",
  254. policy: &tailcfg.SSHPolicy{
  255. Rules: []*tailcfg.SSHRule{
  256. {
  257. Action: someAction,
  258. Principals: []*tailcfg.SSHPrincipal{{Any: true}},
  259. SSHUsers: map[string]string{
  260. "other": "other1",
  261. },
  262. },
  263. {
  264. Action: someAction,
  265. Principals: []*tailcfg.SSHPrincipal{{Any: true}},
  266. SSHUsers: map[string]string{
  267. "*": "ubuntu",
  268. "alice": "thealice",
  269. },
  270. AcceptEnv: []string{"EXAMPLE", "?_?", "TEST_*"},
  271. },
  272. {
  273. Action: someAction,
  274. Principals: []*tailcfg.SSHPrincipal{{Any: true}},
  275. SSHUsers: map[string]string{
  276. "other2": "other3",
  277. },
  278. },
  279. {
  280. Action: someAction,
  281. Principals: []*tailcfg.SSHPrincipal{{Any: true}},
  282. SSHUsers: map[string]string{
  283. "*": "ubuntu",
  284. "alice": "thealice",
  285. "mark": "markthe",
  286. },
  287. AcceptEnv: []string{"*"},
  288. },
  289. },
  290. },
  291. ci: &sshConnInfo{sshUser: "alice"},
  292. wantUser: "thealice",
  293. wantAcceptEnv: []string{"EXAMPLE", "?_?", "TEST_*"},
  294. wantMatch: true,
  295. },
  296. {
  297. name: "no-matches-returns-failure",
  298. policy: &tailcfg.SSHPolicy{
  299. Rules: []*tailcfg.SSHRule{
  300. {
  301. Action: someAction,
  302. Principals: []*tailcfg.SSHPrincipal{{Any: true}},
  303. SSHUsers: map[string]string{
  304. "other": "other1",
  305. },
  306. },
  307. {
  308. Action: someAction,
  309. Principals: []*tailcfg.SSHPrincipal{{Any: true}},
  310. SSHUsers: map[string]string{
  311. "fedora": "ubuntu",
  312. },
  313. AcceptEnv: []string{"EXAMPLE", "?_?", "TEST_*"},
  314. },
  315. {
  316. Action: someAction,
  317. Principals: []*tailcfg.SSHPrincipal{{Any: true}},
  318. SSHUsers: map[string]string{
  319. "other2": "other3",
  320. },
  321. },
  322. {
  323. Action: someAction,
  324. Principals: []*tailcfg.SSHPrincipal{{Any: true}},
  325. SSHUsers: map[string]string{
  326. "mark": "markthe",
  327. },
  328. AcceptEnv: []string{"*"},
  329. },
  330. },
  331. },
  332. ci: &sshConnInfo{sshUser: "alice"},
  333. wantUser: "",
  334. wantAcceptEnv: nil,
  335. wantMatch: false,
  336. },
  337. }
  338. for _, tt := range tests {
  339. t.Run(tt.name, func(t *testing.T) {
  340. c := &conn{
  341. info: tt.ci,
  342. srv: &server{logf: t.Logf},
  343. }
  344. got, gotUser, gotAcceptEnv, match := c.evalSSHPolicy(tt.policy)
  345. if match != tt.wantMatch {
  346. t.Errorf("match = %v; want %v", match, tt.wantMatch)
  347. }
  348. if gotUser != tt.wantUser {
  349. t.Errorf("user = %q; want %q", gotUser, tt.wantUser)
  350. }
  351. if tt.wantMatch == true && got == nil {
  352. t.Errorf("expected non-nil action on success")
  353. }
  354. if !slices.Equal(gotAcceptEnv, tt.wantAcceptEnv) {
  355. t.Errorf("acceptEnv = %v; want %v", gotAcceptEnv, tt.wantAcceptEnv)
  356. }
  357. })
  358. }
  359. }
  360. // localState implements ipnLocalBackend for testing.
  361. type localState struct {
  362. sshEnabled bool
  363. matchingRule *tailcfg.SSHRule
  364. // serverActions is a map of the action name to the action.
  365. // It is served for paths like https://unused/ssh-action/<action-name>.
  366. // The action name is the last part of the action URL.
  367. serverActions map[string]*tailcfg.SSHAction
  368. }
  369. var (
  370. currentUser = os.Getenv("USER") // Use the current user for the test.
  371. testSigner gossh.Signer
  372. testSignerOnce sync.Once
  373. )
  374. func (ts *localState) Dialer() *tsdial.Dialer {
  375. return &tsdial.Dialer{}
  376. }
  377. func (ts *localState) GetSSH_HostKeys() ([]gossh.Signer, error) {
  378. testSignerOnce.Do(func() {
  379. _, priv, err := ed25519.GenerateKey(rand.Reader)
  380. if err != nil {
  381. panic(err)
  382. }
  383. s, err := gossh.NewSignerFromSigner(priv)
  384. if err != nil {
  385. panic(err)
  386. }
  387. testSigner = s
  388. })
  389. return []gossh.Signer{testSigner}, nil
  390. }
  391. func (ts *localState) ShouldRunSSH() bool {
  392. return ts.sshEnabled
  393. }
  394. func (ts *localState) NetMap() *netmap.NetworkMap {
  395. var policy *tailcfg.SSHPolicy
  396. if ts.matchingRule != nil {
  397. policy = &tailcfg.SSHPolicy{
  398. Rules: []*tailcfg.SSHRule{
  399. ts.matchingRule,
  400. },
  401. }
  402. }
  403. return &netmap.NetworkMap{
  404. SelfNode: (&tailcfg.Node{
  405. ID: 1,
  406. }).View(),
  407. SSHPolicy: policy,
  408. }
  409. }
  410. func (ts *localState) WhoIs(proto string, ipp netip.AddrPort) (n tailcfg.NodeView, u tailcfg.UserProfile, ok bool) {
  411. if proto != "tcp" {
  412. return tailcfg.NodeView{}, tailcfg.UserProfile{}, false
  413. }
  414. return (&tailcfg.Node{
  415. ID: 2,
  416. StableID: "peer-id",
  417. }).View(), tailcfg.UserProfile{
  418. LoginName: "peer",
  419. }, true
  420. }
  421. func (ts *localState) DoNoiseRequest(req *http.Request) (*http.Response, error) {
  422. rec := httptest.NewRecorder()
  423. k, ok := strings.CutPrefix(req.URL.Path, "/ssh-action/")
  424. if !ok {
  425. rec.WriteHeader(http.StatusNotFound)
  426. }
  427. a, ok := ts.serverActions[k]
  428. if !ok {
  429. rec.WriteHeader(http.StatusNotFound)
  430. return rec.Result(), nil
  431. }
  432. rec.WriteHeader(http.StatusOK)
  433. if err := json.NewEncoder(rec).Encode(a); err != nil {
  434. return nil, err
  435. }
  436. return rec.Result(), nil
  437. }
  438. func (ts *localState) TailscaleVarRoot() string {
  439. return ""
  440. }
  441. func (ts *localState) NodeKey() key.NodePublic {
  442. return key.NewNode().Public()
  443. }
  444. func newSSHRule(action *tailcfg.SSHAction) *tailcfg.SSHRule {
  445. return &tailcfg.SSHRule{
  446. SSHUsers: map[string]string{
  447. "*": currentUser,
  448. },
  449. Action: action,
  450. Principals: []*tailcfg.SSHPrincipal{
  451. {
  452. Any: true,
  453. },
  454. },
  455. }
  456. }
  457. func TestSSHRecordingCancelsSessionsOnUploadFailure(t *testing.T) {
  458. if runtime.GOOS != "linux" && runtime.GOOS != "darwin" {
  459. t.Skipf("skipping on %q; only runs on linux and darwin", runtime.GOOS)
  460. }
  461. var handler http.HandlerFunc
  462. recordingServer := mockRecordingServer(t, func(w http.ResponseWriter, r *http.Request) {
  463. handler(w, r)
  464. })
  465. s := &server{
  466. logf: t.Logf,
  467. lb: &localState{
  468. sshEnabled: true,
  469. matchingRule: newSSHRule(
  470. &tailcfg.SSHAction{
  471. Accept: true,
  472. Recorders: []netip.AddrPort{
  473. netip.MustParseAddrPort(recordingServer.Listener.Addr().String()),
  474. },
  475. OnRecordingFailure: &tailcfg.SSHRecorderFailureAction{
  476. RejectSessionWithMessage: "session rejected",
  477. TerminateSessionWithMessage: "session terminated",
  478. },
  479. },
  480. ),
  481. },
  482. }
  483. defer s.Shutdown()
  484. const sshUser = "alice"
  485. cfg := &gossh.ClientConfig{
  486. User: sshUser,
  487. HostKeyCallback: gossh.InsecureIgnoreHostKey(),
  488. }
  489. tests := []struct {
  490. name string
  491. handler func(w http.ResponseWriter, r *http.Request)
  492. sshCommand string
  493. wantClientOutput string
  494. clientOutputMustNotContain []string
  495. }{
  496. {
  497. name: "upload-denied",
  498. handler: func(w http.ResponseWriter, r *http.Request) {
  499. w.WriteHeader(http.StatusForbidden)
  500. },
  501. sshCommand: "echo hello",
  502. wantClientOutput: "session rejected\r\n",
  503. clientOutputMustNotContain: []string{"hello"},
  504. },
  505. {
  506. name: "upload-fails-after-starting",
  507. handler: func(w http.ResponseWriter, r *http.Request) {
  508. w.WriteHeader(http.StatusOK)
  509. w.(http.Flusher).Flush()
  510. r.Body.Read(make([]byte, 1))
  511. time.Sleep(100 * time.Millisecond)
  512. },
  513. sshCommand: "echo hello && sleep 1 && echo world",
  514. wantClientOutput: "\r\n\r\nsession terminated\r\n\r\n",
  515. clientOutputMustNotContain: []string{"world"},
  516. },
  517. }
  518. src, dst := must.Get(netip.ParseAddrPort("100.100.100.101:2231")), must.Get(netip.ParseAddrPort("100.100.100.102:22"))
  519. for _, tt := range tests {
  520. t.Run(tt.name, func(t *testing.T) {
  521. s.logf = t.Logf
  522. tstest.Replace(t, &handler, tt.handler)
  523. sc, dc := memnet.NewTCPConn(src, dst, 1024)
  524. var wg sync.WaitGroup
  525. wg.Add(1)
  526. go func() {
  527. defer wg.Done()
  528. c, chans, reqs, err := gossh.NewClientConn(sc, sc.RemoteAddr().String(), cfg)
  529. if err != nil {
  530. t.Errorf("client: %v", err)
  531. return
  532. }
  533. client := gossh.NewClient(c, chans, reqs)
  534. defer client.Close()
  535. session, err := client.NewSession()
  536. if err != nil {
  537. t.Errorf("client: %v", err)
  538. return
  539. }
  540. defer session.Close()
  541. t.Logf("client established session")
  542. got, err := session.CombinedOutput(tt.sshCommand)
  543. if err != nil {
  544. t.Logf("client got: %q: %v", got, err)
  545. } else {
  546. t.Errorf("client did not get kicked out: %q", got)
  547. }
  548. gotStr := string(got)
  549. if !strings.HasSuffix(gotStr, tt.wantClientOutput) {
  550. t.Errorf("client got %q, want %q", got, tt.wantClientOutput)
  551. }
  552. for _, x := range tt.clientOutputMustNotContain {
  553. if strings.Contains(gotStr, x) {
  554. t.Errorf("client output must not contain %q", x)
  555. }
  556. }
  557. }()
  558. if err := s.HandleSSHConn(dc); err != nil {
  559. t.Errorf("unexpected error: %v", err)
  560. }
  561. wg.Wait()
  562. })
  563. }
  564. }
  565. func TestMultipleRecorders(t *testing.T) {
  566. if runtime.GOOS != "linux" && runtime.GOOS != "darwin" {
  567. t.Skipf("skipping on %q; only runs on linux and darwin", runtime.GOOS)
  568. }
  569. done := make(chan struct{})
  570. recordingServer := mockRecordingServer(t, func(w http.ResponseWriter, r *http.Request) {
  571. defer close(done)
  572. w.WriteHeader(http.StatusOK)
  573. w.(http.Flusher).Flush()
  574. io.ReadAll(r.Body)
  575. })
  576. badRecorder, err := net.Listen("tcp", ":0")
  577. if err != nil {
  578. t.Fatal(err)
  579. }
  580. badRecorderAddr := badRecorder.Addr().String()
  581. badRecorder.Close()
  582. badRecordingServer500 := mockRecordingServer(t, func(w http.ResponseWriter, r *http.Request) {
  583. w.WriteHeader(http.StatusInternalServerError)
  584. })
  585. s := &server{
  586. logf: t.Logf,
  587. lb: &localState{
  588. sshEnabled: true,
  589. matchingRule: newSSHRule(
  590. &tailcfg.SSHAction{
  591. Accept: true,
  592. Recorders: []netip.AddrPort{
  593. netip.MustParseAddrPort(badRecorderAddr),
  594. netip.MustParseAddrPort(badRecordingServer500.Listener.Addr().String()),
  595. netip.MustParseAddrPort(recordingServer.Listener.Addr().String()),
  596. },
  597. OnRecordingFailure: &tailcfg.SSHRecorderFailureAction{
  598. RejectSessionWithMessage: "session rejected",
  599. TerminateSessionWithMessage: "session terminated",
  600. },
  601. },
  602. ),
  603. },
  604. }
  605. defer s.Shutdown()
  606. src, dst := must.Get(netip.ParseAddrPort("100.100.100.101:2231")), must.Get(netip.ParseAddrPort("100.100.100.102:22"))
  607. sc, dc := memnet.NewTCPConn(src, dst, 1024)
  608. const sshUser = "alice"
  609. cfg := &gossh.ClientConfig{
  610. User: sshUser,
  611. HostKeyCallback: gossh.InsecureIgnoreHostKey(),
  612. }
  613. var wg sync.WaitGroup
  614. wg.Add(1)
  615. go func() {
  616. defer wg.Done()
  617. c, chans, reqs, err := gossh.NewClientConn(sc, sc.RemoteAddr().String(), cfg)
  618. if err != nil {
  619. t.Errorf("client: %v", err)
  620. return
  621. }
  622. client := gossh.NewClient(c, chans, reqs)
  623. defer client.Close()
  624. session, err := client.NewSession()
  625. if err != nil {
  626. t.Errorf("client: %v", err)
  627. return
  628. }
  629. defer session.Close()
  630. t.Logf("client established session")
  631. out, err := session.CombinedOutput("echo Ran echo!")
  632. if err != nil {
  633. t.Errorf("client: %v", err)
  634. }
  635. if string(out) != "Ran echo!\n" {
  636. t.Errorf("client: unexpected output: %q", out)
  637. }
  638. }()
  639. if err := s.HandleSSHConn(dc); err != nil {
  640. t.Errorf("unexpected error: %v", err)
  641. }
  642. wg.Wait()
  643. select {
  644. case <-done:
  645. case <-time.After(1 * time.Second):
  646. t.Fatal("timed out waiting for recording")
  647. }
  648. }
  649. // TestSSHRecordingNonInteractive tests that the SSH server records the SSH session
  650. // when the client is not interactive (i.e. no PTY).
  651. // It starts a local SSH server and a recording server. The recording server
  652. // records the SSH session and returns it to the test.
  653. // The test then verifies that the recording has a valid CastHeader, it does not
  654. // validate the contents of the recording.
  655. func TestSSHRecordingNonInteractive(t *testing.T) {
  656. if runtime.GOOS != "linux" && runtime.GOOS != "darwin" {
  657. t.Skipf("skipping on %q; only runs on linux and darwin", runtime.GOOS)
  658. }
  659. var recording []byte
  660. ctx, cancel := context.WithTimeout(context.Background(), time.Second)
  661. recordingServer := mockRecordingServer(t, func(w http.ResponseWriter, r *http.Request) {
  662. defer cancel()
  663. w.WriteHeader(http.StatusOK)
  664. w.(http.Flusher).Flush()
  665. var err error
  666. recording, err = io.ReadAll(r.Body)
  667. if err != nil {
  668. t.Error(err)
  669. return
  670. }
  671. })
  672. s := &server{
  673. logf: t.Logf,
  674. lb: &localState{
  675. sshEnabled: true,
  676. matchingRule: newSSHRule(
  677. &tailcfg.SSHAction{
  678. Accept: true,
  679. Recorders: []netip.AddrPort{
  680. must.Get(netip.ParseAddrPort(recordingServer.Listener.Addr().String())),
  681. },
  682. OnRecordingFailure: &tailcfg.SSHRecorderFailureAction{
  683. RejectSessionWithMessage: "session rejected",
  684. TerminateSessionWithMessage: "session terminated",
  685. },
  686. },
  687. ),
  688. },
  689. }
  690. defer s.Shutdown()
  691. src, dst := must.Get(netip.ParseAddrPort("100.100.100.101:2231")), must.Get(netip.ParseAddrPort("100.100.100.102:22"))
  692. sc, dc := memnet.NewTCPConn(src, dst, 1024)
  693. const sshUser = "alice"
  694. cfg := &gossh.ClientConfig{
  695. User: sshUser,
  696. HostKeyCallback: gossh.InsecureIgnoreHostKey(),
  697. }
  698. var wg sync.WaitGroup
  699. wg.Add(1)
  700. go func() {
  701. defer wg.Done()
  702. c, chans, reqs, err := gossh.NewClientConn(sc, sc.RemoteAddr().String(), cfg)
  703. if err != nil {
  704. t.Errorf("client: %v", err)
  705. return
  706. }
  707. client := gossh.NewClient(c, chans, reqs)
  708. defer client.Close()
  709. session, err := client.NewSession()
  710. if err != nil {
  711. t.Errorf("client: %v", err)
  712. return
  713. }
  714. defer session.Close()
  715. t.Logf("client established session")
  716. _, err = session.CombinedOutput("echo Ran echo!")
  717. if err != nil {
  718. t.Errorf("client: %v", err)
  719. }
  720. }()
  721. if err := s.HandleSSHConn(dc); err != nil {
  722. t.Errorf("unexpected error: %v", err)
  723. }
  724. wg.Wait()
  725. <-ctx.Done() // wait for recording to finish
  726. var ch sessionrecording.CastHeader
  727. if err := json.NewDecoder(bytes.NewReader(recording)).Decode(&ch); err != nil {
  728. t.Fatal(err)
  729. }
  730. if ch.SSHUser != sshUser {
  731. t.Errorf("SSHUser = %q; want %q", ch.SSHUser, sshUser)
  732. }
  733. if ch.Command != "echo Ran echo!" {
  734. t.Errorf("Command = %q; want %q", ch.Command, "echo Ran echo!")
  735. }
  736. }
  737. func TestSSHAuthFlow(t *testing.T) {
  738. if runtime.GOOS != "linux" && runtime.GOOS != "darwin" {
  739. t.Skipf("skipping on %q; only runs on linux and darwin", runtime.GOOS)
  740. }
  741. acceptRule := newSSHRule(&tailcfg.SSHAction{
  742. Accept: true,
  743. Message: "Welcome to Tailscale SSH!",
  744. })
  745. rejectRule := newSSHRule(&tailcfg.SSHAction{
  746. Reject: true,
  747. Message: "Go Away!",
  748. })
  749. tests := []struct {
  750. name string
  751. sshUser string // defaults to alice
  752. state *localState
  753. wantBanners []string
  754. usesPassword bool
  755. authErr bool
  756. }{
  757. {
  758. name: "no-policy",
  759. state: &localState{
  760. sshEnabled: true,
  761. },
  762. authErr: true,
  763. wantBanners: []string{"tailscale: failed to evaluate SSH policy"},
  764. },
  765. {
  766. name: "accept",
  767. state: &localState{
  768. sshEnabled: true,
  769. matchingRule: acceptRule,
  770. },
  771. wantBanners: []string{"Welcome to Tailscale SSH!"},
  772. },
  773. {
  774. name: "reject",
  775. state: &localState{
  776. sshEnabled: true,
  777. matchingRule: rejectRule,
  778. },
  779. wantBanners: []string{"Go Away!"},
  780. authErr: true,
  781. },
  782. {
  783. name: "simple-check",
  784. state: &localState{
  785. sshEnabled: true,
  786. matchingRule: newSSHRule(&tailcfg.SSHAction{
  787. HoldAndDelegate: "https://unused/ssh-action/accept",
  788. }),
  789. serverActions: map[string]*tailcfg.SSHAction{
  790. "accept": acceptRule.Action,
  791. },
  792. },
  793. wantBanners: []string{"Welcome to Tailscale SSH!"},
  794. },
  795. {
  796. name: "multi-check",
  797. state: &localState{
  798. sshEnabled: true,
  799. matchingRule: newSSHRule(&tailcfg.SSHAction{
  800. Message: "First",
  801. HoldAndDelegate: "https://unused/ssh-action/check1",
  802. }),
  803. serverActions: map[string]*tailcfg.SSHAction{
  804. "check1": {
  805. Message: "url-here",
  806. HoldAndDelegate: "https://unused/ssh-action/check2",
  807. },
  808. "check2": acceptRule.Action,
  809. },
  810. },
  811. wantBanners: []string{"First", "url-here", "Welcome to Tailscale SSH!"},
  812. },
  813. {
  814. name: "check-reject",
  815. state: &localState{
  816. sshEnabled: true,
  817. matchingRule: newSSHRule(&tailcfg.SSHAction{
  818. Message: "First",
  819. HoldAndDelegate: "https://unused/ssh-action/reject",
  820. }),
  821. serverActions: map[string]*tailcfg.SSHAction{
  822. "reject": rejectRule.Action,
  823. },
  824. },
  825. wantBanners: []string{"First", "Go Away!"},
  826. authErr: true,
  827. },
  828. {
  829. name: "force-password-auth",
  830. sshUser: "alice+password",
  831. state: &localState{
  832. sshEnabled: true,
  833. matchingRule: acceptRule,
  834. },
  835. usesPassword: true,
  836. wantBanners: []string{"Welcome to Tailscale SSH!"},
  837. },
  838. }
  839. s := &server{
  840. logf: logger.Discard,
  841. }
  842. defer s.Shutdown()
  843. src, dst := must.Get(netip.ParseAddrPort("100.100.100.101:2231")), must.Get(netip.ParseAddrPort("100.100.100.102:22"))
  844. for _, tc := range tests {
  845. t.Run(tc.name, func(t *testing.T) {
  846. sc, dc := memnet.NewTCPConn(src, dst, 1024)
  847. s.lb = tc.state
  848. sshUser := "alice"
  849. if tc.sshUser != "" {
  850. sshUser = tc.sshUser
  851. }
  852. var passwordUsed atomic.Bool
  853. cfg := &gossh.ClientConfig{
  854. User: sshUser,
  855. HostKeyCallback: gossh.InsecureIgnoreHostKey(),
  856. Auth: []gossh.AuthMethod{
  857. gossh.PasswordCallback(func() (secret string, err error) {
  858. if !tc.usesPassword {
  859. t.Error("unexpected use of PasswordCallback")
  860. return "", errors.New("unexpected use of PasswordCallback")
  861. }
  862. passwordUsed.Store(true)
  863. return "any-pass", nil
  864. }),
  865. },
  866. BannerCallback: func(message string) error {
  867. if len(tc.wantBanners) == 0 {
  868. t.Errorf("unexpected banner: %q", message)
  869. } else if message != tc.wantBanners[0] {
  870. t.Errorf("banner = %q; want %q", message, tc.wantBanners[0])
  871. } else {
  872. t.Logf("banner = %q", message)
  873. tc.wantBanners = tc.wantBanners[1:]
  874. }
  875. return nil
  876. },
  877. }
  878. var wg sync.WaitGroup
  879. wg.Add(1)
  880. go func() {
  881. defer wg.Done()
  882. c, chans, reqs, err := gossh.NewClientConn(sc, sc.RemoteAddr().String(), cfg)
  883. if err != nil {
  884. if !tc.authErr {
  885. t.Errorf("client: %v", err)
  886. }
  887. return
  888. } else if tc.authErr {
  889. c.Close()
  890. t.Errorf("client: expected error, got nil")
  891. return
  892. }
  893. client := gossh.NewClient(c, chans, reqs)
  894. defer client.Close()
  895. session, err := client.NewSession()
  896. if err != nil {
  897. t.Errorf("client: %v", err)
  898. return
  899. }
  900. defer session.Close()
  901. _, err = session.CombinedOutput("echo Ran echo!")
  902. if err != nil {
  903. t.Errorf("client: %v", err)
  904. }
  905. }()
  906. if err := s.HandleSSHConn(dc); err != nil {
  907. t.Errorf("unexpected error: %v", err)
  908. }
  909. wg.Wait()
  910. if len(tc.wantBanners) > 0 {
  911. t.Errorf("missing banners: %v", tc.wantBanners)
  912. }
  913. })
  914. }
  915. }
  916. func TestSSH(t *testing.T) {
  917. var logf logger.Logf = t.Logf
  918. sys := &tsd.System{}
  919. eng, err := wgengine.NewFakeUserspaceEngine(logf, sys.Set, sys.HealthTracker(), sys.UserMetricsRegistry())
  920. if err != nil {
  921. t.Fatal(err)
  922. }
  923. sys.Set(eng)
  924. sys.Set(new(mem.Store))
  925. lb, err := ipnlocal.NewLocalBackend(logf, logid.PublicID{}, sys, 0)
  926. if err != nil {
  927. t.Fatal(err)
  928. }
  929. defer lb.Shutdown()
  930. dir := t.TempDir()
  931. lb.SetVarRoot(dir)
  932. srv := &server{
  933. lb: lb,
  934. logf: logf,
  935. }
  936. sc, err := srv.newConn()
  937. if err != nil {
  938. t.Fatal(err)
  939. }
  940. // Remove the auth checks for the test
  941. sc.insecureSkipTailscaleAuth = true
  942. u, err := user.Current()
  943. if err != nil {
  944. t.Fatal(err)
  945. }
  946. um, err := userLookup(u.Username)
  947. if err != nil {
  948. t.Fatal(err)
  949. }
  950. sc.localUser = um
  951. sc.info = &sshConnInfo{
  952. sshUser: "test",
  953. src: netip.MustParseAddrPort("1.2.3.4:32342"),
  954. dst: netip.MustParseAddrPort("1.2.3.5:22"),
  955. node: (&tailcfg.Node{}).View(),
  956. uprof: tailcfg.UserProfile{},
  957. }
  958. sc.action0 = &tailcfg.SSHAction{Accept: true}
  959. sc.finalAction = sc.action0
  960. sc.Handler = func(s ssh.Session) {
  961. sc.newSSHSession(s).run()
  962. }
  963. ln, err := net.Listen("tcp4", "127.0.0.1:0")
  964. if err != nil {
  965. t.Fatal(err)
  966. }
  967. defer ln.Close()
  968. port := ln.Addr().(*net.TCPAddr).Port
  969. go func() {
  970. for {
  971. c, err := ln.Accept()
  972. if err != nil {
  973. if !errors.Is(err, net.ErrClosed) {
  974. t.Errorf("Accept: %v", err)
  975. }
  976. return
  977. }
  978. go sc.HandleConn(c)
  979. }
  980. }()
  981. execSSH := func(args ...string) *exec.Cmd {
  982. cmd := exec.Command("ssh",
  983. "-F",
  984. "none",
  985. "-v",
  986. "-p", fmt.Sprint(port),
  987. "-o", "StrictHostKeyChecking=no",
  988. "[email protected]")
  989. cmd.Args = append(cmd.Args, args...)
  990. return cmd
  991. }
  992. t.Run("env", func(t *testing.T) {
  993. if cibuild.On() {
  994. t.Skip("Skipping for now; see https://github.com/tailscale/tailscale/issues/4051")
  995. }
  996. cmd := execSSH("LANG=foo env")
  997. cmd.Env = append(os.Environ(), "LOCAL_ENV=bar")
  998. got, err := cmd.CombinedOutput()
  999. if err != nil {
  1000. t.Fatal(err, string(got))
  1001. }
  1002. m := parseEnv(got)
  1003. if got := m["USER"]; got == "" || got != u.Username {
  1004. t.Errorf("USER = %q; want %q", got, u.Username)
  1005. }
  1006. if got := m["HOME"]; got == "" || got != u.HomeDir {
  1007. t.Errorf("HOME = %q; want %q", got, u.HomeDir)
  1008. }
  1009. if got := m["PWD"]; got == "" || got != u.HomeDir {
  1010. t.Errorf("PWD = %q; want %q", got, u.HomeDir)
  1011. }
  1012. if got := m["SHELL"]; got == "" {
  1013. t.Errorf("no SHELL")
  1014. }
  1015. if got, want := m["LANG"], "foo"; got != want {
  1016. t.Errorf("LANG = %q; want %q", got, want)
  1017. }
  1018. if got := m["LOCAL_ENV"]; got != "" {
  1019. t.Errorf("LOCAL_ENV leaked over ssh: %v", got)
  1020. }
  1021. t.Logf("got: %+v", m)
  1022. })
  1023. t.Run("stdout_stderr", func(t *testing.T) {
  1024. cmd := execSSH("sh", "-c", "echo foo; echo bar >&2")
  1025. var outBuf, errBuf bytes.Buffer
  1026. cmd.Stdout = &outBuf
  1027. cmd.Stderr = &errBuf
  1028. if err := cmd.Run(); err != nil {
  1029. t.Fatal(err)
  1030. }
  1031. t.Logf("Got: %q and %q", outBuf.Bytes(), errBuf.Bytes())
  1032. // TODO: figure out why these aren't right. should be
  1033. // "foo\n" and "bar\n", not "\n" and "bar\n".
  1034. })
  1035. t.Run("large_file", func(t *testing.T) {
  1036. const wantSize = 1e6
  1037. var outBuf bytes.Buffer
  1038. cmd := execSSH("head", "-c", strconv.Itoa(wantSize), "/dev/zero")
  1039. cmd.Stdout = &outBuf
  1040. if err := cmd.Run(); err != nil {
  1041. t.Fatal(err)
  1042. }
  1043. if gotSize := outBuf.Len(); gotSize != wantSize {
  1044. t.Fatalf("got %d, want %d", gotSize, int(wantSize))
  1045. }
  1046. })
  1047. t.Run("stdin", func(t *testing.T) {
  1048. if cibuild.On() {
  1049. t.Skip("Skipping for now; see https://github.com/tailscale/tailscale/issues/4051")
  1050. }
  1051. cmd := execSSH("cat")
  1052. var outBuf bytes.Buffer
  1053. cmd.Stdout = &outBuf
  1054. const str = "foo\nbar\n"
  1055. cmd.Stdin = strings.NewReader(str)
  1056. if err := cmd.Run(); err != nil {
  1057. t.Fatal(err)
  1058. }
  1059. if got := outBuf.String(); got != str {
  1060. t.Errorf("got %q; want %q", got, str)
  1061. }
  1062. })
  1063. }
  1064. func parseEnv(out []byte) map[string]string {
  1065. e := map[string]string{}
  1066. for line := range lineiter.Bytes(out) {
  1067. if i := bytes.IndexByte(line, '='); i != -1 {
  1068. e[string(line[:i])] = string(line[i+1:])
  1069. }
  1070. }
  1071. return e
  1072. }
  1073. func TestAcceptEnvPair(t *testing.T) {
  1074. tests := []struct {
  1075. in string
  1076. want bool
  1077. }{
  1078. {"TERM=x", true},
  1079. {"term=x", false},
  1080. {"TERM", false},
  1081. {"LC_FOO=x", true},
  1082. {"LD_PRELOAD=naah", false},
  1083. {"TERM=screen-256color", true},
  1084. }
  1085. for _, tt := range tests {
  1086. if got := acceptEnvPair(tt.in); got != tt.want {
  1087. t.Errorf("for %q, got %v; want %v", tt.in, got, tt.want)
  1088. }
  1089. }
  1090. }
  1091. func TestPathFromPAMEnvLine(t *testing.T) {
  1092. u := &user.User{Username: "foo", HomeDir: "/Homes/Foo"}
  1093. tests := []struct {
  1094. line string
  1095. u *user.User
  1096. want string
  1097. }{
  1098. {"", u, ""},
  1099. {`PATH DEFAULT="/run/wrappers/bin:@{HOME}/.nix-profile/bin:/etc/profiles/per-user/@{PAM_USER}/bin:/nix/var/nix/profiles/default/bin:/run/current-system/sw/bin"`,
  1100. u, "/run/wrappers/bin:/Homes/Foo/.nix-profile/bin:/etc/profiles/per-user/foo/bin:/nix/var/nix/profiles/default/bin:/run/current-system/sw/bin"},
  1101. {`PATH DEFAULT="@{SOMETHING_ELSE}:nope:@{HOME}"`,
  1102. u, ""},
  1103. }
  1104. for i, tt := range tests {
  1105. got := pathFromPAMEnvLine([]byte(tt.line), tt.u)
  1106. if got != tt.want {
  1107. t.Errorf("%d. got %q; want %q", i, got, tt.want)
  1108. }
  1109. }
  1110. }
  1111. func TestExpandDefaultPathTmpl(t *testing.T) {
  1112. u := &user.User{Username: "foo", HomeDir: "/Homes/Foo"}
  1113. tests := []struct {
  1114. t string
  1115. u *user.User
  1116. want string
  1117. }{
  1118. {"", u, ""},
  1119. {`/run/wrappers/bin:@{HOME}/.nix-profile/bin:/etc/profiles/per-user/@{PAM_USER}/bin:/nix/var/nix/profiles/default/bin:/run/current-system/sw/bin`,
  1120. u, "/run/wrappers/bin:/Homes/Foo/.nix-profile/bin:/etc/profiles/per-user/foo/bin:/nix/var/nix/profiles/default/bin:/run/current-system/sw/bin"},
  1121. {`@{SOMETHING_ELSE}:nope:@{HOME}`, u, ""},
  1122. }
  1123. for i, tt := range tests {
  1124. got := expandDefaultPathTmpl(tt.t, tt.u)
  1125. if got != tt.want {
  1126. t.Errorf("%d. got %q; want %q", i, got, tt.want)
  1127. }
  1128. }
  1129. }
  1130. func TestPathFromPAMEnvLineOnNixOS(t *testing.T) {
  1131. if runtime.GOOS != "linux" {
  1132. t.Skip("skipping on non-linux")
  1133. }
  1134. if distro.Get() != distro.NixOS {
  1135. t.Skip("skipping on non-NixOS")
  1136. }
  1137. u, err := user.Current()
  1138. if err != nil {
  1139. t.Fatal(err)
  1140. }
  1141. got := defaultPathForUserOnNixOS(u)
  1142. if got == "" {
  1143. x, err := os.ReadFile("/etc/pam/environment")
  1144. t.Fatalf("no result. file was: err=%v, contents=%s", err, x)
  1145. }
  1146. t.Logf("success; got=%q", got)
  1147. }
  1148. func TestStdOsUserUserAssumptions(t *testing.T) {
  1149. v := reflect.TypeFor[user.User]()
  1150. if got, want := v.NumField(), 5; got != want {
  1151. t.Errorf("os/user.User has %v fields; this package assumes %v", got, want)
  1152. }
  1153. }
  1154. func mockRecordingServer(t *testing.T, handleRecord http.HandlerFunc) *httptest.Server {
  1155. t.Helper()
  1156. mux := http.NewServeMux()
  1157. mux.HandleFunc("POST /record", func(http.ResponseWriter, *http.Request) {
  1158. t.Errorf("v1 recording endpoint called")
  1159. })
  1160. mux.HandleFunc("HEAD /v2/record", func(http.ResponseWriter, *http.Request) {})
  1161. mux.HandleFunc("POST /v2/record", handleRecord)
  1162. h2s := &http2.Server{}
  1163. srv := httptest.NewUnstartedServer(h2c.NewHandler(mux, h2s))
  1164. if err := http2.ConfigureServer(srv.Config, h2s); err != nil {
  1165. t.Errorf("configuring HTTP/2 support in recording server: %v", err)
  1166. }
  1167. srv.Start()
  1168. t.Cleanup(srv.Close)
  1169. return srv
  1170. }