mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-10-12 01:58:46 +02:00
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.
This commit is contained in:
1 parent
c7b2134977
commit
cee5bbe7ae
3 files changed
+109
-2
No files matched your search
@@ -224,7 +224,7 @@ func persistManagedConfig(cfg *ctrld.Config, logger *ctrld.Logger) {
|
||||
return
|
||||
}
|
||||
if err := recordManagedConfig(cdUID); err != nil {
|
||||
logger.Warn().Err(err).Msg("could not record that this configuration came from the API")
|
||||
logger.Warn().Err(err).Msg("Could not record that this configuration came from the API")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,6 +246,6 @@ func recordManagedConfigForStart(fetchedThisRun, wasManagedBeforeWrite bool) {
|
||||
if err := recordManagedConfig(cdUID); err != nil {
|
||||
// Not fatal: this run is fine. It only costs the next start its
|
||||
// fallback if the API is in maintenance then.
|
||||
mainLog.Load().Warn().Err(err).Msg("could not record that this configuration came from the API")
|
||||
mainLog.Load().Warn().Err(err).Msg("Could not record that this configuration came from the API")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -218,6 +218,17 @@ func (p *prog) syncAllowedDestinations() {
|
||||
var entries []string
|
||||
if rc != nil {
|
||||
entries = rc.DestinationIPs
|
||||
} else if al != nil && p.startedInAPIMaintenance.Load() {
|
||||
// A maintenance fallback start holds no resolver config, and the
|
||||
// organization's list is not persisted with the configuration on disk,
|
||||
// so the exception set below is empty: raw-IP destinations the
|
||||
// organization allowed stay blocked until the API answers and the
|
||||
// recovery refresh re-applies the list. Failing closed is deliberate -
|
||||
// the list is the organization's network topology, which is kept out of
|
||||
// persisted state - so say so in the retained journal rather than leave
|
||||
// the blocked destinations unexplained.
|
||||
journal(p.Warn()).Str("reason", "api_maintenance").
|
||||
Msg("Firewall: organization allowed destinations are not enforced until the Control D API answers; started on the configuration on disk during maintenance")
|
||||
}
|
||||
p.applyAllowedDestinations(al, entries)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user