Something went wrong. Try again.
forked from tailscale/tailscale
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074// Copyright (c) Tailscale Inc & contributors// SPDX-License-Identifier: BSD-3-Clause
//go:build (linux && !android) || (darwin && !ios) || freebsd || openbsd
// Policy-enforcement tests: a material policy change closes the whole// connection. New session channels and all four forwarding gates// re-evaluate the policy before running. RuleExpires arms a conn-level// timer, and the login-grace timer closes conns that never complete// auth.
package tailssh
import ( "errors" "io" "net" "net/http" "net/netip" "os" "strings" "sync" "testing" "testing/synctest" "time"
"tailscale.com/envknob" "tailscale.com/net/memnet" "tailscale.com/tailcfg" testssh "tailscale.com/tempfork/sshtest/ssh" "tailscale.com/tstest" "tailscale.com/types/netmap")
// policyFlipper is a localState whose matching rule can be swapped at// runtime. NetMap reads it under a lock, so mid-flow policy flips are// race-free against the concurrent policy re-evaluations from// OnPolicyChange's kick, the auth-completion recheck, and the session// and forward gates. onNoise, if set, runs before serving a delegate// action and is used to swap the policy mid-HoldAndDelegate.type policyFlipper struct { *localState
mu sync.Mutex rule *tailcfg.SSHRule // rules, when non-nil, is served by NetMap in place of rule. rules []*tailcfg.SSHRule
onNoise func()}
func (p *policyFlipper) setRule(r *tailcfg.SSHRule) { p.mu.Lock() defer p.mu.Unlock() p.rule = r}
func (p *policyFlipper) NetMap() *netmap.NetworkMap { p.mu.Lock() var policy *tailcfg.SSHPolicy if p.rules != nil { policy = &tailcfg.SSHPolicy{Rules: p.rules} } else if p.rule != nil { policy = &tailcfg.SSHPolicy{Rules: []*tailcfg.SSHRule{p.rule}} } p.mu.Unlock() return &netmap.NetworkMap{ SelfNode: (&tailcfg.Node{ID: 1}).View(), SSHPolicy: policy, }}
func (p *policyFlipper) NetMapNoPeers() *netmap.NetworkMap { return p.NetMap() }
func (p *policyFlipper) DoNoiseRequest(req *http.Request) (*http.Response, error) { if p.onNoise != nil { p.onNoise() } return p.localState.DoNoiseRequest(req)}
// newPolicyRule returns a rule granting the sshUser alice access as lu// with the given action, matching any principal.func newPolicyRule(action *tailcfg.SSHAction, lu *userMeta) *tailcfg.SSHRule { return &tailcfg.SSHRule{ SSHUsers: map[string]string{"alice": lu.Username}, Action: action, Principals: []*tailcfg.SSHPrincipal{{Any: true}}, }}
// startPolicyConn accepts a connection without starting client authentication.func startPolicyConn(t *testing.T, p *policyFlipper) (*server, net.Conn, <-chan struct{}) { t.Helper() s := &server{logf: tstest.WhileTestRunningLogger(t), lb: p} t.Cleanup(s.Shutdown) src, dst := netip.MustParseAddrPort("100.100.100.101:2231"), netip.MustParseAddrPort("100.100.100.102:22") sc, dc := memnet.NewTCPConn(src, dst, 4096) t.Cleanup(func() { sc.Close() }) if err := sc.SetDeadline(time.Now().Add(60 * time.Second)); err != nil { t.Fatal(err) } done := make(chan struct{}) go func() { defer close(done) if err := s.HandleSSHConn(dc); err != nil { t.Errorf("HandleSSHConn: %v", err) } }() return s, sc, done}
func dialPolicyConn(t *testing.T, p *policyFlipper) (*server, *testssh.Client, <-chan struct{}) { t.Helper() s, client, done, err := tryDialPolicyConn(t, p) if err != nil { t.Fatalf("client handshake: %v", err) } // NewClientConn can return before the asynchronous auth-completion // policy check. Drain it before a test flips policy to exercise a gate. s.mu.Lock() var c *conn for c = range s.activeConns { } s.mu.Unlock() if c == nil { t.Fatal("connection disappeared after authentication") } select { case <-c.authRecheckDone: case <-time.After(10 * time.Second): t.Fatal("auth-completion policy check did not finish") } return s, client, done}
// tryDialPolicyConn also allows tests to assert rejection during authentication.func tryDialPolicyConn(t *testing.T, p *policyFlipper) (*server, *testssh.Client, <-chan struct{}, error) { t.Helper() s, sc, done := startPolicyConn(t, p) cl, chans, reqs, err := testssh.NewClientConn(sc, sc.RemoteAddr().String(), &testssh.ClientConfig{ User: "alice", HostKeyCallback: testssh.InsecureIgnoreHostKey(), }) if err != nil { return s, nil, done, err } client := testssh.NewClient(cl, chans, reqs) t.Cleanup(func() { client.Close() }) return s, client, done, nil}
// eventually polls cond every 25ms until it holds or d elapses, failing// the test with msg otherwise.func eventually(t *testing.T, d time.Duration, msg string, cond func() bool) { t.Helper() deadline := time.Now().Add(d) for time.Now().Before(deadline) { if cond() { return } time.Sleep(25 * time.Millisecond) } t.Fatal(msg)}
// sessionFails reports whether opening a new session channel on the// client fails, as it does once the transport is down. Errors are// expected, not logged.func sessionFails(client *testssh.Client) bool { sess, err := client.NewSession() if err != nil { return true } sess.Close() return false}
// sessionTestUser returns the local user the session-running tests map// alice to. Under root that is root itself, because the direct-exec// fallback refuses to run a non-root user's session as root. Otherwise// it is fwdTestIdentity's non-root case.func sessionTestUser(t *testing.T) *userMeta { t.Helper() if os.Geteuid() == 0 { lu, err := userLookup("root") if err != nil { t.Skipf("no local root user: %v", err) } return lu } return fwdTestIdentity(t)}
// TestSSHRevokeMidSessionClosesConn pins mid-session revocation closing// the conn. An authed conn running a session, whose policy flips to// Reject, gets the whole connection closed by OnPolicyChange. A client// attempting a fresh session channel sees transport failure, never the// stale Accept.func TestSSHRevokeMidSessionClosesConn(t *testing.T) { lu := sessionTestUser(t) accept := &tailcfg.SSHAction{Accept: true} reject := &tailcfg.SSHAction{Reject: true} p := &policyFlipper{ localState: &localState{ sshEnabled: true, varRoot: t.TempDir(), }, rule: newPolicyRule(accept, lu), } s, client, handleConnDone := dialPolicyConn(t, p)
sess, err := client.NewSession() if err != nil { t.Fatalf("first session: %v", err) } if err := sess.Start("sleep 30"); err != nil { t.Fatalf("session start: %v", err) } t.Cleanup(func() { sess.Close() })
// Revoke mid-session. The rule flips to Reject and the policy-change // kick fires. p.setRule(newPolicyRule(reject, lu)) s.OnPolicyChange()
// The whole connection goes down. The running session's Wait errors // via its canceled ctx. The transport closes. A fresh session // channel fails instead of running under the stale Accept. sessErr := make(chan error, 1) go func() { sessErr <- sess.Wait() }() select { case err := <-sessErr: if err == nil { t.Fatal("revoked session Wait returned nil; want error") } case <-time.After(10 * time.Second): t.Fatal("session not killed after policy revocation") } eventually(t, 10*time.Second, "connection not closed after policy revocation", func() bool { return sessionFails(client) }) select { case <-handleConnDone: case <-time.After(10 * time.Second): t.Fatal("server side of revoked connection did not return") }}
// TestSSHNarrowingClosesForwardOnlyConn pins narrowing for a// forwarding-only conn with no sessions. Narrowing// AllowLocalPortForwarding mid-conn closes the connection and its// established forward. A pure extension, false to true, leaves the conn// alive.func TestSSHNarrowingClosesForwardOnlyConn(t *testing.T) { // TCP -L dials now use the credentialed monitor child, like -R binds. exe := makeExecutableReachable(t) t.Setenv("TS_TEST_SSH_FWD_PREFIX", strings.Join(testFwdPrefix, " ")) tstest.Replace(t, &monitorSpawnPrefix, testMonitorPrefix)
// A local TCP service to -L forward to. svc, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } t.Cleanup(func() { svc.Close() }) go func() { for { nc, err := svc.Accept() if err != nil { return } nc.(interface{ SetDeadline(time.Time) error }).SetDeadline(time.Now().Add(30 * time.Second)) go func() { defer nc.Close() io.Copy(nc, nc) // echo }() } }()
lu := sessionTestUser(t) allow := &tailcfg.SSHAction{Accept: true, AllowLocalPortForwarding: true} p := &policyFlipper{ localState: &localState{ sshEnabled: true, varRoot: t.TempDir(), }, rule: newPolicyRule(allow, lu), } s, client, _ := dialPolicyConn(t, p) s.tailscaledPath = exe
// Establish a live -L forward with no session channel anywhere. fwd, err := client.Dial("tcp", svc.Addr().String()) if err != nil { t.Fatalf("forward dial: %v", err) } const msg = "through the forward" if _, err := fwd.Write([]byte(msg)); err != nil { t.Fatalf("write through forward: %v", err) } got := make([]byte, len(msg)) if _, err := io.ReadFull(fwd, got); err != nil { t.Fatalf("read through forward: %v", err) }
// The rule still accepts the user but now denies forwarding. p.setRule(newPolicyRule(&tailcfg.SSHAction{Accept: true}, lu)) s.OnPolicyChange()
// Both the established forward and its SSH transport close. fwdErr := make(chan error, 1) go func() { _, err := fwd.Read(got) fwdErr <- err }() select { case err := <-fwdErr: if err == nil { t.Fatal("established forward still alive after narrowing") } case <-time.After(10 * time.Second): t.Fatal("established forward not torn down after narrowing") } // Wait for transport shutdown rather than racing NewSession against // the test client's mux teardown (which can strand a channel-open wait). clientDone := make(chan error, 1) go func() { clientDone <- client.Wait() }() select { case err := <-clientDone: if err == nil { t.Fatal("client Wait returned nil after policy revocation") } case <-time.After(10 * time.Second): t.Fatal("connection not closed after narrowing") }}
// TestSSHRevokeClosesReverseForwardListener pins the forwarding-only// revocation case on the -R path. A conn with no sessions whose policy// flips to Reject gets the whole connection closed. The monitor-bound// reverse-forward listener dies with it and stops accepting.func TestSSHRevokeClosesReverseForwardListener(t *testing.T) { lu := sessionTestUser(t) // The reverse bind goes through the conn's lazily spawned monitor. // Install the monitor seams before the first forward request. exe := makeExecutableReachable(t) t.Setenv("TS_TEST_SSH_FWD_PREFIX", strings.Join(testFwdPrefix, " ")) old := monitorSpawnPrefix monitorSpawnPrefix = testMonitorPrefix t.Cleanup(func() { monitorSpawnPrefix = old })
allow := &tailcfg.SSHAction{Accept: true, AllowRemotePortForwarding: true} p := &policyFlipper{ localState: &localState{ sshEnabled: true, varRoot: t.TempDir(), }, rule: newPolicyRule(allow, lu), } s, client, _ := dialPolicyConn(t, p) // The conn shares this server pointer: the monitor spawns lazily on // the first forward request, so the reachable copy must be in place // before then. The fwd children run as the bound user, and under a // root run they can exec the copy but not the original. s.tailscaledPath = exe
ln, err := client.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("reverse forward (tcpip-forward): %v", err) } t.Cleanup(func() { ln.Close() }) fwdAddr := ln.Addr().String()
// Each successful dial makes the server open a forwarded-tcpip channel // to the client, and the client's per-forward queue holds only one. A // second pending connection blocks its forwarding goroutine with the // forward-list lock held by x/crypto/ssh's forwardList and wedges // the listener's Close at cleanup. Drain every dial. drain := func() { acc := make(chan net.Conn, 1) go func() { c, err := ln.Accept() if err == nil { acc <- c } }() select { case c := <-acc: c.Close() case <-time.After(time.Second): } }
// The bound listener accepts before revocation. if tc, err := net.DialTimeout("tcp", fwdAddr, 5*time.Second); err != nil { t.Fatalf("dial forwarded listener %s: %v", fwdAddr, err) } else { tc.Close() drain() }
// Revoke. The rule flips to Reject and the kick closes the conn. The // listener must stop accepting via the gliderssh ctx cancel. p.setRule(newPolicyRule(&tailcfg.SSHAction{Reject: true}, lu)) s.OnPolicyChange()
eventually(t, 10*time.Second, "reverse-forward listener still accepting after revocation", func() bool { tc, err := net.DialTimeout("tcp", fwdAddr, 500*time.Millisecond) if err != nil { return true } tc.Close() drain() return false }) eventually(t, 10*time.Second, "connection not closed after revocation", func() bool { return sessionFails(client) })}
// TestSSHForwardExtensionKeepsConn pins the other direction of the// material-change rule. Granting more, AllowLocalPortForwarding false// to true, must not kill the live conn.func TestSSHForwardExtensionKeepsConn(t *testing.T) { lu := sessionTestUser(t) p := &policyFlipper{localState: &localState{sshEnabled: true, varRoot: t.TempDir()}, rule: newPolicyRule(&tailcfg.SSHAction{Accept: true}, lu)} s, client, _ := dialPolicyConn(t, p)
sess, err := client.NewSession() if err != nil { t.Fatalf("first session: %v", err) } if out, err := sess.CombinedOutput("echo ok"); err != nil || string(out) == "" { t.Fatalf("first session: %q, %v", out, err) } sess.Close()
// Pure extension. The conn must survive and keep serving sessions. p.setRule(newPolicyRule(&tailcfg.SSHAction{Accept: true, AllowLocalPortForwarding: true}, lu)) s.OnPolicyChange() time.Sleep(200 * time.Millisecond) // a kick, if one fired, lands here sess2, err := client.NewSession() if err != nil { t.Fatalf("session after extension: %v (conn should have stayed up)", err) } sess2.Close()}
// TestSSHPolicyMappedUIDRecheck exercises auth-time latching with a policy// mapping by UID, which userLookup resolves to a canonical username.func TestSSHPolicyMappedUIDRecheck(t *testing.T) { lu := sessionTestUser(t) resolved, err := userLookup(lu.Uid) if err != nil || resolved.Username != lu.Username || resolved.Uid != lu.Uid || lu.Uid == lu.Username { t.Skipf("local UID %q cannot be used as a canonicalizing policy mapping: user=%v, err=%v", lu.Uid, resolved, err) } p := &policyFlipper{ localState: &localState{sshEnabled: true, varRoot: t.TempDir()}, rule: &tailcfg.SSHRule{ SSHUsers: map[string]string{"alice": lu.Uid}, Action: &tailcfg.SSHAction{Accept: true}, Principals: []*tailcfg.SSHPrincipal{{Any: true}}, }, } srv, client, _ := dialPolicyConn(t, p) srv.mu.Lock() var c *conn for c = range srv.activeConns { } srv.mu.Unlock() if c == nil { t.Fatal("authenticated connection missing") } if c.mappedLocalUser != lu.Uid || c.localUser.Username != lu.Username { t.Fatalf("latched mapping = %q, resolved user = %q; want %q and %q", c.mappedLocalUser, c.localUser.Username, lu.Uid, lu.Username) } if !c.recheckPolicy() { t.Fatal("unchanged UID mapping revoked the connection") } sess, err := client.NewSession() if err != nil { t.Fatalf("new session after unchanged UID mapping: %v", err) } sess.Close()}
// TestSSHStaleLocalUserClosed pins the stale-action guard. When the// policy re-maps the ssh user to a different local user, a new session// channel on the old conn is refused and the conn closed. It never runs// under the old latched localUser.func TestSSHStaleLocalUserClosed(t *testing.T) { lu := sessionTestUser(t) other := "root" if lu.Username == "root" { other = "daemon" } p := &policyFlipper{localState: &localState{sshEnabled: true, varRoot: t.TempDir()}, rule: newPolicyRule(&tailcfg.SSHAction{Accept: true}, lu)} _, client, _ := dialPolicyConn(t, p)
sess, err := client.NewSession() if err != nil { t.Fatalf("first session: %v", err) } sess.Close()
// Re-map alice to a different local user and open a NEW session. p.setRule(&tailcfg.SSHRule{ SSHUsers: map[string]string{"alice": other}, Action: &tailcfg.SSHAction{Accept: true}, Principals: []*tailcfg.SSHPrincipal{{Any: true}}, }) sess2, err := client.NewSession() if err != nil { t.Fatalf("new session channel: %v", err) } // The session must not run under the stale local user. It is refused // with the revocation notice and SSH-layer exit 255. The conn is // closed, asserted below. out, err := sess2.CombinedOutput("echo should-not-run") if err == nil { t.Fatalf("new session ran under stale local user (out=%q); want refusal", out) } if !strings.Contains(string(out), "access revoked") { t.Errorf("refusal output = %q; want access-revoked notice", out) } var exitErr *testssh.ExitError if !errors.As(err, &exitErr) || exitErr.ExitStatus() != 255 { t.Errorf("refusal error = %v; want exit status 255 (SSH-layer failure), got %v", err, exitErr) } eventually(t, 10*time.Second, "connection not closed after local-user change", func() bool { return sessionFails(client) })}
// TestSSHHoldAndDelegateCompletionRecheck pins the auth-completion// recheck. A conn that completes HoldAndDelegate auth with unchanged// policy proceeds normally. One whose policy was revoked while the// delegate fetch was in flight is closed at completion.func TestSSHHoldAndDelegateCompletionRecheck(t *testing.T) { lu := sessionTestUser(t) holdRule := func(action *tailcfg.SSHAction) *tailcfg.SSHRule { return &tailcfg.SSHRule{ SSHUsers: map[string]string{"alice": lu.Username}, Action: action, Principals: []*tailcfg.SSHPrincipal{{Any: true}}, } }
t.Run("unchanged-policy-proceeds", func(t *testing.T) { p := &policyFlipper{ localState: &localState{ sshEnabled: true, varRoot: t.TempDir(), serverActions: map[string]*tailcfg.SSHAction{"accept": {Accept: true}}, }, rule: holdRule(&tailcfg.SSHAction{HoldAndDelegate: "https://unused/ssh-action/accept"}), } _, client, _ := dialPolicyConn(t, p) sess, err := client.NewSession() if err != nil { t.Fatalf("unchanged policy rejected session after delegation: %v", err) } sess.Close() })
t.Run("revoked-during-delegate-closes", func(t *testing.T) { p := &policyFlipper{ localState: &localState{ sshEnabled: true, varRoot: t.TempDir(), serverActions: map[string]*tailcfg.SSHAction{"accept": {Accept: true}}, }, rule: holdRule(&tailcfg.SSHAction{HoldAndDelegate: "https://unused/ssh-action/accept"}), } // Revoke the netmap rule while the delegate fetch is in flight // and control still answers "accept". The auth-completion // recheck must close the conn at completion. p.onNoise = func() { p.setRule(holdRule(&tailcfg.SSHAction{Reject: true})) } _, client, handleConnDone, err := tryDialPolicyConn(t, p) if err == nil { eventually(t, 10*time.Second, "conn that authed against a revoked rule stayed up", func() bool { return sessionFails(client) }) } select { case <-handleConnDone: case <-time.After(10 * time.Second): t.Fatal("server side of revoked hold conn did not return") } })}
// TestSSHRuleExpiryClosesConn pins the conn-level RuleExpires timer. A// rule whose expiry lands 2s after auth closes the conn at expiry,// because the fresh evaluation fails closed on the expired rule. The// expiry is set after auth, like TestSSHRuleExpiryRearmsOnFreshMatch:// the 2s window starts before the host key generation and the// handshake otherwise, so on a slow runner the rule could already be// expired when the conn auths.func TestSSHRuleExpiryClosesConn(t *testing.T) { lu := sessionTestUser(t) accept := &tailcfg.SSHAction{Accept: true} p := &policyFlipper{ localState: &localState{ sshEnabled: true, varRoot: t.TempDir(), }, rule: newPolicyRule(accept, lu), } s, client, _ := dialPolicyConn(t, p)
sess, err := client.NewSession() if err != nil { t.Fatalf("session before expiry: %v", err) } t.Cleanup(func() { sess.Close() }) if err := sess.Start("sleep 30"); err != nil { t.Fatalf("session start: %v", err) }
// Auth is complete. Arm the expiry now: the fresh rule matches // identically, so the kick's re-evaluation is non-material and only // the armed timer closes the conn, 2s in. exp := time.Now().Add(2 * time.Second) fresh := newPolicyRule(accept, lu) fresh.RuleExpires = &exp p.setRule(fresh) s.OnPolicyChange()
// The expiry timer fires ~2s in. Eval fails closed on the expired // rule and the conn closes. eventually(t, 10*time.Second, "conn with expired rule stayed up", func() bool { return sessionFails(client) }) sessErr := make(chan error, 1) go func() { sessErr <- sess.Wait() }() select { case err := <-sessErr: if err == nil { t.Fatal("session on expired-rule conn returned nil; want error") } case <-time.After(10 * time.Second): t.Fatal("session on expired-rule conn not killed") }}
// TestSSHRuleExpiryRearmsOnFreshMatch pins the ruleTimer re-arm. A// mid-conn policy edit whose fresh matched rule carries a RuleExpires is// non-material at edit time, because the grant is unchanged. The// conn-level timer must then be re-armed to the fresh rule's expiry.func TestSSHRuleExpiryRearmsOnFreshMatch(t *testing.T) { lu := sessionTestUser(t) accept := &tailcfg.SSHAction{Accept: true} // The auth-time rule has no expiry. p := &policyFlipper{ localState: &localState{ sshEnabled: true, varRoot: t.TempDir(), }, rule: newPolicyRule(accept, lu), } s, client, _ := dialPolicyConn(t, p)
sess, err := client.NewSession() if err != nil { t.Fatalf("session: %v", err) } t.Cleanup(func() { sess.Close() }) if err := sess.Start("sleep 30"); err != nil { t.Fatalf("session start: %v", err) }
// Mid-conn edit. The grant is the same, but the fresh rule expires in // 5s. The kick's re-evaluation is non-material with an identical // action, so only the re-armed timer closes the conn at the new // expiry. exp := time.Now().Add(5 * time.Second) fresh := newPolicyRule(accept, lu) fresh.RuleExpires = &exp p.setRule(fresh) s.OnPolicyChange()
// The re-evaluation was non-material, so the conn must survive the // immediate kick. time.Sleep(500 * time.Millisecond) if sessionFails(client) { t.Fatal("conn closed immediately after a non-material policy edit") } // It must also close at the rearmed rule's expiry. eventually(t, 15*time.Second, "conn stayed up past the rearmed rule's expiry", func() bool { return sessionFails(client) })}
// TestSSHRuleExpiryFallbackKeepsConn covers expiry of the// prompt-free Accept rules that check mode grants. They carry a short// RuleExpires, with a base hold rule behind them. Expiration affects// only new connections. When the accept rule lapses, evaluation falls// back to the hold rule. The conn keeps its grant. The rule timer// stays unarmed. New session channels still open.//// The test runs in a synctest bubble, so the expiry cannot race the// handshake on a slow runner. A bubble cannot hold child processes.// The empty PATH keeps the conn lifecycle free of subprocesses. The// session channel opens without running a command. The session-kill// side of expiry is pinned on the real clock.func TestSSHRuleExpiryFallbackKeepsConn(t *testing.T) { synctest.Test(t, func(t *testing.T) { t.Setenv("PATH", t.TempDir())
lu := sessionTestUser(t) exp := time.Now().Add(2 * time.Second) acceptRule := newPolicyRule(&tailcfg.SSHAction{Accept: true}, lu) acceptRule.RuleExpires = &exp holdRule := newPolicyRule(&tailcfg.SSHAction{HoldAndDelegate: "https://unused/hold"}, lu) p := &policyFlipper{ localState: &localState{sshEnabled: true, varRoot: t.TempDir()}, rules: []*tailcfg.SSHRule{acceptRule, holdRule}, } s, client, _ := dialPolicyConn(t, p)
s.mu.Lock() var c *conn for c = range s.activeConns { } s.mu.Unlock() if c == nil { t.Fatal("connection disappeared after authentication") }
// Sleep past the expiry. The bubble jumps straight to the // timer. The recheck falls back to the hold rule, which has no // expiry, and leaves the timer unarmed. time.Sleep(3 * time.Second) c.mu.Lock() armed := c.ruleTimer != nil c.mu.Unlock() if armed { t.Fatal("rule timer still armed after the latched accept rule expired") } if sessionFails(client) { t.Fatal("conn closed when the latched accept rule expired") } })}
// TestSSHLoginGraceTimeExpires pins the pre-auth grace timer, in parity// with sshd's LoginGraceTime. A conn that never completes auth is closed// at the deadline, shortened for this test. Authed conns are unaffected;// every other test in this package auths within the default 120s.func TestSSHLoginGraceTimeExpires(t *testing.T) { envknob.SetenvForTest(t, "TS_SSH_LOGIN_GRACE_TIME", "250ms") s := &server{logf: t.Logf, lb: &localState{sshEnabled: true, varRoot: t.TempDir()}} src, dst := netip.MustParseAddrPort("100.100.100.101:2231"), netip.MustParseAddrPort("100.100.100.102:22") sc, dc := memnet.NewTCPConn(src, dst, 1024) defer sc.Close()
done := make(chan struct{}) go func() { defer close(done) if err := s.HandleSSHConn(dc); err != nil { t.Errorf("HandleSSHConn: %v", err) } }() // The client never sends a byte. The handshake blocks on its first // read until the grace timer closes the raw conn. select { case <-done: case <-time.After(5 * time.Second): t.Fatal("conn that never completed auth was not closed at the grace deadline") }}
// TestMaterialPolicyChange pins the material-change definition field by// field on synthetic conns. Every privilege and monitoring reduction// closes. Every pure extension does not.func TestMaterialPolicyChange(t *testing.T) { lu := sessionTestUser(t) currentUser := lu.Username addr := netip.MustParseAddrPort("127.0.0.1:1")
rule := func(a *tailcfg.SSHAction, acceptEnv []string) *tailcfg.SSHRule { return &tailcfg.SSHRule{ SSHUsers: map[string]string{"alice": currentUser}, Action: a, AcceptEnv: acceptEnv, Principals: []*tailcfg.SSHPrincipal{{Any: true}}, } } accept := func(mod func(*tailcfg.SSHAction)) *tailcfg.SSHAction { a := &tailcfg.SSHAction{Accept: true} if mod != nil { mod(a) } return a }
tests := []struct { name string latched *tailcfg.SSHAction // what the conn authed with latchEnv []string // acceptEnv latched at auth fresh *tailcfg.SSHAction freshEnv []string want bool }{ {name: "unchanged", latched: accept(nil), fresh: accept(nil), want: false}, {name: "reject", latched: accept(nil), fresh: &tailcfg.SSHAction{Reject: true}, want: true}, { name: "accept-to-hold", latched: accept(nil), fresh: &tailcfg.SSHAction{HoldAndDelegate: "https://unused/ssh-action/x"}, want: true, }, { // The fresh rule still requires delegation. For a conn that // already went through delegation, action0 was the hold that // control answered with this conn's finalAction. Control's // grant stands, so it is not material; the loop below gives // this case its action0. name: "hold-unchanged-for-delegated-conn", latched: accept(nil), fresh: &tailcfg.SSHAction{HoldAndDelegate: "https://unused/ssh-action/x"}, want: false, }, { name: "forwarding-flag-narrowed", latched: accept(func(a *tailcfg.SSHAction) { a.AllowLocalPortForwarding = true }), fresh: accept(nil), want: true, }, { name: "forwarding-flag-extension", latched: accept(nil), fresh: accept(func(a *tailcfg.SSHAction) { a.AllowLocalPortForwarding = true }), want: false, }, { name: "recorder-removed", latched: accept(func(a *tailcfg.SSHAction) { a.Recorders = []netip.AddrPort{addr} }), fresh: accept(nil), want: true, }, { name: "recorder-added", latched: accept(nil), fresh: accept(func(a *tailcfg.SSHAction) { a.Recorders = []netip.AddrPort{addr} }), want: true, }, { name: "recording-failure-narrowed", latched: accept(func(a *tailcfg.SSHAction) { a.OnRecordingFailure = &tailcfg.SSHRecorderFailureAction{TerminateSessionWithMessage: "x"} }), fresh: accept(nil), want: true, }, { name: "duration-shortened", latched: accept(func(a *tailcfg.SSHAction) { a.SessionDuration = time.Minute }), fresh: accept(func(a *tailcfg.SSHAction) { a.SessionDuration = 10 * time.Second }), want: true, }, { name: "duration-extended", latched: accept(func(a *tailcfg.SSHAction) { a.SessionDuration = 10 * time.Second }), fresh: accept(func(a *tailcfg.SSHAction) { a.SessionDuration = time.Minute }), want: false, }, { name: "duration-uncapped", latched: accept(func(a *tailcfg.SSHAction) { a.SessionDuration = 10 * time.Second }), fresh: accept(nil), want: false, }, { name: "acceptenv-narrowed", latched: accept(nil), latchEnv: []string{"FOO_*"}, fresh: accept(nil), freshEnv: []string{"FOO_1"}, want: true, }, { name: "acceptenv-extension", latched: accept(nil), latchEnv: []string{"FOO_1"}, fresh: accept(nil), freshEnv: []string{"FOO_*"}, want: false, }, }
for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { p := &policyFlipper{localState: &localState{sshEnabled: true, varRoot: t.TempDir()}, rule: rule(tt.fresh, tt.freshEnv)} c := &conn{ srv: &server{logf: t.Logf, lb: p}, action0: tt.latched, finalAction: tt.latched, acceptEnv: tt.latchEnv, localUser: lu, mappedLocalUser: lu.Username, info: &sshConnInfo{ sshUser: "alice", src: netip.MustParseAddrPort("1.2.3.4:30343"), dst: netip.MustParseAddrPort("100.100.100.102:22"), }, } if tt.name == "hold-unchanged-for-delegated-conn" { // The conn went through delegation. Action0 was the hold. c.action0 = &tailcfg.SSHAction{HoldAndDelegate: "https://unused/ssh-action/x"} } got, why, _ := c.materialPolicyChange() if got != tt.want { t.Errorf("materialPolicyChange = %v (%q); want %v", got, why, tt.want) } }) } // A localUser change is material even when the fresh rule still // accepts and the fresh user resolves. t.Run("local-user-changed", func(t *testing.T) { other := "root" if lu.Username == "root" { other = "daemon" } p := &policyFlipper{localState: &localState{sshEnabled: true, varRoot: t.TempDir()}, rule: &tailcfg.SSHRule{ SSHUsers: map[string]string{"alice": other}, Action: &tailcfg.SSHAction{Accept: true}, Principals: []*tailcfg.SSHPrincipal{{Any: true}}, }} c := &conn{ srv: &server{logf: t.Logf, lb: p}, action0: &tailcfg.SSHAction{Accept: true}, finalAction: &tailcfg.SSHAction{Accept: true}, localUser: lu, mappedLocalUser: lu.Username, info: &sshConnInfo{ sshUser: "alice", src: netip.MustParseAddrPort("1.2.3.4:30343"), dst: netip.MustParseAddrPort("100.100.100.102:22"), }, } got, _, _ := c.materialPolicyChange() if !got { t.Error("localUser change not material") } })
// An expired rule fails closed in evaluation, which the conn-level // timer relies on. The fresh evaluation rejects once the rule // expires. t.Run("expired-rule-fails-closed", func(t *testing.T) { exp := time.Now().Add(-time.Second) r := rule(&tailcfg.SSHAction{Accept: true}, nil) r.RuleExpires = &exp p := &policyFlipper{localState: &localState{sshEnabled: true, varRoot: t.TempDir()}, rule: r} c := &conn{ srv: &server{logf: t.Logf, lb: p}, action0: &tailcfg.SSHAction{Accept: true}, finalAction: &tailcfg.SSHAction{Accept: true}, localUser: lu, mappedLocalUser: lu.Username, info: &sshConnInfo{ sshUser: "alice", src: netip.MustParseAddrPort("1.2.3.4:30343"), dst: netip.MustParseAddrPort("100.100.100.102:22"), }, } got, _, _ := c.materialPolicyChange() if !got { t.Error("expired rule did not fail closed in fresh evaluation") } })}
// TestSSHHoldDelegateGraceExtension checks that entering delegation replaces// the initial grace timer, without imposing a short deadline on key exchange.func TestSSHHoldDelegateGraceExtension(t *testing.T) { lu := sessionTestUser(t) p := &policyFlipper{ localState: &localState{ sshEnabled: true, varRoot: t.TempDir(), serverActions: map[string]*tailcfg.SSHAction{"accept": {Accept: true}}, }, rule: newPolicyRule(&tailcfg.SSHAction{HoldAndDelegate: "https://unused/ssh-action/accept"}, lu), } srv, sc, done := startPolicyConn(t, p) eventually(t, 10*time.Second, "connection not registered", func() bool { return srv.NumActiveConns() == 1 }) srv.mu.Lock() var c *conn for c = range srv.activeConns { } srv.mu.Unlock() c.mu.Lock() initial := c.graceTimer c.mu.Unlock() called := make(chan struct{}) p.onNoise = func() { c.mu.Lock() if c.graceTimer == nil || c.graceTimer == initial || c.authCompleted.Load() { t.Error("delegation did not replace the pre-auth grace timer") } c.mu.Unlock() close(called) } cl, _, _, err := testssh.NewClientConn(sc, sc.RemoteAddr().String(), &testssh.ClientConfig{ User: "alice", HostKeyCallback: testssh.InsecureIgnoreHostKey(), }) if err != nil { t.Fatal(err) } cl.Close() <-done select { case <-called: default: t.Fatal("delegate fetch did not run") }}
// TestSSHRuleExpiryNotRearmedAfterTeardown pins the teardownDone guard.// A recheck goroutine that completes after the conn's teardown, in// stopConnTimers as deferred by HandleSSHConn, must not re-arm// ruleTimer. The re-evaluation here is non-material, since the unchanged// rule still matches, which is exactly the branch that re-arms.func TestSSHRuleExpiryNotRearmedAfterTeardown(t *testing.T) { lu := sessionTestUser(t) accept := &tailcfg.SSHAction{Accept: true} exp := time.Now().Add(time.Hour) // far away: only the guard stops the arm rule := newPolicyRule(accept, lu) rule.RuleExpires = &exp p := &policyFlipper{localState: &localState{sshEnabled: true, varRoot: t.TempDir()}, rule: rule} s, client, _ := dialPolicyConn(t, p)
sess, err := client.NewSession() if err != nil { t.Fatalf("session: %v", err) } t.Cleanup(func() { sess.Close() }) if err := sess.Start("sleep 30"); err != nil { t.Fatalf("session start: %v", err) }
// The live, authed conn. s.mu.Lock() var c *conn for cc := range s.activeConns { c = cc } s.mu.Unlock() if c == nil { t.Fatal("no active conn") }
// Run the conn teardown, stopConnTimers, then the straggling // recheck. Its non-material branch must not re-arm the timer. c.stopConnTimers() if ok := c.recheckPolicy(); !ok { t.Fatal("recheckPolicy rejected an unchanged matching rule") } c.mu.Lock() rearmed := c.ruleTimer != nil c.mu.Unlock() if rearmed { t.Fatal("ruleTimer re-armed after stopConnTimers; want teardownDone to suppress the arm") }}