feat: Add IP blacklisting support to reverse proxy
Enhance proxy security by implementing IP blacklisting functionality: - Add support for loading blacklist configuration from YAML files - Implement middleware to block requests from blacklisted IPs - Add rate-limited logging for blocked IP requests - Update docker-compose example to mount blacklist configuration - Modify request handling to check and enforce IP blacklist rules
- commit
- 1bec0decfa1613a00508c98584ad247b7d428340
- author
- bamen · · 2025-03-09 08:08 UTC
- parent
- 8da07b4
- signature
- unsigned
3 files · +310 -8
M docker-compose.yml.example +1 -0
| @@ -7,6 +7,7 @@ services: | |||
| 7 | 7 | volumes: | |
| 8 | 8 | - ./data:/data | |
| 9 | 9 | - ./data/config.yml:/config.yml # Mount config.yml directly at the root | |
| 10 | + | - ./data/blacklist.yml:/blacklist.yml # Mount blacklist.yml | |
| 10 | 11 | - ./data/certs:/certs # Mount certs directory to persist Let's Encrypt certificates | |
| 11 | 12 | # For Docker: | |
| 12 | 13 | - /var/run/docker.sock:/var/run/docker.sock # Required for Docker management | |
| @@ -7,6 +7,7 @@ services: | |||||
| 7 | volumes: | 7 | volumes: | ||
| 8 | - ./data:/data | 8 | - ./data:/data | ||
| 9 | - ./data/config.yml:/config.yml # Mount config.yml directly at the root | 9 | - ./data/config.yml:/config.yml # Mount config.yml directly at the root | ||
| 10 | + | - ./data/blacklist.yml:/blacklist.yml # Mount blacklist.yml | |||
| 10 | - ./data/certs:/certs # Mount certs directory to persist Let's Encrypt certificates | 11 | - ./data/certs:/certs # Mount certs directory to persist Let's Encrypt certificates | ||
| 11 | # For Docker: | 12 | # For Docker: | ||
| 12 | - /var/run/docker.sock:/var/run/docker.sock # Required for Docker management | 13 | - /var/run/docker.sock:/var/run/docker.sock # Required for Docker management | ||
A internal/proxy/blacklist.go +148 -0
| @@ -0,0 +1,148 @@ | |||
| 1 | + | package proxy | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "net" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 7 | + | "sync" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/charmbracelet/log" | |
| 11 | + | "gopkg.in/yaml.v3" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // BlacklistConfig represents the structure of the blacklist YAML file (.yaml or .yml) | |
| 15 | + | type BlacklistConfig struct { | |
| 16 | + | IPs []string `yaml:"ips"` | |
| 17 | + | Ranges []string `yaml:"ranges"` | |
| 18 | + | Networks []*net.IPNet `yaml:"-"` // Parsed IP networks | |
| 19 | + | LastMod time.Time `yaml:"-"` // Last modification time | |
| 20 | + | mu sync.RWMutex `yaml:"-"` // Mutex for thread safety | |
| 21 | + | path string `yaml:"-"` // Path to blacklist file | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // NewBlacklist creates a new IP blacklist | |
| 25 | + | func NewBlacklist(path string) (*BlacklistConfig, error) { | |
| 26 | + | blacklist := &BlacklistConfig{ | |
| 27 | + | path: path, | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | // Load initial blacklist | |
| 31 | + | if err := blacklist.Load(); err != nil { | |
| 32 | + | // If file doesn't exist, create an empty blacklist | |
| 33 | + | if os.IsNotExist(err) { | |
| 34 | + | log.Info("Blacklist file not found, creating empty blacklist", "path", path) | |
| 35 | + | blacklist.IPs = []string{} | |
| 36 | + | blacklist.Ranges = []string{} | |
| 37 | + | blacklist.Networks = []*net.IPNet{} | |
| 38 | + | ||
| 39 | + | // Create directory if needed | |
| 40 | + | dir := filepath.Dir(path) | |
| 41 | + | if err := os.MkdirAll(dir, 0755); err != nil { | |
| 42 | + | log.Error("Failed to create directory for blacklist", "dir", dir, "error", err) | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // Write empty blacklist file | |
| 46 | + | emptyConfig := &BlacklistConfig{ | |
| 47 | + | IPs: []string{}, | |
| 48 | + | Ranges: []string{}, | |
| 49 | + | } | |
| 50 | + | data, _ := yaml.Marshal(emptyConfig) | |
| 51 | + | if err := os.WriteFile(path, data, 0644); err != nil { | |
| 52 | + | log.Error("Failed to create empty blacklist file", "path", path, "error", err) | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | return blacklist, nil | |
| 56 | + | } | |
| 57 | + | return nil, err | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | return blacklist, nil | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | // Load reads the blacklist from disk | |
| 64 | + | func (b *BlacklistConfig) Load() error { | |
| 65 | + | b.mu.Lock() | |
| 66 | + | defer b.mu.Unlock() | |
| 67 | + | ||
| 68 | + | // Get file info for last modified time | |
| 69 | + | fileInfo, err := os.Stat(b.path) | |
| 70 | + | if err != nil { | |
| 71 | + | return err | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // Check if file hasn't been modified | |
| 75 | + | if fileInfo.ModTime() == b.LastMod { | |
| 76 | + | return nil // File hasn't changed | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | // Read the blacklist file | |
| 80 | + | data, err := os.ReadFile(b.path) | |
| 81 | + | if err != nil { | |
| 82 | + | return err | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // Create a temporary config to unmarshal into | |
| 86 | + | var tempConfig BlacklistConfig | |
| 87 | + | if err := yaml.Unmarshal(data, &tempConfig); err != nil { | |
| 88 | + | return err | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | // Parse IP ranges into networks | |
| 92 | + | networks := make([]*net.IPNet, 0, len(tempConfig.Ranges)) | |
| 93 | + | for _, cidr := range tempConfig.Ranges { | |
| 94 | + | _, network, err := net.ParseCIDR(cidr) | |
| 95 | + | if err != nil { | |
| 96 | + | log.Warn("Invalid CIDR in blacklist", "cidr", cidr, "error", err) | |
| 97 | + | continue | |
| 98 | + | } | |
| 99 | + | networks = append(networks, network) | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | // Update the blacklist | |
| 103 | + | b.IPs = tempConfig.IPs | |
| 104 | + | b.Ranges = tempConfig.Ranges | |
| 105 | + | b.Networks = networks | |
| 106 | + | b.LastMod = fileInfo.ModTime() | |
| 107 | + | ||
| 108 | + | log.Info("Loaded blacklist", | |
| 109 | + | "ips", len(b.IPs), | |
| 110 | + | "ranges", len(b.Ranges), | |
| 111 | + | "last_modified", b.LastMod.Format(time.RFC3339)) | |
| 112 | + | ||
| 113 | + | return nil | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | // IsBlocked checks if an IP is in the blacklist | |
| 117 | + | func (b *BlacklistConfig) IsBlocked(ip string) bool { | |
| 118 | + | // Try to reload the blacklist first | |
| 119 | + | if err := b.Load(); err != nil { | |
| 120 | + | log.Error("Failed to reload blacklist", "error", err) | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | b.mu.RLock() | |
| 124 | + | defer b.mu.RUnlock() | |
| 125 | + | ||
| 126 | + | // Check direct IP match first (fastest) | |
| 127 | + | for _, blockedIP := range b.IPs { | |
| 128 | + | if ip == blockedIP { | |
| 129 | + | return true | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | // Parse the IP | |
| 134 | + | parsedIP := net.ParseIP(ip) | |
| 135 | + | if parsedIP == nil { | |
| 136 | + | log.Warn("Failed to parse IP", "ip", ip) | |
| 137 | + | return false | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | // Check if IP is in any blocked ranges | |
| 141 | + | for _, network := range b.Networks { | |
| 142 | + | if network.Contains(parsedIP) { | |
| 143 | + | return true | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | return false | |
| 148 | + | } | |
| @@ -0,0 +1,148 @@ | |||||
| 1 | + | package proxy | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "net" | |||
| 5 | + | "os" | |||
| 6 | + | "path/filepath" | |||
| 7 | + | "sync" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/charmbracelet/log" | |||
| 11 | + | "gopkg.in/yaml.v3" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // BlacklistConfig represents the structure of the blacklist YAML file (.yaml or .yml) | |||
| 15 | + | type BlacklistConfig struct { | |||
| 16 | + | IPs []string `yaml:"ips"` | |||
| 17 | + | Ranges []string `yaml:"ranges"` | |||
| 18 | + | Networks []*net.IPNet `yaml:"-"` // Parsed IP networks | |||
| 19 | + | LastMod time.Time `yaml:"-"` // Last modification time | |||
| 20 | + | mu sync.RWMutex `yaml:"-"` // Mutex for thread safety | |||
| 21 | + | path string `yaml:"-"` // Path to blacklist file | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | // NewBlacklist creates a new IP blacklist | |||
| 25 | + | func NewBlacklist(path string) (*BlacklistConfig, error) { | |||
| 26 | + | blacklist := &BlacklistConfig{ | |||
| 27 | + | path: path, | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | // Load initial blacklist | |||
| 31 | + | if err := blacklist.Load(); err != nil { | |||
| 32 | + | // If file doesn't exist, create an empty blacklist | |||
| 33 | + | if os.IsNotExist(err) { | |||
| 34 | + | log.Info("Blacklist file not found, creating empty blacklist", "path", path) | |||
| 35 | + | blacklist.IPs = []string{} | |||
| 36 | + | blacklist.Ranges = []string{} | |||
| 37 | + | blacklist.Networks = []*net.IPNet{} | |||
| 38 | + | ||||
| 39 | + | // Create directory if needed | |||
| 40 | + | dir := filepath.Dir(path) | |||
| 41 | + | if err := os.MkdirAll(dir, 0755); err != nil { | |||
| 42 | + | log.Error("Failed to create directory for blacklist", "dir", dir, "error", err) | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | // Write empty blacklist file | |||
| 46 | + | emptyConfig := &BlacklistConfig{ | |||
| 47 | + | IPs: []string{}, | |||
| 48 | + | Ranges: []string{}, | |||
| 49 | + | } | |||
| 50 | + | data, _ := yaml.Marshal(emptyConfig) | |||
| 51 | + | if err := os.WriteFile(path, data, 0644); err != nil { | |||
| 52 | + | log.Error("Failed to create empty blacklist file", "path", path, "error", err) | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | return blacklist, nil | |||
| 56 | + | } | |||
| 57 | + | return nil, err | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | return blacklist, nil | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | // Load reads the blacklist from disk | |||
| 64 | + | func (b *BlacklistConfig) Load() error { | |||
| 65 | + | b.mu.Lock() | |||
| 66 | + | defer b.mu.Unlock() | |||
| 67 | + | ||||
| 68 | + | // Get file info for last modified time | |||
| 69 | + | fileInfo, err := os.Stat(b.path) | |||
| 70 | + | if err != nil { | |||
| 71 | + | return err | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | // Check if file hasn't been modified | |||
| 75 | + | if fileInfo.ModTime() == b.LastMod { | |||
| 76 | + | return nil // File hasn't changed | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | // Read the blacklist file | |||
| 80 | + | data, err := os.ReadFile(b.path) | |||
| 81 | + | if err != nil { | |||
| 82 | + | return err | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | // Create a temporary config to unmarshal into | |||
| 86 | + | var tempConfig BlacklistConfig | |||
| 87 | + | if err := yaml.Unmarshal(data, &tempConfig); err != nil { | |||
| 88 | + | return err | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | // Parse IP ranges into networks | |||
| 92 | + | networks := make([]*net.IPNet, 0, len(tempConfig.Ranges)) | |||
| 93 | + | for _, cidr := range tempConfig.Ranges { | |||
| 94 | + | _, network, err := net.ParseCIDR(cidr) | |||
| 95 | + | if err != nil { | |||
| 96 | + | log.Warn("Invalid CIDR in blacklist", "cidr", cidr, "error", err) | |||
| 97 | + | continue | |||
| 98 | + | } | |||
| 99 | + | networks = append(networks, network) | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | // Update the blacklist | |||
| 103 | + | b.IPs = tempConfig.IPs | |||
| 104 | + | b.Ranges = tempConfig.Ranges | |||
| 105 | + | b.Networks = networks | |||
| 106 | + | b.LastMod = fileInfo.ModTime() | |||
| 107 | + | ||||
| 108 | + | log.Info("Loaded blacklist", | |||
| 109 | + | "ips", len(b.IPs), | |||
| 110 | + | "ranges", len(b.Ranges), | |||
| 111 | + | "last_modified", b.LastMod.Format(time.RFC3339)) | |||
| 112 | + | ||||
| 113 | + | return nil | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | // IsBlocked checks if an IP is in the blacklist | |||
| 117 | + | func (b *BlacklistConfig) IsBlocked(ip string) bool { | |||
| 118 | + | // Try to reload the blacklist first | |||
| 119 | + | if err := b.Load(); err != nil { | |||
| 120 | + | log.Error("Failed to reload blacklist", "error", err) | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | b.mu.RLock() | |||
| 124 | + | defer b.mu.RUnlock() | |||
| 125 | + | ||||
| 126 | + | // Check direct IP match first (fastest) | |||
| 127 | + | for _, blockedIP := range b.IPs { | |||
| 128 | + | if ip == blockedIP { | |||
| 129 | + | return true | |||
| 130 | + | } | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | // Parse the IP | |||
| 134 | + | parsedIP := net.ParseIP(ip) | |||
| 135 | + | if parsedIP == nil { | |||
| 136 | + | log.Warn("Failed to parse IP", "ip", ip) | |||
| 137 | + | return false | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | // Check if IP is in any blocked ranges | |||
| 141 | + | for _, network := range b.Networks { | |||
| 142 | + | if network.Contains(parsedIP) { | |||
| 143 | + | return true | |||
| 144 | + | } | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | return false | |||
| 148 | + | } | |||
M internal/proxy/proxy.go +161 -8
| @@ -32,6 +32,12 @@ type Proxy struct { | |||
| 32 | 32 | mu sync.RWMutex | |
| 33 | 33 | certManager *autocert.Manager | |
| 34 | 34 | serverStarted bool | |
| 35 | + | blacklist *BlacklistConfig | |
| 36 | + | ||
| 37 | + | // Fields for throttling blacklist logs | |
| 38 | + | lastBlockedLog time.Time | |
| 39 | + | blockedIPCounter map[string]int | |
| 40 | + | blockedIPCountMu sync.Mutex | |
| 35 | 41 | } | |
| 36 | 42 | ||
| 37 | 43 | // ProxyRouteInfo contains the information needed to route traffic to a container | |
| @@ -45,6 +51,11 @@ type ProxyRouteInfo struct { | |||
| 45 | 51 | Active bool | |
| 46 | 52 | } | |
| 47 | 53 | ||
| 54 | + | // Create blacklistedIPs set to track IPs for middleware skipping | |
| 55 | + | type requestContext struct { | |
| 56 | + | blacklisted bool | |
| 57 | + | } | |
| 58 | + | ||
| 48 | 59 | // NewProxy creates a new reverse proxy | |
| 49 | 60 | func NewProxy(app interfaces.AppInterface) (*Proxy, error) { | |
| 50 | 61 | config := app.GetConfig().ReverseProxy | |
| @@ -52,26 +63,96 @@ func NewProxy(app interfaces.AppInterface) (*Proxy, error) { | |||
| 52 | 63 | // Create the routes map | |
| 53 | 64 | routes := make(map[string]*ProxyRouteInfo) | |
| 54 | 65 | ||
| 66 | + | // Initialize the blacklist | |
| 67 | + | // Try both .yml and .yaml extensions, preferring .yml | |
| 68 | + | var blacklist *BlacklistConfig | |
| 69 | + | var err error | |
| 70 | + | ||
| 71 | + | storageDir := app.GetConfig().General.StorageDir | |
| 72 | + | // Prefer .yml over .yaml for consistency with config.yml | |
| 73 | + | blacklistPath := storageDir + "/blacklist.yml" | |
| 74 | + | blacklistLegacy := storageDir + "/blacklist.yaml" | |
| 75 | + | ||
| 76 | + | // Check if legacy blacklist.yaml exists but not .yml | |
| 77 | + | if _, statErrLegacy := os.Stat(blacklistLegacy); statErrLegacy == nil { | |
| 78 | + | if _, statErrYml := os.Stat(blacklistPath); statErrYml != nil && os.IsNotExist(statErrYml) { | |
| 79 | + | log.Info("Found legacy blacklist.yaml file, using it", "path", blacklistLegacy) | |
| 80 | + | blacklist, err = NewBlacklist(blacklistLegacy) | |
| 81 | + | } else { | |
| 82 | + | // Both exist or only .yml exists, prefer .yml | |
| 83 | + | log.Debug("Using blacklist.yml file", "path", blacklistPath) | |
| 84 | + | blacklist, err = NewBlacklist(blacklistPath) | |
| 85 | + | } | |
| 86 | + | } else { | |
| 87 | + | // No legacy file, use .yml | |
| 88 | + | log.Debug("Using blacklist.yml file", "path", blacklistPath) | |
| 89 | + | blacklist, err = NewBlacklist(blacklistPath) | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | if err != nil { | |
| 93 | + | log.Error("Failed to initialize blacklist", "error", err) | |
| 94 | + | // Continue anyway with nil blacklist | |
| 95 | + | } | |
| 96 | + | ||
| 55 | 97 | // Create the HTTPS echo instance | |
| 56 | 98 | httpsServer := echo.New() | |
| 57 | 99 | httpsServer.HideBanner = true | |
| 58 | 100 | httpsServer.Use(middleware.Recover()) | |
| 59 | - | httpsServer.Use(middleware.Logger()) | |
| 101 | + | ||
| 102 | + | // Custom logger that skips blocked IPs | |
| 103 | + | httpsServer.Use(func(next echo.HandlerFunc) echo.HandlerFunc { | |
| 104 | + | return func(c echo.Context) error { | |
| 105 | + | // Store the context in the request context | |
| 106 | + | c.Set("reqContext", &requestContext{blacklisted: false}) | |
| 107 | + | return next(c) | |
| 108 | + | } | |
| 109 | + | }) | |
| 110 | + | ||
| 111 | + | httpsServer.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{ | |
| 112 | + | Skipper: func(c echo.Context) bool { | |
| 113 | + | // Skip logging if the request is marked as blacklisted | |
| 114 | + | if reqCtx, ok := c.Get("reqContext").(*requestContext); ok && reqCtx.blacklisted { | |
| 115 | + | return true | |
| 116 | + | } | |
| 117 | + | return false | |
| 118 | + | }, | |
| 119 | + | })) | |
| 60 | 120 | ||
| 61 | 121 | // Create the HTTP echo instance (for redirects to HTTPS) | |
| 62 | 122 | httpServer := echo.New() | |
| 63 | 123 | httpServer.HideBanner = true | |
| 64 | 124 | httpServer.Use(middleware.Recover()) | |
| 65 | - | httpServer.Use(middleware.Logger()) | |
| 125 | + | ||
| 126 | + | // Custom logger that skips blocked IPs | |
| 127 | + | httpServer.Use(func(next echo.HandlerFunc) echo.HandlerFunc { | |
| 128 | + | return func(c echo.Context) error { | |
| 129 | + | // Store the context in the request context | |
| 130 | + | c.Set("reqContext", &requestContext{blacklisted: false}) | |
| 131 | + | return next(c) | |
| 132 | + | } | |
| 133 | + | }) | |
| 134 | + | ||
| 135 | + | httpServer.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{ | |
| 136 | + | Skipper: func(c echo.Context) bool { | |
| 137 | + | // Skip logging if the request is marked as blacklisted | |
| 138 | + | if reqCtx, ok := c.Get("reqContext").(*requestContext); ok && reqCtx.blacklisted { | |
| 139 | + | return true | |
| 140 | + | } | |
| 141 | + | return false | |
| 142 | + | }, | |
| 143 | + | })) | |
| 66 | 144 | ||
| 67 | 145 | // Create the proxy | |
| 68 | 146 | p := &Proxy{ | |
| 69 | - | config: config, | |
| 70 | - | app: app, | |
| 71 | - | httpsServer: httpsServer, | |
| 72 | - | httpServer: httpServer, | |
| 73 | - | routes: routes, | |
| 74 | - | serverStarted: false, | |
| 147 | + | config: config, | |
| 148 | + | app: app, | |
| 149 | + | httpsServer: httpsServer, | |
| 150 | + | httpServer: httpServer, | |
| 151 | + | routes: routes, | |
| 152 | + | serverStarted: false, | |
| 153 | + | blacklist: blacklist, | |
| 154 | + | blockedIPCounter: make(map[string]int), | |
| 155 | + | lastBlockedLog: time.Time{}, // Zero time | |
| 75 | 156 | } | |
| 76 | 157 | ||
| 77 | 158 | // Set up the certificate manager | |
| @@ -321,6 +402,20 @@ func (p *Proxy) Start() error { | |||
| 321 | 402 | return nil | |
| 322 | 403 | } | |
| 323 | 404 | ||
| 405 | + | // Get client IP | |
| 406 | + | clientIP := c.RealIP() | |
| 407 | + | ||
| 408 | + | // Check if IP is blacklisted (if blacklist exists) | |
| 409 | + | if p.blacklist != nil && p.blacklist.IsBlocked(clientIP) { | |
| 410 | + | // Mark request as blacklisted to skip logging | |
| 411 | + | if reqCtx, ok := c.Get("reqContext").(*requestContext); ok { | |
| 412 | + | reqCtx.blacklisted = true | |
| 413 | + | } | |
| 414 | + | ||
| 415 | + | p.logBlockedIP(clientIP, c.Request().URL.Path, c.Request().UserAgent()) | |
| 416 | + | return c.String(http.StatusForbidden, "Forbidden") | |
| 417 | + | } | |
| 418 | + | ||
| 324 | 419 | host := c.Request().Host | |
| 325 | 420 | if strings.Contains(host, ":") { | |
| 326 | 421 | host = strings.Split(host, ":")[0] | |
| @@ -539,6 +634,20 @@ func (p *Proxy) configureRoutes() { | |||
| 539 | 634 | ||
| 540 | 635 | // Add a handler for all incoming requests (HTTPS) | |
| 541 | 636 | p.httpsServer.Any("/*", func(c echo.Context) error { | |
| 637 | + | // Get client IP | |
| 638 | + | clientIP := c.RealIP() | |
| 639 | + | ||
| 640 | + | // Check if IP is blacklisted (if blacklist exists) | |
| 641 | + | if p.blacklist != nil && p.blacklist.IsBlocked(clientIP) { | |
| 642 | + | // Mark request as blacklisted to skip logging | |
| 643 | + | if reqCtx, ok := c.Get("reqContext").(*requestContext); ok { | |
| 644 | + | reqCtx.blacklisted = true | |
| 645 | + | } | |
| 646 | + | ||
| 647 | + | p.logBlockedIP(clientIP, c.Request().URL.Path, c.Request().UserAgent()) | |
| 648 | + | return c.String(http.StatusForbidden, "Forbidden") | |
| 649 | + | } | |
| 650 | + | ||
| 542 | 651 | host := c.Request().Host | |
| 543 | 652 | ||
| 544 | 653 | // Strip the port from the host | |
| @@ -802,3 +911,47 @@ func (p *Proxy) GetRoutes() map[string]*ProxyRouteInfo { | |||
| 802 | 911 | ||
| 803 | 912 | return routes | |
| 804 | 913 | } | |
| 914 | + | ||
| 915 | + | // Add a helper method to log blocked IPs with rate limiting | |
| 916 | + | func (p *Proxy) logBlockedIP(clientIP, path, userAgent string) { | |
| 917 | + | p.blockedIPCountMu.Lock() | |
| 918 | + | defer p.blockedIPCountMu.Unlock() | |
| 919 | + | ||
| 920 | + | now := time.Now() | |
| 921 | + | ||
| 922 | + | // If this is the first block or it's been more than 5 minutes since the last summary | |
| 923 | + | if p.lastBlockedLog.IsZero() || now.Sub(p.lastBlockedLog) > 5*time.Minute { | |
| 924 | + | // Log this block and reset counters | |
| 925 | + | log.Info("Blocked request from blacklisted IP", | |
| 926 | + | "ip", clientIP, | |
| 927 | + | "path", path, | |
| 928 | + | "user_agent", userAgent) | |
| 929 | + | ||
| 930 | + | // Reset counters | |
| 931 | + | p.blockedIPCounter = make(map[string]int) | |
| 932 | + | p.blockedIPCounter[clientIP] = 1 | |
| 933 | + | p.lastBlockedLog = now | |
| 934 | + | return | |
| 935 | + | } | |
| 936 | + | ||
| 937 | + | // Increment counter for this IP | |
| 938 | + | p.blockedIPCounter[clientIP]++ | |
| 939 | + | ||
| 940 | + | // If it's been at least 60 seconds since the last log, print a summary | |
| 941 | + | if now.Sub(p.lastBlockedLog) >= 60*time.Second { | |
| 942 | + | // Log summary of blocked requests | |
| 943 | + | totalBlocked := 0 | |
| 944 | + | for _, count := range p.blockedIPCounter { | |
| 945 | + | totalBlocked += count | |
| 946 | + | } | |
| 947 | + | ||
| 948 | + | log.Info("Blocked IP summary", | |
| 949 | + | "unique_ips", len(p.blockedIPCounter), | |
| 950 | + | "total_requests", totalBlocked, | |
| 951 | + | "since", p.lastBlockedLog.Format(time.RFC3339)) | |
| 952 | + | ||
| 953 | + | // Reset counters | |
| 954 | + | p.blockedIPCounter = make(map[string]int) | |
| 955 | + | p.lastBlockedLog = now | |
| 956 | + | } | |
| 957 | + | } | |
| @@ -32,6 +32,12 @@ type Proxy struct { | |||||
| 32 | mu sync.RWMutex | 32 | mu sync.RWMutex | ||
| 33 | certManager *autocert.Manager | 33 | certManager *autocert.Manager | ||
| 34 | serverStarted bool | 34 | serverStarted bool | ||
| 35 | + | blacklist *BlacklistConfig | |||
| 36 | + | ||||
| 37 | + | // Fields for throttling blacklist logs | |||
| 38 | + | lastBlockedLog time.Time | |||
| 39 | + | blockedIPCounter map[string]int | |||
| 40 | + | blockedIPCountMu sync.Mutex | |||
| 35 | } | 41 | } | ||
| 36 | 42 | ||||
| 37 | // ProxyRouteInfo contains the information needed to route traffic to a container | 43 | // ProxyRouteInfo contains the information needed to route traffic to a container | ||
| @@ -45,6 +51,11 @@ type ProxyRouteInfo struct { | |||||
| 45 | Active bool | 51 | Active bool | ||
| 46 | } | 52 | } | ||
| 47 | 53 | ||||
| 54 | + | // Create blacklistedIPs set to track IPs for middleware skipping | |||
| 55 | + | type requestContext struct { | |||
| 56 | + | blacklisted bool | |||
| 57 | + | } | |||
| 58 | + | ||||
| 48 | // NewProxy creates a new reverse proxy | 59 | // NewProxy creates a new reverse proxy | ||
| 49 | func NewProxy(app interfaces.AppInterface) (*Proxy, error) { | 60 | func NewProxy(app interfaces.AppInterface) (*Proxy, error) { | ||
| 50 | config := app.GetConfig().ReverseProxy | 61 | config := app.GetConfig().ReverseProxy | ||
| @@ -52,26 +63,96 @@ func NewProxy(app interfaces.AppInterface) (*Proxy, error) { | |||||
| 52 | // Create the routes map | 63 | // Create the routes map | ||
| 53 | routes := make(map[string]*ProxyRouteInfo) | 64 | routes := make(map[string]*ProxyRouteInfo) | ||
| 54 | 65 | ||||
| 66 | + | // Initialize the blacklist | |||
| 67 | + | // Try both .yml and .yaml extensions, preferring .yml | |||
| 68 | + | var blacklist *BlacklistConfig | |||
| 69 | + | var err error | |||
| 70 | + | ||||
| 71 | + | storageDir := app.GetConfig().General.StorageDir | |||
| 72 | + | // Prefer .yml over .yaml for consistency with config.yml | |||
| 73 | + | blacklistPath := storageDir + "/blacklist.yml" | |||
| 74 | + | blacklistLegacy := storageDir + "/blacklist.yaml" | |||
| 75 | + | ||||
| 76 | + | // Check if legacy blacklist.yaml exists but not .yml | |||
| 77 | + | if _, statErrLegacy := os.Stat(blacklistLegacy); statErrLegacy == nil { | |||
| 78 | + | if _, statErrYml := os.Stat(blacklistPath); statErrYml != nil && os.IsNotExist(statErrYml) { | |||
| 79 | + | log.Info("Found legacy blacklist.yaml file, using it", "path", blacklistLegacy) | |||
| 80 | + | blacklist, err = NewBlacklist(blacklistLegacy) | |||
| 81 | + | } else { | |||
| 82 | + | // Both exist or only .yml exists, prefer .yml | |||
| 83 | + | log.Debug("Using blacklist.yml file", "path", blacklistPath) | |||
| 84 | + | blacklist, err = NewBlacklist(blacklistPath) | |||
| 85 | + | } | |||
| 86 | + | } else { | |||
| 87 | + | // No legacy file, use .yml | |||
| 88 | + | log.Debug("Using blacklist.yml file", "path", blacklistPath) | |||
| 89 | + | blacklist, err = NewBlacklist(blacklistPath) | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | if err != nil { | |||
| 93 | + | log.Error("Failed to initialize blacklist", "error", err) | |||
| 94 | + | // Continue anyway with nil blacklist | |||
| 95 | + | } | |||
| 96 | + | ||||
| 55 | // Create the HTTPS echo instance | 97 | // Create the HTTPS echo instance | ||
| 56 | httpsServer := echo.New() | 98 | httpsServer := echo.New() | ||
| 57 | httpsServer.HideBanner = true | 99 | httpsServer.HideBanner = true | ||
| 58 | httpsServer.Use(middleware.Recover()) | 100 | httpsServer.Use(middleware.Recover()) | ||
| 59 | - | httpsServer.Use(middleware.Logger()) | 101 | + | |
| 102 | + | // Custom logger that skips blocked IPs | |||
| 103 | + | httpsServer.Use(func(next echo.HandlerFunc) echo.HandlerFunc { | |||
| 104 | + | return func(c echo.Context) error { | |||
| 105 | + | // Store the context in the request context | |||
| 106 | + | c.Set("reqContext", &requestContext{blacklisted: false}) | |||
| 107 | + | return next(c) | |||
| 108 | + | } | |||
| 109 | + | }) | |||
| 110 | + | ||||
| 111 | + | httpsServer.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{ | |||
| 112 | + | Skipper: func(c echo.Context) bool { | |||
| 113 | + | // Skip logging if the request is marked as blacklisted | |||
| 114 | + | if reqCtx, ok := c.Get("reqContext").(*requestContext); ok && reqCtx.blacklisted { | |||
| 115 | + | return true | |||
| 116 | + | } | |||
| 117 | + | return false | |||
| 118 | + | }, | |||
| 119 | + | })) | |||
| 60 | 120 | ||||
| 61 | // Create the HTTP echo instance (for redirects to HTTPS) | 121 | // Create the HTTP echo instance (for redirects to HTTPS) | ||
| 62 | httpServer := echo.New() | 122 | httpServer := echo.New() | ||
| 63 | httpServer.HideBanner = true | 123 | httpServer.HideBanner = true | ||
| 64 | httpServer.Use(middleware.Recover()) | 124 | httpServer.Use(middleware.Recover()) | ||
| 65 | - | httpServer.Use(middleware.Logger()) | 125 | + | |
| 126 | + | // Custom logger that skips blocked IPs | |||
| 127 | + | httpServer.Use(func(next echo.HandlerFunc) echo.HandlerFunc { | |||
| 128 | + | return func(c echo.Context) error { | |||
| 129 | + | // Store the context in the request context | |||
| 130 | + | c.Set("reqContext", &requestContext{blacklisted: false}) | |||
| 131 | + | return next(c) | |||
| 132 | + | } | |||
| 133 | + | }) | |||
| 134 | + | ||||
| 135 | + | httpServer.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{ | |||
| 136 | + | Skipper: func(c echo.Context) bool { | |||
| 137 | + | // Skip logging if the request is marked as blacklisted | |||
| 138 | + | if reqCtx, ok := c.Get("reqContext").(*requestContext); ok && reqCtx.blacklisted { | |||
| 139 | + | return true | |||
| 140 | + | } | |||
| 141 | + | return false | |||
| 142 | + | }, | |||
| 143 | + | })) | |||
| 66 | 144 | ||||
| 67 | // Create the proxy | 145 | // Create the proxy | ||
| 68 | p := &Proxy{ | 146 | p := &Proxy{ | ||
| 69 | - | config: config, | 147 | + | config: config, |
| 70 | - | app: app, | 148 | + | app: app, |
| 71 | - | httpsServer: httpsServer, | 149 | + | httpsServer: httpsServer, |
| 72 | - | httpServer: httpServer, | 150 | + | httpServer: httpServer, |
| 73 | - | routes: routes, | 151 | + | routes: routes, |
| 74 | - | serverStarted: false, | 152 | + | serverStarted: false, |
| 153 | + | blacklist: blacklist, | |||
| 154 | + | blockedIPCounter: make(map[string]int), | |||
| 155 | + | lastBlockedLog: time.Time{}, // Zero time | |||
| 75 | } | 156 | } | ||
| 76 | 157 | ||||
| 77 | // Set up the certificate manager | 158 | // Set up the certificate manager | ||
| @@ -321,6 +402,20 @@ func (p *Proxy) Start() error { | |||||
| 321 | return nil | 402 | return nil | ||
| 322 | } | 403 | } | ||
| 323 | 404 | ||||
| 405 | + | // Get client IP | |||
| 406 | + | clientIP := c.RealIP() | |||
| 407 | + | ||||
| 408 | + | // Check if IP is blacklisted (if blacklist exists) | |||
| 409 | + | if p.blacklist != nil && p.blacklist.IsBlocked(clientIP) { | |||
| 410 | + | // Mark request as blacklisted to skip logging | |||
| 411 | + | if reqCtx, ok := c.Get("reqContext").(*requestContext); ok { | |||
| 412 | + | reqCtx.blacklisted = true | |||
| 413 | + | } | |||
| 414 | + | ||||
| 415 | + | p.logBlockedIP(clientIP, c.Request().URL.Path, c.Request().UserAgent()) | |||
| 416 | + | return c.String(http.StatusForbidden, "Forbidden") | |||
| 417 | + | } | |||
| 418 | + | ||||
| 324 | host := c.Request().Host | 419 | host := c.Request().Host | ||
| 325 | if strings.Contains(host, ":") { | 420 | if strings.Contains(host, ":") { | ||
| 326 | host = strings.Split(host, ":")[0] | 421 | host = strings.Split(host, ":")[0] | ||
| @@ -539,6 +634,20 @@ func (p *Proxy) configureRoutes() { | |||||
| 539 | 634 | ||||
| 540 | // Add a handler for all incoming requests (HTTPS) | 635 | // Add a handler for all incoming requests (HTTPS) | ||
| 541 | p.httpsServer.Any("/*", func(c echo.Context) error { | 636 | p.httpsServer.Any("/*", func(c echo.Context) error { | ||
| 637 | + | // Get client IP | |||
| 638 | + | clientIP := c.RealIP() | |||
| 639 | + | ||||
| 640 | + | // Check if IP is blacklisted (if blacklist exists) | |||
| 641 | + | if p.blacklist != nil && p.blacklist.IsBlocked(clientIP) { | |||
| 642 | + | // Mark request as blacklisted to skip logging | |||
| 643 | + | if reqCtx, ok := c.Get("reqContext").(*requestContext); ok { | |||
| 644 | + | reqCtx.blacklisted = true | |||
| 645 | + | } | |||
| 646 | + | ||||
| 647 | + | p.logBlockedIP(clientIP, c.Request().URL.Path, c.Request().UserAgent()) | |||
| 648 | + | return c.String(http.StatusForbidden, "Forbidden") | |||
| 649 | + | } | |||
| 650 | + | ||||
| 542 | host := c.Request().Host | 651 | host := c.Request().Host | ||
| 543 | 652 | ||||
| 544 | // Strip the port from the host | 653 | // Strip the port from the host | ||
| @@ -802,3 +911,47 @@ func (p *Proxy) GetRoutes() map[string]*ProxyRouteInfo { | |||||
| 802 | 911 | ||||
| 803 | return routes | 912 | return routes | ||
| 804 | } | 913 | } | ||
| 914 | + | ||||
| 915 | + | // Add a helper method to log blocked IPs with rate limiting | |||
| 916 | + | func (p *Proxy) logBlockedIP(clientIP, path, userAgent string) { | |||
| 917 | + | p.blockedIPCountMu.Lock() | |||
| 918 | + | defer p.blockedIPCountMu.Unlock() | |||
| 919 | + | ||||
| 920 | + | now := time.Now() | |||
| 921 | + | ||||
| 922 | + | // If this is the first block or it's been more than 5 minutes since the last summary | |||
| 923 | + | if p.lastBlockedLog.IsZero() || now.Sub(p.lastBlockedLog) > 5*time.Minute { | |||
| 924 | + | // Log this block and reset counters | |||
| 925 | + | log.Info("Blocked request from blacklisted IP", | |||
| 926 | + | "ip", clientIP, | |||
| 927 | + | "path", path, | |||
| 928 | + | "user_agent", userAgent) | |||
| 929 | + | ||||
| 930 | + | // Reset counters | |||
| 931 | + | p.blockedIPCounter = make(map[string]int) | |||
| 932 | + | p.blockedIPCounter[clientIP] = 1 | |||
| 933 | + | p.lastBlockedLog = now | |||
| 934 | + | return | |||
| 935 | + | } | |||
| 936 | + | ||||
| 937 | + | // Increment counter for this IP | |||
| 938 | + | p.blockedIPCounter[clientIP]++ | |||
| 939 | + | ||||
| 940 | + | // If it's been at least 60 seconds since the last log, print a summary | |||
| 941 | + | if now.Sub(p.lastBlockedLog) >= 60*time.Second { | |||
| 942 | + | // Log summary of blocked requests | |||
| 943 | + | totalBlocked := 0 | |||
| 944 | + | for _, count := range p.blockedIPCounter { | |||
| 945 | + | totalBlocked += count | |||
| 946 | + | } | |||
| 947 | + | ||||
| 948 | + | log.Info("Blocked IP summary", | |||
| 949 | + | "unique_ips", len(p.blockedIPCounter), | |||
| 950 | + | "total_requests", totalBlocked, | |||
| 951 | + | "since", p.lastBlockedLog.Format(time.RFC3339)) | |||
| 952 | + | ||||
| 953 | + | // Reset counters | |||
| 954 | + | p.blockedIPCounter = make(map[string]int) | |||
| 955 | + | p.lastBlockedLog = now | |||
| 956 | + | } | |||
| 957 | + | } | |||