From 08e556854d19bef21ded2394ac15015bdc4a359f Mon Sep 17 00:00:00 2001 From: Renn F Date: Thu, 8 Oct 2026 06:17:06 +0200 Subject: [PATCH] feat(echo): the adapter lifecycle surface Ports the fastapi-guard middleware lifecycle ops the FEATURE_MATRIX_GO adapter row left PARTIAL/MISSING (guard/middleware.py): mark_initialized (:131), get_initialization_status (:705), reset (:685), agent_stats (:256) and refresh_cloud_ip_ranges (:661), as explicit functions over the engine handle (the same seam as GuardWebSocket and AddStatusRoute; the reference ops are methods on SecurityMiddleware because the Python middleware owns its machinery, while this adapter's machinery is the engine New receives). The engine side (Initialize/Close, MarkInitialized, AgentStats with the AgentStatsProvider seam) landed in guard-core-go #66; the agent-side get_stats dict view in guard-agent-go #26. - MarkInitialized: a warmed engine makes Initialize a no-op. - GetInitializationStatus: the payload AddStatusRoute serves. - Reset: the rate limiter's windows (redis and in-memory). - AgentStats: enabled/degraded merged with the wired handler's stats. - RefreshCloudIPRanges: the redis-backed refresh at the configured TTL (the reference cloud_handler.refresh_async) or the in-memory refresh (the reference cloud_handler.refresh); no blocked providers is the reference's no-op early return. --- go.mod | 2 +- go.sum | 4 +- lifecycle.go | 79 +++++++++++++++++++++ lifecycle_integration_test.go | 66 +++++++++++++++++ lifecycle_test.go | 129 ++++++++++++++++++++++++++++++++++ 5 files changed, 277 insertions(+), 3 deletions(-) create mode 100644 lifecycle.go create mode 100644 lifecycle_integration_test.go create mode 100644 lifecycle_test.go diff --git a/go.mod b/go.mod index d0a9fb3..ec717de 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.26.0 require ( github.com/labstack/echo/v4 v4.16.0 - github.com/rennf93/guard-core-go/v4 v4.3.2-0.20261007214542-f0bb238e52f7 + github.com/rennf93/guard-core-go/v4 v4.3.2-0.20261008041134-d630201abe44 ) require ( diff --git a/go.sum b/go.sum index 8c06805..156f701 100644 --- a/go.sum +++ b/go.sum @@ -24,8 +24,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0= github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4= -github.com/rennf93/guard-core-go/v4 v4.3.2-0.20261007214542-f0bb238e52f7 h1:yO6a1Cb4Gyxq6H3IwnheNRN28cydOqSnvq3223V3tIU= -github.com/rennf93/guard-core-go/v4 v4.3.2-0.20261007214542-f0bb238e52f7/go.mod h1:ThG64TPXFAbFtoxKZyusi9k9ZNrj/BXR7Cr7fJmpdys= +github.com/rennf93/guard-core-go/v4 v4.3.2-0.20261008041134-d630201abe44 h1:K7KYyNMZcnz/koeD4Q0MlQLdfZ2rHTH2NeY3yExE6io= +github.com/rennf93/guard-core-go/v4 v4.3.2-0.20261008041134-d630201abe44/go.mod h1:ThG64TPXFAbFtoxKZyusi9k9ZNrj/BXR7Cr7fJmpdys= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= diff --git a/lifecycle.go b/lifecycle.go new file mode 100644 index 0000000..48abe72 --- /dev/null +++ b/lifecycle.go @@ -0,0 +1,79 @@ +package echo + +import ( + "errors" + + "github.com/rennf93/guard-core-go/v4/guardcore" +) + +// The adapter lifecycle surface, ported from fastapi-guard +// guard/middleware.py: mark_initialized (:131), get_initialization_status +// (:705), reset (:685), agent_stats (:256) and refresh_cloud_ip_ranges +// (:661). The reference middleware owns its machinery internally, so the +// ops are methods on SecurityMiddleware; this adapter's handle is the +// engine it wraps (New receives it), so the ops hang off explicit +// functions over the engine, the same seam as GuardWebSocket and +// AddStatusRoute. The engine side of the lifecycle (Initialize/Close, +// MarkInitialized, AgentStats) lives in guardcore. + +// MarkInitialized marks the engine initialized without running the +// startup I/O (the reference mark_initialized: a stack warmed externally +// tells the middleware to consider itself initialized); a later +// engine.Initialize becomes a no-op. +func MarkInitialized(engine *guardcore.Engine) { + if engine == nil { + return + } + engine.MarkInitialized() +} + +// GetInitializationStatus snapshots the engine's initialization surface +// (the reference get_initialization_status serving the handler +// initializer's payload): the cloud-provider table, the geo-ip snapshot +// and the redis probe. AddStatusRoute serves the same payload over HTTP. +func GetInitializationStatus(engine *guardcore.Engine) (InitializationStatus, error) { + if engine == nil { + return InitializationStatus{}, errors.New("engine must not be nil") + } + return initializationStatus(engine), nil +} + +// Reset drops the rate limiter's state (the reference reset, which awaits +// rate_limit_handler.reset): the redis sliding windows and the in-memory +// fallback counters. +func Reset(engine *guardcore.Engine) { + if engine == nil || engine.RateLimit == nil { + return + } + engine.RateLimit.Reset() +} + +// AgentStats mirrors the reference agent_stats property: agentless +// answers {"enabled": false, "degraded": }; a wired handler +// answers {"enabled": true, "degraded": false} merged with its +// AgentStats() dict when it provides the stats surface. +func AgentStats(engine *guardcore.Engine) map[string]any { + if engine == nil { + return map[string]any{"enabled": false, "degraded": false} + } + return engine.AgentStats() +} + +// RefreshCloudIPRanges refreshes the blocked cloud providers' ranges on +// demand (the reference refresh_cloud_ip_ranges): with redis configured +// the refresh runs through the redis-backed store at the configured TTL +// (the reference cloud_handler.refresh_async), otherwise in memory (the +// reference cloud_handler.refresh). A config without blocked providers +// is a no-op, exactly like the reference early return. +func RefreshCloudIPRanges(engine *guardcore.Engine) error { + if engine == nil { + return errors.New("engine must not be nil") + } + if len(engine.Config.BlockCloudProviders) == 0 { + return nil + } + if engine.Config.EnableRedis { + return engine.Cloud.InitializeRedis(engine.Redis, engine.Config.BlockCloudProviders, engine.Config.CloudIPRefreshInterval) + } + return engine.Cloud.RefreshAsync(engine.Config.BlockCloudProviders, engine.Config.CloudIPRefreshInterval) +} diff --git a/lifecycle_integration_test.go b/lifecycle_integration_test.go new file mode 100644 index 0000000..919853d --- /dev/null +++ b/lifecycle_integration_test.go @@ -0,0 +1,66 @@ +//go:build integration + +package echo + +import ( + "context" + "os" + "testing" + + "github.com/rennf93/guard-core-go/v4/guardcore" +) + +// The redis arm of refresh_cloud_ip_ranges: the refresh runs through the +// redis-backed store (the reference cloud_handler.refresh_async with the +// configured TTL), answering from the cached ranges without a fetch. +func TestRefreshCloudIPRangesWithRedis(t *testing.T) { + host := os.Getenv("REDIS_HOST") + if host == "" { + t.Skip("REDIS_HOST not set") + } + cfg := guardcore.DefaultSecurityConfig() + cfg.EnableRedis = true + cfg.RedisURL = "redis://" + host + ":6379" + cfg.RedisPrefix = "guard_core_echo_test:" + cfg.RedisFailOpen = false + cfg.BlockCloudProviders = []string{"AWS"} + engine, err := guardcore.NewEngine(cfg) + if err != nil { + t.Fatalf("engine: %v", err) + } + // A fresh manager keeps the shared package default untouched for the + // other tests; the pinned fetcher keeps the startup refresh away from + // the provider registry (the fetch failure is the reference's + // logged-and-tolerated arm). + engine.Cloud = guardcore.NewCloudManager() + engine.Cloud.SetRangeFetcher(func(provider string) ([]string, map[string]string, error) { + return nil, nil, context.Canceled + }) + defer engine.Cloud.SetRangeFetcher(nil) + if err := engine.Initialize(); err != nil { + t.Fatalf("engine initialize: %v", err) + } + t.Cleanup(func() { + _, _ = engine.Redis.DeletePattern("cloud_ip_v2:*") + _, _ = engine.Redis.DeletePattern("banned_ips:*") + _, _ = engine.Redis.DeletePattern("rate_limit:rate:*") + _ = engine.Close() + }) + seed := guardcore.NewRedisCloudIPStore(engine.Redis) + if err := seed.Set("AWS", []string{"198.51.100.0/24"}, 300); err != nil { + t.Fatalf("seed cache: %v", err) + } + // The redis-backed refresh answers from the cache (the reference + // refresh_async cached arm), so the seeded entry must be the one + // enforced afterwards. + if err := RefreshCloudIPRanges(engine); err != nil { + t.Fatalf("the redis-backed refresh must answer from the cache, got %v", err) + } + status := engine.Cloud.Status()["AWS"] + if !status.Ready || status.Entries != 1 { + t.Fatalf("the cached range must be installed by the refresh, got %+v", status) + } + if !engine.Cloud.IsCloudIP("198.51.100.7", []string{"AWS"}) { + t.Fatal("the cached range must be enforceable after the refresh") + } +} diff --git a/lifecycle_test.go b/lifecycle_test.go new file mode 100644 index 0000000..385712b --- /dev/null +++ b/lifecycle_test.go @@ -0,0 +1,129 @@ +package echo + +// The adapter lifecycle surface tests: mark_initialized / +// get_initialization_status / reset / agent_stats / refresh_cloud_ip_ranges +// (fastapi-guard guard/middleware.py), each over the engine handle. + +import ( + "testing" + + "github.com/rennf93/guard-core-go/v4/guardcore" +) + +type statsAgentHandler struct { + guardcore.AgentHandlerFunc + stats map[string]any +} + +func (s *statsAgentHandler) AgentStats() map[string]any { return s.stats } + +type bareAgentHandler struct{ guardcore.AgentHandlerFunc } + +func TestMarkInitializedSkipsStartupIO(t *testing.T) { + cfg := guardcore.DefaultSecurityConfig() + cfg.EnableRedis = true + cfg.RedisURL = "redis://127.0.0.1:1" + cfg.RedisFailOpen = false + engine, err := guardcore.NewEngine(cfg) + if err != nil { + t.Fatalf("engine: %v", err) + } + // The redis endpoint is unreachable, so a real startup would fail + // closed: marking initialized must make Initialize a no-op. + MarkInitialized(engine) + if err := engine.Initialize(); err != nil { + t.Fatalf("a marked-initialized engine must skip the startup I/O, got %v", err) + } + MarkInitialized(nil) // the defensive no-op arm +} + +func TestGetInitializationStatus(t *testing.T) { + if _, err := GetInitializationStatus(nil); err == nil { + t.Fatal("a nil engine must error") + } + engine := newTestEngine(t, nil) + status, err := GetInitializationStatus(engine) + if err != nil { + t.Fatalf("status: %v", err) + } + if status.CloudProviders == nil { + t.Fatal("the payload must carry the cloud_providers table") + } +} + +func TestResetDropsRateLimitState(t *testing.T) { + engine := newTestEngine(t, func(c *guardcore.SecurityConfig) { + c.RateLimit = 1 + c.RateLimitWindow = 60 + }) + first, err := engine.RateLimit.CheckRateLimitByIP("203.0.113.77", "reset-test") + if err != nil || !first { + t.Fatalf("the first request must pass, got (%v, %v)", first, err) + } + second, err := engine.RateLimit.CheckRateLimitByIP("203.0.113.77", "reset-test") + if err != nil || second { + t.Fatalf("the second request must trip the limit, got (%v, %v)", second, err) + } + Reset(engine) + third, err := engine.RateLimit.CheckRateLimitByIP("203.0.113.77", "reset-test") + if err != nil || !third { + t.Fatalf("after the reset the window must be dropped, got (%v, %v)", third, err) + } + Reset(nil) // the defensive no-op arm + Reset(&guardcore.Engine{}) // the nil-manager arm +} + +func TestAgentStats(t *testing.T) { + if stats := AgentStats(nil); stats["enabled"] != false { + t.Fatalf("a nil engine must report disabled, got %v", stats) + } + agentless := newTestEngine(t, nil) + if stats := AgentStats(agentless); stats["enabled"] != false || stats["degraded"] != false { + t.Fatalf("an agentless engine must report disabled and not degraded, got %v", stats) + } + wired := newTestEngine(t, func(c *guardcore.SecurityConfig) { + c.EnableAgent = true + c.AgentHandler = &statsAgentHandler{stats: map[string]any{"running": true}} + }) + stats := AgentStats(wired) + if stats["enabled"] != true || stats["running"] != true { + t.Fatalf("a wired stats handler must merge its counters, got %v", stats) + } + bare := newTestEngine(t, func(c *guardcore.SecurityConfig) { + c.EnableAgent = true + c.AgentHandler = &bareAgentHandler{} + }) + if stats := AgentStats(bare); stats["enabled"] != true { + t.Fatalf("a handler without the stats surface must still report enabled, got %v", stats) + } +} + +func TestRefreshCloudIPRangesWithoutRedis(t *testing.T) { + if err := RefreshCloudIPRanges(nil); err == nil { + t.Fatal("a nil engine must error") + } + empty := newTestEngine(t, nil) + if err := RefreshCloudIPRanges(empty); err != nil { + t.Fatalf("no blocked providers must be a no-op, got %v", err) + } + engine := newTestEngine(t, func(c *guardcore.SecurityConfig) { + c.BlockCloudProviders = []string{"AWS"} + }) + // A fresh manager keeps the shared package default untouched for the + // other tests. + engine.Cloud = guardcore.NewCloudManager() + engine.Cloud.SetRangeFetcher(func(provider string) ([]string, map[string]string, error) { + return []string{"203.0.113.0/24"}, nil, nil + }) + defer engine.Cloud.SetRangeFetcher(nil) + if err := RefreshCloudIPRanges(engine); err != nil { + t.Fatalf("the in-memory refresh must answer, got %v", err) + } + status := engine.Cloud.Status()["AWS"] + if !status.Ready || status.LastRefreshed.IsZero() { + t.Fatalf("the refresh must install and stamp the provider ranges, got %+v", status) + } + if !engine.Cloud.IsCloudIP("203.0.113.9", []string{"AWS"}) { + t.Fatal("the refreshed ranges must be enforceable") + } +}