diff --git a/internal/api/apikeys.go b/internal/api/apikeys.go index ed28d10..de9aba0 100644 --- a/internal/api/apikeys.go +++ b/internal/api/apikeys.go @@ -79,19 +79,42 @@ func (s *Server) getAPIKey(c *gin.Context) { func (s *Server) createAPIKey(c *gin.Context) { actor := auth.GetActorFromContext(c) - if actor == nil || actor.User == nil { + if actor == nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"}) return } + ownerUser := actor.User + if ownerUser == nil { + if actor.Role != auth.RoleAdmin { + c.JSON(http.StatusForbidden, gin.H{"error": "Current authentication has no user identity; API keys cannot be created"}) + return + } + users, err := s.authManager.GetUsers() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Cannot list users to attribute API key: " + err.Error()}) + return + } + for i := range users { + if users[i].Role == auth.RoleAdmin { + ownerUser = &users[i] + break + } + } + if ownerUser == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "No admin user available to own the new API key"}) + return + } + } + var req struct { - Name string `json:"name" binding:"required"` - Description string `json:"description"` - Role auth.Role `json:"role"` - Permissions []string `json:"permissions"` - Deployments []string `json:"deployments"` - ExpiresIn int `json:"expires_in"` - UserID int64 `json:"user_id"` + Name string `json:"name" binding:"required"` + Description string `json:"description"` + Role auth.Role `json:"role"` + Permissions []string `json:"permissions"` + Deployments auth.DeploymentAccess `json:"deployments"` + ExpiresIn int `json:"expires_in"` + UserID int64 `json:"user_id"` } if err := c.ShouldBindJSON(&req); err != nil { @@ -99,7 +122,7 @@ func (s *Server) createAPIKey(c *gin.Context) { return } - userID := actor.User.ID + userID := ownerUser.ID if req.UserID > 0 && actor.Role == auth.RoleAdmin { userID = req.UserID } @@ -179,7 +202,7 @@ func (s *Server) revokeAPIKey(c *gin.Context) { } func apiKeyToResponse(k *auth.APIKey) gin.H { - return gin.H{ + response := gin.H{ "id": k.ID, "key_id": k.KeyID, "user_id": k.UserID, @@ -189,10 +212,100 @@ func apiKeyToResponse(k *auth.APIKey) gin.H { "role": k.Role, "permissions": k.Permissions, "deployments": k.Deployments, - "expires_at": k.ExpiresAt, - "last_used_at": k.LastUsedAt, "last_used_ip": k.LastUsedIP, "is_active": k.IsActive, "created_at": k.CreatedAt, } + if k.ExpiresAt.IsZero() { + response["expires_at"] = nil + } else { + response["expires_at"] = k.ExpiresAt + } + if k.LastUsedAt.IsZero() { + response["last_used_at"] = nil + } else { + response["last_used_at"] = k.LastUsedAt + } + return response +} + +func (s *Server) updateAPIKey(c *gin.Context) { + key, ok := s.getAPIKeyWithAuth(c) + if !ok { + return + } + actor := auth.GetActorFromContext(c) + if actor == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"}) + return + } + + var req struct { + Name *string `json:"name"` + Description *string `json:"description"` + Role *auth.Role `json:"role"` + Permissions *[]string `json:"permissions"` + Deployments *auth.DeploymentAccess `json:"deployments"` + ExpiresIn *int `json:"expires_in"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + if req.Name != nil { + key.Name = *req.Name + } + if req.Description != nil { + key.Description = *req.Description + } + if req.Role != nil { + if *req.Role != "" && !req.Role.IsValid() { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid role"}) + return + } + if actor.Role != auth.RoleAdmin && *req.Role == auth.RoleAdmin { + c.JSON(http.StatusForbidden, gin.H{"error": "Cannot grant admin role"}) + return + } + key.Role = *req.Role + } + if req.Permissions != nil { + if actor.Role != auth.RoleAdmin { + for _, p := range *req.Permissions { + if !actor.HasPermission(auth.Permission(p)) { + c.JSON(http.StatusForbidden, gin.H{"error": "Cannot grant permission you don't have: " + p}) + return + } + } + } + key.Permissions = *req.Permissions + } + if req.Deployments != nil { + key.Deployments = *req.Deployments + } + if req.ExpiresIn != nil { + if *req.ExpiresIn > 0 { + key.ExpiresAt = time.Now().Add(time.Duration(*req.ExpiresIn) * time.Second) + } else { + key.ExpiresAt = time.Time{} + } + } + + updated, err := s.authManager.UpdateAPIKey( + key.ID, + key.Name, + key.Description, + key.Role, + key.Permissions, + key.Deployments, + key.ExpiresAt, + ) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update API key"}) + return + } + + c.JSON(http.StatusOK, gin.H{"api_key": apiKeyToResponse(updated)}) } diff --git a/internal/api/apikeys_test.go b/internal/api/apikeys_test.go index eda02e7..b3edca7 100644 --- a/internal/api/apikeys_test.go +++ b/internal/api/apikeys_test.go @@ -416,8 +416,11 @@ func TestCreateAPIKeyWithDeploymentScope(t *testing.T) { _ = json.Unmarshal(w.Body.Bytes(), &resp) apiKey := resp["api_key"].(map[string]interface{}) - deployments := apiKey["deployments"].([]interface{}) + deployments := apiKey["deployments"].(map[string]interface{}) if len(deployments) != 2 { t.Errorf("Expected 2 deployments, got %d", len(deployments)) } + if deployments["app-a"] != "admin" || deployments["app-b"] != "admin" { + t.Errorf("Legacy array deployments should default to admin level, got %v", deployments) + } } diff --git a/internal/api/container_exec.go b/internal/api/container_exec.go index ace77fc..45d6027 100644 --- a/internal/api/container_exec.go +++ b/internal/api/container_exec.go @@ -7,12 +7,14 @@ import ( "net/http" "os" "os/exec" + "strings" "sync" "time" "github.com/creack/pty" "github.com/flatrun/agent/internal/auth" "github.com/flatrun/agent/internal/contextkeys" + "github.com/flatrun/agent/pkg/models" "github.com/gin-gonic/gin" "github.com/gorilla/websocket" ) @@ -86,6 +88,15 @@ func (s *Server) containerExec(c *gin.Context) { sendError(conn, "No access to this container") return } + if deploymentName, err := containerDeploymentName(containerID); err == nil && deploymentName != "" { + if blocked, reason, err := s.protectedDeploymentActionBlocked(deploymentName, protectedActionTerminal); err != nil { + sendError(conn, "Failed to check protected mode: "+err.Error()) + return + } else if blocked { + sendTerminalError(conn, "protected_mode", reason) + return + } + } if s.authMiddleware.IsAuthEnabled() { if err := conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"auth_success"}`)); err != nil { return @@ -146,6 +157,7 @@ func (s *Server) containerExec(c *gin.Context) { wg.Add(1) go func() { defer wg.Done() + var commandBuffer strings.Builder for { select { case <-done: @@ -169,7 +181,13 @@ func (s *Server) containerExec(c *gin.Context) { } } - // Write input to PTY + if blocked, err := s.handleTerminalInput(containerID, ptmx, conn, message, &commandBuffer); err != nil { + log.Printf("PTY write error: %v", err) + return + } else if blocked { + continue + } + if _, err := ptmx.Write(message); err != nil { log.Printf("PTY write error: %v", err) return @@ -187,6 +205,43 @@ func (s *Server) containerExec(c *gin.Context) { wg.Wait() } +func (s *Server) handleTerminalInput(containerID string, ptmx *os.File, conn *websocket.Conn, message []byte, commandBuffer *strings.Builder) (bool, error) { + for _, b := range message { + switch b { + case '\r', '\n': + command := strings.TrimSpace(commandBuffer.String()) + commandBuffer.Reset() + if command == "" { + continue + } + blocked, rule, err := s.protectedContainerCommandBlocked(containerID, command) + if err != nil { + return false, err + } + if blocked { + if _, err := ptmx.Write([]byte{0x15}); err != nil { + return false, err + } + sendTerminalCommandBlocked(conn, command, rule) + return true, nil + } + case 0x03: + commandBuffer.Reset() + case 0x7f, 0x08: + current := commandBuffer.String() + if len(current) > 0 { + commandBuffer.Reset() + commandBuffer.WriteString(current[:len(current)-1]) + } + default: + if b >= 0x20 { + commandBuffer.WriteByte(b) + } + } + } + return false, nil +} + func detectShell(containerID string) string { shells := []string{"/bin/bash", "/bin/sh", "sh"} @@ -206,6 +261,35 @@ func sendError(conn *websocket.Conn, msg string) { _ = conn.WriteMessage(websocket.TextMessage, []byte("\r\n\x1b[31mError: "+msg+"\x1b[0m\r\n")) } +func sendTerminalError(conn *websocket.Conn, code, msg string) { + payload, err := json.Marshal(gin.H{ + "type": "error", + "code": code, + "message": msg, + }) + if err != nil { + sendError(conn, msg) + return + } + _ = conn.WriteMessage(websocket.TextMessage, payload) +} + +func sendTerminalCommandBlocked(conn *websocket.Conn, command string, rule *models.ProtectedCommandRule) { + ruleLabel := "" + if rule != nil { + ruleLabel = rule.Name + if ruleLabel == "" { + ruleLabel = rule.ID + } + } + msg := "\r\n\x1b[31mCommand blocked: " + command + "\x1b[0m" + if ruleLabel != "" { + msg += "\r\n\x1b[90mRule: " + ruleLabel + "\x1b[0m" + } + msg += "\r\n" + _ = conn.WriteMessage(websocket.TextMessage, []byte(msg)) +} + func (s *Server) containerExecHTTP(c *gin.Context) { containerID := c.Param("id") if !s.requireContainerAccess(c, containerID, auth.AccessLevelWrite) { @@ -227,6 +311,40 @@ func (s *Server) containerExecHTTP(c *gin.Context) { req.Args = []string{"-c", "echo 'Shell ready'"} } + commandLine := strings.Join(append([]string{req.Command}, req.Args...), " ") + if deploymentName, err := containerDeploymentName(containerID); err == nil && deploymentName != "" { + if blocked, reason, err := s.protectedDeploymentActionBlocked(deploymentName, protectedActionExec); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check protected mode: " + err.Error()}) + return + } else if blocked { + c.JSON(http.StatusLocked, gin.H{ + "error": reason, + "action": protectedActionExec, + "reason": reason, + }) + return + } + } + blocked, rule, err := s.protectedContainerCommandBlocked(containerID, commandLine) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check protected command rules: " + err.Error()}) + return + } + if blocked { + ruleName := rule.Name + if ruleName == "" { + ruleName = rule.ID + } + c.JSON(http.StatusLocked, gin.H{ + "error": "Command blocked by deployment protected mode", + "command": commandLine, + "rule": ruleName, + "match": rule.Match, + "pattern": rule.Pattern, + }) + return + } + args := append([]string{"exec", containerID, req.Command}, req.Args...) cmd := exec.Command("docker", args...) cmd.Env = os.Environ() diff --git a/internal/api/protected_mode.go b/internal/api/protected_mode.go new file mode 100644 index 0000000..5d67cd5 --- /dev/null +++ b/internal/api/protected_mode.go @@ -0,0 +1,240 @@ +package api + +import ( + "fmt" + "net/http" + "regexp" + "strings" + + "github.com/flatrun/agent/pkg/models" + "github.com/gin-gonic/gin" +) + +const ( + protectedActionDeleteDeployment = "delete_deployment" + protectedActionUpdateDeployment = "update_deployment" + protectedActionUpdateMetadata = "update_metadata" + protectedActionUpdateEnv = "update_env" + protectedActionDeleteFile = "delete_file" + protectedActionUploadFile = "upload_file" + protectedActionCreateDir = "create_dir" + protectedActionQuickAction = "quick_action" + protectedActionTerminal = "terminal" + protectedActionExec = "exec" + protectedActionRebuild = "rebuild_deployment" +) + +var defaultProtectedActions = map[string]struct{}{ + protectedActionDeleteDeployment: {}, + protectedActionUpdateDeployment: {}, + protectedActionUpdateEnv: {}, + protectedActionDeleteFile: {}, + protectedActionRebuild: {}, +} + +var protectedCommandRuleMatches = map[string]struct{}{ + "contains": {}, + "equals": {}, + "prefix": {}, + "suffix": {}, + "matches": {}, +} + +func (s *Server) requireUnprotectedDeploymentAction(c *gin.Context, deploymentName, action string) bool { + blocked, reason, err := s.protectedDeploymentActionBlocked(deploymentName, action) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"}) + return false + } + if blocked { + c.JSON(http.StatusLocked, gin.H{ + "error": reason, + "action": action, + "reason": reason, + }) + return false + } + return true +} + +func (s *Server) protectedDeploymentActionBlocked(deploymentName, action string) (bool, string, error) { + deployment, err := s.manager.GetDeployment(deploymentName) + if err != nil { + return false, "", err + } + if !deploymentProtectedModeEnabled(deployment.Metadata) { + return false, "", nil + } + if protectedActionBlocked(deployment.Metadata.ProtectedMode, action) { + return true, protectedActionBlockedReason(deployment.Metadata.ProtectedMode, action), nil + } + return false, "", nil +} + +func deploymentProtectedModeEnabled(metadata *models.ServiceMetadata) bool { + return metadata != nil && metadata.ProtectedMode != nil && metadata.ProtectedMode.Enabled +} + +func protectedActionBlocked(cfg *models.ProtectedModeConfig, action string) bool { + if cfg == nil || !cfg.Enabled { + return false + } + for _, blocked := range cfg.BlockedActions { + if strings.EqualFold(strings.TrimSpace(blocked), action) { + return true + } + } + if action == protectedActionTerminal && cfg.DisableTerminal { + return true + } + if len(cfg.BlockedActions) > 0 { + return false + } + _, blocked := defaultProtectedActions[action] + return blocked +} + +func protectedActionBlockedReason(cfg *models.ProtectedModeConfig, action string) string { + if action == protectedActionTerminal && cfg != nil && cfg.DisableTerminal { + return "Terminal access is disabled for this deployment by protected mode settings" + } + return fmt.Sprintf("%s is blocked for this deployment", protectedActionLabel(action)) +} + +func protectedActionLabel(action string) string { + switch action { + case protectedActionDeleteDeployment: + return "Deleting this deployment" + case protectedActionUpdateDeployment: + return "Editing the compose configuration" + case protectedActionUpdateMetadata: + return "Updating deployment metadata" + case protectedActionUpdateEnv: + return "Editing environment variables" + case protectedActionDeleteFile: + return "Deleting deployment files" + case protectedActionUploadFile: + return "Uploading deployment files" + case protectedActionCreateDir: + return "Creating deployment directories" + case protectedActionQuickAction: + return "Running quick actions" + case protectedActionTerminal: + return "Opening the terminal" + case protectedActionExec: + return "Executing container commands" + case protectedActionRebuild: + return "Rebuilding this deployment" + default: + return "This action" + } +} + +func validateProtectedModeConfig(cfg *models.ProtectedModeConfig) error { + if cfg == nil { + return nil + } + for i, rule := range cfg.BlockedCommandRules { + match := strings.ToLower(strings.TrimSpace(rule.Match)) + if match == "" { + return fmt.Errorf("blocked_command_rules[%d].match is required", i) + } + if _, ok := protectedCommandRuleMatches[match]; !ok { + return fmt.Errorf("blocked_command_rules[%d].match must be one of: contains, equals, prefix, suffix, matches", i) + } + if strings.TrimSpace(rule.Pattern) == "" { + return fmt.Errorf("blocked_command_rules[%d].pattern is required", i) + } + if match == "matches" { + if _, err := regexp.Compile(rule.Pattern); err != nil { + return fmt.Errorf("blocked_command_rules[%d].pattern is not a valid regex: %w", i, err) + } + } + } + return nil +} + +func protectedCommandBlocked(cfg *models.ProtectedModeConfig, command string) (bool, *models.ProtectedCommandRule, error) { + if cfg == nil || !cfg.Enabled { + return false, nil, nil + } + normalizedCommand := strings.ToLower(strings.Join(strings.Fields(command), " ")) + for _, rule := range cfg.BlockedCommandRules { + ruleCopy := rule + matched, err := protectedCommandRuleMatchesCommand(ruleCopy, command, normalizedCommand) + if err != nil { + return false, nil, err + } + if matched { + return true, &ruleCopy, nil + } + } + return false, nil, nil +} + +func protectedCommandBlockMessage(command string, rule *models.ProtectedCommandRule) string { + if rule == nil { + return fmt.Sprintf("Command blocked: %s", command) + } + ruleLabel := rule.Name + if ruleLabel == "" { + ruleLabel = rule.ID + } + if ruleLabel == "" { + return fmt.Sprintf("Command blocked: %s", command) + } + return fmt.Sprintf("Command blocked: %s (rule: %s)", command, ruleLabel) +} + +func protectedCommandRuleMatchesCommand(rule models.ProtectedCommandRule, command, normalizedCommand string) (bool, error) { + match := strings.ToLower(strings.TrimSpace(rule.Match)) + pattern := strings.Join(strings.Fields(rule.Pattern), " ") + if match == "" || pattern == "" { + return false, nil + } + + target := normalizedCommand + needle := strings.ToLower(pattern) + if rule.CaseSensitive { + target = strings.Join(strings.Fields(command), " ") + needle = pattern + } + + switch match { + case "contains": + return strings.Contains(target, needle), nil + case "equals": + return target == needle, nil + case "prefix": + return strings.HasPrefix(target, needle), nil + case "suffix": + return strings.HasSuffix(target, needle), nil + case "matches": + expr := pattern + if !rule.CaseSensitive { + expr = "(?i)" + expr + } + re, err := regexp.Compile(expr) + if err != nil { + return false, err + } + return re.MatchString(command), nil + default: + return false, fmt.Errorf("unsupported protected command rule match type: %s", rule.Match) + } +} + +func (s *Server) protectedContainerCommandBlocked(containerID, command string) (bool, *models.ProtectedCommandRule, error) { + deploymentName, err := containerDeploymentName(containerID) + if err != nil || deploymentName == "" { + return false, nil, err + } + deployment, err := s.manager.GetDeployment(deploymentName) + if err != nil { + return false, nil, err + } + if deployment.Metadata == nil { + return false, nil, nil + } + return protectedCommandBlocked(deployment.Metadata.ProtectedMode, command) +} diff --git a/internal/api/protected_mode_test.go b/internal/api/protected_mode_test.go new file mode 100644 index 0000000..bcc5911 --- /dev/null +++ b/internal/api/protected_mode_test.go @@ -0,0 +1,135 @@ +package api + +import ( + "testing" + + "github.com/flatrun/agent/pkg/models" +) + +func TestProtectedCommandBlockedUsesConfiguredRules(t *testing.T) { + cfg := &models.ProtectedModeConfig{ + Enabled: true, + BlockedCommandRules: []models.ProtectedCommandRule{ + {ID: "fresh", Match: "contains", Pattern: "artisan migrate:fresh"}, + {ID: "wipe", Match: "matches", Pattern: `(?i)\b(db:wipe|migrate:reset)\b`}, + }, + } + + tests := []struct { + name string + command string + wantBlock bool + wantRule string + }{ + { + name: "contains match", + command: "php artisan migrate:fresh --seed", + wantBlock: true, + wantRule: "fresh", + }, + { + name: "regex match", + command: "php artisan db:wipe", + wantBlock: true, + wantRule: "wipe", + }, + { + name: "allowed command", + command: "php artisan migrate --force", + wantBlock: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + blocked, rule, err := protectedCommandBlocked(cfg, tt.command) + if err != nil { + t.Fatalf("protectedCommandBlocked returned error: %v", err) + } + if blocked != tt.wantBlock { + t.Fatalf("blocked = %v, want %v", blocked, tt.wantBlock) + } + if tt.wantRule != "" && (rule == nil || rule.ID != tt.wantRule) { + t.Fatalf("rule = %#v, want ID %q", rule, tt.wantRule) + } + }) + } +} + +func TestProtectedActionBlockedHonorsDisableTerminalWithCustomActions(t *testing.T) { + cfg := &models.ProtectedModeConfig{ + Enabled: true, + BlockedActions: []string{"delete_deployment"}, + DisableTerminal: true, + } + + if !protectedActionBlocked(cfg, protectedActionTerminal) { + t.Fatal("expected terminal to be blocked when disable_terminal is enabled") + } +} + +func TestProtectedCommandBlockedSupportsMatchTypes(t *testing.T) { + tests := []struct { + match string + pattern string + command string + }{ + {match: "equals", pattern: "npm run reset", command: "npm run reset"}, + {match: "prefix", pattern: "mysql -e drop", command: "mysql -e drop database app"}, + {match: "suffix", pattern: "--delete-data", command: "app maintenance --delete-data"}, + } + + for _, tt := range tests { + t.Run(tt.match, func(t *testing.T) { + cfg := &models.ProtectedModeConfig{ + Enabled: true, + BlockedCommandRules: []models.ProtectedCommandRule{{ + ID: tt.match, + Match: tt.match, + Pattern: tt.pattern, + }}, + } + blocked, rule, err := protectedCommandBlocked(cfg, tt.command) + if err != nil { + t.Fatalf("protectedCommandBlocked returned error: %v", err) + } + if !blocked || rule == nil || rule.ID != tt.match { + t.Fatalf("expected command to be blocked by %q, blocked=%v rule=%#v", tt.match, blocked, rule) + } + }) + } +} + +func TestValidateProtectedModeConfigRejectsInvalidRules(t *testing.T) { + tests := []struct { + name string + cfg *models.ProtectedModeConfig + }{ + { + name: "missing match", + cfg: &models.ProtectedModeConfig{ + BlockedCommandRules: []models.ProtectedCommandRule{{Pattern: "drop database"}}, + }, + }, + { + name: "unknown match", + cfg: &models.ProtectedModeConfig{ + BlockedCommandRules: []models.ProtectedCommandRule{{Match: "includes", Pattern: "drop database"}}, + }, + }, + { + name: "invalid regex", + cfg: &models.ProtectedModeConfig{ + BlockedCommandRules: []models.ProtectedCommandRule{{Match: "matches", Pattern: "["}}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := validateProtectedModeConfig(tt.cfg); err == nil { + t.Fatal("expected validation error") + } + }) + } +} diff --git a/internal/api/server.go b/internal/api/server.go index 72545ae..9a02076 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -65,6 +65,7 @@ type Server struct { authManager *auth.Manager proxyOrchestrator *proxy.Orchestrator filesManager *files.Manager + systemFilesManager *files.SystemManager servicesManager *system.ServicesManager databaseManager *database.Manager infraManager *infra.Manager @@ -145,6 +146,7 @@ func New(cfg *config.Config, configPath string) *Server { proxyOrchestrator := proxy.NewOrchestrator(cfg) filesManager := files.NewManager(cfg.DeploymentsPath) + systemFilesManager := files.NewSystemManager(cfg.SystemFilesRoot) servicesManager := system.NewServicesManager() databaseManager := database.NewManager() infraManager := infra.NewManager(cfg) @@ -248,6 +250,7 @@ func New(cfg *config.Config, configPath string) *Server { authManager: authManager, proxyOrchestrator: proxyOrchestrator, filesManager: filesManager, + systemFilesManager: systemFilesManager, servicesManager: servicesManager, databaseManager: databaseManager, infraManager: infraManager, @@ -289,6 +292,7 @@ func (s *Server) setupRoutes() { // WebSocket endpoint handles its own auth via first-message api.GET("/containers/:id/exec", s.containerExec) + api.GET("/system/terminal", s.systemTerminal) // Setup endpoints (public, gated by setup state) setupGroup := api.Group("/setup") @@ -319,6 +323,7 @@ func (s *Server) setupRoutes() { protected.POST("/deployments", s.authMiddleware.RequirePermission(auth.PermDeploymentsWrite), s.createDeployment) protected.PUT("/deployments/:name", s.authMiddleware.RequirePermission(auth.PermDeploymentsWrite), s.authMiddleware.RequireDeploymentAccess(auth.AccessLevelWrite), s.updateDeployment) protected.PUT("/deployments/:name/metadata", s.authMiddleware.RequirePermission(auth.PermDeploymentsWrite), s.authMiddleware.RequireDeploymentAccess(auth.AccessLevelWrite), s.updateDeploymentMetadata) + protected.PUT("/deployments/:name/protected-mode", s.authMiddleware.RequirePermission(auth.PermDeploymentsWrite), s.authMiddleware.RequireDeploymentAccess(auth.AccessLevelAdmin), s.updateDeploymentProtectedMode) protected.DELETE("/deployments/:name", s.authMiddleware.RequirePermission(auth.PermDeploymentsDelete), s.authMiddleware.RequireDeploymentAccess(auth.AccessLevelAdmin), s.deleteDeployment) protected.POST("/deployments/:name/start", s.authMiddleware.RequirePermission(auth.PermDeploymentsWrite), s.authMiddleware.RequireDeploymentAccess(auth.AccessLevelWrite), s.startDeployment) protected.POST("/deployments/:name/stop", s.authMiddleware.RequirePermission(auth.PermDeploymentsWrite), s.authMiddleware.RequireDeploymentAccess(auth.AccessLevelWrite), s.stopDeployment) @@ -434,8 +439,20 @@ func (s *Server) setupRoutes() { protected.POST("/deployments/:name/files/*path", s.authMiddleware.RequirePermission(auth.PermDeploymentsWrite), s.authMiddleware.RequireDeploymentAccess(auth.AccessLevelWrite), s.uploadDeploymentFile) protected.DELETE("/deployments/:name/files/*path", s.authMiddleware.RequirePermission(auth.PermDeploymentsDelete), s.authMiddleware.RequireDeploymentAccess(auth.AccessLevelAdmin), s.deleteDeploymentFile) protected.POST("/deployments/:name/mkdir/*path", s.authMiddleware.RequirePermission(auth.PermDeploymentsWrite), s.authMiddleware.RequireDeploymentAccess(auth.AccessLevelWrite), s.createDeploymentDir) + protected.POST("/deployments/:name/touch/*path", s.authMiddleware.RequirePermission(auth.PermDeploymentsWrite), s.authMiddleware.RequireDeploymentAccess(auth.AccessLevelWrite), s.createDeploymentFile) + protected.PUT("/deployments/:name/permissions/*path", s.authMiddleware.RequirePermission(auth.PermDeploymentsWrite), s.authMiddleware.RequireDeploymentAccess(auth.AccessLevelWrite), s.chmodDeploymentFile) protected.GET("/deployments/:name/files-info", s.authMiddleware.RequirePermission(auth.PermDeploymentsRead), s.authMiddleware.RequireDeploymentAccess(auth.AccessLevelRead), s.getDeploymentFilesInfo) + // System file endpoints (admin-only, scoped to SystemFilesRoot) + protected.GET("/system/files", s.authMiddleware.RequirePermission(auth.PermSystemFiles), s.listSystemFiles) + protected.GET("/system/files-info", s.authMiddleware.RequirePermission(auth.PermSystemFiles), s.getSystemFilesInfo) + protected.GET("/system/files/*path", s.authMiddleware.RequirePermission(auth.PermSystemFiles), s.getSystemFile) + protected.POST("/system/files/*path", s.authMiddleware.RequirePermission(auth.PermSystemFiles), s.uploadSystemFile) + protected.DELETE("/system/files/*path", s.authMiddleware.RequirePermission(auth.PermSystemFiles), s.deleteSystemFile) + protected.POST("/system/mkdir/*path", s.authMiddleware.RequirePermission(auth.PermSystemFiles), s.createSystemDir) + protected.POST("/system/touch/*path", s.authMiddleware.RequirePermission(auth.PermSystemFiles), s.createSystemFile) + protected.PUT("/system/permissions/*path", s.authMiddleware.RequirePermission(auth.PermSystemFiles), s.chmodSystemFile) + // Deployment environment endpoints protected.GET("/deployments/:name/env", s.authMiddleware.RequirePermission(auth.PermDeploymentsRead), s.authMiddleware.RequireDeploymentAccess(auth.AccessLevelRead), s.getDeploymentEnv) protected.PUT("/deployments/:name/env", s.authMiddleware.RequirePermission(auth.PermDeploymentsWrite), s.authMiddleware.RequireDeploymentAccess(auth.AccessLevelWrite), s.updateDeploymentEnv) @@ -573,6 +590,7 @@ func (s *Server) setupRoutes() { apiKeysGroup.GET("", s.listAPIKeys) apiKeysGroup.GET("/:id", s.getAPIKey) apiKeysGroup.POST("", s.authMiddleware.RequirePermission(auth.PermAPIKeysWrite), s.createAPIKey) + apiKeysGroup.PUT("/:id", s.authMiddleware.RequirePermission(auth.PermAPIKeysWrite), s.updateAPIKey) apiKeysGroup.DELETE("/:id", s.authMiddleware.RequirePermission(auth.PermAPIKeysDelete), s.deleteAPIKey) apiKeysGroup.POST("/:id/revoke", s.authMiddleware.RequirePermission(auth.PermAPIKeysDelete), s.revokeAPIKey) } @@ -1401,6 +1419,9 @@ func (s *Server) getDeploymentEnv(c *gin.Context) { func (s *Server) updateDeploymentEnv(c *gin.Context) { name := c.Param("name") + if !s.requireUnprotectedDeploymentAction(c, name, protectedActionUpdateEnv) { + return + } var req struct { EnvVars []EnvVar `json:"env_vars"` @@ -1453,6 +1474,9 @@ func generateRandomPassword(length int) string { func (s *Server) updateDeployment(c *gin.Context) { name := c.Param("name") + if !s.requireUnprotectedDeploymentAction(c, name, protectedActionUpdateDeployment) { + return + } var req struct { ComposeContent string `json:"compose_content" binding:"required"` @@ -1512,6 +1536,20 @@ func (s *Server) updateDeploymentMetadata(c *gin.Context) { return } + if _, ok := sentFields["protected_mode"]; ok { + if !s.requireDeploymentAccess(c, name, auth.AccessLevelAdmin) { + return + } + if err := validateProtectedModeConfig(incoming.ProtectedMode); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": err.Error(), + }) + return + } + } else if !s.requireUnprotectedDeploymentAction(c, name, protectedActionUpdateMetadata) { + return + } + deployment, err := s.manager.GetDeployment(name) if err != nil { c.JSON(http.StatusNotFound, gin.H{ @@ -1582,12 +1620,61 @@ func mergeMetadata(existing, incoming *models.ServiceMetadata, sentFields map[st if _, ok := sentFields["backup"]; ok { merged.Backup = incoming.Backup } + if _, ok := sentFields["protected_mode"]; ok { + merged.ProtectedMode = incoming.ProtectedMode + } return &merged } +func (s *Server) updateDeploymentProtectedMode(c *gin.Context) { + name := c.Param("name") + + var req models.ProtectedModeConfig + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": err.Error(), + }) + return + } + if err := validateProtectedModeConfig(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": err.Error(), + }) + return + } + + deployment, err := s.manager.GetDeployment(name) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{ + "error": "Deployment not found", + }) + return + } + if deployment.Metadata == nil { + deployment.Metadata = &models.ServiceMetadata{Name: name} + } + deployment.Metadata.ProtectedMode = &req + + if err := s.manager.SaveMetadata(name, deployment.Metadata); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Protected mode updated", + "name": name, + "protected_mode": deployment.Metadata.ProtectedMode, + }) +} + func (s *Server) deleteDeployment(c *gin.Context) { name := c.Param("name") + if !s.requireUnprotectedDeploymentAction(c, name, protectedActionDeleteDeployment) { + return + } deleteSSL := c.DefaultQuery("delete_ssl", "true") == "true" deleteDatabase := c.DefaultQuery("delete_database", "false") == "true" @@ -1719,6 +1806,9 @@ func (s *Server) restartDeployment(c *gin.Context) { func (s *Server) rebuildDeployment(c *gin.Context) { name := c.Param("name") + if !s.requireUnprotectedDeploymentAction(c, name, protectedActionRebuild) { + return + } auth, opts := s.deploymentAuthOptions(name) defer auth.Close() @@ -1773,6 +1863,10 @@ func (s *Server) deployDeployment(c *gin.Context) { return } + if req.Action == "rebuild" && !s.requireUnprotectedDeploymentAction(c, name, protectedActionRebuild) { + return + } + if _, err := s.manager.GetDeployment(name); err != nil { c.JSON(http.StatusNotFound, gin.H{ "error": "Deployment not found: " + err.Error(), @@ -1884,6 +1978,46 @@ func (s *Server) getDeploymentImages(c *gin.Context) { func (s *Server) executeQuickAction(c *gin.Context) { name := c.Param("name") actionID := c.Param("actionId") + if !s.requireUnprotectedDeploymentAction(c, name, protectedActionQuickAction) { + return + } + + deployment, err := s.manager.GetDeployment(name) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{ + "error": "Deployment not found", + }) + return + } + if deployment.Metadata != nil { + for _, action := range deployment.Metadata.QuickActions { + if action.ID != actionID { + continue + } + blocked, rule, err := protectedCommandBlocked(deployment.Metadata.ProtectedMode, action.Command) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to check protected command rules: " + err.Error(), + }) + return + } + if blocked { + ruleName := rule.Name + if ruleName == "" { + ruleName = rule.ID + } + c.JSON(http.StatusLocked, gin.H{ + "error": "Quick action command blocked by deployment protected mode", + "action_id": actionID, + "rule": ruleName, + "match": rule.Match, + "pattern": rule.Pattern, + }) + return + } + break + } + } output, err := s.manager.ExecuteQuickAction(name, actionID) if err != nil { @@ -2260,6 +2394,9 @@ func (s *Server) getSettings(c *gin.Context) { "auto_block_threshold": s.config.Security.AutoBlockThreshold, "auto_block_duration": s.config.Security.AutoBlockDuration.String(), }, + "system_terminal": gin.H{ + "protected_mode": s.config.SystemTerminal.ProtectedMode, + }, }, }) } @@ -2320,6 +2457,9 @@ func (s *Server) updateSettings(c *gin.Context) { AutoBlockThreshold int `json:"auto_block_threshold"` AutoBlockDuration string `json:"auto_block_duration"` } `json:"security,omitempty"` + SystemTerminal *struct { + ProtectedMode *models.ProtectedModeConfig `json:"protected_mode"` + } `json:"system_terminal,omitempty"` } if err := c.ShouldBindJSON(&req); err != nil { @@ -2443,6 +2583,14 @@ func (s *Server) updateSettings(c *gin.Context) { } } + if req.SystemTerminal != nil && req.SystemTerminal.ProtectedMode != nil { + if err := validateProtectedModeConfig(req.SystemTerminal.ProtectedMode); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + s.config.SystemTerminal.ProtectedMode = *req.SystemTerminal.ProtectedMode + } + s.infraManager.UpdateConfig(s.config) s.proxyOrchestrator.UpdateConfig(s.config) @@ -2506,6 +2654,9 @@ func (s *Server) updateSettings(c *gin.Context) { "auto_block_threshold": s.config.Security.AutoBlockThreshold, "auto_block_duration": s.config.Security.AutoBlockDuration.String(), }, + "system_terminal": gin.H{ + "protected_mode": s.config.SystemTerminal.ProtectedMode, + }, }, }) } @@ -5110,6 +5261,9 @@ func (s *Server) getDeploymentFile(c *gin.Context) { func (s *Server) uploadDeploymentFile(c *gin.Context) { name := c.Param("name") path := c.Param("path") + if !s.requireUnprotectedDeploymentAction(c, name, protectedActionUploadFile) { + return + } file, _, err := c.Request.FormFile("file") if err != nil { @@ -5137,6 +5291,9 @@ func (s *Server) uploadDeploymentFile(c *gin.Context) { func (s *Server) deleteDeploymentFile(c *gin.Context) { name := c.Param("name") path := c.Param("path") + if !s.requireUnprotectedDeploymentAction(c, name, protectedActionDeleteFile) { + return + } if err := s.filesManager.DeleteFile(name, path); err != nil { c.JSON(http.StatusInternalServerError, gin.H{ @@ -5153,6 +5310,9 @@ func (s *Server) deleteDeploymentFile(c *gin.Context) { func (s *Server) createDeploymentDir(c *gin.Context) { name := c.Param("name") path := c.Param("path") + if !s.requireUnprotectedDeploymentAction(c, name, protectedActionCreateDir) { + return + } if err := s.filesManager.CreateDirectory(name, path); err != nil { c.JSON(http.StatusInternalServerError, gin.H{ @@ -5168,6 +5328,55 @@ func (s *Server) createDeploymentDir(c *gin.Context) { }) } +func (s *Server) createDeploymentFile(c *gin.Context) { + name := c.Param("name") + path := c.Param("path") + if !s.requireUnprotectedDeploymentAction(c, name, protectedActionUploadFile) { + return + } + + if err := s.filesManager.CreateFile(name, path); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": err.Error(), + }) + return + } + + info, _ := s.filesManager.GetFileInfo(name, path) + c.JSON(http.StatusOK, gin.H{ + "message": "File created", + "file": info, + }) +} + +func (s *Server) chmodDeploymentFile(c *gin.Context) { + name := c.Param("name") + path := c.Param("path") + + var req struct { + Mode int `json:"mode" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + if req.Mode < 0 || req.Mode > 0o777 { + c.JSON(http.StatusBadRequest, gin.H{"error": "mode must be between 0 and 0777"}) + return + } + + if err := s.filesManager.Chmod(name, path, os.FileMode(req.Mode)); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + info, _ := s.filesManager.GetFileInfo(name, path) + c.JSON(http.StatusOK, gin.H{ + "message": "Permissions updated", + "file": info, + }) +} + func (s *Server) getDeploymentFilesInfo(c *gin.Context) { name := c.Param("name") diff --git a/internal/api/system_files.go b/internal/api/system_files.go new file mode 100644 index 0000000..e6ffa20 --- /dev/null +++ b/internal/api/system_files.go @@ -0,0 +1,183 @@ +package api + +import ( + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/gin-gonic/gin" +) + +func (s *Server) listSystemFiles(c *gin.Context) { + path := c.DefaultQuery("path", "/") + + filesList, err := s.systemFilesManager.List(path) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{ + "files": filesList, + "path": path, + }) +} + +func (s *Server) getSystemFile(c *gin.Context) { + path := c.Param("path") + + if c.Query("info") == "true" { + info, err := s.systemFilesManager.GetFileInfo(path) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, info) + return + } + + if c.Query("list") == "true" { + filesList, err := s.systemFilesManager.List(path) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{ + "files": filesList, + "path": path, + }) + return + } + + file, info, err := s.systemFilesManager.ReadFile(path) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + defer file.Close() + + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", info.Name)) + c.Header("Content-Length", fmt.Sprintf("%d", info.Size)) + c.DataFromReader(http.StatusOK, info.Size, "application/octet-stream", file, nil) +} + +func (s *Server) uploadSystemFile(c *gin.Context) { + path := c.Param("path") + + file, _, err := c.Request.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "No file provided"}) + return + } + defer file.Close() + + if err := s.systemFilesManager.WriteFile(path, file); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + info, _ := s.systemFilesManager.GetFileInfo(path) + c.JSON(http.StatusOK, gin.H{ + "message": "File uploaded successfully", + "file": info, + }) +} + +func (s *Server) deleteSystemFile(c *gin.Context) { + path := c.Param("path") + + if err := s.systemFilesManager.DeleteFile(path); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Deleted successfully"}) +} + +func (s *Server) createSystemDir(c *gin.Context) { + path := c.Param("path") + + if err := s.systemFilesManager.CreateDirectory(path); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + info, _ := s.systemFilesManager.GetFileInfo(path) + c.JSON(http.StatusOK, gin.H{ + "message": "Directory created", + "directory": info, + }) +} + +func (s *Server) createSystemFile(c *gin.Context) { + path := c.Param("path") + + if err := s.systemFilesManager.CreateFile(path); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + info, _ := s.systemFilesManager.GetFileInfo(path) + c.JSON(http.StatusOK, gin.H{ + "message": "File created", + "file": info, + }) +} + +func (s *Server) chmodSystemFile(c *gin.Context) { + path := c.Param("path") + + var req struct { + Mode int `json:"mode" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + if req.Mode < 0 || req.Mode > 0o777 { + c.JSON(http.StatusBadRequest, gin.H{"error": "mode must be between 0 and 0777"}) + return + } + + if err := s.systemFilesManager.Chmod(path, os.FileMode(req.Mode)); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + info, _ := s.systemFilesManager.GetFileInfo(path) + c.JSON(http.StatusOK, gin.H{ + "message": "Permissions updated", + "file": info, + }) +} + +func (s *Server) getSystemFilesInfo(c *gin.Context) { + path := c.DefaultQuery("path", "/") + skipUsage := c.Query("usage") == "false" + + homePath := "/" + if home, err := os.UserHomeDir(); err == nil { + root := s.systemFilesManager.Root() + if rel, err := filepath.Rel(root, home); err == nil && !strings.HasPrefix(rel, "..") { + if rel == "." { + homePath = "/" + } else { + homePath = "/" + filepath.ToSlash(rel) + } + } + } + + response := gin.H{ + "root": s.systemFilesManager.Root(), + "home_path": homePath, + "path": path, + } + if !skipUsage { + totalSize, fileCount, err := s.systemFilesManager.GetDiskUsage(path) + if err != nil { + totalSize = 0 + fileCount = 0 + } + response["total_size"] = totalSize + response["file_count"] = fileCount + } + + c.JSON(http.StatusOK, response) +} diff --git a/internal/api/system_terminal.go b/internal/api/system_terminal.go new file mode 100644 index 0000000..3e7f00a --- /dev/null +++ b/internal/api/system_terminal.go @@ -0,0 +1,205 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/flatrun/agent/internal/auth" + "github.com/flatrun/agent/internal/contextkeys" + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" +) + +type systemTerminalMessage struct { + Type string `json:"type"` + Token string `json:"token,omitempty"` + Command string `json:"command,omitempty"` +} + +type systemTerminalSession struct { + cwd string +} + +func (s *Server) systemTerminal(c *gin.Context) { + conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + return + } + defer conn.Close() + + if !s.authenticateSystemTerminal(c, conn) { + return + } + if s.config != nil && s.config.SystemTerminal.ProtectedMode.Enabled && s.config.SystemTerminal.ProtectedMode.DisableTerminal { + sendSystemTerminalError(conn, "System terminal access is disabled by global protected mode settings") + return + } + + cwd := "/" + if s.config != nil && s.config.DeploymentsPath != "" { + if abs, err := filepath.Abs(s.config.DeploymentsPath); err == nil { + cwd = abs + } + } + session := &systemTerminalSession{cwd: cwd} + + _ = conn.WriteJSON(gin.H{"type": "auth_success"}) + sendSystemTerminalOutput(conn, fmt.Sprintf("Connected to system terminal\nWorking directory: %s\n%s", session.cwd, systemPrompt(session.cwd))) + + for { + _, message, err := conn.ReadMessage() + if err != nil { + return + } + + var req systemTerminalMessage + if err := json.Unmarshal(message, &req); err != nil { + sendSystemTerminalError(conn, "Invalid terminal message") + continue + } + if req.Type != "command" { + continue + } + + output, err := s.runSystemTerminalCommand(session, req.Command) + if err != nil { + sendSystemTerminalError(conn, err.Error()+"\n"+systemPrompt(session.cwd)) + continue + } + sendSystemTerminalOutput(conn, output+systemPrompt(session.cwd)) + } +} + +func (s *Server) authenticateSystemTerminal(c *gin.Context, conn *websocket.Conn) bool { + if s.authMiddleware == nil || !s.authMiddleware.IsAuthEnabled() { + c.Set(contextkeys.Actor, &auth.ActorContext{Type: "anonymous", Role: auth.RoleAdmin}) + return true + } + + _ = conn.SetReadDeadline(time.Now().Add(10 * time.Second)) + _, message, err := conn.ReadMessage() + if err != nil { + sendSystemTerminalError(conn, "Authentication timeout") + return false + } + + var authMsg systemTerminalMessage + if err := json.Unmarshal(message, &authMsg); err != nil || authMsg.Type != "auth" { + sendSystemTerminalError(conn, "Invalid auth message format") + return false + } + + actor, err := s.authMiddleware.ActorForTokenString(authMsg.Token, c.ClientIP()) + if err != nil { + sendSystemTerminalError(conn, "Invalid or expired token") + return false + } + if !actor.HasPermission(auth.PermSystemWrite) { + sendSystemTerminalError(conn, "Permission denied: system:write required") + return false + } + + c.Set(contextkeys.Actor, actor) + _ = conn.SetReadDeadline(time.Time{}) + return true +} + +func (s *Server) runSystemTerminalCommand(session *systemTerminalSession, raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", nil + } + + if s != nil && s.config != nil { + blocked, rule, err := protectedCommandBlocked(&s.config.SystemTerminal.ProtectedMode, raw) + if err != nil { + return "", err + } + if blocked { + return "", errors.New(protectedCommandBlockMessage(raw, rule)) + } + } + + parts := strings.Fields(raw) + if len(parts) == 0 { + return "", nil + } + + command := parts[0] + args := parts[1:] + if command == "ll" { + command = "ls" + args = append([]string{"-la"}, args...) + raw = strings.Join(append([]string{command}, args...), " ") + } + if command == "la" { + command = "ls" + args = append([]string{"-A"}, args...) + raw = strings.Join(append([]string{command}, args...), " ") + } + + if command == "cd" { + target := session.cwd + if len(args) > 0 { + target = resolveSystemTerminalPath(session.cwd, args[0]) + } + info, err := os.Stat(target) + if err != nil { + return "", err + } + if !info.IsDir() { + return "", fmt.Errorf("not a directory: %s", target) + } + session.cwd = target + return "", nil + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, "sh", "-lc", raw) + cmd.Dir = session.cwd + output, err := cmd.CombinedOutput() + if ctx.Err() == context.DeadlineExceeded { + return string(output), fmt.Errorf("command timed out") + } + if err != nil { + msg := strings.TrimSpace(string(output)) + if msg == "" { + msg = err.Error() + } + return string(output), errors.New(msg) + } + return string(output), nil +} + +func resolveSystemTerminalPath(cwd, target string) string { + if target == "" || target == "~" { + if home, err := os.UserHomeDir(); err == nil { + return home + } + } + if filepath.IsAbs(target) { + return filepath.Clean(target) + } + return filepath.Clean(filepath.Join(cwd, target)) +} + +func systemPrompt(cwd string) string { + return fmt.Sprintf("\r\n%s $ ", cwd) +} + +func sendSystemTerminalOutput(conn *websocket.Conn, output string) { + _ = conn.WriteJSON(gin.H{"type": "output", "data": output}) +} + +func sendSystemTerminalError(conn *websocket.Conn, msg string) { + _ = conn.WriteJSON(gin.H{"type": "error", "message": msg}) +} diff --git a/internal/api/system_terminal_test.go b/internal/api/system_terminal_test.go new file mode 100644 index 0000000..f80f735 --- /dev/null +++ b/internal/api/system_terminal_test.go @@ -0,0 +1,72 @@ +package api + +import ( + "strings" + "testing" + + "github.com/flatrun/agent/pkg/config" + "github.com/flatrun/agent/pkg/models" +) + +func TestRunSystemTerminalCommandAllowsInspectionCommands(t *testing.T) { + session := &systemTerminalSession{cwd: t.TempDir()} + server := &Server{config: &config.Config{}} + + output, err := server.runSystemTerminalCommand(session, "pwd") + if err != nil { + t.Fatalf("pwd failed: %v", err) + } + if !strings.Contains(output, session.cwd) { + t.Fatalf("pwd output %q does not contain cwd %q", output, session.cwd) + } +} + +func TestRunSystemTerminalCommandAllowsNonAllowlistedCommands(t *testing.T) { + session := &systemTerminalSession{cwd: t.TempDir()} + server := &Server{config: &config.Config{}} + + output, err := server.runSystemTerminalCommand(session, "printf system-terminal") + if err != nil { + t.Fatalf("printf failed: %v", err) + } + if !strings.Contains(output, "system-terminal") { + t.Fatalf("output = %q, want output containing system-terminal", output) + } +} + +func TestRunSystemTerminalCommandChangesDirectory(t *testing.T) { + tmpDir := t.TempDir() + session := &systemTerminalSession{cwd: "/"} + server := &Server{config: &config.Config{}} + + if _, err := server.runSystemTerminalCommand(session, "cd "+tmpDir); err != nil { + t.Fatalf("cd failed: %v", err) + } + if session.cwd != tmpDir { + t.Fatalf("cwd = %q, want %q", session.cwd, tmpDir) + } +} + +func TestRunSystemTerminalCommandAppliesGlobalProtectedRules(t *testing.T) { + session := &systemTerminalSession{cwd: t.TempDir()} + server := &Server{config: &config.Config{ + SystemTerminal: config.SystemTerminalConfig{ + ProtectedMode: models.ProtectedModeConfig{ + Enabled: true, + BlockedCommandRules: []models.ProtectedCommandRule{{ + ID: "remove", + Match: "contains", + Pattern: "rm -rf", + }}, + }, + }, + }} + + _, err := server.runSystemTerminalCommand(session, "rm -rf app") + if err == nil { + t.Fatal("expected command to be blocked") + } + if !strings.Contains(err.Error(), "Command blocked") { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/internal/auth/db.go b/internal/auth/db.go index e586c63..26978bb 100644 --- a/internal/auth/db.go +++ b/internal/auth/db.go @@ -487,6 +487,31 @@ func (db *DB) UpdateAPIKeyLastUsed(id int64, ip string) error { return err } +func (db *DB) UpdateAPIKey(key *APIKey) error { + db.mu.Lock() + defer db.mu.Unlock() + + var roleVal sql.NullString + if key.Role != "" { + roleVal = sql.NullString{String: string(key.Role), Valid: true} + } + + var expiresAt sql.NullTime + if !key.ExpiresAt.IsZero() { + expiresAt = sql.NullTime{Time: key.ExpiresAt, Valid: true} + } + + _, err := db.conn.Exec(` + UPDATE api_keys + SET name = ?, description = ?, role = ?, permissions = ?, deployments = ?, expires_at = ? + WHERE id = ?`, + key.Name, key.Description, roleVal, + key.GetPermissionsJSON(), key.GetDeploymentsJSON(), expiresAt, + key.ID, + ) + return err +} + func (db *DB) DeleteAPIKey(id int64) error { db.mu.Lock() defer db.mu.Unlock() diff --git a/internal/auth/manager.go b/internal/auth/manager.go index 76a88a7..ccc3be8 100644 --- a/internal/auth/manager.go +++ b/internal/auth/manager.go @@ -190,7 +190,7 @@ func (m *Manager) ValidateCredentials(username, password string) (*User, error) return user, nil } -func (m *Manager) CreateAPIKey(userID int64, name, description string, role Role, permissions, deployments []string, expiresAt time.Time) (*APIKey, string, error) { +func (m *Manager) CreateAPIKey(userID int64, name, description string, role Role, permissions []string, deployments DeploymentAccess, expiresAt time.Time) (*APIKey, string, error) { plainKey, keyHash, keyID, prefix, err := GenerateAPIKey() if err != nil { return nil, "", err @@ -220,7 +220,7 @@ func (m *Manager) CreateAPIKey(userID int64, name, description string, role Role return key, plainKey, nil } -func (m *Manager) CreateAPIKeyFromRaw(rawKey string, userID int64, name, description string, role Role, permissions, deployments []string, expiresAt time.Time) (*APIKey, error) { +func (m *Manager) CreateAPIKeyFromRaw(rawKey string, userID int64, name, description string, role Role, permissions []string, deployments DeploymentAccess, expiresAt time.Time) (*APIKey, error) { keyHash := HashAPIKey(rawKey) idBytes := make([]byte, keyIDLength/2) @@ -310,6 +310,24 @@ func (m *Manager) UpdateAPIKeyLastUsed(keyID int64, ip string) error { return m.db.UpdateAPIKeyLastUsed(keyID, ip) } +func (m *Manager) UpdateAPIKey(id int64, name, description string, role Role, permissions []string, deployments DeploymentAccess, expiresAt time.Time) (*APIKey, error) { + existing, err := m.db.GetAPIKeyByID(id) + if err != nil { + return nil, err + } + existing.Name = name + existing.Description = description + existing.Role = role + existing.Permissions = permissions + existing.Deployments = deployments + existing.ExpiresAt = expiresAt + + if err := m.db.UpdateAPIKey(existing); err != nil { + return nil, err + } + return existing, nil +} + func (m *Manager) DeleteAPIKey(id int64) error { return m.db.DeleteAPIKey(id) } diff --git a/internal/auth/models.go b/internal/auth/models.go index 7396d84..6bb67a9 100644 --- a/internal/auth/models.go +++ b/internal/auth/models.go @@ -1,7 +1,9 @@ package auth import ( + "bytes" "encoding/json" + "fmt" "time" ) @@ -54,22 +56,53 @@ func (u *User) GetPermissionsJSON() string { return string(b) } +type DeploymentAccess map[string]string + +func (d *DeploymentAccess) UnmarshalJSON(data []byte) error { + trimmed := bytes.TrimSpace(data) + if len(trimmed) == 0 || string(trimmed) == "null" { + *d = nil + return nil + } + if trimmed[0] == '{' { + var m map[string]string + if err := json.Unmarshal(data, &m); err != nil { + return err + } + *d = m + return nil + } + if trimmed[0] == '[' { + var arr []string + if err := json.Unmarshal(data, &arr); err != nil { + return err + } + out := make(map[string]string, len(arr)) + for _, name := range arr { + out[name] = AccessLevelAdmin + } + *d = out + return nil + } + return fmt.Errorf("deployments must be an object {name:level} or array of names") +} + type APIKey struct { - ID int64 `json:"id"` - KeyID string `json:"key_id"` - UserID int64 `json:"user_id"` - Name string `json:"name"` - Description string `json:"description,omitempty"` - KeyHash string `json:"-"` - KeyPrefix string `json:"key_prefix"` - Role Role `json:"role,omitempty"` - Permissions []string `json:"permissions,omitempty"` - Deployments []string `json:"deployments,omitempty"` - ExpiresAt time.Time `json:"expires_at,omitempty"` - LastUsedAt time.Time `json:"last_used_at,omitempty"` - LastUsedIP string `json:"last_used_ip,omitempty"` - IsActive bool `json:"is_active"` - CreatedAt time.Time `json:"created_at"` + ID int64 `json:"id"` + KeyID string `json:"key_id"` + UserID int64 `json:"user_id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + KeyHash string `json:"-"` + KeyPrefix string `json:"key_prefix"` + Role Role `json:"role,omitempty"` + Permissions []string `json:"permissions,omitempty"` + Deployments DeploymentAccess `json:"deployments,omitempty"` + ExpiresAt time.Time `json:"expires_at,omitempty"` + LastUsedAt time.Time `json:"last_used_at,omitempty"` + LastUsedIP string `json:"last_used_ip,omitempty"` + IsActive bool `json:"is_active"` + CreatedAt time.Time `json:"created_at"` } type Session struct { @@ -130,14 +163,11 @@ func (a *ActorContext) CanAccessDeployment(name string, requiredLevel string) bo } if a.APIKey != nil && len(a.APIKey.Deployments) > 0 { - found := false - for _, d := range a.APIKey.Deployments { - if d == name { - found = true - break - } + keyLevel, ok := a.APIKey.Deployments[name] + if !ok { + return false } - if !found { + if !accessLevelSufficient(keyLevel, requiredLevel) { return false } } @@ -184,11 +214,11 @@ func ParsePermissionsJSON(s string) []string { return perms } -func ParseDeploymentsJSON(s string) []string { +func ParseDeploymentsJSON(s string) DeploymentAccess { if s == "" { return nil } - var deps []string - _ = json.Unmarshal([]byte(s), &deps) - return deps + var d DeploymentAccess + _ = (&d).UnmarshalJSON([]byte(s)) + return d } diff --git a/internal/auth/models_test.go b/internal/auth/models_test.go index d8dc1f5..93d6fbb 100644 --- a/internal/auth/models_test.go +++ b/internal/auth/models_test.go @@ -119,7 +119,7 @@ func TestActorContextCanAccessDeployment(t *testing.T) { actor: &ActorContext{ Role: RoleOperator, Deployments: map[string]string{"app-a": "write", "app-b": "write"}, - APIKey: &APIKey{Deployments: []string{"app-a"}}, + APIKey: &APIKey{Deployments: DeploymentAccess{"app-a": AccessLevelAdmin}}, }, deploymentName: "app-b", requiredLevel: "read", @@ -130,7 +130,7 @@ func TestActorContextCanAccessDeployment(t *testing.T) { actor: &ActorContext{ Role: RoleOperator, Deployments: map[string]string{"app-a": "write"}, - APIKey: &APIKey{Deployments: []string{"app-a"}}, + APIKey: &APIKey{Deployments: DeploymentAccess{"app-a": AccessLevelAdmin}}, }, deploymentName: "app-a", requiredLevel: "write", @@ -188,7 +188,7 @@ func TestAPIKeyGetPermissionsJSON(t *testing.T) { } func TestAPIKeyGetDeploymentsJSON(t *testing.T) { - key := &APIKey{Deployments: []string{"app-a", "app-b"}} + key := &APIKey{Deployments: DeploymentAccess{"app-a": AccessLevelAdmin, "app-b": AccessLevelRead}} json := key.GetDeploymentsJSON() if json == "" { diff --git a/internal/auth/permissions.go b/internal/auth/permissions.go index 7d87153..2812cbf 100644 --- a/internal/auth/permissions.go +++ b/internal/auth/permissions.go @@ -60,6 +60,7 @@ const ( PermSystemRead Permission = "system:read" PermSystemWrite Permission = "system:write" + PermSystemFiles Permission = "system:files" PermDNSRead Permission = "dns:read" PermDNSWrite Permission = "dns:write" @@ -94,7 +95,7 @@ var adminPermissions = []Permission{ PermDatabasesRead, PermDatabasesWrite, PermDatabasesDelete, PermInfrastructureRead, PermInfrastructureWrite, PermSchedulerRead, PermSchedulerWrite, PermSchedulerDelete, - PermSystemRead, PermSystemWrite, + PermSystemRead, PermSystemWrite, PermSystemFiles, PermDNSRead, PermDNSWrite, PermRegistriesRead, PermRegistriesWrite, PermRegistriesDelete, PermTemplatesRead, PermTemplatesWrite, diff --git a/internal/files/manager.go b/internal/files/manager.go index 9938bcd..ccf67f1 100644 --- a/internal/files/manager.go +++ b/internal/files/manager.go @@ -123,6 +123,41 @@ func (m *Manager) CreateDirectory(deploymentName, relativePath string) error { return os.MkdirAll(dirPath, 0755) } +func (m *Manager) CreateFile(deploymentName, relativePath string) error { + filePath, err := m.resolvePath(deploymentName, relativePath) + if err != nil { + return err + } + + dir := filepath.Dir(filePath) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create parent directories: %w", err) + } + + file, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644) + if err != nil { + if os.IsExist(err) { + return fmt.Errorf("file already exists") + } + return fmt.Errorf("failed to create file: %w", err) + } + return file.Close() +} + +func (m *Manager) Chmod(deploymentName, relativePath string, mode os.FileMode) error { + if mode&^0o777 != 0 { + return fmt.Errorf("mode must be 0-0777 (no setuid, setgid, or sticky bit)") + } + target, err := m.resolvePath(deploymentName, relativePath) + if err != nil { + return err + } + if _, err := os.Stat(target); err != nil { + return err + } + return os.Chmod(target, mode) +} + func (m *Manager) WriteFile(deploymentName, relativePath string, content io.Reader) error { filePath, err := m.resolvePath(deploymentName, relativePath) if err != nil { diff --git a/internal/files/system_manager.go b/internal/files/system_manager.go new file mode 100644 index 0000000..1607651 --- /dev/null +++ b/internal/files/system_manager.go @@ -0,0 +1,277 @@ +package files + +import ( + "fmt" + "io" + "log" + "os" + "path/filepath" + "sort" + "strings" +) + +// SystemManager provides filesystem access scoped to a configurable root path. +// Unlike Manager, it does not require a deployment name; operations are taken +// against paths relative to the root. +type SystemManager struct { + root string +} + +// NewSystemManager constructs a SystemManager rooted at rootPath. If rootPath +// is empty or invalid it defaults to "/" and a warning is logged: operations +// against the root filesystem are very permissive and should be gated by an +// admin-only permission. +func NewSystemManager(rootPath string) *SystemManager { + if rootPath == "" { + rootPath = "/" + } + abs, err := filepath.Abs(rootPath) + if err != nil { + log.Printf("files.SystemManager: failed to resolve root %q, defaulting to /: %v", rootPath, err) + abs = "/" + } + if abs == "/" { + log.Printf("files.SystemManager: root is /; operations span the entire filesystem") + } + return &SystemManager{root: abs} +} + +// Root returns the configured root path. +func (m *SystemManager) Root() string { + return m.root +} + +// resolvePath cleans relativePath, joins it with the root, and ensures the +// result stays within the root prefix. It rejects any path that would escape. +func (m *SystemManager) resolvePath(relativePath string) (string, error) { + clean := filepath.Clean("/" + strings.TrimPrefix(relativePath, "/")) + + full := filepath.Join(m.root, clean) + + absFull, err := filepath.Abs(full) + if err != nil { + return "", fmt.Errorf("failed to resolve path: %w", err) + } + + if m.root == "/" { + return absFull, nil + } + + rootWithSep := m.root + if !strings.HasSuffix(rootWithSep, string(os.PathSeparator)) { + rootWithSep += string(os.PathSeparator) + } + if absFull != m.root && !strings.HasPrefix(absFull, rootWithSep) { + return "", fmt.Errorf("path traversal detected") + } + + return absFull, nil +} + +// relPath returns the path of fullPath relative to the root, formatted with a +// leading slash to match how the deployment Manager reports paths. +func (m *SystemManager) relPath(fullPath string) string { + rel, err := filepath.Rel(m.root, fullPath) + if err != nil || rel == "." { + return "/" + } + return "/" + filepath.ToSlash(rel) +} + +func (m *SystemManager) List(relativePath string) ([]FileInfo, error) { + dirPath, err := m.resolvePath(relativePath) + if err != nil { + return nil, err + } + + entries, err := os.ReadDir(dirPath) + if err != nil { + if os.IsNotExist(err) { + return []FileInfo{}, nil + } + return nil, fmt.Errorf("failed to read directory: %w", err) + } + + files := make([]FileInfo, 0, len(entries)) + for _, entry := range entries { + info, err := entry.Info() + if err != nil { + continue + } + fullPath := filepath.Join(dirPath, entry.Name()) + fileInfo := FileInfo{ + Name: entry.Name(), + Path: m.relPath(fullPath), + Size: info.Size(), + IsDir: entry.IsDir(), + ModTime: info.ModTime(), + Permissions: info.Mode().String(), + } + if entry.IsDir() { + subEntries, _ := os.ReadDir(fullPath) + fileInfo.ChildCount = len(subEntries) + } + files = append(files, fileInfo) + } + + sort.Slice(files, func(i, j int) bool { + if files[i].IsDir != files[j].IsDir { + return files[i].IsDir + } + return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name) + }) + + return files, nil +} + +func (m *SystemManager) GetFileInfo(relativePath string) (*FileInfo, error) { + filePath, err := m.resolvePath(relativePath) + if err != nil { + return nil, err + } + info, err := os.Stat(filePath) + if err != nil { + return nil, fmt.Errorf("failed to stat path: %w", err) + } + fi := &FileInfo{ + Name: info.Name(), + Path: m.relPath(filePath), + Size: info.Size(), + IsDir: info.IsDir(), + ModTime: info.ModTime(), + Permissions: info.Mode().String(), + } + if info.IsDir() { + entries, _ := os.ReadDir(filePath) + fi.ChildCount = len(entries) + } + return fi, nil +} + +func (m *SystemManager) ReadFile(relativePath string) (io.ReadCloser, *FileInfo, error) { + filePath, err := m.resolvePath(relativePath) + if err != nil { + return nil, nil, err + } + info, err := os.Stat(filePath) + if err != nil { + return nil, nil, fmt.Errorf("failed to stat file: %w", err) + } + if info.IsDir() { + return nil, nil, fmt.Errorf("path is a directory") + } + file, err := os.Open(filePath) + if err != nil { + return nil, nil, fmt.Errorf("failed to open file: %w", err) + } + fi := &FileInfo{ + Name: info.Name(), + Path: m.relPath(filePath), + Size: info.Size(), + IsDir: false, + ModTime: info.ModTime(), + Permissions: info.Mode().String(), + } + return file, fi, nil +} + +func (m *SystemManager) WriteFile(relativePath string, content io.Reader) error { + filePath, err := m.resolvePath(relativePath) + if err != nil { + return err + } + dir := filepath.Dir(filePath) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create parent directories: %w", err) + } + file, err := os.Create(filePath) + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + defer file.Close() + if _, err := io.Copy(file, content); err != nil { + return fmt.Errorf("failed to write file: %w", err) + } + return nil +} + +func (m *SystemManager) CreateDirectory(relativePath string) error { + dirPath, err := m.resolvePath(relativePath) + if err != nil { + return err + } + return os.MkdirAll(dirPath, 0755) +} + +func (m *SystemManager) CreateFile(relativePath string) error { + filePath, err := m.resolvePath(relativePath) + if err != nil { + return err + } + dir := filepath.Dir(filePath) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create parent directories: %w", err) + } + file, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644) + if err != nil { + if os.IsExist(err) { + return fmt.Errorf("file already exists") + } + return fmt.Errorf("failed to create file: %w", err) + } + return file.Close() +} + +func (m *SystemManager) DeleteFile(relativePath string) error { + filePath, err := m.resolvePath(relativePath) + if err != nil { + return err + } + if filePath == m.root { + return fmt.Errorf("cannot delete system files root") + } + info, err := os.Stat(filePath) + if err != nil { + return fmt.Errorf("failed to stat path: %w", err) + } + if info.IsDir() { + return os.RemoveAll(filePath) + } + return os.Remove(filePath) +} + +func (m *SystemManager) Chmod(relativePath string, mode os.FileMode) error { + if mode&^0o777 != 0 { + return fmt.Errorf("mode must be 0-0777 (no setuid, setgid, or sticky bit)") + } + target, err := m.resolvePath(relativePath) + if err != nil { + return err + } + if _, err := os.Stat(target); err != nil { + return err + } + return os.Chmod(target, mode) +} + +// GetDiskUsage returns the total byte size of regular files under relativePath. +// The walk only traverses paths that resolve inside the root. Callers should be +// aware that walking the entire root (e.g. "/") can be very slow. +func (m *SystemManager) GetDiskUsage(relativePath string) (int64, int64, error) { + dirPath, err := m.resolvePath(relativePath) + if err != nil { + return 0, 0, err + } + var totalSize, fileCount int64 + err = filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + if !info.IsDir() { + totalSize += info.Size() + fileCount++ + } + return nil + }) + return totalSize, fileCount, err +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 0b10872..1528a64 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/flatrun/agent/pkg/models" "gopkg.in/yaml.v3" ) @@ -22,6 +23,7 @@ type ClusterConfig struct { type Config struct { DeploymentsPath string `yaml:"deployments_path"` + SystemFilesRoot string `yaml:"system_files_root"` DockerSocket string `yaml:"docker_socket"` API APIConfig `yaml:"api"` Auth AuthConfig `yaml:"auth"` @@ -34,6 +36,7 @@ type Config struct { Security SecurityConfig `yaml:"security"` Audit AuditConfig `yaml:"audit"` Cluster ClusterConfig `yaml:"cluster"` + SystemTerminal SystemTerminalConfig `yaml:"system_terminal"` } type DomainConfig struct { @@ -113,15 +116,15 @@ type InfrastructureConfig struct { } type PowerDNSConfig struct { - Enabled bool `yaml:"enabled" json:"enabled"` - Container string `yaml:"container" json:"container"` - Image string `yaml:"image" json:"image"` - APIPort int `yaml:"api_port" json:"api_port"` - DNSPort int `yaml:"dns_port" json:"dns_port"` - APIKey string `yaml:"api_key" json:"api_key"` - DataPath string `yaml:"data_path" json:"data_path"` - DefaultSOA string `yaml:"default_soa" json:"default_soa"` - Nameservers string `yaml:"nameservers" json:"nameservers"` + Enabled bool `yaml:"enabled" json:"enabled"` + Container string `yaml:"container" json:"container"` + Image string `yaml:"image" json:"image"` + APIPort int `yaml:"api_port" json:"api_port"` + DNSPort int `yaml:"dns_port" json:"dns_port"` + APIKey string `yaml:"api_key" json:"api_key"` + DataPath string `yaml:"data_path" json:"data_path"` + DefaultSOA string `yaml:"default_soa" json:"default_soa"` + Nameservers string `yaml:"nameservers" json:"nameservers"` } type SharedDatabaseConfig struct { @@ -172,6 +175,10 @@ type AuditConfig struct { CleanupInterval time.Duration `yaml:"cleanup_interval" json:"cleanup_interval"` } +type SystemTerminalConfig struct { + ProtectedMode models.ProtectedModeConfig `yaml:"protected_mode" json:"protected_mode"` +} + func FindConfigPath(providedPath string) string { if providedPath != "" && providedPath != "config.yml" { return providedPath @@ -230,6 +237,13 @@ func setDefaults(cfg *Config) { cfg.DeploymentsPath = home + cfg.DeploymentsPath[1:] } } + if cfg.SystemFilesRoot == "" { + cfg.SystemFilesRoot = "/" + } else if strings.HasPrefix(cfg.SystemFilesRoot, "~/") { + if home, err := os.UserHomeDir(); err == nil { + cfg.SystemFilesRoot = home + cfg.SystemFilesRoot[1:] + } + } if cfg.DockerSocket == "" { cfg.DockerSocket = detectDockerHost() } diff --git a/pkg/models/deployment.go b/pkg/models/deployment.go index bacd047..a6aea39 100644 --- a/pkg/models/deployment.go +++ b/pkg/models/deployment.go @@ -24,18 +24,19 @@ type Service struct { } type ServiceMetadata struct { - Name string `yaml:"name" json:"name"` - Type string `yaml:"type" json:"type"` - Networking NetworkingConfig `yaml:"networking" json:"networking"` - SSL SSLConfig `yaml:"ssl" json:"ssl"` - HealthCheck HealthCheckConfig `yaml:"healthcheck" json:"healthcheck"` - QuickActions []QuickAction `yaml:"quick_actions,omitempty" json:"quick_actions,omitempty"` - Security *DeploymentSecurityConfig `yaml:"security,omitempty" json:"security,omitempty"` - Backup *BackupSpec `yaml:"backup,omitempty" json:"backup,omitempty"` - CredentialID string `yaml:"credential_id,omitempty" json:"credential_id,omitempty"` - ServiceCredentials map[string]string `yaml:"service_credentials,omitempty" json:"service_credentials,omitempty"` - Domains []DomainConfig `yaml:"domains,omitempty" json:"domains,omitempty"` - Databases []DatabaseConfig `yaml:"databases,omitempty" json:"databases,omitempty"` + Name string `yaml:"name" json:"name"` + Type string `yaml:"type" json:"type"` + Networking NetworkingConfig `yaml:"networking" json:"networking"` + SSL SSLConfig `yaml:"ssl" json:"ssl"` + HealthCheck HealthCheckConfig `yaml:"healthcheck" json:"healthcheck"` + QuickActions []QuickAction `yaml:"quick_actions,omitempty" json:"quick_actions,omitempty"` + Security *DeploymentSecurityConfig `yaml:"security,omitempty" json:"security,omitempty"` + Backup *BackupSpec `yaml:"backup,omitempty" json:"backup,omitempty"` + ProtectedMode *ProtectedModeConfig `yaml:"protected_mode,omitempty" json:"protected_mode,omitempty"` + CredentialID string `yaml:"credential_id,omitempty" json:"credential_id,omitempty"` + ServiceCredentials map[string]string `yaml:"service_credentials,omitempty" json:"service_credentials,omitempty"` + Domains []DomainConfig `yaml:"domains,omitempty" json:"domains,omitempty"` + Databases []DatabaseConfig `yaml:"databases,omitempty" json:"databases,omitempty"` } type DomainConfig struct { @@ -169,6 +170,22 @@ type QuickAction struct { Service string `yaml:"service,omitempty" json:"service,omitempty"` } +type ProtectedModeConfig struct { + Enabled bool `yaml:"enabled" json:"enabled"` + BlockedActions []string `yaml:"blocked_actions,omitempty" json:"blocked_actions,omitempty"` + BlockedCommandRules []ProtectedCommandRule `yaml:"blocked_command_rules,omitempty" json:"blocked_command_rules,omitempty"` + DisableTerminal bool `yaml:"disable_terminal,omitempty" json:"disable_terminal,omitempty"` +} + +type ProtectedCommandRule struct { + ID string `yaml:"id,omitempty" json:"id,omitempty"` + Name string `yaml:"name,omitempty" json:"name,omitempty"` + Match string `yaml:"match" json:"match"` + Pattern string `yaml:"pattern" json:"pattern"` + CaseSensitive bool `yaml:"case_sensitive,omitempty" json:"case_sensitive,omitempty"` + Description string `yaml:"description,omitempty" json:"description,omitempty"` +} + type NetworkingConfig struct { Expose bool `yaml:"expose" json:"expose"` Domain string `yaml:"domain" json:"domain"`