Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
79 changes: 79 additions & 0 deletions lifecycle.go
Original file line number Diff line number Diff line change
@@ -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": <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)
}
66 changes: 66 additions & 0 deletions lifecycle_integration_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
129 changes: 129 additions & 0 deletions lifecycle_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading