detached 1bec0de

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:
77 volumes:
88 - ./data:/data
99 - ./data/config.yml:/config.yml # Mount config.yml directly at the root
10+ - ./data/blacklist.yml:/blacklist.yml # Mount blacklist.yml
1011 - ./data/certs:/certs # Mount certs directory to persist Let's Encrypt certificates
1112 # For Docker:
1213 - /var/run/docker.sock:/var/run/docker.sock # Required for Docker management
@@ -7,6 +7,7 @@ services:
7 volumes:7 volumes:
8 - ./data:/data8 - ./data:/data
9 - ./data/config.yml:/config.yml # Mount config.yml directly at the root9 - ./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 certificates11 - ./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 management13 - /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 {
3232 mu sync.RWMutex
3333 certManager *autocert.Manager
3434 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
3541 }
3642
3743 // ProxyRouteInfo contains the information needed to route traffic to a container
@@ -45,6 +51,11 @@ type ProxyRouteInfo struct {
4551 Active bool
4652 }
4753
54+// Create blacklistedIPs set to track IPs for middleware skipping
55+type requestContext struct {
56+ blacklisted bool
57+}
58+
4859 // NewProxy creates a new reverse proxy
4960 func NewProxy(app interfaces.AppInterface) (*Proxy, error) {
5061 config := app.GetConfig().ReverseProxy
@@ -52,26 +63,96 @@ func NewProxy(app interfaces.AppInterface) (*Proxy, error) {
5263 // Create the routes map
5364 routes := make(map[string]*ProxyRouteInfo)
5465
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+
5597 // Create the HTTPS echo instance
5698 httpsServer := echo.New()
5799 httpsServer.HideBanner = true
58100 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+ }))
60120
61121 // Create the HTTP echo instance (for redirects to HTTPS)
62122 httpServer := echo.New()
63123 httpServer.HideBanner = true
64124 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+ }))
66144
67145 // Create the proxy
68146 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
75156 }
76157
77158 // Set up the certificate manager
@@ -321,6 +402,20 @@ func (p *Proxy) Start() error {
321402 return nil
322403 }
323404
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+
324419 host := c.Request().Host
325420 if strings.Contains(host, ":") {
326421 host = strings.Split(host, ":")[0]
@@ -539,6 +634,20 @@ func (p *Proxy) configureRoutes() {
539634
540635 // Add a handler for all incoming requests (HTTPS)
541636 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+
542651 host := c.Request().Host
543652
544653 // Strip the port from the host
@@ -802,3 +911,47 @@ func (p *Proxy) GetRoutes() map[string]*ProxyRouteInfo {
802911
803912 return routes
804913 }
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.RWMutex32 mu sync.RWMutex
33 certManager *autocert.Manager33 certManager *autocert.Manager
34 serverStarted bool34 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 container43 // ProxyRouteInfo contains the information needed to route traffic to a container
@@ -45,6 +51,11 @@ type ProxyRouteInfo struct {
45 Active bool51 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 proxy59 // 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().ReverseProxy61 config := app.GetConfig().ReverseProxy
@@ -52,26 +63,96 @@ func NewProxy(app interfaces.AppInterface) (*Proxy, error) {
52 // Create the routes map63 // 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 instance97 // Create the HTTPS echo instance
56 httpsServer := echo.New()98 httpsServer := echo.New()
57 httpsServer.HideBanner = true99 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 = true123 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 proxy145 // 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 manager158 // Set up the certificate manager
@@ -321,6 +402,20 @@ func (p *Proxy) Start() error {
321 return nil402 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().Host419 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().Host651 host := c.Request().Host
543 652
544 // Strip the port from the host653 // Strip the port from the host
@@ -802,3 +911,47 @@ func (p *Proxy) GetRoutes() map[string]*ProxyRouteInfo {
802 911
803 return routes912 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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 106

↑↓ moveenter openesc close