562 lines
17 KiB
Go
562 lines
17 KiB
Go
// Copyright (c) HashiCorp, Inc.
|
|
// SPDX-License-Identifier: MPL-2.0
|
|
|
|
package autoconf
|
|
|
|
import (
|
|
"context"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"fmt"
|
|
"net"
|
|
"net/url"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/mock"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/hashicorp/consul/agent/cache"
|
|
cachetype "github.com/hashicorp/consul/agent/cache-types"
|
|
"github.com/hashicorp/consul/agent/config"
|
|
"github.com/hashicorp/consul/agent/connect"
|
|
"github.com/hashicorp/consul/agent/leafcert"
|
|
"github.com/hashicorp/consul/agent/metadata"
|
|
"github.com/hashicorp/consul/agent/structs"
|
|
"github.com/hashicorp/consul/lib/retry"
|
|
"github.com/hashicorp/consul/sdk/testutil"
|
|
)
|
|
|
|
func TestAutoEncrypt_generateCSR(t *testing.T) {
|
|
type testCase struct {
|
|
conf *config.RuntimeConfig
|
|
|
|
// to validate the csr
|
|
expectedSubject pkix.Name
|
|
expectedSigAlg x509.SignatureAlgorithm
|
|
expectedPubAlg x509.PublicKeyAlgorithm
|
|
expectedDNSNames []string
|
|
expectedIPs []net.IP
|
|
expectedURIs []*url.URL
|
|
}
|
|
|
|
cases := map[string]testCase{
|
|
"ip-sans": {
|
|
conf: &config.RuntimeConfig{
|
|
Datacenter: "dc1",
|
|
NodeName: "test-node",
|
|
AutoEncryptTLS: true,
|
|
AutoEncryptIPSAN: []net.IP{net.IPv4(198, 18, 0, 1), net.IPv4(198, 18, 0, 2)},
|
|
},
|
|
expectedSubject: pkix.Name{},
|
|
expectedSigAlg: x509.ECDSAWithSHA256,
|
|
expectedPubAlg: x509.ECDSA,
|
|
expectedDNSNames: defaultDNSSANs,
|
|
expectedIPs: append(defaultIPSANs,
|
|
net.IP{198, 18, 0, 1},
|
|
net.IP{198, 18, 0, 2},
|
|
),
|
|
expectedURIs: []*url.URL{
|
|
{
|
|
Scheme: "spiffe",
|
|
Host: unknownTrustDomain,
|
|
Path: "/agent/client/dc/dc1/id/test-node",
|
|
},
|
|
},
|
|
},
|
|
"dns-sans": {
|
|
conf: &config.RuntimeConfig{
|
|
Datacenter: "dc1",
|
|
NodeName: "test-node",
|
|
AutoEncryptTLS: true,
|
|
AutoEncryptDNSSAN: []string{"foo.local", "bar.local"},
|
|
},
|
|
expectedSubject: pkix.Name{},
|
|
expectedSigAlg: x509.ECDSAWithSHA256,
|
|
expectedPubAlg: x509.ECDSA,
|
|
expectedDNSNames: append(defaultDNSSANs, "foo.local", "bar.local"),
|
|
expectedIPs: defaultIPSANs,
|
|
expectedURIs: []*url.URL{
|
|
{
|
|
Scheme: "spiffe",
|
|
Host: unknownTrustDomain,
|
|
Path: "/agent/client/dc/dc1/id/test-node",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
for name, tcase := range cases {
|
|
t.Run(name, func(t *testing.T) {
|
|
ac := AutoConfig{config: tcase.conf}
|
|
|
|
csr, _, err := ac.generateCSR()
|
|
require.NoError(t, err)
|
|
|
|
request, err := connect.ParseCSR(csr)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, request)
|
|
|
|
require.Equal(t, tcase.expectedSubject, request.Subject)
|
|
require.Equal(t, tcase.expectedSigAlg, request.SignatureAlgorithm)
|
|
require.Equal(t, tcase.expectedPubAlg, request.PublicKeyAlgorithm)
|
|
require.Equal(t, tcase.expectedDNSNames, request.DNSNames)
|
|
require.Equal(t, tcase.expectedIPs, request.IPAddresses)
|
|
require.Equal(t, tcase.expectedURIs, request.URIs)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAutoEncrypt_hosts(t *testing.T) {
|
|
type testCase struct {
|
|
serverProvider ServerProvider
|
|
config *config.RuntimeConfig
|
|
|
|
hosts []string
|
|
err string
|
|
}
|
|
|
|
providerNone := newMockServerProvider(t)
|
|
providerNone.On("FindLANServer").Return(nil).Times(0)
|
|
|
|
providerWithServer := newMockServerProvider(t)
|
|
providerWithServer.On("FindLANServer").Return(&metadata.Server{Addr: &net.TCPAddr{IP: net.IPv4(198, 18, 0, 1), Port: 1234}}).Times(0)
|
|
|
|
cases := map[string]testCase{
|
|
"router-override": {
|
|
serverProvider: providerWithServer,
|
|
config: &config.RuntimeConfig{
|
|
RetryJoinLAN: []string{"127.0.0.1:9876", "192.168.1.2:4321"},
|
|
},
|
|
hosts: []string{"198.18.0.1:1234"},
|
|
},
|
|
"various-addresses": {
|
|
serverProvider: providerNone,
|
|
config: &config.RuntimeConfig{
|
|
RetryJoinLAN: []string{
|
|
"192.168.1.1:5432",
|
|
"start.local",
|
|
"[::ffff:172.16.5.4]",
|
|
"main.dev:6789",
|
|
"198.18.0.1",
|
|
"foo.com",
|
|
"[2001:db8::1234]:1234",
|
|
"abc.local:9876",
|
|
},
|
|
},
|
|
hosts: []string{
|
|
"192.168.1.1",
|
|
"start.local",
|
|
"[::ffff:172.16.5.4]",
|
|
"main.dev",
|
|
"198.18.0.1",
|
|
"foo.com",
|
|
"2001:db8::1234",
|
|
"abc.local",
|
|
},
|
|
},
|
|
"split-host-port-error": {
|
|
serverProvider: providerNone,
|
|
config: &config.RuntimeConfig{
|
|
RetryJoinLAN: []string{"this-is-not:a:ip:and_port"},
|
|
},
|
|
err: "no auto-encrypt server addresses available for use",
|
|
},
|
|
}
|
|
|
|
for name, tcase := range cases {
|
|
t.Run(name, func(t *testing.T) {
|
|
ac := AutoConfig{
|
|
config: tcase.config,
|
|
logger: testutil.Logger(t),
|
|
acConfig: Config{
|
|
ServerProvider: tcase.serverProvider,
|
|
},
|
|
}
|
|
|
|
hosts, err := ac.joinHosts()
|
|
if tcase.err != "" {
|
|
testutil.RequireErrorContains(t, err, tcase.err)
|
|
} else {
|
|
require.NoError(t, err)
|
|
require.Equal(t, tcase.hosts, hosts)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAutoEncrypt_InitialCerts(t *testing.T) {
|
|
token := "1a148388-3dd7-4db4-9eea-520424b4a86a"
|
|
datacenter := "foo"
|
|
nodeName := "bar"
|
|
|
|
mcfg := newMockedConfig(t)
|
|
|
|
_, indexedRoots, cert := testCerts(t, nodeName, datacenter)
|
|
|
|
// The following are called once for each round through the auto-encrypt initial certs outer loop
|
|
// (not the per-host direct rpc attempts but the one involving the RetryWaiter)
|
|
mcfg.tokens.On("AgentToken").Return(token).Times(2)
|
|
mcfg.serverProvider.On("FindLANServer").Return(nil).Times(2)
|
|
|
|
request := structs.CASignRequest{
|
|
WriteRequest: structs.WriteRequest{Token: token},
|
|
Datacenter: datacenter,
|
|
// this gets removed by the mock code as its non-deterministic what it will be
|
|
CSR: "",
|
|
}
|
|
|
|
// first failure
|
|
mcfg.directRPC.On("RPC",
|
|
datacenter,
|
|
nodeName,
|
|
&net.TCPAddr{IP: net.IPv4(198, 18, 0, 1), Port: 8300},
|
|
"AutoEncrypt.Sign",
|
|
&request,
|
|
&structs.SignedResponse{},
|
|
).Once().Return(fmt.Errorf("injected error"))
|
|
// second failure
|
|
mcfg.directRPC.On("RPC",
|
|
datacenter,
|
|
nodeName,
|
|
&net.TCPAddr{IP: net.IPv4(198, 18, 0, 2), Port: 8300},
|
|
"AutoEncrypt.Sign",
|
|
&request,
|
|
&structs.SignedResponse{},
|
|
).Once().Return(fmt.Errorf("injected error"))
|
|
// third times is successfuly (second attempt to first server)
|
|
mcfg.directRPC.On("RPC",
|
|
datacenter,
|
|
nodeName,
|
|
&net.TCPAddr{IP: net.IPv4(198, 18, 0, 1), Port: 8300},
|
|
"AutoEncrypt.Sign",
|
|
&request,
|
|
&structs.SignedResponse{},
|
|
).Once().Return(nil).Run(func(args mock.Arguments) {
|
|
resp, ok := args.Get(5).(*structs.SignedResponse)
|
|
require.True(t, ok)
|
|
resp.ConnectCARoots = *indexedRoots
|
|
resp.IssuedCert = *cert
|
|
resp.VerifyServerHostname = true
|
|
})
|
|
|
|
mcfg.Config.Waiter = &retry.Waiter{MinFailures: 2, MaxWait: time.Millisecond}
|
|
|
|
ac := AutoConfig{
|
|
config: &config.RuntimeConfig{
|
|
Datacenter: datacenter,
|
|
NodeName: nodeName,
|
|
RetryJoinLAN: []string{"198.18.0.1:1234", "198.18.0.2:3456"},
|
|
ServerPort: 8300,
|
|
},
|
|
acConfig: mcfg.Config,
|
|
logger: testutil.Logger(t),
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
resp, err := ac.autoEncryptInitialCerts(ctx)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, resp)
|
|
require.True(t, resp.VerifyServerHostname)
|
|
require.NotEmpty(t, resp.IssuedCert.PrivateKeyPEM)
|
|
resp.IssuedCert.PrivateKeyPEM = ""
|
|
cert.PrivateKeyPEM = ""
|
|
require.Equal(t, cert, &resp.IssuedCert)
|
|
require.Equal(t, indexedRoots, &resp.ConnectCARoots)
|
|
require.Empty(t, resp.ManualCARoots)
|
|
}
|
|
|
|
func TestAutoEncrypt_InitialConfiguration(t *testing.T) {
|
|
token := "010494ae-ee45-4433-903c-a58c91297714"
|
|
nodeName := "auto-encrypt"
|
|
datacenter := "dc1"
|
|
|
|
mcfg := newMockedConfig(t)
|
|
loader := setupRuntimeConfig(t)
|
|
loader.addConfigHCL(`
|
|
auto_encrypt {
|
|
tls = true
|
|
}
|
|
`)
|
|
loader.opts.FlagValues.NodeName = &nodeName
|
|
mcfg.Config.Loader = loader.Load
|
|
|
|
indexedRoots, cert, extraCerts := mcfg.setupInitialTLS(t, nodeName, datacenter, token)
|
|
|
|
// prepopulation is going to grab the token to populate the correct cache key
|
|
mcfg.tokens.On("AgentToken").Return(token).Times(0)
|
|
|
|
// no server provider
|
|
mcfg.serverProvider.On("FindLANServer").Return(&metadata.Server{Addr: &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 8300}}).Times(1)
|
|
|
|
populateResponse := func(args mock.Arguments) {
|
|
resp, ok := args.Get(5).(*structs.SignedResponse)
|
|
require.True(t, ok)
|
|
*resp = structs.SignedResponse{
|
|
VerifyServerHostname: true,
|
|
ConnectCARoots: *indexedRoots,
|
|
IssuedCert: *cert,
|
|
ManualCARoots: extraCerts,
|
|
}
|
|
}
|
|
|
|
expectedRequest := structs.CASignRequest{
|
|
WriteRequest: structs.WriteRequest{Token: token},
|
|
Datacenter: datacenter,
|
|
// TODO (autoconf) Maybe in the future we should populate a CSR
|
|
// and do some manual parsing/verification of the contents. The
|
|
// bits not having to do with the signing key such as the requested
|
|
// SANs and CN. For now though the mockDirectRPC type will empty
|
|
// the CSR so we have to pass in an empty string to the expectation.
|
|
CSR: "",
|
|
}
|
|
|
|
mcfg.directRPC.On(
|
|
"RPC",
|
|
datacenter,
|
|
nodeName,
|
|
&net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 8300},
|
|
"AutoEncrypt.Sign",
|
|
&expectedRequest,
|
|
&structs.SignedResponse{}).Return(nil).Run(populateResponse)
|
|
|
|
ac, err := New(mcfg.Config)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, ac)
|
|
|
|
cfg, err := ac.InitialConfiguration(context.Background())
|
|
require.NoError(t, err)
|
|
require.NotNil(t, cfg)
|
|
|
|
}
|
|
|
|
func TestAutoEncrypt_TokenUpdate(t *testing.T) {
|
|
testAC := startedAutoConfig(t, true)
|
|
|
|
newToken := "1a4cc445-86ed-46b4-a355-bbf5a11dddb0"
|
|
|
|
rootsCtx, rootsCancel := context.WithCancel(context.Background())
|
|
testAC.mcfg.cache.On("Notify",
|
|
mock.Anything,
|
|
cachetype.ConnectCARootName,
|
|
&structs.DCSpecificRequest{Datacenter: testAC.ac.config.Datacenter},
|
|
rootsWatchID,
|
|
mock.Anything,
|
|
).Return(nil).Once().Run(func(args mock.Arguments) {
|
|
rootsCancel()
|
|
})
|
|
|
|
leafCtx, leafCancel := context.WithCancel(context.Background())
|
|
testAC.mcfg.leafCerts.On("Notify",
|
|
mock.Anything,
|
|
&leafcert.ConnectCALeafRequest{
|
|
Datacenter: "dc1",
|
|
Agent: "autoconf",
|
|
Token: newToken,
|
|
DNSSAN: defaultDNSSANs,
|
|
IPSAN: defaultIPSANs,
|
|
},
|
|
leafWatchID,
|
|
mock.Anything,
|
|
).Return(nil).Once().Run(func(args mock.Arguments) {
|
|
leafCancel()
|
|
})
|
|
|
|
// this will be retrieved once when resetting the leaf cert watch
|
|
testAC.mcfg.tokens.On("AgentToken").Return(newToken).Once()
|
|
|
|
// send the notification about the token update
|
|
testAC.tokenUpdates <- struct{}{}
|
|
|
|
// wait for the leaf cert watches
|
|
require.True(t, waitForChans(100*time.Millisecond, leafCtx.Done(), rootsCtx.Done()), "New cache watches were not started within 100ms")
|
|
}
|
|
|
|
func TestAutoEncrypt_RootsUpdate(t *testing.T) {
|
|
testAC := startedAutoConfig(t, true)
|
|
|
|
secondCA := connect.TestCA(t, testAC.initialRoots.Roots[0])
|
|
secondRoots := structs.IndexedCARoots{
|
|
ActiveRootID: secondCA.ID,
|
|
TrustDomain: connect.TestClusterID,
|
|
Roots: []*structs.CARoot{
|
|
secondCA,
|
|
testAC.initialRoots.Roots[0],
|
|
},
|
|
QueryMeta: structs.QueryMeta{
|
|
Index: 99,
|
|
},
|
|
}
|
|
|
|
updatedCtx, cancel := context.WithCancel(context.Background())
|
|
testAC.mcfg.tlsCfg.On("UpdateAutoTLSCA",
|
|
[]string{secondCA.RootCert, testAC.initialRoots.Roots[0].RootCert},
|
|
).Return(nil).Once().Run(func(args mock.Arguments) {
|
|
cancel()
|
|
})
|
|
|
|
// when a cache event comes in we end up recalculating the fallback timer which requires this call
|
|
testAC.mcfg.tlsCfg.On("AutoEncryptCert").Return(&x509.Certificate{
|
|
NotAfter: time.Now().Add(10 * time.Minute),
|
|
}).Once()
|
|
|
|
req := structs.DCSpecificRequest{Datacenter: "dc1"}
|
|
require.True(t, testAC.mcfg.cache.sendNotification(context.Background(), req.CacheInfo().Key, cache.UpdateEvent{
|
|
CorrelationID: rootsWatchID,
|
|
Result: &secondRoots,
|
|
Meta: cache.ResultMeta{
|
|
Index: secondRoots.Index,
|
|
},
|
|
}))
|
|
|
|
require.True(t, waitForChans(100*time.Millisecond, updatedCtx.Done()), "TLS certificates were not updated within the alotted time")
|
|
}
|
|
|
|
func TestAutoEncrypt_CertUpdate(t *testing.T) {
|
|
testAC := startedAutoConfig(t, true)
|
|
secondCert := newLeaf(t, "autoconf", "dc1", testAC.initialRoots.Roots[0], 99, 10*time.Minute)
|
|
|
|
updatedCtx, cancel := context.WithCancel(context.Background())
|
|
testAC.mcfg.tlsCfg.On("UpdateAutoTLSCert",
|
|
secondCert.CertPEM,
|
|
"redacted",
|
|
).Return(nil).Once().Run(func(args mock.Arguments) {
|
|
cancel()
|
|
})
|
|
|
|
// when a cache event comes in we end up recalculating the fallback timer which requires this call
|
|
testAC.mcfg.tlsCfg.On("AutoEncryptCert").Return(&x509.Certificate{
|
|
NotAfter: secondCert.ValidBefore,
|
|
}).Once()
|
|
|
|
req := leafcert.ConnectCALeafRequest{
|
|
Datacenter: "dc1",
|
|
Agent: "autoconf",
|
|
Token: testAC.originalToken,
|
|
DNSSAN: defaultDNSSANs,
|
|
IPSAN: defaultIPSANs,
|
|
}
|
|
require.True(t, testAC.mcfg.leafCerts.sendNotification(context.Background(), req.Key(), cache.UpdateEvent{
|
|
CorrelationID: leafWatchID,
|
|
Result: secondCert,
|
|
Meta: cache.ResultMeta{
|
|
Index: secondCert.ModifyIndex,
|
|
},
|
|
}))
|
|
|
|
require.True(t, waitForChans(100*time.Millisecond, updatedCtx.Done()), "TLS certificates were not updated within the alotted time")
|
|
}
|
|
|
|
func TestAutoEncrypt_Fallback(t *testing.T) {
|
|
testAC := startedAutoConfig(t, true)
|
|
|
|
// at this point everything is operating normally and we are just
|
|
// waiting for events. We are going to send a new cert that is basically
|
|
// already expired and then allow the fallback routine to kick in.
|
|
secondCert := newLeaf(t, "autoconf", "dc1", testAC.initialRoots.Roots[0], 100, time.Nanosecond)
|
|
secondCA := connect.TestCA(t, testAC.initialRoots.Roots[0])
|
|
secondRoots := structs.IndexedCARoots{
|
|
ActiveRootID: secondCA.ID,
|
|
TrustDomain: connect.TestClusterID,
|
|
Roots: []*structs.CARoot{
|
|
secondCA,
|
|
testAC.initialRoots.Roots[0],
|
|
},
|
|
QueryMeta: structs.QueryMeta{
|
|
Index: 101,
|
|
},
|
|
}
|
|
thirdCert := newLeaf(t, "autoconf", "dc1", secondCA, 102, 10*time.Minute)
|
|
|
|
// setup the expectation for when the certs get updated initially
|
|
updatedCtx, updateCancel := context.WithCancel(context.Background())
|
|
testAC.mcfg.tlsCfg.On("UpdateAutoTLSCert",
|
|
secondCert.CertPEM,
|
|
"redacted",
|
|
).Return(nil).Once().Run(func(args mock.Arguments) {
|
|
updateCancel()
|
|
})
|
|
|
|
// when a cache event comes in we end up recalculating the fallback timer which requires this call
|
|
testAC.mcfg.tlsCfg.On("AutoEncryptCert").Return(&x509.Certificate{
|
|
NotAfter: secondCert.ValidBefore,
|
|
}).Times(2)
|
|
|
|
fallbackCtx, fallbackCancel := context.WithCancel(context.Background())
|
|
|
|
// also testing here that we can change server IPs for ongoing operations
|
|
testAC.mcfg.serverProvider.On("FindLANServer").Once().Return(&metadata.Server{
|
|
Addr: &net.TCPAddr{IP: net.IPv4(198, 18, 23, 2), Port: 8300},
|
|
})
|
|
|
|
// after sending the notification for the cert update another InitialConfiguration RPC
|
|
// will be made to pull down the latest configuration. So we need to set up the response
|
|
// for the second RPC
|
|
populateResponse := func(args mock.Arguments) {
|
|
resp, ok := args.Get(5).(*structs.SignedResponse)
|
|
require.True(t, ok)
|
|
*resp = structs.SignedResponse{
|
|
VerifyServerHostname: true,
|
|
ConnectCARoots: secondRoots,
|
|
IssuedCert: *thirdCert,
|
|
ManualCARoots: testAC.extraCerts,
|
|
}
|
|
|
|
fallbackCancel()
|
|
}
|
|
|
|
expectedRequest := structs.CASignRequest{
|
|
WriteRequest: structs.WriteRequest{Token: testAC.originalToken},
|
|
Datacenter: "dc1",
|
|
// TODO (autoconf) Maybe in the future we should populate a CSR
|
|
// and do some manual parsing/verification of the contents. The
|
|
// bits not having to do with the signing key such as the requested
|
|
// SANs and CN. For now though the mockDirectRPC type will empty
|
|
// the CSR so we have to pass in an empty string to the expectation.
|
|
CSR: "",
|
|
}
|
|
|
|
// the fallback routine to perform auto-encrypt again will need to grab this
|
|
testAC.mcfg.tokens.On("AgentToken").Return(testAC.originalToken).Once()
|
|
|
|
testAC.mcfg.directRPC.On(
|
|
"RPC",
|
|
"dc1",
|
|
"autoconf",
|
|
&net.TCPAddr{IP: net.IPv4(198, 18, 23, 2), Port: 8300},
|
|
"AutoEncrypt.Sign",
|
|
&expectedRequest,
|
|
&structs.SignedResponse{}).Return(nil).Run(populateResponse).Once()
|
|
|
|
testAC.mcfg.expectInitialTLS(t, "autoconf", "dc1", testAC.originalToken, secondCA, &secondRoots, thirdCert, testAC.extraCerts)
|
|
|
|
// after the second RPC we now will use the new certs validity period in the next run loop iteration
|
|
testAC.mcfg.tlsCfg.On("AutoEncryptCert").Return(&x509.Certificate{
|
|
NotAfter: time.Now().Add(10 * time.Minute),
|
|
}).Once()
|
|
|
|
// now that all the mocks are set up we can trigger the whole thing by sending the second expired cert
|
|
// as a cache update event.
|
|
req := leafcert.ConnectCALeafRequest{
|
|
Datacenter: "dc1",
|
|
Agent: "autoconf",
|
|
Token: testAC.originalToken,
|
|
DNSSAN: defaultDNSSANs,
|
|
IPSAN: defaultIPSANs,
|
|
}
|
|
require.True(t, testAC.mcfg.leafCerts.sendNotification(context.Background(), req.Key(), cache.UpdateEvent{
|
|
CorrelationID: leafWatchID,
|
|
Result: secondCert,
|
|
Meta: cache.ResultMeta{
|
|
Index: secondCert.ModifyIndex,
|
|
},
|
|
}))
|
|
|
|
// wait for the TLS certificates to get updated
|
|
require.True(t, waitForChans(100*time.Millisecond, updatedCtx.Done()), "TLS certificates were not updated within the alotted time")
|
|
|
|
// now wait for the fallback routine to be invoked
|
|
require.True(t, waitForChans(100*time.Millisecond, fallbackCtx.Done()), "fallback routines did not get invoked within the alotted time")
|
|
}
|