diff --git a/internal/api.go b/internal/api.go index f066cc3..d749201 100755 --- a/internal/api.go +++ b/internal/api.go @@ -202,6 +202,12 @@ mux.HandleFunc("/list-rotating-certificates", api.listRotatingCertificatesHandler) // ------------------------------------------------------------------------- + // IP Block / RBAC Handlers + // ------------------------------------------------------------------------- + mux.HandleFunc("/blocks", api.blocksHandler) + mux.HandleFunc("/remove-block", api.removeBlockHandler) + + // ------------------------------------------------------------------------- // Utility Handlers // ------------------------------------------------------------------------- // Consistency Handler diff --git a/internal/api/types.go b/internal/api/types.go index e3f6575..35b7411 100755 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -156,3 +156,19 @@ RotationEnabled bool `json:"rotation_enabled"` // Whether automated rotation is currently enabled. RemainDays string `json:"remain_days"` // The number of days remaining until expiration. } + +// --- IP Block Management --- + +// BlockRequest defines the payload to block an IP via RBAC. +type BlockRequest struct { + IP string `json:"ip"` + Reason string `json:"reason"` + By string `json:"by"` +} + +// BlockResponse is returned after a successful block/unblock operation. +type BlockResponse struct { + OK bool `json:"ok"` + IP string `json:"ip,omitempty"` + Message string `json:"message,omitempty"` +} diff --git a/internal/api_handlers.go b/internal/api_handlers.go index ecb61fb..821ff02 100755 --- a/internal/api_handlers.go +++ b/internal/api_handlers.go @@ -895,3 +895,91 @@ w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(rotatingCerts) } + +// ---------------- IP Block / RBAC Handlers ---------------- + +// blocksHandler handles GET (list) and POST (add) for blocked IPs. +func (api *API) blocksHandler(w http.ResponseWriter, r *http.Request) { + ctx := context.Background() + w.Header().Set("Content-Type", "application/json") + + switch r.Method { + case http.MethodGet: + blocks, err := api.Manager.DB.ListBlocks(ctx) + if err != nil { + http.Error(w, fmt.Sprintf("failed to list blocks: %v", err), http.StatusInternalServerError) + return + } + if blocks == nil { + blocks = []storage.BlockEntry{} + } + json.NewEncoder(w).Encode(blocks) + + case http.MethodPost: + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "failed to read body", http.StatusBadRequest) + return + } + var req internalapi.BlockRequest + if err := json.Unmarshal(body, &req); err != nil || req.IP == "" { + http.Error(w, "invalid request: ip required", http.StatusBadRequest) + return + } + if req.By == "" { + req.By = "admin" + } + if req.Reason == "" { + req.Reason = "manual" + } + + if err := api.Manager.DB.AddBlock(ctx, req.IP, req.Reason, req.By); err != nil { + http.Error(w, fmt.Sprintf("failed to add block: %v", err), http.StatusInternalServerError) + return + } + + ips, err := api.Manager.DB.ListBlockedIPs(ctx) + if err == nil { + _ = api.Manager.InjectRBACIntoAllListeners(ctx, ips) + } + + json.NewEncoder(w).Encode(internalapi.BlockResponse{OK: true, IP: req.IP}) + + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +// removeBlockHandler handles POST /remove-block to unblock an IP. +func (api *API) removeBlockHandler(w http.ResponseWriter, r *http.Request) { + ctx := context.Background() + w.Header().Set("Content-Type", "application/json") + + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "failed to read body", http.StatusBadRequest) + return + } + var req internalapi.BlockRequest + if err := json.Unmarshal(body, &req); err != nil || req.IP == "" { + http.Error(w, "invalid request: ip required", http.StatusBadRequest) + return + } + + if err := api.Manager.DB.RemoveBlock(ctx, req.IP); err != nil { + http.Error(w, fmt.Sprintf("failed to remove block: %v", err), http.StatusInternalServerError) + return + } + + ips, err := api.Manager.DB.ListBlockedIPs(ctx) + if err == nil { + _ = api.Manager.InjectRBACIntoAllListeners(ctx, ips) + } + + json.NewEncoder(w).Encode(internalapi.BlockResponse{OK: true, IP: req.IP}) +} diff --git a/internal/app/app.go b/internal/app/app.go index 05fac16..eeb1367 100755 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -198,6 +198,15 @@ return fmt.Errorf("failed to load initial configuration: %w", err) } + // 4b. Inject blocked IPs into RBAC filter chains from DB + if blockedIPs, err := storage.ListBlockedIPs(ctx); err == nil && len(blockedIPs) > 0 { + if err := manager.InjectRBACIntoAllListeners(ctx, blockedIPs); err != nil { + log.Warnf("failed to inject RBAC on startup: %v", err) + } else { + log.Infof("RBAC: injected %d blocked IPs into listener filter chains", len(blockedIPs)) + } + } + var wg sync.WaitGroup // Use a background context for the WaitGroup to avoid deadlocks during shutdown wgCtx := context.Background() diff --git a/internal/pkg/snapshot/rbac.go b/internal/pkg/snapshot/rbac.go new file mode 100644 index 0000000..6a25946 --- /dev/null +++ b/internal/pkg/snapshot/rbac.go @@ -0,0 +1,124 @@ +package snapshot + +import ( + "context" + "fmt" + + corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" + listenerv3 "github.com/envoyproxy/go-control-plane/envoy/config/listener/v3" + rbacv3 "github.com/envoyproxy/go-control-plane/envoy/config/rbac/v3" + network_rbacv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/network/rbac/v3" + cachev3 "github.com/envoyproxy/go-control-plane/pkg/cache/v3" + "github.com/envoyproxy/go-control-plane/pkg/cache/types" + resourcev3 "github.com/envoyproxy/go-control-plane/pkg/resource/v3" + "google.golang.org/protobuf/types/known/anypb" + "google.golang.org/protobuf/types/known/wrapperspb" +) + +const rbacFilterName = "envoy.filters.network.rbac" + +// buildRBACFilter constructs a network RBAC filter that DENYs the given IPs. +// Returns nil when blockedIPs is empty (caller should strip any existing filter). +func buildRBACFilter(blockedIPs []string) (*listenerv3.Filter, error) { + if len(blockedIPs) == 0 { + return nil, nil + } + + principals := make([]*rbacv3.Principal, 0, len(blockedIPs)) + for _, ip := range blockedIPs { + principals = append(principals, &rbacv3.Principal{ + Identifier: &rbacv3.Principal_RemoteIp{ + RemoteIp: &corev3.CidrRange{ + AddressPrefix: ip, + PrefixLen: wrapperspb.UInt32(32), + }, + }, + }) + } + + rbacConfig := &network_rbacv3.RBAC{ + StatPrefix: "netwatch_block", + Rules: &rbacv3.RBAC{ + Action: rbacv3.RBAC_DENY, + Policies: map[string]*rbacv3.Policy{ + "blocked-ips": { + Permissions: []*rbacv3.Permission{ + {Rule: &rbacv3.Permission_Any{Any: true}}, + }, + Principals: principals, + }, + }, + }, + } + + typedConfig, err := anypb.New(rbacConfig) + if err != nil { + return nil, fmt.Errorf("failed to marshal RBAC config: %w", err) + } + + return &listenerv3.Filter{ + Name: rbacFilterName, + ConfigType: &listenerv3.Filter_TypedConfig{TypedConfig: typedConfig}, + }, nil +} + +// InjectRBACIntoAllListeners prepends a DENY RBAC filter to every filter chain +// of every listener. If blockedIPs is empty, any existing RBAC filter is removed. +// The snapshot version is bumped so Envoy picks up the change immediately. +func (sm *SnapshotManager) InjectRBACIntoAllListeners(ctx context.Context, blockedIPs []string) error { + snap, err := sm.Cache.GetSnapshot(sm.NodeID) + if err != nil { + return fmt.Errorf("no active snapshot: %w", err) + } + + rbacFilter, err := buildRBACFilter(blockedIPs) + if err != nil { + return err + } + + // Copy all non-listener resources unchanged + resources := map[resourcev3.Type][]types.Resource{} + for _, rtype := range []resourcev3.Type{ + resourcev3.ClusterType, + resourcev3.RouteType, + resourcev3.SecretType, + resourcev3.ExtensionConfigType, + } { + for _, v := range snap.GetResources(rtype) { + resources[rtype] = append(resources[rtype], v) + } + } + + // Mutate listeners in-place (they're already cloned from DB on each update) + for _, item := range snap.GetResources(resourcev3.ListenerType) { + listener, ok := item.(*listenerv3.Listener) + if !ok { + resources[resourcev3.ListenerType] = append(resources[resourcev3.ListenerType], item) + continue + } + for _, fc := range listener.GetFilterChains() { + // Strip any previous RBAC filter + stripped := make([]*listenerv3.Filter, 0, len(fc.Filters)) + for _, f := range fc.Filters { + if f.GetName() != rbacFilterName { + stripped = append(stripped, f) + } + } + if rbacFilter != nil { + fc.Filters = append([]*listenerv3.Filter{rbacFilter}, stripped...) + } else { + fc.Filters = stripped + } + } + resources[resourcev3.ListenerType] = append(resources[resourcev3.ListenerType], listener) + } + + newSnap, err := cachev3.NewSnapshot( + fmt.Sprintf("rbac-%d", len(blockedIPs)), + resources, + ) + if err != nil { + return fmt.Errorf("failed to create RBAC snapshot: %w", err) + } + return sm.Cache.SetSnapshot(ctx, sm.NodeID, newSnap) +} diff --git a/internal/pkg/snapshot/resource_io.go b/internal/pkg/snapshot/resource_io.go index 99c7caf..88cd5a5 100755 --- a/internal/pkg/snapshot/resource_io.go +++ b/internal/pkg/snapshot/resource_io.go @@ -13,6 +13,7 @@ _ "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/listener/proxy_protocol/v3" _ "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/listener/tls_inspector/v3" _ "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/network/http_connection_manager/v3" + _ "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/network/rbac/v3" _ "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/network/tcp_proxy/v3" secretv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/transport_sockets/tls/v3" // ADDED: SDS Secret Import diff --git a/internal/pkg/storage/sqlite.go b/internal/pkg/storage/sqlite.go index ff4ec5e..19f3480 100755 --- a/internal/pkg/storage/sqlite.go +++ b/internal/pkg/storage/sqlite.go @@ -69,7 +69,13 @@ data TEXT NOT NULL, enabled BOOLEAN DEFAULT 1, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - );` + ); + CREATE TABLE IF NOT EXISTS blocked_ips ( + ip TEXT PRIMARY KEY, + reason TEXT, + blocked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + blocked_by TEXT DEFAULT 'auto' + );` } func (s *SQLiteStrategy) SaveCertificateSQL(ph []string) string { diff --git a/internal/pkg/storage/storage.go b/internal/pkg/storage/storage.go index d9f9e23..05a6853 100755 --- a/internal/pkg/storage/storage.go +++ b/internal/pkg/storage/storage.go @@ -920,3 +920,75 @@ _, err := s.db.ExecContext(ctx, query, args...) return err } + +// ============================================================================= +// BLOCK MANAGEMENT +// ============================================================================= + +// BlockEntry represents a single blocked IP record. +type BlockEntry struct { + IP string `json:"ip"` + Reason string `json:"reason"` + BlockedAt time.Time `json:"blocked_at"` + BlockedBy string `json:"blocked_by"` +} + +// AddBlock inserts or replaces a blocked IP entry. +func (s *Storage) AddBlock(ctx context.Context, ip, reason, by string) error { + _, err := s.db.ExecContext(ctx, + `INSERT INTO blocked_ips(ip, reason, blocked_at, blocked_by) + VALUES(?, ?, CURRENT_TIMESTAMP, ?) + ON CONFLICT(ip) DO UPDATE SET reason=excluded.reason, blocked_at=excluded.blocked_at, blocked_by=excluded.blocked_by`, + ip, reason, by, + ) + return err +} + +// RemoveBlock deletes a blocked IP entry. +func (s *Storage) RemoveBlock(ctx context.Context, ip string) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM blocked_ips WHERE ip=?`, ip) + return err +} + +// ListBlocks returns all blocked IPs. +func (s *Storage) ListBlocks(ctx context.Context) ([]BlockEntry, error) { + rows, err := s.db.QueryContext(ctx, `SELECT ip, reason, blocked_at, blocked_by FROM blocked_ips ORDER BY blocked_at DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []BlockEntry + for rows.Next() { + var e BlockEntry + var ts string + if err := rows.Scan(&e.IP, &e.Reason, &ts, &e.BlockedBy); err != nil { + return nil, err + } + for _, layout := range []string{"2006-01-02 15:04:05", "2006-01-02T15:04:05Z", time.RFC3339} { + if t, err := time.Parse(layout, ts); err == nil { + e.BlockedAt = t + break + } + } + out = append(out, e) + } + return out, rows.Err() +} + +// ListBlockedIPs returns just the IP strings — used for RBAC injection. +func (s *Storage) ListBlockedIPs(ctx context.Context) ([]string, error) { + rows, err := s.db.QueryContext(ctx, `SELECT ip FROM blocked_ips`) + if err != nil { + return nil, err + } + defer rows.Close() + var ips []string + for rows.Next() { + var ip string + if err := rows.Scan(&ip); err != nil { + return nil, err + } + ips = append(ips, ip) + } + return ips, rows.Err() +}