mirror of
https://github.com/tailscale/tailscale.git
synced 2026-11-04 16:43:10 -05:00
Check mode grants recently approved users a prompt-free Accept rule with a short RuleExpires. When that rule expired, the policy recheck matched the base HoldAndDelegate rule instead. The recheck read that as a policy edit and closed the connection. Live sessions died when the prompt-free window lapsed. Remember the rule that matched at authentication. If it has expired and a hold rule matches instead, keep the connection. Only new connections re-authenticate. The expiry timer now fires at least 1ms past a lapsed deadline. A zero-delay refire lands at the deadline, where the rule is not yet expired, and re-arms. The real clock moves on between refires; a stopped clock, such as a synctest bubble, does not. The fallback test runs in a synctest bubble, so the expiry cannot race the handshake on a slow runner. A bubble cannot hold child processes: the test opens a session channel but runs no command, and the empty PATH keeps the conn lifecycle free of subprocesses. The session-kill side of expiry stays pinned on the real clock. A policy edit that swaps a still-valid accept rule for a hold rule still closes the connection. Recorder, identity, and accept-env checks still run on the fallback path. Updates tailscale/corp#50380 Change-Id: I46fa7d42142e90d00843744427c91ba29d68a467 Signed-off-by: James Tucker <james@tailscale.com>
560 lines
17 KiB
Go
560 lines
17 KiB
Go
// Copyright (c) Tailscale Inc & contributors
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
//go:build (linux && !android) || (darwin && !ios) || freebsd || openbsd
|
|
|
|
package tailssh
|
|
|
|
import (
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/netip"
|
|
"os"
|
|
"os/user"
|
|
"testing"
|
|
"testing/synctest"
|
|
"time"
|
|
|
|
"tailscale.com/tailcfg"
|
|
testssh "tailscale.com/tempfork/sshtest/ssh"
|
|
)
|
|
|
|
func TestPolicyRecheckRestrictions(t *testing.T) {
|
|
lu := sessionTestUser(t)
|
|
recorder := []netip.AddrPort{netip.MustParseAddrPort("127.0.0.1:1")}
|
|
hold := &tailcfg.SSHAction{HoldAndDelegate: "https://unused/hold"}
|
|
src := netip.MustParseAddrPort("1.2.3.4:1234")
|
|
dst := netip.MustParseAddrPort("100.100.100.102:22")
|
|
tests := []struct {
|
|
name string
|
|
initial *tailcfg.SSHAction
|
|
old *tailcfg.SSHAction
|
|
fresh *tailcfg.SSHAction
|
|
oldEnv []string
|
|
latchExpired bool
|
|
want bool
|
|
}{
|
|
{
|
|
name: "duplicate-recorder-replaced",
|
|
old: &tailcfg.SSHAction{
|
|
Accept: true,
|
|
Recorders: []netip.AddrPort{recorder[0], recorder[0]},
|
|
},
|
|
fresh: &tailcfg.SSHAction{
|
|
Accept: true,
|
|
Recorders: []netip.AddrPort{recorder[0], netip.MustParseAddrPort("127.0.0.1:2")},
|
|
},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "recording-fail-closed",
|
|
old: &tailcfg.SSHAction{
|
|
Accept: true,
|
|
Recorders: recorder,
|
|
},
|
|
fresh: &tailcfg.SSHAction{
|
|
Accept: true,
|
|
Recorders: recorder,
|
|
OnRecordingFailure: &tailcfg.SSHRecorderFailureAction{
|
|
RejectSessionWithMessage: "required",
|
|
TerminateSessionWithMessage: "required",
|
|
},
|
|
},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "delegated-env-removed",
|
|
old: &tailcfg.SSHAction{Accept: true},
|
|
initial: hold,
|
|
fresh: hold,
|
|
oldEnv: []string{"FOO_*"},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "delegated-recording-added",
|
|
old: &tailcfg.SSHAction{Accept: true},
|
|
initial: hold,
|
|
fresh: &tailcfg.SSHAction{
|
|
HoldAndDelegate: hold.HoldAndDelegate,
|
|
Recorders: recorder,
|
|
},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "delegated-failure-action-added",
|
|
old: &tailcfg.SSHAction{Accept: true},
|
|
initial: &tailcfg.SSHAction{
|
|
HoldAndDelegate: hold.HoldAndDelegate,
|
|
Recorders: recorder,
|
|
},
|
|
fresh: &tailcfg.SSHAction{
|
|
HoldAndDelegate: hold.HoldAndDelegate,
|
|
Recorders: recorder,
|
|
OnRecordingFailure: &tailcfg.SSHRecorderFailureAction{
|
|
RejectSessionWithMessage: "required",
|
|
},
|
|
},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "delegated-recorder-override",
|
|
old: &tailcfg.SSHAction{
|
|
Accept: true,
|
|
Recorders: recorder,
|
|
},
|
|
initial: hold,
|
|
fresh: &tailcfg.SSHAction{
|
|
HoldAndDelegate: hold.HoldAndDelegate,
|
|
Recorders: []netip.AddrPort{
|
|
netip.MustParseAddrPort("127.0.0.1:2"),
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "forwarding-removed",
|
|
old: &tailcfg.SSHAction{
|
|
Accept: true,
|
|
AllowLocalPortForwarding: true,
|
|
},
|
|
fresh: &tailcfg.SSHAction{Accept: true},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "duration-shortened",
|
|
old: &tailcfg.SSHAction{Accept: true},
|
|
fresh: &tailcfg.SSHAction{
|
|
Accept: true,
|
|
SessionDuration: time.Minute,
|
|
},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "accept-to-hold",
|
|
old: &tailcfg.SSHAction{Accept: true},
|
|
fresh: hold,
|
|
want: true,
|
|
},
|
|
{
|
|
// The latched accept rule expired. The hold rule matched
|
|
// instead. This is not a revocation. Established sessions keep
|
|
// the grant. Only new connections re-authenticate.
|
|
name: "accept-to-hold-on-rule-expiry",
|
|
old: &tailcfg.SSHAction{Accept: true},
|
|
fresh: hold,
|
|
latchExpired: true,
|
|
},
|
|
{
|
|
name: "unchanged",
|
|
old: &tailcfg.SSHAction{Accept: true},
|
|
fresh: &tailcfg.SSHAction{Accept: true},
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
for _, lookupFails := range []bool{false, true} {
|
|
name := fmt.Sprintf("%s/lookupFails=%v", tt.name, lookupFails)
|
|
t.Run(name, func(t *testing.T) {
|
|
initial := tt.initial
|
|
if initial == nil {
|
|
initial = tt.old
|
|
}
|
|
p := &policyFlipper{
|
|
localState: &localState{sshEnabled: true},
|
|
rule: newPolicyRule(tt.fresh, lu),
|
|
}
|
|
latched := newPolicyRule(tt.old, lu)
|
|
exp := time.Now().Add(time.Hour)
|
|
if tt.latchExpired {
|
|
exp = time.Now().Add(-time.Hour)
|
|
}
|
|
latched.RuleExpires = &exp
|
|
c := &conn{
|
|
srv: &server{
|
|
logf: t.Logf,
|
|
lb: p,
|
|
},
|
|
localUser: lu,
|
|
mappedLocalUser: lu.Username,
|
|
action0: initial,
|
|
finalAction: tt.old,
|
|
latchedRule: latched,
|
|
acceptEnv: tt.oldEnv,
|
|
info: &sshConnInfo{
|
|
sshUser: "alice",
|
|
src: src,
|
|
dst: dst,
|
|
},
|
|
}
|
|
lookup := userLookup
|
|
if lookupFails {
|
|
lookup = func(string) (*userMeta, error) {
|
|
return nil, errors.New("lookup unavailable")
|
|
}
|
|
}
|
|
changed, why, _ := c.materialPolicyChangeWithUserLookup(lookup)
|
|
if changed != tt.want {
|
|
t.Fatalf("material=%v (%s), want %v", changed, why, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestPolicyRecheckMappedAliasIdentity covers policy spellings that look up
|
|
// to a different canonical username (for example a numeric UID or an alias).
|
|
func TestPolicyRecheckMappedAliasIdentity(t *testing.T) {
|
|
canonical := &userMeta{User: user.User{Username: "canonical", Uid: "1001", Gid: "1001"}}
|
|
tests := []struct {
|
|
name string
|
|
mapping string
|
|
lookupUser *userMeta
|
|
lookupErr error
|
|
freshAction *tailcfg.SSHAction
|
|
wantChange bool
|
|
}{
|
|
{name: "unchanged-alias", mapping: "alias", lookupUser: canonical},
|
|
{name: "remapped-alias-same-account", mapping: "other-alias", lookupUser: canonical, wantChange: true},
|
|
{name: "uid-changed", mapping: "alias", lookupUser: &userMeta{User: user.User{Username: "canonical", Uid: "1002", Gid: "1001"}}, wantChange: true},
|
|
{name: "canonical-name-changed", mapping: "alias", lookupUser: &userMeta{User: user.User{Username: "other", Uid: "1001", Gid: "1001"}}, wantChange: true},
|
|
{name: "gid-changed", mapping: "alias", lookupUser: &userMeta{User: user.User{Username: "canonical", Uid: "1001", Gid: "1002"}}, wantChange: true},
|
|
{name: "account-deleted", mapping: "alias", lookupErr: user.UnknownUserError("alias"), wantChange: true},
|
|
{name: "nss-outage", mapping: "alias", lookupErr: errors.New("NSS temporarily unavailable")},
|
|
{name: "nss-outage-forwarding-revoked", mapping: "alias", lookupErr: errors.New("NSS temporarily unavailable"), freshAction: &tailcfg.SSHAction{Accept: true}, wantChange: true},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
freshAction := tt.freshAction
|
|
if freshAction == nil {
|
|
freshAction = &tailcfg.SSHAction{Accept: true, AllowLocalPortForwarding: true}
|
|
}
|
|
p := &policyFlipper{
|
|
localState: &localState{sshEnabled: true},
|
|
rule: &tailcfg.SSHRule{
|
|
SSHUsers: map[string]string{"alice": tt.mapping},
|
|
Action: freshAction,
|
|
Principals: []*tailcfg.SSHPrincipal{{Any: true}},
|
|
},
|
|
}
|
|
c := &conn{
|
|
srv: &server{logf: t.Logf, lb: p},
|
|
localUser: canonical,
|
|
mappedLocalUser: "alias",
|
|
action0: &tailcfg.SSHAction{Accept: true, AllowLocalPortForwarding: true},
|
|
finalAction: &tailcfg.SSHAction{Accept: true, AllowLocalPortForwarding: true},
|
|
info: &sshConnInfo{
|
|
sshUser: "alice",
|
|
src: netip.MustParseAddrPort("1.2.3.4:1234"),
|
|
dst: netip.MustParseAddrPort("100.100.100.102:22"),
|
|
},
|
|
}
|
|
lookedUp := false
|
|
changed, why, _ := c.materialPolicyChangeWithUserLookup(func(name string) (*userMeta, error) {
|
|
lookedUp = true
|
|
if name != tt.mapping {
|
|
t.Errorf("lookup argument = %q, want %q", name, tt.mapping)
|
|
}
|
|
return tt.lookupUser, tt.lookupErr
|
|
})
|
|
if changed != tt.wantChange {
|
|
t.Errorf("material=%v (%s), want %v", changed, why, tt.wantChange)
|
|
}
|
|
if lookedUp != (tt.mapping == "alias") {
|
|
t.Errorf("lookup called=%v for mapping %q", lookedUp, tt.mapping)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestPolicyRecheckEqualMapping exercises the lookup during rule matching as
|
|
// well as the later identity check. Inconclusive results must not become a
|
|
// user mismatch, bypass other restrictions, or allow a root account.
|
|
func TestPolicyRecheckEqualMapping(t *testing.T) {
|
|
alice := &userMeta{User: user.User{Username: "alice", Uid: "1001", Gid: "1001"}}
|
|
root := &userMeta{User: user.User{Username: "root", Uid: "0", Gid: "0"}}
|
|
for _, tt := range []struct {
|
|
name string
|
|
old *userMeta
|
|
fresh *userMeta
|
|
err error
|
|
revokeForwarding bool
|
|
want bool
|
|
}{
|
|
{name: "unchanged", old: alice, fresh: alice},
|
|
{name: "outage", old: alice, err: errors.New("getent unavailable")},
|
|
{name: "outage-forwarding-revoked", old: alice, err: errors.New("getent unavailable"), revokeForwarding: true, want: true},
|
|
{name: "deleted", old: alice, err: user.UnknownUserError("alice"), want: true},
|
|
{name: "changed-to-root", old: alice, fresh: root, want: true},
|
|
{name: "root-during-outage", old: root, err: errors.New("getent unavailable"), want: true},
|
|
} {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
old := &tailcfg.SSHAction{Accept: true, AllowLocalPortForwarding: true}
|
|
fresh := &tailcfg.SSHAction{Accept: true, AllowLocalPortForwarding: !tt.revokeForwarding}
|
|
p := &policyFlipper{
|
|
localState: &localState{sshEnabled: true},
|
|
rule: &tailcfg.SSHRule{
|
|
SSHUsers: map[string]string{"*": "="},
|
|
Principals: []*tailcfg.SSHPrincipal{{Any: true}},
|
|
Action: fresh,
|
|
},
|
|
}
|
|
c := &conn{
|
|
srv: &server{logf: t.Logf, lb: p},
|
|
localUser: tt.old, mappedLocalUser: "alice",
|
|
action0: old, finalAction: old,
|
|
info: &sshConnInfo{sshUser: "alice"},
|
|
}
|
|
calls := 0
|
|
changed, why, _ := c.materialPolicyChangeWithUserLookup(func(name string) (*userMeta, error) {
|
|
calls++
|
|
if name != "alice" {
|
|
t.Errorf("lookup(%q), want alice", name)
|
|
}
|
|
return tt.fresh, tt.err
|
|
})
|
|
if changed != tt.want {
|
|
t.Errorf("material=%v (%s), want %v", changed, why, tt.want)
|
|
}
|
|
if calls != 1 {
|
|
t.Errorf("lookup called %d times, want 1", calls)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestForwardingDeniedBeforePolicyLookup(t *testing.T) {
|
|
// No server: any attempt at policy evaluation would panic. A denied
|
|
// channel must not do policy or account lookups, regardless of its type.
|
|
for _, action := range []*tailcfg.SSHAction{nil, {Accept: true}} {
|
|
c := &conn{finalAction: action}
|
|
if c.mayForwardLocalPortTo(nil, "localhost", 80) {
|
|
t.Error("local forwarding allowed")
|
|
}
|
|
if c.mayReversePortForwardTo(nil, "localhost", 80) {
|
|
t.Error("remote forwarding allowed")
|
|
}
|
|
if _, err := c.mayForwardLocalUnixTo(nil, "/unused"); err == nil {
|
|
t.Error("local Unix forwarding allowed")
|
|
}
|
|
if _, err := c.mayReverseUnixForwardTo(nil, "/unused"); err == nil {
|
|
t.Error("remote Unix forwarding allowed")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestForwardingPolicyUsesLatchedIdentity(t *testing.T) {
|
|
// This synthetic account cannot be looked up on the host. Even "="
|
|
// rules must use the latched identity on the per-forward hot path.
|
|
lu := &userMeta{User: user.User{Username: "tailscale-nonexistent-test-account", Uid: "1001", Gid: "1001"}}
|
|
action := &tailcfg.SSHAction{Accept: true, AllowLocalPortForwarding: true, AllowRemotePortForwarding: true}
|
|
p := &policyFlipper{localState: &localState{sshEnabled: true}, rule: &tailcfg.SSHRule{
|
|
SSHUsers: map[string]string{"*": "="}, Action: action,
|
|
Principals: []*tailcfg.SSHPrincipal{{Any: true}},
|
|
}}
|
|
c := &conn{srv: &server{logf: t.Logf, lb: p}, localUser: lu, mappedLocalUser: lu.Username,
|
|
action0: action, finalAction: action, info: &sshConnInfo{sshUser: lu.Username}}
|
|
defer c.stopConnTimers()
|
|
if !c.mayForwardLocalPortTo(nil, "localhost", 80) {
|
|
t.Fatal("local forwarding denied")
|
|
}
|
|
if !c.mayReversePortForwardTo(nil, "localhost", 80) {
|
|
t.Fatal("remote forwarding denied")
|
|
}
|
|
p.setRule(&tailcfg.SSHRule{Action: &tailcfg.SSHAction{Reject: true}, Principals: []*tailcfg.SSHPrincipal{{Any: true}}})
|
|
if c.recheckForwardingPolicy() {
|
|
t.Fatal("revoked forwarding allowed")
|
|
}
|
|
}
|
|
|
|
func TestRecordersChanged(t *testing.T) {
|
|
a := netip.MustParseAddrPort("127.0.0.1:1")
|
|
b := netip.MustParseAddrPort("127.0.0.1:2")
|
|
for _, tt := range []struct {
|
|
name string
|
|
old, fresh []netip.AddrPort
|
|
want bool
|
|
}{
|
|
{name: "empty", old: nil, fresh: []netip.AddrPort{}},
|
|
{name: "added", fresh: []netip.AddrPort{a}, want: true},
|
|
{name: "removed", old: []netip.AddrPort{a}, want: true},
|
|
{name: "reordered", old: []netip.AddrPort{a, b}, fresh: []netip.AddrPort{b, a}},
|
|
{name: "duplicate-replaced", old: []netip.AddrPort{a, a}, fresh: []netip.AddrPort{a, b}, want: true},
|
|
{name: "duplicate-introduced", old: []netip.AddrPort{a, b}, fresh: []netip.AddrPort{a, a}, want: true},
|
|
{name: "duplicate-counts-changed", old: []netip.AddrPort{a, a, b}, fresh: []netip.AddrPort{a, b, b}, want: true},
|
|
{name: "duplicates-reordered", old: []netip.AddrPort{a, a, b}, fresh: []netip.AddrPort{b, a, a}},
|
|
} {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := recordersChanged(tt.old, tt.fresh); got != tt.want {
|
|
t.Errorf("recordersChanged(%v, %v) = %v; want %v", tt.old, tt.fresh, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRecordingFailureChange(t *testing.T) {
|
|
old := &tailcfg.SSHRecorderFailureAction{NotifyURL: "https://control/old"}
|
|
fresh := &tailcfg.SSHRecorderFailureAction{NotifyURL: "https://control/new"}
|
|
if !recFailureChanged(old, fresh) {
|
|
t.Error("changed notification destination ignored")
|
|
}
|
|
if recFailureChanged(nil, &tailcfg.SSHRecorderFailureAction{}) {
|
|
t.Error("nil and empty actions differ")
|
|
}
|
|
}
|
|
|
|
func TestIsUserMissing(t *testing.T) {
|
|
if !isUserMissing(fmt.Errorf("lookup: %w", user.UnknownUserError("gone"))) {
|
|
t.Error("unknown user not recognized")
|
|
}
|
|
if !isUserMissing(user.UnknownUserIdError(1001)) {
|
|
t.Error("unknown UID not recognized")
|
|
}
|
|
err := &os.PathError{
|
|
Op: "open",
|
|
Path: "/etc/passwd",
|
|
Err: os.ErrNotExist,
|
|
}
|
|
if isUserMissing(err) {
|
|
t.Error("missing database classified as missing user")
|
|
}
|
|
}
|
|
|
|
type graceTestConn struct {
|
|
net.Conn
|
|
closed chan struct{}
|
|
}
|
|
|
|
func (c *graceTestConn) Close() error {
|
|
close(c.closed)
|
|
return nil
|
|
}
|
|
|
|
func TestLoginGraceExtensionDeadline(t *testing.T) {
|
|
synctest.Test(t, func(t *testing.T) {
|
|
nc := &graceTestConn{closed: make(chan struct{})}
|
|
c := &conn{srv: &server{logf: t.Logf}, netConn: nc}
|
|
defer c.stopConnTimers()
|
|
c.mu.Lock()
|
|
c.armLoginGraceLocked(time.Second)
|
|
c.mu.Unlock()
|
|
c.extendLoginGrace(holdDelegateGrace)
|
|
time.Sleep(2 * time.Second)
|
|
synctest.Wait()
|
|
select {
|
|
case <-nc.closed:
|
|
t.Fatal("superseded grace timer closed the connection")
|
|
default:
|
|
}
|
|
time.Sleep(holdDelegateGrace)
|
|
synctest.Wait()
|
|
select {
|
|
case <-nc.closed:
|
|
default:
|
|
t.Fatal("extended grace timer did not close the connection")
|
|
}
|
|
})
|
|
}
|
|
|
|
// blockedAuthSigner pauses after the server accepts an unsigned key query.
|
|
type blockedAuthSigner struct {
|
|
testssh.Signer
|
|
entered chan struct{}
|
|
release chan struct{}
|
|
}
|
|
|
|
func (s *blockedAuthSigner) Sign(io.Reader, []byte) (*testssh.Signature, error) {
|
|
close(s.entered)
|
|
<-s.release
|
|
return nil, errors.New("test stopped before signing")
|
|
}
|
|
|
|
func TestLoginGraceIncompleteAuthentication(t *testing.T) {
|
|
for _, method := range []string{"unsigned-key", "partial-password"} {
|
|
t.Run(method, func(t *testing.T) {
|
|
lu := sessionTestUser(t)
|
|
p := &policyFlipper{
|
|
localState: &localState{
|
|
sshEnabled: true,
|
|
varRoot: t.TempDir(),
|
|
},
|
|
rule: newPolicyRule(&tailcfg.SSHAction{Accept: true}, lu),
|
|
}
|
|
srv, sc, done := startPolicyConn(t, p)
|
|
entered, release := make(chan struct{}), make(chan struct{})
|
|
cfg := &testssh.ClientConfig{
|
|
User: "alice",
|
|
HostKeyCallback: testssh.InsecureIgnoreHostKey(),
|
|
}
|
|
if method == "unsigned-key" {
|
|
_, key, err := ed25519.GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
signer, err := testssh.NewSignerFromKey(key)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg.SkipNoneAuth = true
|
|
cfg.Auth = []testssh.AuthMethod{
|
|
testssh.PublicKeys(&blockedAuthSigner{
|
|
Signer: signer,
|
|
entered: entered,
|
|
release: release,
|
|
}),
|
|
}
|
|
} else {
|
|
cfg.User += forcePasswordSuffix
|
|
password := testssh.PasswordCallback(func() (string, error) {
|
|
close(entered)
|
|
<-release
|
|
return "", errors.New("test stopped before password")
|
|
})
|
|
cfg.Auth = []testssh.AuthMethod{password}
|
|
}
|
|
clientDone := make(chan struct{})
|
|
addr := sc.RemoteAddr().String()
|
|
go func() {
|
|
defer close(clientDone)
|
|
cl, _, _, _ := testssh.NewClientConn(sc, addr, cfg)
|
|
if cl != nil {
|
|
cl.Close()
|
|
}
|
|
}()
|
|
defer func() {
|
|
close(release)
|
|
sc.Close()
|
|
<-clientDone
|
|
}()
|
|
select {
|
|
case <-entered:
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatal("authentication did not reach continuation")
|
|
}
|
|
srv.mu.Lock()
|
|
var c *conn
|
|
for c = range srv.activeConns {
|
|
}
|
|
srv.mu.Unlock()
|
|
if c == nil {
|
|
t.Fatal("connection disappeared before grace expiry")
|
|
}
|
|
c.mu.Lock()
|
|
active := c.graceTimer != nil && !c.authCompleted.Load()
|
|
if active {
|
|
c.graceTimer.Reset(0)
|
|
}
|
|
c.mu.Unlock()
|
|
if !active {
|
|
t.Fatal("incomplete authentication disabled login grace")
|
|
}
|
|
select {
|
|
case <-done:
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatal("grace expiry did not close the unfinished handshake")
|
|
}
|
|
})
|
|
}
|
|
}
|