Skip to content

Commit 5b413c3

Browse files
committed
feat(security): Add whitelist, better IP extraction
- Whitelist table with default internal IPs/paths prevents self-blocking - CRUD API for whitelist management (/api/security/whitelist) - RejectUnknownDomains config drops connections to unconfigured hosts - Real client IP extracted from CF-Connecting-IP/X-Forwarded-For headers - Docker gateway auto-whitelisted on startup - /stats endpoint includes networks and ports counts Signed-off-by: nfebe <fenn25.fn@gmail.com>
1 parent fd179b6 commit 5b413c3

File tree

12 files changed

+598
-49
lines changed

12 files changed

+598
-49
lines changed

internal/api/security_handlers.go

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,84 @@ func (s *Server) unblockIP(c *gin.Context) {
264264
c.JSON(http.StatusOK, gin.H{"message": "IP unblocked successfully"})
265265
}
266266

267-
// getEventsByIP returns all security events for a specific IP
267+
func (s *Server) listWhitelist(c *gin.Context) {
268+
if s.securityManager == nil {
269+
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Security module not enabled"})
270+
return
271+
}
272+
273+
entries, err := s.securityManager.GetWhitelist()
274+
if err != nil {
275+
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
276+
return
277+
}
278+
279+
c.JSON(http.StatusOK, gin.H{"whitelist": entries})
280+
}
281+
282+
func (s *Server) addWhitelistEntry(c *gin.Context) {
283+
if s.securityManager == nil {
284+
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Security module not enabled"})
285+
return
286+
}
287+
288+
var req struct {
289+
Value string `json:"value" binding:"required"`
290+
Type string `json:"type" binding:"required"`
291+
Reason string `json:"reason"`
292+
}
293+
if err := c.ShouldBindJSON(&req); err != nil {
294+
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
295+
return
296+
}
297+
298+
if req.Type != "ip" && req.Type != "cidr" && req.Type != "path" {
299+
c.JSON(http.StatusBadRequest, gin.H{"error": "Type must be 'ip', 'cidr', or 'path'"})
300+
return
301+
}
302+
303+
id, err := s.securityManager.AddWhitelistEntry(req.Value, req.Type, req.Reason)
304+
if err != nil {
305+
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
306+
return
307+
}
308+
309+
c.JSON(http.StatusCreated, gin.H{"id": id})
310+
}
311+
312+
func (s *Server) removeWhitelistEntry(c *gin.Context) {
313+
if s.securityManager == nil {
314+
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Security module not enabled"})
315+
return
316+
}
317+
318+
idStr := c.Param("id")
319+
id, err := strconv.ParseInt(idStr, 10, 64)
320+
if err != nil {
321+
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
322+
return
323+
}
324+
325+
if err := s.securityManager.RemoveWhitelistEntry(id); err != nil {
326+
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
327+
return
328+
}
329+
330+
c.JSON(http.StatusOK, gin.H{"message": "Entry removed"})
331+
}
332+
333+
func (s *Server) listWhitelistInternal(c *gin.Context) {
334+
token := c.GetHeader("X-Internal-Token")
335+
expectedToken := s.config.Security.InternalAPIToken
336+
337+
if token == "" || token != expectedToken {
338+
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid internal token"})
339+
return
340+
}
341+
342+
s.listWhitelist(c)
343+
}
344+
268345
func (s *Server) getEventsByIP(c *gin.Context) {
269346
if s.securityManager == nil {
270347
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Security module not enabled"})

internal/api/server.go

Lines changed: 46 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,11 @@ func New(cfg *config.Config, configPath string) *Server {
107107
if err := securityManager.InitNginxConfigs(nginxConfigPath); err != nil {
108108
log.Printf("Warning: Failed to initialize security nginx configs: %v", err)
109109
}
110+
// Add Docker gateway IP to whitelist
111+
gatewayIP := infraManager.GetDockerHostIP()
112+
if err := securityManager.AddDockerGatewayToWhitelist(gatewayIP); err != nil {
113+
log.Printf("Warning: Failed to add Docker gateway to whitelist: %v", err)
114+
}
110115
}
111116
}
112117

@@ -284,6 +289,9 @@ func (s *Server) setupRoutes() {
284289
protected.POST("/security/protected-routes", s.addProtectedRoute)
285290
protected.PUT("/security/protected-routes/:id", s.updateProtectedRoute)
286291
protected.DELETE("/security/protected-routes/:id", s.deleteProtectedRoute)
292+
protected.GET("/security/whitelist", s.listWhitelist)
293+
protected.POST("/security/whitelist", s.addWhitelistEntry)
294+
protected.DELETE("/security/whitelist/:id", s.removeWhitelistEntry)
287295
protected.GET("/security/realtime-capture", s.getRealtimeCaptureStatus)
288296
protected.PUT("/security/realtime-capture", s.setRealtimeCaptureStatus)
289297
protected.GET("/security/health", s.getSecurityHealth)
@@ -303,8 +311,9 @@ func (s *Server) setupRoutes() {
303311
api.POST("/security/events/ingest", s.ingestSecurityEvent)
304312
api.POST("/traffic/ingest", s.ingestTrafficLog)
305313

306-
// Internal nginx endpoint - token-authenticated for blocked IPs
314+
// Internal nginx endpoints - token-authenticated
307315
api.GET("/_internal/blocked-ips", s.listBlockedIPsInternal)
316+
api.GET("/_internal/whitelist", s.listWhitelistInternal)
308317
}
309318
}
310319

@@ -1185,12 +1194,13 @@ func (s *Server) getSettings(c *gin.Context) {
11851194
"subdomain_style": s.config.Domain.SubdomainStyle,
11861195
},
11871196
"nginx": gin.H{
1188-
"enabled": s.config.Nginx.Enabled,
1189-
"image": s.config.Nginx.Image,
1190-
"container_name": s.config.Nginx.ContainerName,
1191-
"config_path": s.config.Nginx.ConfigPath,
1192-
"reload_command": s.config.Nginx.ReloadCommand,
1193-
"external": s.config.Nginx.External,
1197+
"enabled": s.config.Nginx.Enabled,
1198+
"image": s.config.Nginx.Image,
1199+
"container_name": s.config.Nginx.ContainerName,
1200+
"config_path": s.config.Nginx.ConfigPath,
1201+
"reload_command": s.config.Nginx.ReloadCommand,
1202+
"external": s.config.Nginx.External,
1203+
"reject_unknown_domains": s.config.Nginx.RejectUnknownDomains,
11941204
},
11951205
"certbot": gin.H{
11961206
"enabled": s.config.Certbot.Enabled,
@@ -1241,12 +1251,13 @@ func (s *Server) updateSettings(c *gin.Context) {
12411251
SubdomainStyle string `json:"subdomain_style"`
12421252
} `json:"domain,omitempty"`
12431253
Nginx *struct {
1244-
Enabled bool `json:"enabled"`
1245-
Image string `json:"image"`
1246-
ContainerName string `json:"container_name"`
1247-
ConfigPath string `json:"config_path"`
1248-
ReloadCommand string `json:"reload_command"`
1249-
External bool `json:"external"`
1254+
Enabled bool `json:"enabled"`
1255+
Image string `json:"image"`
1256+
ContainerName string `json:"container_name"`
1257+
ConfigPath string `json:"config_path"`
1258+
ReloadCommand string `json:"reload_command"`
1259+
External bool `json:"external"`
1260+
RejectUnknownDomains *bool `json:"reject_unknown_domains"`
12501261
} `json:"nginx,omitempty"`
12511262
Certbot *struct {
12521263
Enabled bool `json:"enabled"`
@@ -1318,6 +1329,9 @@ func (s *Server) updateSettings(c *gin.Context) {
13181329
if req.Nginx.ReloadCommand != "" {
13191330
s.config.Nginx.ReloadCommand = req.Nginx.ReloadCommand
13201331
}
1332+
if req.Nginx.RejectUnknownDomains != nil {
1333+
s.config.Nginx.RejectUnknownDomains = *req.Nginx.RejectUnknownDomains
1334+
}
13211335
}
13221336

13231337
if req.Certbot != nil {
@@ -1426,12 +1440,13 @@ func (s *Server) updateSettings(c *gin.Context) {
14261440
"subdomain_style": s.config.Domain.SubdomainStyle,
14271441
},
14281442
"nginx": gin.H{
1429-
"enabled": s.config.Nginx.Enabled,
1430-
"image": s.config.Nginx.Image,
1431-
"container_name": s.config.Nginx.ContainerName,
1432-
"config_path": s.config.Nginx.ConfigPath,
1433-
"reload_command": s.config.Nginx.ReloadCommand,
1434-
"external": s.config.Nginx.External,
1443+
"enabled": s.config.Nginx.Enabled,
1444+
"image": s.config.Nginx.Image,
1445+
"container_name": s.config.Nginx.ContainerName,
1446+
"config_path": s.config.Nginx.ConfigPath,
1447+
"reload_command": s.config.Nginx.ReloadCommand,
1448+
"external": s.config.Nginx.External,
1449+
"reject_unknown_domains": s.config.Nginx.RejectUnknownDomains,
14351450
},
14361451
"certbot": gin.H{
14371452
"enabled": s.config.Certbot.Enabled,
@@ -2793,13 +2808,25 @@ func (s *Server) getSystemStats(c *gin.Context) {
27932808
imageStats, _ := s.networksManager.GetImageStats()
27942809
volumeStats, _ := s.networksManager.GetVolumeStats()
27952810

2811+
var networkCount, portCount int
2812+
if networks, err := s.networksManager.ListNetworks(); err == nil {
2813+
networkCount = len(networks)
2814+
}
2815+
if containers, err := s.networksManager.ListContainers(); err == nil {
2816+
for _, container := range containers {
2817+
portCount += len(container.Ports)
2818+
}
2819+
}
2820+
27962821
systemStats, _ := system.GetSystemStats()
27972822

27982823
c.JSON(http.StatusOK, gin.H{
27992824
"deployments": stats,
28002825
"containers": containerStats,
28012826
"images": imageStats,
28022827
"volumes": volumeStats,
2828+
"networks": gin.H{"total": networkCount},
2829+
"ports": gin.H{"total": portCount},
28032830
"system": systemStats,
28042831
})
28052832
}

internal/infra/manager.go

Lines changed: 109 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,15 @@ package infra
22

33
import (
44
"bytes"
5+
"crypto/ecdsa"
6+
"crypto/elliptic"
7+
"crypto/rand"
8+
"crypto/x509"
9+
"crypto/x509/pkix"
510
"encoding/json"
11+
"encoding/pem"
612
"fmt"
13+
"math/big"
714
"os"
815
"os/exec"
916
"path/filepath"
@@ -357,7 +364,9 @@ func (m *Manager) SetNginxRealtimeCaptureWithStatus(enabled bool) (map[string]in
357364

358365
if enabled {
359366
// Write nginx.conf with Lua support
360-
nginxConf, err := templates.GetNginxConfig(true)
367+
nginxConf, err := templates.GetNginxConfigWithData(true, templates.NginxConfigData{
368+
RejectUnknownDomains: m.config.Nginx.RejectUnknownDomains,
369+
})
361370
if err != nil {
362371
errors = append(errors, fmt.Sprintf("failed to get nginx lua config template: %v", err))
363372
} else {
@@ -414,6 +423,19 @@ func (m *Manager) SetNginxRealtimeCaptureWithStatus(enabled bool) (map[string]in
414423
}
415424
result["conf_files_written"] = true
416425
}
426+
427+
// Ensure ssl directory exists
428+
sslDir := filepath.Join(nginxDir, "ssl")
429+
if err := os.MkdirAll(sslDir, 0755); err != nil {
430+
errors = append(errors, fmt.Sprintf("failed to create ssl directory: %v", err))
431+
}
432+
433+
// Generate default SSL cert if rejecting unknown domains
434+
if m.config.Nginx.RejectUnknownDomains {
435+
if err := m.ensureDefaultSSLCert(nginxDir); err != nil {
436+
errors = append(errors, fmt.Sprintf("failed to generate default SSL cert: %v", err))
437+
}
438+
}
417439
} else {
418440
// Delete nginx.conf - container will use default from image
419441
if _, err := os.Stat(confPath); err == nil {
@@ -577,6 +599,77 @@ func (m *Manager) getNginxDir() string {
577599
return filepath.Dir(configPath)
578600
}
579601

602+
func (m *Manager) ensureDefaultSSLCert(nginxDir string) error {
603+
sslDir := filepath.Join(nginxDir, "ssl")
604+
if err := os.MkdirAll(sslDir, 0755); err != nil {
605+
return fmt.Errorf("failed to create ssl directory: %w", err)
606+
}
607+
608+
certPath := filepath.Join(sslDir, "default.crt")
609+
keyPath := filepath.Join(sslDir, "default.key")
610+
611+
if _, err := os.Stat(certPath); err == nil {
612+
if _, err := os.Stat(keyPath); err == nil {
613+
return nil
614+
}
615+
}
616+
617+
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
618+
if err != nil {
619+
return fmt.Errorf("failed to generate private key: %w", err)
620+
}
621+
622+
serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
623+
if err != nil {
624+
return fmt.Errorf("failed to generate serial number: %w", err)
625+
}
626+
627+
template := x509.Certificate{
628+
SerialNumber: serialNumber,
629+
Subject: pkix.Name{
630+
Organization: []string{"FlatRun Default"},
631+
CommonName: "localhost",
632+
},
633+
NotBefore: time.Now(),
634+
NotAfter: time.Now().AddDate(10, 0, 0),
635+
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
636+
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
637+
BasicConstraintsValid: true,
638+
}
639+
640+
certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
641+
if err != nil {
642+
return fmt.Errorf("failed to create certificate: %w", err)
643+
}
644+
645+
certOut, err := os.Create(certPath)
646+
if err != nil {
647+
return fmt.Errorf("failed to create cert file: %w", err)
648+
}
649+
defer certOut.Close()
650+
651+
if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: certDER}); err != nil {
652+
return fmt.Errorf("failed to write certificate: %w", err)
653+
}
654+
655+
keyBytes, err := x509.MarshalECPrivateKey(priv)
656+
if err != nil {
657+
return fmt.Errorf("failed to marshal private key: %w", err)
658+
}
659+
660+
keyOut, err := os.Create(keyPath)
661+
if err != nil {
662+
return fmt.Errorf("failed to create key file: %w", err)
663+
}
664+
defer keyOut.Close()
665+
666+
if err := pem.Encode(keyOut, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes}); err != nil {
667+
return fmt.Errorf("failed to write private key: %w", err)
668+
}
669+
670+
return nil
671+
}
672+
580673
// SecurityHealthCheck represents the result of a security setup health check
581674
type SecurityHealthCheck struct {
582675
Status string `json:"status"`
@@ -1049,6 +1142,7 @@ func (m *Manager) checkNginxInternalAPIReachable() bool {
10491142
var securityVolumeMounts = []string{
10501143
"./nginx.conf:/usr/local/openresty/nginx/conf/nginx.conf:ro",
10511144
"./lua:/etc/nginx/lua:ro",
1145+
"./ssl:/etc/nginx/ssl:ro",
10521146
}
10531147

10541148
func (m *Manager) getNginxComposePath() string {
@@ -1215,8 +1309,21 @@ func (m *Manager) RefreshSecurityScripts() (*RefreshSecurityScriptsResult, error
12151309
result.Errors = append(result.Errors, fmt.Sprintf("failed to create conf.d directory: %v", err))
12161310
}
12171311

1312+
sslDir := filepath.Join(nginxDir, "ssl")
1313+
if err := os.MkdirAll(sslDir, 0755); err != nil {
1314+
result.Errors = append(result.Errors, fmt.Sprintf("failed to create ssl directory: %v", err))
1315+
}
1316+
1317+
if m.config.Nginx.RejectUnknownDomains {
1318+
if err := m.ensureDefaultSSLCert(nginxDir); err != nil {
1319+
result.Errors = append(result.Errors, fmt.Sprintf("failed to generate default SSL cert: %v", err))
1320+
}
1321+
}
1322+
12181323
// Write nginx.conf with Lua support
1219-
nginxConf, err := templates.GetNginxConfig(true)
1324+
nginxConf, err := templates.GetNginxConfigWithData(true, templates.NginxConfigData{
1325+
RejectUnknownDomains: m.config.Nginx.RejectUnknownDomains,
1326+
})
12201327
if err != nil {
12211328
result.Errors = append(result.Errors, fmt.Sprintf("failed to get nginx lua config template: %v", err))
12221329
} else {

0 commit comments

Comments
 (0)