mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-10-12 01:58:46 +02:00
334 lines
11 KiB
Go
334 lines
11 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"net/netip"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Control-D-Inc/ctrld"
|
|
"github.com/Control-D-Inc/ctrld/internal/controld"
|
|
)
|
|
|
|
// progForRefresh builds a prog that can run a configuration refresh: Firewall
|
|
// Mode on, and a buffered reload channel so a refresh that decides ctrld must
|
|
// reload does not block on a listener that does not exist in a test.
|
|
func progForRefresh() *prog {
|
|
p := progWithAllowList()
|
|
p.rc = &controld.ResolverConfig{}
|
|
p.apiReloadCh = make(chan *ctrld.Config, 1)
|
|
return p
|
|
}
|
|
|
|
// refresh runs one configuration refresh through the handler apiConfigReload
|
|
// uses, which is the point: a test that called applyAllowedDestinations directly
|
|
// would still pass if the refresh path stopped calling it.
|
|
func refresh(t *testing.T, p *prog, forced bool, rc *controld.ResolverConfig) {
|
|
t.Helper()
|
|
p.applyFetchedResolverConfig(context.Background(), discardLogger(), rc, forced, time.Now().Unix())
|
|
}
|
|
|
|
// TestRefreshInternalDomains covers the master-specific refresh handler after
|
|
// apiConfigReload delegates to it, including changes with unchanged excludes.
|
|
func TestRefreshInternalDomains(t *testing.T) {
|
|
for _, forced := range []bool{false, true} {
|
|
name := "scheduled"
|
|
if forced {
|
|
name = "forced"
|
|
}
|
|
t.Run(name, func(t *testing.T) {
|
|
// No platform firewall or service is installed by this fixture.
|
|
p := &prog{rc: &controld.ResolverConfig{}, apiReloadCh: make(chan *ctrld.Config, 1)}
|
|
p.logger.Store(discardLogger())
|
|
for _, step := range []struct {
|
|
name string
|
|
entries []controld.SplitDNS
|
|
reload bool
|
|
}{
|
|
{"absent", nil, false},
|
|
{"add", []controld.SplitDNS{{Domain: "corp.example", Mode: "os"}}, true},
|
|
{"unchanged", []controld.SplitDNS{{Domain: "CORP.EXAMPLE.", Mode: "os"}}, false},
|
|
{"mode", []controld.SplitDNS{{Domain: "corp.example", Mode: "resolvers", Resolvers: []string{"127.0.0.2"}}}, true},
|
|
{"resolver", []controld.SplitDNS{{Domain: "corp.example", Mode: "resolvers", Resolvers: []string{"127.0.0.3"}}}, true},
|
|
{"domain", []controld.SplitDNS{{Domain: "office.example", Mode: "resolvers", Resolvers: []string{"127.0.0.3"}}}, true},
|
|
{"remove", nil, true},
|
|
{"empty unchanged", []controld.SplitDNS{}, false},
|
|
} {
|
|
t.Run(step.name, func(t *testing.T) {
|
|
rc := &controld.ResolverConfig{SplitDNS: step.entries}
|
|
refresh(t, p, forced, rc)
|
|
gotReload := false
|
|
select {
|
|
case cfg := <-p.apiReloadCh:
|
|
gotReload = true
|
|
if cfg != nil {
|
|
t.Fatal("managed change must request config regeneration")
|
|
}
|
|
default:
|
|
}
|
|
if gotReload != step.reload {
|
|
t.Fatalf("reload = %v, want %v", gotReload, step.reload)
|
|
}
|
|
if p.rc != rc {
|
|
t.Fatal("refresh did not retain the authoritative response")
|
|
}
|
|
})
|
|
}
|
|
// Changes to Internal Domains must not override a custom config.
|
|
rc := &controld.ResolverConfig{SplitDNS: []controld.SplitDNS{{Domain: "corp.example", Mode: "os"}}}
|
|
rc.Ctrld.CustomConfig = "custom-config-is-authoritative"
|
|
refresh(t, p, false, rc)
|
|
select {
|
|
case <-p.apiReloadCh:
|
|
t.Fatal("Internal Domains overrode unchanged custom config")
|
|
default:
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRefreshAppliesAllowedDestinations drives the real refresh handler for both
|
|
// the scheduled and the forced path, in the case where nothing else about the
|
|
// configuration changed - no custom config, unchanged exclusions - so the
|
|
// refresh takes its early return. That is where an allowed-destination update is
|
|
// easiest to lose, because the refresh has no other work to do.
|
|
func TestRefreshAppliesAllowedDestinations(t *testing.T) {
|
|
direct := netip.MustParseAddr("203.0.113.10")
|
|
inRange := netip.MustParseAddr("198.51.100.7")
|
|
|
|
for _, forced := range []bool{false, true} {
|
|
name := "scheduled refresh"
|
|
if forced {
|
|
name = "forced refresh"
|
|
}
|
|
t.Run(name, func(t *testing.T) {
|
|
p := progForRefresh()
|
|
|
|
refresh(t, p, forced, &controld.ResolverConfig{
|
|
DestinationIPs: []string{"203.0.113.10", "198.51.100.0/24"},
|
|
})
|
|
if !p.allowList.Contains(direct) || !p.allowList.Contains(inRange) {
|
|
t.Fatalf("refresh did not apply the organization list: %s=%v %s=%v",
|
|
direct, p.allowList.Contains(direct), inRange, p.allowList.Contains(inRange))
|
|
}
|
|
select {
|
|
case cfg := <-p.apiReloadCh:
|
|
t.Fatalf("unchanged configuration signaled a reload (%v)", cfg)
|
|
default:
|
|
}
|
|
|
|
// A later refresh withdraws one entry and keeps the other.
|
|
refresh(t, p, forced, &controld.ResolverConfig{
|
|
DestinationIPs: []string{"203.0.113.10"},
|
|
})
|
|
if p.allowList.Contains(inRange) {
|
|
t.Fatalf("%s still allowed after the refresh that withdrew it", inRange)
|
|
}
|
|
if !p.allowList.Contains(direct) {
|
|
t.Fatalf("%s should still be allowed", direct)
|
|
}
|
|
|
|
// And one clears the list entirely.
|
|
refresh(t, p, forced, &controld.ResolverConfig{})
|
|
if p.allowList.Contains(direct) {
|
|
t.Fatalf("%s still allowed after the refresh that cleared the list", direct)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRefreshAppliesAllowedDestinationsWhenReloading covers the other branch of
|
|
// the same handler: a refresh that also changes the exclusion list, and so
|
|
// signals a ctrld reload, must still apply the destinations - and they must
|
|
// survive the allowlist flush that the reload performs.
|
|
func TestRefreshAppliesAllowedDestinationsWhenReloading(t *testing.T) {
|
|
p := progForRefresh()
|
|
p.rc = &controld.ResolverConfig{Exclude: []string{"example.com"}}
|
|
direct := netip.MustParseAddr("203.0.113.10")
|
|
|
|
refresh(t, p, false, &controld.ResolverConfig{
|
|
Exclude: []string{"example.com", "example.net"},
|
|
DestinationIPs: []string{"203.0.113.10"},
|
|
})
|
|
|
|
select {
|
|
case <-p.apiReloadCh:
|
|
default:
|
|
t.Fatal("exclusion list change did not signal a reload")
|
|
}
|
|
if !p.allowList.Contains(direct) {
|
|
t.Fatalf("%s not allowed after a refresh that reloaded ctrld", direct)
|
|
}
|
|
}
|
|
|
|
// TestRefreshKeepsAllowedDestinationsPendingUntilMirrored pins that the refresh
|
|
// path reports honestly: while platform enforcement is rejecting the change, the
|
|
// refresh leaves it pending and retries it, instead of recording it as applied.
|
|
func TestRefreshKeepsAllowedDestinationsPendingUntilMirrored(t *testing.T) {
|
|
p := progForRefresh()
|
|
var calls []mirrorCall
|
|
failing := true
|
|
stubMirror(t, &calls, &failing)
|
|
|
|
rc := &controld.ResolverConfig{DestinationIPs: []string{"203.0.113.10"}}
|
|
refresh(t, p, false, rc)
|
|
if got := p.pendingDestinations(p.allowList); got != 1 {
|
|
t.Fatalf("pendingDestinations = %d after a rejected mirror, want 1", got)
|
|
}
|
|
|
|
refresh(t, p, false, rc)
|
|
if len(calls) != 2 {
|
|
t.Fatalf("refresh did not retry the rejected change: calls = %v", calls)
|
|
}
|
|
|
|
failing = false
|
|
refresh(t, p, false, rc)
|
|
if got := p.pendingDestinations(p.allowList); got != 0 {
|
|
t.Fatalf("pendingDestinations = %d after the mirror succeeded, want 0", got)
|
|
}
|
|
}
|
|
|
|
// stubResolverConfigFetch replaces the refresh loop's API call for the duration
|
|
// of a test, and points cdUID at a device so apiConfigReload does not return
|
|
// immediately. Each fetch returns the config the test currently wants and
|
|
// reports on fetched, which is how a test knows a refresh cycle has run.
|
|
func stubResolverConfigFetch(t *testing.T, config func() *controld.ResolverConfig, fetched chan<- struct{}) {
|
|
t.Helper()
|
|
origFetch, origUID := fetchResolverConfigFn, cdUID
|
|
t.Cleanup(func() { fetchResolverConfigFn, cdUID = origFetch, origUID })
|
|
|
|
cdUID = "test-uid"
|
|
fetchResolverConfigFn = func(context.Context, *controld.ResolverConfigRequest, bool) (*controld.ResolverConfig, error) {
|
|
rc := config()
|
|
select {
|
|
case fetched <- struct{}{}:
|
|
default:
|
|
}
|
|
return rc, nil
|
|
}
|
|
}
|
|
|
|
// startRefreshLoop runs apiConfigReload in the background and joins it before the
|
|
// test finishes. The loop must not outlive the test: it calls through the same
|
|
// package-level stubs the next test replaces, so a leaked one would both race
|
|
// those globals and act on another test's prog.
|
|
func startRefreshLoop(t *testing.T, p *prog) {
|
|
t.Helper()
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
p.apiConfigReload()
|
|
}()
|
|
t.Cleanup(func() {
|
|
close(p.stopCh)
|
|
select {
|
|
case <-done:
|
|
case <-time.After(5 * time.Second):
|
|
t.Error("apiConfigReload did not stop")
|
|
}
|
|
})
|
|
}
|
|
|
|
// waitForCondition polls until cond holds, failing the test if it never does. The
|
|
// refresh loop runs in its own goroutine, so its effects land asynchronously.
|
|
func waitForCondition(t *testing.T, what string, cond func() bool) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if cond() {
|
|
return
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
t.Fatalf("timed out waiting for %s", what)
|
|
}
|
|
|
|
func TestApiConfigReloadUsesRuntimeMetadata(t *testing.T) {
|
|
originalFetch, originalUID := fetchResolverConfigFn, cdUID
|
|
t.Cleanup(func() {
|
|
fetchResolverConfigFn = originalFetch
|
|
cdUID = originalUID
|
|
})
|
|
cdUID = "test-uid"
|
|
|
|
captured := make(chan *controld.ResolverConfigRequest, 1)
|
|
fetchResolverConfigFn = func(_ context.Context, req *controld.ResolverConfigRequest, _ bool) (*controld.ResolverConfig, error) {
|
|
captured <- req
|
|
return &controld.ResolverConfig{}, nil
|
|
}
|
|
|
|
p := progForRefresh()
|
|
p.cfg = &ctrld.Config{}
|
|
p.stopCh = make(chan struct{})
|
|
p.apiForceReloadCh = make(chan struct{})
|
|
startRefreshLoop(t, p)
|
|
p.apiForceReloadCh <- struct{}{}
|
|
|
|
select {
|
|
case req := <-captured:
|
|
if _, ok := req.Metadata["username"]; ok {
|
|
t.Error("periodic/forced API refresh included username")
|
|
}
|
|
if req.IncludeHostnameHints {
|
|
t.Error("periodic/forced API refresh requested hostname hints")
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("periodic/forced API refresh did not fetch resolver config")
|
|
}
|
|
}
|
|
|
|
// TestApiConfigReloadAppliesAllowedDestinations drives apiConfigReload itself -
|
|
// the loop that owns the refresh ticker and the forced-reload channel - rather
|
|
// than the handler it calls, so the wiring between them is covered too: removing
|
|
// the handler call from the loop must fail a test, not just removing the work
|
|
// inside the handler.
|
|
func TestApiConfigReloadAppliesAllowedDestinations(t *testing.T) {
|
|
direct := netip.MustParseAddr("203.0.113.10")
|
|
|
|
for _, forced := range []bool{true, false} {
|
|
name := "forced reload"
|
|
if !forced {
|
|
name = "refresh ticker"
|
|
}
|
|
t.Run(name, func(t *testing.T) {
|
|
p := progForRefresh()
|
|
p.cfg = &ctrld.Config{}
|
|
refetch := 1 // seconds; only the ticker path waits for it
|
|
p.cfg.Service.RefetchTime = &refetch
|
|
p.stopCh = make(chan struct{})
|
|
p.apiForceReloadCh = make(chan struct{})
|
|
|
|
var mu sync.Mutex
|
|
destinations := []string{"203.0.113.10"}
|
|
fetched := make(chan struct{}, 1)
|
|
stubResolverConfigFetch(t, func() *controld.ResolverConfig {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
return &controld.ResolverConfig{DestinationIPs: append([]string(nil), destinations...)}
|
|
}, fetched)
|
|
|
|
startRefreshLoop(t, p)
|
|
|
|
if forced {
|
|
p.apiForceReloadCh <- struct{}{}
|
|
}
|
|
waitForCondition(t, "the destination to be applied", func() bool {
|
|
return p.allowList.Contains(direct)
|
|
})
|
|
|
|
// The organization withdraws it; the next cycle must take it away.
|
|
mu.Lock()
|
|
destinations = nil
|
|
mu.Unlock()
|
|
|
|
if forced {
|
|
p.apiForceReloadCh <- struct{}{}
|
|
}
|
|
waitForCondition(t, "the withdrawn destination to stop being allowed", func() bool {
|
|
return !p.allowList.Contains(direct)
|
|
})
|
|
})
|
|
}
|
|
}
|