// Package banview mirrors the gateway's active IP bans for the admin console and // collects operator unban requests for the gateway to apply. Like ratewatch it is // in-memory, single-instance and resets on a backend restart by design — the // gateway re-reports its active set on the next sync, and the durable effect (the // ban itself) lives in the gateway, not here. package banview import ( "sort" "sync" "time" ) // Ban is one active IP ban as reported by the gateway. type Ban struct { IP string Reason string Since time.Time Expires time.Time } // View holds the last-reported active bans and the operator's pending unbans. type View struct { now func() time.Time mu sync.Mutex bans map[string]Ban // last reported active set, keyed by IP unban map[string]struct{} // IPs an operator marked for unban } // New constructs an empty View. func New() *View { return &View{now: time.Now, bans: make(map[string]Ban), unban: make(map[string]struct{})} } // Ingest replaces the mirrored active set with the gateway's latest report, // skipping entries with an empty IP or one that has already expired. func (v *View) Ingest(active []Ban) { now := v.now() v.mu.Lock() defer v.mu.Unlock() v.bans = make(map[string]Ban, len(active)) for _, b := range active { if b.IP == "" || !now.Before(b.Expires) { continue } v.bans[b.IP] = b } } // Recent returns the mirrored active bans, most recently banned first. func (v *View) Recent() []Ban { now := v.now() v.mu.Lock() defer v.mu.Unlock() out := make([]Ban, 0, len(v.bans)) for _, b := range v.bans { if now.Before(b.Expires) { out = append(out, b) } } sort.Slice(out, func(i, j int) bool { return out[i].Since.After(out[j].Since) }) return out } // RequestUnban records an operator request to lift the ban on ip; the gateway // applies it on its next sync (so the console reflects it within the sync // interval). An empty ip is ignored. func (v *View) RequestUnban(ip string) { if ip == "" { return } v.mu.Lock() defer v.mu.Unlock() v.unban[ip] = struct{}{} } // DrainUnbans returns and clears the IPs operators have marked for unban since the // previous drain. It returns nil when there are none. func (v *View) DrainUnbans() []string { v.mu.Lock() defer v.mu.Unlock() if len(v.unban) == 0 { return nil } out := make([]string, 0, len(v.unban)) for ip := range v.unban { out = append(out, ip) } clear(v.unban) return out }