Skip to content

Commit b4e3859

Browse files
authored
Merge pull request #42 from flatrun/feat/security-monitoring-improvements
feat: Security monitoring improvements
2 parents 50bad75 + b06b780 commit b4e3859

File tree

17 files changed

+1952
-43
lines changed

17 files changed

+1952
-43
lines changed

internal/api/security_handlers.go

Lines changed: 57 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -395,8 +395,12 @@ func (s *Server) updateDeploymentSecurity(c *gin.Context) {
395395
}
396396

397397
vhostUpdated := false
398+
var hookStatus interface{}
399+
398400
if s.proxyOrchestrator != nil && s.proxyOrchestrator.NginxManager().VirtualHostExists(name) {
399-
if err := s.proxyOrchestrator.NginxManager().UpdateVirtualHost(deployment); err != nil {
401+
nginxMgr := s.proxyOrchestrator.NginxManager()
402+
403+
if err := nginxMgr.UpdateVirtualHost(deployment); err != nil {
400404
c.JSON(http.StatusOK, gin.H{
401405
"security": securityConfig,
402406
"vhost_updated": false,
@@ -405,30 +409,49 @@ func (s *Server) updateDeploymentSecurity(c *gin.Context) {
405409
return
406410
}
407411

408-
if err := s.proxyOrchestrator.NginxManager().TestConfig(); err != nil {
412+
if err := nginxMgr.ValidateSecurityHooks(name, securityConfig.Enabled); err != nil {
413+
c.JSON(http.StatusOK, gin.H{
414+
"security": securityConfig,
415+
"vhost_updated": true,
416+
"validation_error": err.Error(),
417+
"warning": "Vhost updated but security hook validation failed",
418+
})
419+
return
420+
}
421+
422+
hookStatus, _ = nginxMgr.GetSecurityHookStatus(name)
423+
424+
if err := nginxMgr.TestConfig(); err != nil {
409425
c.JSON(http.StatusOK, gin.H{
410426
"security": securityConfig,
411-
"vhost_updated": false,
427+
"vhost_updated": true,
428+
"hook_status": hookStatus,
412429
"warning": "Security config saved but nginx config test failed: " + err.Error(),
413430
})
414431
return
415432
}
416433

417-
if err := s.proxyOrchestrator.NginxManager().Reload(); err != nil {
434+
if err := nginxMgr.Reload(); err != nil {
418435
c.JSON(http.StatusOK, gin.H{
419436
"security": securityConfig,
420437
"vhost_updated": true,
438+
"hook_status": hookStatus,
421439
"warning": "Nginx reload failed (may need manual reload): " + err.Error(),
422440
})
423441
return
424442
}
425443
vhostUpdated = true
426444
}
427445

428-
c.JSON(http.StatusOK, gin.H{
446+
response := gin.H{
429447
"security": securityConfig,
430448
"vhost_updated": vhostUpdated,
431-
})
449+
}
450+
if hookStatus != nil {
451+
response["hook_status"] = hookStatus
452+
}
453+
454+
c.JSON(http.StatusOK, response)
432455
}
433456

434457
// getDeploymentSecurityEvents returns security events for a deployment
@@ -675,3 +698,31 @@ func (s *Server) updateSecuritySettings(c *gin.Context) {
675698

676699
c.JSON(http.StatusOK, result)
677700
}
701+
702+
// refreshSecurityScripts regenerates Lua scripts with correct agent IP and reloads nginx
703+
func (s *Server) refreshSecurityScripts(c *gin.Context) {
704+
if !s.config.Security.Enabled {
705+
c.JSON(http.StatusBadRequest, gin.H{
706+
"error": "Security module not enabled",
707+
})
708+
return
709+
}
710+
711+
if !s.infraManager.IsNginxRunning() {
712+
c.JSON(http.StatusServiceUnavailable, gin.H{
713+
"error": "Nginx container is not running",
714+
})
715+
return
716+
}
717+
718+
result, err := s.infraManager.RefreshSecurityScripts()
719+
if err != nil {
720+
c.JSON(http.StatusInternalServerError, gin.H{
721+
"error": err.Error(),
722+
"result": result,
723+
})
724+
return
725+
}
726+
727+
c.JSON(http.StatusOK, result)
728+
}

internal/api/server.go

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
"github.com/flatrun/agent/internal/proxy"
2727
"github.com/flatrun/agent/internal/security"
2828
"github.com/flatrun/agent/internal/system"
29+
"github.com/flatrun/agent/internal/traffic"
2930
"github.com/flatrun/agent/pkg/config"
3031
"github.com/flatrun/agent/pkg/models"
3132
"github.com/flatrun/agent/pkg/plugins"
@@ -53,6 +54,7 @@ type Server struct {
5354
infraManager *infra.Manager
5455
credentialsManager *credentials.Manager
5556
securityManager *security.Manager
57+
trafficManager *traffic.Manager
5658
}
5759

5860
func New(cfg *config.Config, configPath string) *Server {
@@ -99,6 +101,12 @@ func New(cfg *config.Config, configPath string) *Server {
99101
}
100102
}
101103

104+
var trafficManager *traffic.Manager
105+
trafficManager, err := traffic.NewManager(cfg.DeploymentsPath, 7)
106+
if err != nil {
107+
log.Printf("Warning: Failed to initialize traffic manager: %v", err)
108+
}
109+
102110
s := &Server{
103111
config: cfg,
104112
configPath: configPath,
@@ -115,6 +123,7 @@ func New(cfg *config.Config, configPath string) *Server {
115123
infraManager: infraManager,
116124
credentialsManager: credentialsManager,
117125
securityManager: securityManager,
126+
trafficManager: trafficManager,
118127
}
119128

120129
s.setupRoutes()
@@ -224,6 +233,7 @@ func (s *Server) setupRoutes() {
224233
protected.POST("/databases/tables/schema", s.describeTable)
225234
protected.POST("/databases/query", s.executeDatabaseQuery)
226235
protected.POST("/databases/users", s.listDatabaseUsers)
236+
protected.POST("/databases/users/by-database", s.listUsersByDatabase)
227237
protected.POST("/databases/create", s.createDatabaseInServer)
228238
protected.POST("/databases/delete", s.deleteDatabaseInServer)
229239
protected.POST("/databases/users/create", s.createDatabaseUser)
@@ -268,13 +278,21 @@ func (s *Server) setupRoutes() {
268278
protected.GET("/security/realtime-capture", s.getRealtimeCaptureStatus)
269279
protected.PUT("/security/realtime-capture", s.setRealtimeCaptureStatus)
270280
protected.GET("/security/health", s.getSecurityHealth)
281+
protected.POST("/security/refresh", s.refreshSecurityScripts)
271282
protected.GET("/deployments/:name/security", s.getDeploymentSecurity)
272283
protected.PUT("/deployments/:name/security", s.updateDeploymentSecurity)
273284
protected.GET("/deployments/:name/security/events", s.getDeploymentSecurityEvents)
285+
286+
// Traffic endpoints
287+
protected.GET("/traffic/logs", s.getTrafficLogs)
288+
protected.GET("/traffic/stats", s.getTrafficStats)
289+
protected.POST("/traffic/cleanup", s.cleanupTrafficLogs)
290+
protected.GET("/deployments/:name/traffic", s.getDeploymentTrafficStats)
274291
}
275292

276-
// Security event ingest endpoint (no auth - called by nginx Lua)
293+
// Ingest endpoints (no auth - called by nginx Lua)
277294
api.POST("/security/events/ingest", s.ingestSecurityEvent)
295+
api.POST("/traffic/ingest", s.ingestTrafficLog)
278296
}
279297
}
280298

@@ -3464,6 +3482,31 @@ func (s *Server) listDatabaseUsers(c *gin.Context) {
34643482
})
34653483
}
34663484

3485+
func (s *Server) listUsersByDatabase(c *gin.Context) {
3486+
var req struct {
3487+
database.ConnectionConfig
3488+
Database string `json:"database" binding:"required"`
3489+
}
3490+
if err := c.ShouldBindJSON(&req); err != nil {
3491+
c.JSON(http.StatusBadRequest, gin.H{
3492+
"error": err.Error(),
3493+
})
3494+
return
3495+
}
3496+
3497+
users, err := s.databaseManager.ListDatabaseUsers(&req.ConnectionConfig, req.Database)
3498+
if err != nil {
3499+
c.JSON(http.StatusInternalServerError, gin.H{
3500+
"error": err.Error(),
3501+
})
3502+
return
3503+
}
3504+
3505+
c.JSON(http.StatusOK, gin.H{
3506+
"users": users,
3507+
})
3508+
}
3509+
34673510
func (s *Server) createDatabaseInServer(c *gin.Context) {
34683511
var req struct {
34693512
database.ConnectionConfig

internal/api/server_test.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1044,3 +1044,83 @@ func TestProxySyncResultFailed(t *testing.T) {
10441044
t.Error("expected Created to be false for failed")
10451045
}
10461046
}
1047+
1048+
func TestListUsersByDatabaseRequestStructure(t *testing.T) {
1049+
type listUsersByDatabaseRequest struct {
1050+
Type string `json:"type"`
1051+
Host string `json:"host"`
1052+
Port int `json:"port"`
1053+
Username string `json:"username"`
1054+
Password string `json:"password"`
1055+
Database string `json:"database"`
1056+
}
1057+
1058+
tests := []struct {
1059+
name string
1060+
req listUsersByDatabaseRequest
1061+
wantMissing string
1062+
}{
1063+
{
1064+
name: "valid request",
1065+
req: listUsersByDatabaseRequest{
1066+
Type: "mysql",
1067+
Host: "localhost",
1068+
Port: 3306,
1069+
Username: "root",
1070+
Password: "secret",
1071+
Database: "testdb",
1072+
},
1073+
wantMissing: "",
1074+
},
1075+
{
1076+
name: "missing database",
1077+
req: listUsersByDatabaseRequest{
1078+
Type: "mysql",
1079+
Host: "localhost",
1080+
Port: 3306,
1081+
Username: "root",
1082+
Password: "secret",
1083+
},
1084+
wantMissing: "database",
1085+
},
1086+
}
1087+
1088+
for _, tt := range tests {
1089+
t.Run(tt.name, func(t *testing.T) {
1090+
if tt.wantMissing == "database" && tt.req.Database != "" {
1091+
t.Error("test setup error: database should be empty for missing database test")
1092+
}
1093+
if tt.wantMissing == "" && tt.req.Database == "" {
1094+
t.Error("test setup error: database should not be empty for valid request test")
1095+
}
1096+
})
1097+
}
1098+
}
1099+
1100+
func TestListUsersByDatabaseResponseStructure(t *testing.T) {
1101+
type userInfo struct {
1102+
Name string `json:"name"`
1103+
Host string `json:"host,omitempty"`
1104+
}
1105+
1106+
type response struct {
1107+
Users []userInfo `json:"users"`
1108+
}
1109+
1110+
resp := response{
1111+
Users: []userInfo{
1112+
{Name: "app_user", Host: "%"},
1113+
{Name: "readonly", Host: "localhost"},
1114+
},
1115+
}
1116+
1117+
if len(resp.Users) != 2 {
1118+
t.Errorf("expected 2 users, got %d", len(resp.Users))
1119+
}
1120+
if resp.Users[0].Name != "app_user" {
1121+
t.Errorf("expected first user 'app_user', got '%s'", resp.Users[0].Name)
1122+
}
1123+
if resp.Users[1].Host != "localhost" {
1124+
t.Errorf("expected second user host 'localhost', got '%s'", resp.Users[1].Host)
1125+
}
1126+
}

0 commit comments

Comments
 (0)