Skip to content

Commit eecaa0b

Browse files
authored
Merge pull request #48 from flatrun/feat/configurable-detection-thresholds
feat(security): Add whitelisting and better security filtering
2 parents 96e9b1f + 2f21a6c commit eecaa0b

16 files changed

Lines changed: 765 additions & 49 deletions

File tree

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: 47 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)
@@ -295,6 +303,7 @@ func (s *Server) setupRoutes() {
295303
// Traffic endpoints
296304
protected.GET("/traffic/logs", s.getTrafficLogs)
297305
protected.GET("/traffic/stats", s.getTrafficStats)
306+
protected.GET("/traffic/unknown-domains", s.getUnknownDomainStats)
298307
protected.POST("/traffic/cleanup", s.cleanupTrafficLogs)
299308
protected.GET("/deployments/:name/traffic", s.getDeploymentTrafficStats)
300309
}
@@ -303,8 +312,9 @@ func (s *Server) setupRoutes() {
303312
api.POST("/security/events/ingest", s.ingestSecurityEvent)
304313
api.POST("/traffic/ingest", s.ingestTrafficLog)
305314

306-
// Internal nginx endpoint - token-authenticated for blocked IPs
315+
// Internal nginx endpoints - token-authenticated
307316
api.GET("/_internal/blocked-ips", s.listBlockedIPsInternal)
317+
api.GET("/_internal/whitelist", s.listWhitelistInternal)
308318
}
309319
}
310320

@@ -1185,12 +1195,13 @@ func (s *Server) getSettings(c *gin.Context) {
11851195
"subdomain_style": s.config.Domain.SubdomainStyle,
11861196
},
11871197
"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,
1198+
"enabled": s.config.Nginx.Enabled,
1199+
"image": s.config.Nginx.Image,
1200+
"container_name": s.config.Nginx.ContainerName,
1201+
"config_path": s.config.Nginx.ConfigPath,
1202+
"reload_command": s.config.Nginx.ReloadCommand,
1203+
"external": s.config.Nginx.External,
1204+
"reject_unknown_domains": s.config.Nginx.RejectUnknownDomains,
11941205
},
11951206
"certbot": gin.H{
11961207
"enabled": s.config.Certbot.Enabled,
@@ -1241,12 +1252,13 @@ func (s *Server) updateSettings(c *gin.Context) {
12411252
SubdomainStyle string `json:"subdomain_style"`
12421253
} `json:"domain,omitempty"`
12431254
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"`
1255+
Enabled bool `json:"enabled"`
1256+
Image string `json:"image"`
1257+
ContainerName string `json:"container_name"`
1258+
ConfigPath string `json:"config_path"`
1259+
ReloadCommand string `json:"reload_command"`
1260+
External bool `json:"external"`
1261+
RejectUnknownDomains *bool `json:"reject_unknown_domains"`
12501262
} `json:"nginx,omitempty"`
12511263
Certbot *struct {
12521264
Enabled bool `json:"enabled"`
@@ -1318,6 +1330,9 @@ func (s *Server) updateSettings(c *gin.Context) {
13181330
if req.Nginx.ReloadCommand != "" {
13191331
s.config.Nginx.ReloadCommand = req.Nginx.ReloadCommand
13201332
}
1333+
if req.Nginx.RejectUnknownDomains != nil {
1334+
s.config.Nginx.RejectUnknownDomains = *req.Nginx.RejectUnknownDomains
1335+
}
13211336
}
13221337

13231338
if req.Certbot != nil {
@@ -1426,12 +1441,13 @@ func (s *Server) updateSettings(c *gin.Context) {
14261441
"subdomain_style": s.config.Domain.SubdomainStyle,
14271442
},
14281443
"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,
1444+
"enabled": s.config.Nginx.Enabled,
1445+
"image": s.config.Nginx.Image,
1446+
"container_name": s.config.Nginx.ContainerName,
1447+
"config_path": s.config.Nginx.ConfigPath,
1448+
"reload_command": s.config.Nginx.ReloadCommand,
1449+
"external": s.config.Nginx.External,
1450+
"reject_unknown_domains": s.config.Nginx.RejectUnknownDomains,
14351451
},
14361452
"certbot": gin.H{
14371453
"enabled": s.config.Certbot.Enabled,
@@ -2793,13 +2809,25 @@ func (s *Server) getSystemStats(c *gin.Context) {
27932809
imageStats, _ := s.networksManager.GetImageStats()
27942810
volumeStats, _ := s.networksManager.GetVolumeStats()
27952811

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

27982824
c.JSON(http.StatusOK, gin.H{
27992825
"deployments": stats,
28002826
"containers": containerStats,
28012827
"images": imageStats,
28022828
"volumes": volumeStats,
2829+
"networks": gin.H{"total": networkCount},
2830+
"ports": gin.H{"total": portCount},
28032831
"system": systemStats,
28042832
})
28052833
}

internal/api/traffic_handlers.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,39 @@ func (s *Server) getTrafficStats(c *gin.Context) {
117117
c.JSON(http.StatusOK, gin.H{"stats": stats})
118118
}
119119

120+
func (s *Server) getUnknownDomainStats(c *gin.Context) {
121+
if s.trafficManager == nil {
122+
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Traffic logging not enabled"})
123+
return
124+
}
125+
126+
since := 24 * time.Hour
127+
if sinceStr := c.Query("since"); sinceStr != "" {
128+
if d, err := time.ParseDuration(sinceStr); err == nil {
129+
since = d
130+
}
131+
}
132+
133+
deployments, err := s.manager.ListDeployments()
134+
if err != nil {
135+
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
136+
return
137+
}
138+
139+
var knownDeployments []string
140+
for _, d := range deployments {
141+
knownDeployments = append(knownDeployments, d.Name)
142+
}
143+
144+
stats, err := s.trafficManager.GetUnknownDomainStats(knownDeployments, since)
145+
if err != nil {
146+
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
147+
return
148+
}
149+
150+
c.JSON(http.StatusOK, gin.H{"stats": stats})
151+
}
152+
120153
// cleanupTrafficLogs removes old traffic logs
121154
func (s *Server) cleanupTrafficLogs(c *gin.Context) {
122155
if s.trafficManager == nil {

internal/infra/manager.go

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -357,7 +357,9 @@ func (m *Manager) SetNginxRealtimeCaptureWithStatus(enabled bool) (map[string]in
357357

358358
if enabled {
359359
// Write nginx.conf with Lua support
360-
nginxConf, err := templates.GetNginxConfig(true)
360+
nginxConf, err := templates.GetNginxConfigWithData(true, templates.NginxConfigData{
361+
RejectUnknownDomains: m.config.Nginx.RejectUnknownDomains,
362+
})
361363
if err != nil {
362364
errors = append(errors, fmt.Sprintf("failed to get nginx lua config template: %v", err))
363365
} else {
@@ -414,6 +416,13 @@ func (m *Manager) SetNginxRealtimeCaptureWithStatus(enabled bool) (map[string]in
414416
}
415417
result["conf_files_written"] = true
416418
}
419+
420+
// Ensure ssl directory exists
421+
sslDir := filepath.Join(nginxDir, "ssl")
422+
if err := os.MkdirAll(sslDir, 0755); err != nil {
423+
errors = append(errors, fmt.Sprintf("failed to create ssl directory: %v", err))
424+
}
425+
417426
} else {
418427
// Delete nginx.conf - container will use default from image
419428
if _, err := os.Stat(confPath); err == nil {
@@ -1049,6 +1058,7 @@ func (m *Manager) checkNginxInternalAPIReachable() bool {
10491058
var securityVolumeMounts = []string{
10501059
"./nginx.conf:/usr/local/openresty/nginx/conf/nginx.conf:ro",
10511060
"./lua:/etc/nginx/lua:ro",
1061+
"./ssl:/etc/nginx/ssl:ro",
10521062
}
10531063

10541064
func (m *Manager) getNginxComposePath() string {
@@ -1215,8 +1225,15 @@ func (m *Manager) RefreshSecurityScripts() (*RefreshSecurityScriptsResult, error
12151225
result.Errors = append(result.Errors, fmt.Sprintf("failed to create conf.d directory: %v", err))
12161226
}
12171227

1228+
sslDir := filepath.Join(nginxDir, "ssl")
1229+
if err := os.MkdirAll(sslDir, 0755); err != nil {
1230+
result.Errors = append(result.Errors, fmt.Sprintf("failed to create ssl directory: %v", err))
1231+
}
1232+
12181233
// Write nginx.conf with Lua support
1219-
nginxConf, err := templates.GetNginxConfig(true)
1234+
nginxConf, err := templates.GetNginxConfigWithData(true, templates.NginxConfigData{
1235+
RejectUnknownDomains: m.config.Nginx.RejectUnknownDomains,
1236+
})
12201237
if err != nil {
12211238
result.Errors = append(result.Errors, fmt.Sprintf("failed to get nginx lua config template: %v", err))
12221239
} else {

0 commit comments

Comments
 (0)