|
|
@@ -0,0 +1,448 @@
|
|
|
+package nebula
|
|
|
+
|
|
|
+import (
|
|
|
+ "context"
|
|
|
+ "encoding/json"
|
|
|
+ "fmt"
|
|
|
+ "io/fs"
|
|
|
+ "log/slog"
|
|
|
+ "net"
|
|
|
+ "net/http"
|
|
|
+ "net/http/httptest"
|
|
|
+ "net/netip"
|
|
|
+ "net/url"
|
|
|
+ "os"
|
|
|
+ "path/filepath"
|
|
|
+ "runtime"
|
|
|
+ "testing"
|
|
|
+ "time"
|
|
|
+
|
|
|
+ "github.com/slackhq/nebula/cert"
|
|
|
+ "github.com/slackhq/nebula/cert_test"
|
|
|
+ "github.com/slackhq/nebula/config"
|
|
|
+ "github.com/stretchr/testify/assert"
|
|
|
+ "github.com/stretchr/testify/require"
|
|
|
+)
|
|
|
+
|
|
|
+func Test_parseInfoAPIListen(t *testing.T) {
|
|
|
+ type testCase struct {
|
|
|
+ listen string
|
|
|
+ addr string
|
|
|
+ wantErr bool
|
|
|
+ }
|
|
|
+ tests := []testCase{
|
|
|
+ {listen: "", wantErr: true},
|
|
|
+ {listen: "unix://", wantErr: true},
|
|
|
+ {listen: "unix://relative/path.sock", wantErr: true},
|
|
|
+ {listen: "not an address", wantErr: true},
|
|
|
+ // tcp host:port addresses are no longer accepted
|
|
|
+ {listen: "127.0.0.1:8085", wantErr: true},
|
|
|
+ {listen: "[::1]:8085", wantErr: true},
|
|
|
+ {listen: "localhost:8085", wantErr: true},
|
|
|
+ }
|
|
|
+
|
|
|
+ // A unix socket path must be absolute for the OS that will bind it, and filepath.IsAbs is
|
|
|
+ // GOOS-specific. CI runs the suite separately on each OS, so assert the platform's own native
|
|
|
+ // absolute path is accepted while the other platform's is rejected.
|
|
|
+ posixPath := "unix:///var/run/nebula.sock"
|
|
|
+ winPath := `unix://C:\nebula\hq.sock`
|
|
|
+ if runtime.GOOS == "windows" {
|
|
|
+ tests = append(tests,
|
|
|
+ testCase{listen: winPath, addr: `C:\nebula\hq.sock`},
|
|
|
+ testCase{listen: posixPath, wantErr: true},
|
|
|
+ )
|
|
|
+ } else {
|
|
|
+ tests = append(tests,
|
|
|
+ testCase{listen: posixPath, addr: "/var/run/nebula.sock"},
|
|
|
+ testCase{listen: winPath, wantErr: true},
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ for _, tt := range tests {
|
|
|
+ addr, err := parseInfoAPIListen(tt.listen)
|
|
|
+ if tt.wantErr {
|
|
|
+ require.Error(t, err, "listen=%q", tt.listen)
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ require.NoError(t, err, "listen=%q", tt.listen)
|
|
|
+ assert.Equal(t, tt.addr, addr, "listen=%q", tt.listen)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func Test_loadInfoAPIConfig(t *testing.T) {
|
|
|
+ c := config.NewC(nil)
|
|
|
+
|
|
|
+ // the listen path must be absolute for the OS running the test (CI is per-OS)
|
|
|
+ listen, wantAddr := "unix:///tmp/hq.sock", "/tmp/hq.sock"
|
|
|
+ if runtime.GOOS == "windows" {
|
|
|
+ listen, wantAddr = `unix://C:\tmp\hq.sock`, `C:\tmp\hq.sock`
|
|
|
+ }
|
|
|
+
|
|
|
+ // absent section means disabled, no error
|
|
|
+ cfg, err := loadInfoAPIConfig(c)
|
|
|
+ require.NoError(t, err)
|
|
|
+ assert.False(t, cfg.enabled)
|
|
|
+
|
|
|
+ // enabled without a listen address is an error
|
|
|
+ setInfoAPIConfig(c, true, "", "")
|
|
|
+ _, err = loadInfoAPIConfig(c)
|
|
|
+ require.Error(t, err)
|
|
|
+
|
|
|
+ // a unix socket gets the default mode
|
|
|
+ setInfoAPIConfig(c, true, listen, "")
|
|
|
+ cfg, err = loadInfoAPIConfig(c)
|
|
|
+ require.NoError(t, err)
|
|
|
+ assert.Equal(t, wantAddr, cfg.addr)
|
|
|
+ assert.Equal(t, fs.FileMode(0o600), cfg.socketMode)
|
|
|
+
|
|
|
+ setInfoAPIConfig(c, true, listen, "0660")
|
|
|
+ cfg, err = loadInfoAPIConfig(c)
|
|
|
+ require.NoError(t, err)
|
|
|
+ assert.Equal(t, fs.FileMode(0o660), cfg.socketMode)
|
|
|
+
|
|
|
+ setInfoAPIConfig(c, true, listen, "withers")
|
|
|
+ _, err = loadInfoAPIConfig(c)
|
|
|
+ require.Error(t, err)
|
|
|
+
|
|
|
+ // mode bits beyond the permission bits are rejected
|
|
|
+ setInfoAPIConfig(c, true, listen, "10600")
|
|
|
+ _, err = loadInfoAPIConfig(c)
|
|
|
+ require.Error(t, err)
|
|
|
+
|
|
|
+ // tcp host:port listen addresses are no longer supported
|
|
|
+ setInfoAPIConfig(c, true, "127.0.0.1:8085", "")
|
|
|
+ _, err = loadInfoAPIConfig(c)
|
|
|
+ require.Error(t, err)
|
|
|
+}
|
|
|
+
|
|
|
+func TestInfoAPIServer_badConfigIsNonFatal(t *testing.T) {
|
|
|
+ // an enabled-but-invalid config must not stop construction; nebula keeps starting and the
|
|
|
+ // feature simply stays disabled until a reload supplies a valid config
|
|
|
+ c := config.NewC(nil)
|
|
|
+ setInfoAPIConfig(c, true, "not-a-unix-socket", "")
|
|
|
+ h := newInfoAPIServerFromConfig(context.Background(), slog.New(slog.DiscardHandler), nil, newHostMap(slog.New(slog.DiscardHandler)), c)
|
|
|
+ require.NotNil(t, h)
|
|
|
+ assert.False(t, h.enabled.Load())
|
|
|
+
|
|
|
+ // no config was recorded, so Start has nothing to bind and is a no-op
|
|
|
+ h.runMu.Lock()
|
|
|
+ assert.Nil(t, h.runCfg)
|
|
|
+ h.runMu.Unlock()
|
|
|
+ h.Start()
|
|
|
+ h.runMu.Lock()
|
|
|
+ assert.Nil(t, h.run)
|
|
|
+ h.runMu.Unlock()
|
|
|
+}
|
|
|
+
|
|
|
+func setInfoAPIConfig(c *config.C, enabled bool, listen, socketMode string) {
|
|
|
+ settings := map[string]any{
|
|
|
+ "enabled": enabled,
|
|
|
+ "listen": listen,
|
|
|
+ }
|
|
|
+ if socketMode != "" {
|
|
|
+ settings["socket_mode"] = socketMode
|
|
|
+ }
|
|
|
+ c.Settings["info_api"] = settings
|
|
|
+}
|
|
|
+
|
|
|
+func newTestInfoAPIServer(t *testing.T) (*infoAPIServer, *config.C) {
|
|
|
+ t.Helper()
|
|
|
+ h := &infoAPIServer{
|
|
|
+ l: slog.New(slog.DiscardHandler),
|
|
|
+ ctx: context.Background(),
|
|
|
+ hostMap: newHostMap(slog.New(slog.DiscardHandler)),
|
|
|
+ }
|
|
|
+ h.hostMap.preferredRanges.Store(&[]netip.Prefix{})
|
|
|
+ return h, config.NewC(nil)
|
|
|
+}
|
|
|
+
|
|
|
+// addTestPeer creates a certificate for a peer owning each addr (as a /24 or /64) and inserts it
|
|
|
+// into the hostmap as an established tunnel
|
|
|
+func addTestPeer(t *testing.T, hm *HostMap, name string, addrs []netip.Addr, unsafeNetworks []netip.Prefix, groups []string) cert.Certificate {
|
|
|
+ t.Helper()
|
|
|
+ networks := make([]netip.Prefix, 0, len(addrs))
|
|
|
+ for _, a := range addrs {
|
|
|
+ bits := 24
|
|
|
+ if a.Is6() {
|
|
|
+ bits = 64
|
|
|
+ }
|
|
|
+ networks = append(networks, netip.PrefixFrom(a, bits))
|
|
|
+ }
|
|
|
+ ca, _, caKey, _ := cert_test.NewTestCaCert(cert.Version2, cert.Curve_CURVE25519, time.Time{}, time.Time{}, nil, nil, nil)
|
|
|
+ crt, _, _, _ := cert_test.NewTestCert(cert.Version2, cert.Curve_CURVE25519, ca, caKey, name, time.Time{}, time.Time{}, networks, unsafeNetworks, groups)
|
|
|
+ fp, err := crt.Fingerprint()
|
|
|
+ require.NoError(t, err)
|
|
|
+
|
|
|
+ hm.unlockedAddHostInfo(&HostInfo{
|
|
|
+ ConnectionState: &ConnectionState{
|
|
|
+ peerCert: &cert.CachedCertificate{Certificate: crt, Fingerprint: fp},
|
|
|
+ },
|
|
|
+ vpnAddrs: addrs,
|
|
|
+ relayState: RelayState{
|
|
|
+ relayForByAddr: map[netip.Addr]*Relay{},
|
|
|
+ relayForByIdx: map[uint32]*Relay{},
|
|
|
+ },
|
|
|
+ }, &Interface{})
|
|
|
+ return crt
|
|
|
+}
|
|
|
+
|
|
|
+func getHost(t *testing.T, h *infoAPIServer, addrParam string) (int, map[string]any) {
|
|
|
+ t.Helper()
|
|
|
+ r := httptest.NewRequest(http.MethodGet, "/v1/host?addr="+url.QueryEscape(addrParam), nil)
|
|
|
+ w := httptest.NewRecorder()
|
|
|
+ h.handleHost(w, r)
|
|
|
+ return decodeResponse(t, w)
|
|
|
+}
|
|
|
+
|
|
|
+func decodeResponse(t *testing.T, w *httptest.ResponseRecorder) (int, map[string]any) {
|
|
|
+ t.Helper()
|
|
|
+ assert.Equal(t, "application/json", w.Header().Get("Content-Type"))
|
|
|
+ var body map[string]any
|
|
|
+ require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
|
|
+ return w.Code, body
|
|
|
+}
|
|
|
+
|
|
|
+func TestInfoAPIServer_handleHost(t *testing.T) {
|
|
|
+ h, _ := newTestInfoAPIServer(t)
|
|
|
+ h.pki = newTestPKI(t, "self", []netip.Addr{netip.MustParseAddr("10.0.0.1")})
|
|
|
+
|
|
|
+ peerV4 := netip.MustParseAddr("10.0.0.99")
|
|
|
+ peerV6 := netip.MustParseAddr("fd00::99")
|
|
|
+ addTestPeer(t, h.hostMap, "laptop-alice", []netip.Addr{peerV4, peerV6},
|
|
|
+ []netip.Prefix{netip.MustParsePrefix("192.168.50.0/24")}, []string{"eng", "ssh"})
|
|
|
+ addTestPeer(t, h.hostMap, "groupless", []netip.Addr{netip.MustParseAddr("10.0.0.77")}, nil, nil)
|
|
|
+
|
|
|
+ // an established peer comes back with its full identity
|
|
|
+ code, body := getHost(t, h, "10.0.0.99")
|
|
|
+ require.Equal(t, http.StatusOK, code)
|
|
|
+ assert.Equal(t, "laptop-alice", body["name"])
|
|
|
+ assert.Equal(t, []any{"10.0.0.99", "fd00::99"}, body["vpnAddrs"])
|
|
|
+ assert.Equal(t, []any{"10.0.0.99/24", "fd00::99/64"}, body["networks"])
|
|
|
+ assert.Equal(t, []any{"192.168.50.0/24"}, body["unsafeNetworks"])
|
|
|
+ assert.Equal(t, []any{"eng", "ssh"}, body["groups"])
|
|
|
+ assert.NotEmpty(t, body["fingerprint"])
|
|
|
+ assert.Equal(t, "2", fmt.Sprintf("%v", body["certVersion"]))
|
|
|
+ assert.NotEmpty(t, body["notBefore"])
|
|
|
+ assert.NotEmpty(t, body["notAfter"])
|
|
|
+
|
|
|
+ // empty cert slices marshal as [] rather than null
|
|
|
+ code, body = getHost(t, h, "10.0.0.77")
|
|
|
+ require.Equal(t, http.StatusOK, code)
|
|
|
+ require.NotNil(t, body["groups"])
|
|
|
+ assert.Empty(t, body["groups"])
|
|
|
+ require.NotNil(t, body["unsafeNetworks"])
|
|
|
+ assert.Empty(t, body["unsafeNetworks"])
|
|
|
+
|
|
|
+ // a port in addr is ignored so RemoteAddr can be passed through directly, including the
|
|
|
+ // bracketed v6 and 4in6 forms
|
|
|
+ for _, q := range []string{"10.0.0.99:54321", "[fd00::99]:443", "::ffff:10.0.0.99"} {
|
|
|
+ code, body = getHost(t, h, q)
|
|
|
+ require.Equal(t, http.StatusOK, code, "addr=%q", q)
|
|
|
+ assert.Equal(t, "laptop-alice", body["name"], "addr=%q", q)
|
|
|
+ }
|
|
|
+
|
|
|
+ // our own address answers from the local cert state
|
|
|
+ code, body = getHost(t, h, "10.0.0.1")
|
|
|
+ require.Equal(t, http.StatusOK, code)
|
|
|
+ assert.Equal(t, "self", body["name"])
|
|
|
+
|
|
|
+ code, body = getHost(t, h, "10.0.0.42")
|
|
|
+ assert.Equal(t, http.StatusNotFound, code)
|
|
|
+ assert.NotEmpty(t, body["error"])
|
|
|
+
|
|
|
+ // a tunnel mid-teardown (no peer cert) is treated as unknown
|
|
|
+ h.hostMap.unlockedAddHostInfo(&HostInfo{
|
|
|
+ ConnectionState: &ConnectionState{},
|
|
|
+ vpnAddrs: []netip.Addr{netip.MustParseAddr("10.0.0.66")},
|
|
|
+ relayState: RelayState{
|
|
|
+ relayForByAddr: map[netip.Addr]*Relay{},
|
|
|
+ relayForByIdx: map[uint32]*Relay{},
|
|
|
+ },
|
|
|
+ }, &Interface{})
|
|
|
+ code, _ = getHost(t, h, "10.0.0.66")
|
|
|
+ assert.Equal(t, http.StatusNotFound, code)
|
|
|
+
|
|
|
+ code, body = getHost(t, h, "not-an-address")
|
|
|
+ assert.Equal(t, http.StatusBadRequest, code)
|
|
|
+ assert.NotEmpty(t, body["error"])
|
|
|
+
|
|
|
+ r := httptest.NewRequest(http.MethodGet, "/v1/host", nil)
|
|
|
+ w := httptest.NewRecorder()
|
|
|
+ h.handleHost(w, r)
|
|
|
+ code, body = decodeResponse(t, w)
|
|
|
+ assert.Equal(t, http.StatusBadRequest, code)
|
|
|
+ assert.NotEmpty(t, body["error"])
|
|
|
+}
|
|
|
+
|
|
|
+func TestInfoAPIServer_handleSelf(t *testing.T) {
|
|
|
+ h, _ := newTestInfoAPIServer(t)
|
|
|
+ h.pki = newTestPKI(t, "lighthouse", []netip.Addr{netip.MustParseAddr("10.0.0.1")})
|
|
|
+
|
|
|
+ r := httptest.NewRequest(http.MethodGet, "/v1/self", nil)
|
|
|
+ w := httptest.NewRecorder()
|
|
|
+ h.handleSelf(w, r)
|
|
|
+ code, body := decodeResponse(t, w)
|
|
|
+ require.Equal(t, http.StatusOK, code)
|
|
|
+ assert.Equal(t, "lighthouse", body["name"])
|
|
|
+ assert.Equal(t, []any{"10.0.0.1"}, body["vpnAddrs"])
|
|
|
+
|
|
|
+ // no cert state available should be an error, not a panic
|
|
|
+ h.pki = nil
|
|
|
+ w = httptest.NewRecorder()
|
|
|
+ h.handleSelf(w, r)
|
|
|
+ code, body = decodeResponse(t, w)
|
|
|
+ assert.Equal(t, http.StatusInternalServerError, code)
|
|
|
+ assert.NotEmpty(t, body["error"])
|
|
|
+}
|
|
|
+
|
|
|
+func unixHTTPClient(path string) *http.Client {
|
|
|
+ return &http.Client{
|
|
|
+ Timeout: time.Second,
|
|
|
+ Transport: &http.Transport{
|
|
|
+ DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
|
|
+ return (&net.Dialer{}).DialContext(ctx, "unix", path)
|
|
|
+ },
|
|
|
+ },
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// waitForServe polls until a GET /v1/self through client succeeds
|
|
|
+func waitForServe(t *testing.T, client *http.Client) {
|
|
|
+ t.Helper()
|
|
|
+ waitFor(t, func() bool {
|
|
|
+ resp, err := client.Get("http://hostquery/v1/self")
|
|
|
+ if err != nil {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ resp.Body.Close()
|
|
|
+ return resp.StatusCode == http.StatusOK
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+func skipIfNoUnixSockets(t *testing.T) {
|
|
|
+ t.Helper()
|
|
|
+ if runtime.GOOS == "windows" {
|
|
|
+ t.Skip("unix socket tests are not supported on windows CI")
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func TestInfoAPIServer_unixLifecycle(t *testing.T) {
|
|
|
+ skipIfNoUnixSockets(t)
|
|
|
+ h, c := newTestInfoAPIServer(t)
|
|
|
+ h.pki = newTestPKI(t, "self", []netip.Addr{netip.MustParseAddr("10.0.0.1")})
|
|
|
+
|
|
|
+ sock := filepath.Join(t.TempDir(), "hq.sock")
|
|
|
+ setInfoAPIConfig(c, true, "unix://"+sock, "")
|
|
|
+ require.NoError(t, h.reload(c, true))
|
|
|
+
|
|
|
+ done := make(chan struct{})
|
|
|
+ go func() {
|
|
|
+ h.Start()
|
|
|
+ close(done)
|
|
|
+ }()
|
|
|
+
|
|
|
+ client := unixHTTPClient(sock)
|
|
|
+ waitForServe(t, client)
|
|
|
+
|
|
|
+ fi, err := os.Stat(sock)
|
|
|
+ require.NoError(t, err)
|
|
|
+ assert.Equal(t, fs.FileMode(0o600), fi.Mode().Perm())
|
|
|
+
|
|
|
+ resp, err := client.Get("http://hostquery/v1/host?addr=10.0.0.1")
|
|
|
+ require.NoError(t, err)
|
|
|
+ resp.Body.Close()
|
|
|
+ assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
|
+
|
|
|
+ h.Stop()
|
|
|
+ select {
|
|
|
+ case <-done:
|
|
|
+ case <-time.After(5 * time.Second):
|
|
|
+ t.Fatal("Start did not return after Stop")
|
|
|
+ }
|
|
|
+ _, err = os.Stat(sock)
|
|
|
+ assert.True(t, os.IsNotExist(err), "socket file should be unlinked on shutdown")
|
|
|
+}
|
|
|
+
|
|
|
+func TestInfoAPIServer_staleSocket(t *testing.T) {
|
|
|
+ skipIfNoUnixSockets(t)
|
|
|
+ h, _ := newTestInfoAPIServer(t)
|
|
|
+ sock := filepath.Join(t.TempDir(), "hq.sock")
|
|
|
+
|
|
|
+ // simulate an unclean exit, a leftover socket file with no listener
|
|
|
+ stale, err := net.ListenUnix("unix", &net.UnixAddr{Name: sock, Net: "unix"})
|
|
|
+ require.NoError(t, err)
|
|
|
+ stale.SetUnlinkOnClose(false)
|
|
|
+ require.NoError(t, stale.Close())
|
|
|
+ _, err = os.Stat(sock)
|
|
|
+ require.NoError(t, err, "stale socket file should exist")
|
|
|
+
|
|
|
+ cfg := infoAPIConfig{addr: sock, socketMode: 0o600}
|
|
|
+ ln, err := h.listen(cfg)
|
|
|
+ require.NoError(t, err, "a stale socket should be removed and rebound")
|
|
|
+ require.NoError(t, ln.Close())
|
|
|
+}
|
|
|
+
|
|
|
+func TestInfoAPIServer_existingFileNotReplaced(t *testing.T) {
|
|
|
+ skipIfNoUnixSockets(t)
|
|
|
+ h, _ := newTestInfoAPIServer(t)
|
|
|
+ path := filepath.Join(t.TempDir(), "hq.sock")
|
|
|
+ require.NoError(t, os.WriteFile(path, []byte("precious"), 0o600))
|
|
|
+
|
|
|
+ cfg := infoAPIConfig{addr: path, socketMode: 0o600}
|
|
|
+ _, err := h.listen(cfg)
|
|
|
+ require.Error(t, err, "a non-socket file at the listen path must not be replaced")
|
|
|
+
|
|
|
+ content, err := os.ReadFile(path)
|
|
|
+ require.NoError(t, err)
|
|
|
+ assert.Equal(t, "precious", string(content))
|
|
|
+}
|
|
|
+
|
|
|
+func TestInfoAPIServer_reload(t *testing.T) {
|
|
|
+ skipIfNoUnixSockets(t)
|
|
|
+ h, c := newTestInfoAPIServer(t)
|
|
|
+ h.pki = newTestPKI(t, "self", []netip.Addr{netip.MustParseAddr("10.0.0.1")})
|
|
|
+ dir := t.TempDir()
|
|
|
+ sock1 := filepath.Join(dir, "hq1.sock")
|
|
|
+ sock2 := filepath.Join(dir, "hq2.sock")
|
|
|
+
|
|
|
+ // initial reload only records config, Control.Start is what launches the runtime
|
|
|
+ setInfoAPIConfig(c, false, "unix://"+sock1, "")
|
|
|
+ require.NoError(t, h.reload(c, true))
|
|
|
+ assert.False(t, h.enabled.Load())
|
|
|
+ h.runMu.Lock()
|
|
|
+ assert.Nil(t, h.run)
|
|
|
+ h.runMu.Unlock()
|
|
|
+
|
|
|
+ // enabling via reload spawns the listener
|
|
|
+ setInfoAPIConfig(c, true, "unix://"+sock1, "")
|
|
|
+ require.NoError(t, h.reload(c, false))
|
|
|
+ waitForServe(t, unixHTTPClient(sock1))
|
|
|
+
|
|
|
+ // changing the listen path restarts on the new address
|
|
|
+ setInfoAPIConfig(c, true, "unix://"+sock2, "")
|
|
|
+ require.NoError(t, h.reload(c, false))
|
|
|
+ waitForServe(t, unixHTTPClient(sock2))
|
|
|
+ waitFor(t, func() bool {
|
|
|
+ _, err := os.Stat(sock1)
|
|
|
+ return os.IsNotExist(err)
|
|
|
+ })
|
|
|
+
|
|
|
+ // reloading an unchanged config does not restart the runtime
|
|
|
+ h.runMu.Lock()
|
|
|
+ rt := h.run
|
|
|
+ h.runMu.Unlock()
|
|
|
+ require.NoError(t, h.reload(c, false))
|
|
|
+ h.runMu.Lock()
|
|
|
+ assert.Same(t, rt, h.run)
|
|
|
+ h.runMu.Unlock()
|
|
|
+
|
|
|
+ // disabling stops the listener
|
|
|
+ setInfoAPIConfig(c, false, "unix://"+sock2, "")
|
|
|
+ require.NoError(t, h.reload(c, false))
|
|
|
+ assert.False(t, h.enabled.Load())
|
|
|
+ waitFor(t, func() bool {
|
|
|
+ h.runMu.Lock()
|
|
|
+ defer h.runMu.Unlock()
|
|
|
+ return h.run == nil
|
|
|
+ })
|
|
|
+}
|