Files
ctrld/cmd/cli/api_maintenance_firewall_test.go
T
Dev Scribe cee5bbe7ae fix(cli): journal that allowed destinations fail closed on a maintenance start
A maintenance fallback start holds no resolver config, and Allowed
Destinations (a master-only Firewall Mode feature) are not persisted with
the configuration on disk. Under Firewall Mode the exception set is
therefore empty until the API answers, so raw-IP destinations the
organization allowed stay blocked for the window, and a persisted pf
table from the previous run is replaced by the empty set.

Keep that fail-closed on purpose: the list is the organization's network
topology and is kept out of persisted state. Journal a Warn when a
fallback start applies the empty set under Firewall Mode so the blocked
destinations are explained, and pin that the first answer after recovery
re-applies the list.

Also capitalize the two managed-config marker log messages to match the
rest of master.
2026-10-07 14:18:04 +07:00

97 lines
3.4 KiB
Go

package cli
import (
"context"
"net/netip"
"testing"
"time"
"github.com/Control-D-Inc/ctrld"
"github.com/Control-D-Inc/ctrld/internal/controld"
)
// TestMaintenanceFallbackFirewallAllowedDestinations covers Firewall Mode on a
// maintenance fallback start. The organization's Allowed Destinations exist
// only in the API answer, which such a run does not have, so the exception set
// is empty until the API returns. That is a deliberate fail-closed state: it
// must be named in the retained journal, and the first answer after recovery
// must re-apply the list even though nothing else is compared as changed.
func TestMaintenanceFallbackFirewallAllowedDestinations(t *testing.T) {
allowed := netip.MustParseAddr("203.0.113.10")
t.Run("fallback start journals that allowed destinations are not enforced", func(t *testing.T) {
logs := captureDebugMainLog(t)
p := progWithAllowList()
p.logger.Store(mainLog.Load())
p.startedInAPIMaintenance.Store(true)
p.syncAllowedDestinations()
events := jsonLogEvents(t, logs, "Firewall: organization allowed destinations are not enforced until the Control D API answers; started on the configuration on disk during maintenance")
if len(events) != 1 {
t.Fatalf("fail-closed warnings: got %d, want 1", len(events))
}
if events[0]["level"] != "warn" {
t.Errorf("level = %v, want warn", events[0]["level"])
}
if events[0]["reason"] != "api_maintenance" {
t.Errorf("reason = %v, want api_maintenance", events[0]["reason"])
}
if events[0]["journal"] != true {
t.Error("the fail-closed warning is not retained in the journal")
}
if p.allowList.Contains(allowed) {
t.Fatalf("%s allowed with no resolver config", allowed)
}
})
t.Run("a normal start with no resolver config stays quiet", func(t *testing.T) {
logs := captureDebugMainLog(t)
p := progWithAllowList()
p.logger.Store(mainLog.Load())
p.syncAllowedDestinations()
if got := len(jsonLogEvents(t, logs, "Firewall: organization allowed destinations are not enforced until the Control D API answers; started on the configuration on disk during maintenance")); got != 0 {
t.Fatalf("fail-closed warnings outside maintenance: got %d, want 0", got)
}
})
t.Run("firewall mode off stays quiet", func(t *testing.T) {
logs := captureDebugMainLog(t)
p := &prog{}
p.logger.Store(mainLog.Load())
p.startedInAPIMaintenance.Store(true)
p.syncAllowedDestinations()
if got := len(jsonLogEvents(t, logs, "Firewall: organization allowed destinations are not enforced until the Control D API answers; started on the configuration on disk during maintenance")); got != 0 {
t.Fatalf("fail-closed warnings with Firewall Mode off: got %d, want 0", got)
}
})
t.Run("pending recovery re-applies the list", func(t *testing.T) {
p := progWithAllowList()
p.apiReloadCh = make(chan *ctrld.Config, 1)
p.startedInAPIMaintenance.Store(true)
// The fallback start applied an empty set.
p.syncAllowedDestinations()
if p.allowList.Contains(allowed) {
t.Fatalf("%s allowed before recovery", allowed)
}
p.applyFetchedResolverConfig(context.Background(), discardLogger(), &controld.ResolverConfig{
DestinationIPs: []string{"203.0.113.10"},
}, false, time.Now().Unix())
if !p.allowList.Contains(allowed) {
t.Fatalf("recovery did not re-apply the organization list: %s blocked", allowed)
}
select {
case <-p.apiReloadCh:
default:
t.Fatal("pending recovery did not deliver the answer to the apply path")
}
})
}