From 901dbe697ec30e8e8a5dcb1599c0c0ba32f335de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=20Jurmanovi=C4=87?= Date: Thu, 18 Sep 2025 01:06:58 +0200 Subject: [PATCH] Use sockets for server creation progress --- go.mod | 3 + go.sum | 6 + local/api/api.go | 2 +- local/controller/controller.go | 4 +- local/controller/server.go | 10 +- local/controller/steam_2fa.go | 139 ------- local/controller/websocket.go | 168 ++++++++ local/model/server.go | 10 +- local/model/websocket.go | 89 ++++ local/service/server.go | 268 +++++++++++- local/service/service.go | 1 + local/service/steam_service.go | 184 ++++++++- local/service/websocket.go | 186 +++++++++ local/utl/command/interactive_executor.go | 390 +++++++++++++++++- local/utl/common/common.go | 2 +- .../error_handler/controller_error_handler.go | 28 +- local/utl/server/server.go | 12 +- 17 files changed, 1314 insertions(+), 188 deletions(-) delete mode 100644 local/controller/steam_2fa.go create mode 100644 local/controller/websocket.go create mode 100644 local/model/websocket.go create mode 100644 local/service/websocket.go diff --git a/go.mod b/go.mod index d7efb91..13282e2 100644 --- a/go.mod +++ b/go.mod @@ -21,10 +21,12 @@ require ( require ( github.com/KyleBanks/depth v1.2.1 // indirect github.com/andybalholm/brotli v1.1.0 // indirect + github.com/fasthttp/websocket v1.5.3 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect github.com/go-openapi/spec v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect + github.com/gofiber/websocket/v2 v2.2.1 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/josharian/intern v1.0.0 // indirect @@ -35,6 +37,7 @@ require ( github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/rivo/uniseg v0.2.0 // indirect + github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee // indirect github.com/swaggo/files/v2 v2.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.51.0 // indirect diff --git a/go.sum b/go.sum index 72dcd87..ceaa612 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1 github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fasthttp/websocket v1.5.3 h1:TPpQuLwJYfd4LJPXvHDYPMFWbLjsT91n3GpWtCQtdek= +github.com/fasthttp/websocket v1.5.3/go.mod h1:46gg/UBmTU1kUaTcwQXpUxtRwG2PvIZYeA8oL6vF3Fs= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= @@ -16,6 +18,8 @@ github.com/gofiber/fiber/v2 v2.52.8 h1:xl4jJQ0BV5EJTA2aWiKw/VddRpHrKeZLF0QPUxqn0 github.com/gofiber/fiber/v2 v2.52.8/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= github.com/gofiber/swagger v1.1.0 h1:ff3rg1fB+Rp5JN/N8jfxTiZtMKe/9tB9QDc79fPiJKQ= github.com/gofiber/swagger v1.1.0/go.mod h1:pRZL0Np35sd+lTODTE5The0G+TMHfNY+oC4hM2/i5m8= +github.com/gofiber/websocket/v2 v2.2.1 h1:C9cjxvloojayOp9AovmpQrk8VqvVnT8Oao3+IUygH7w= +github.com/gofiber/websocket/v2 v2.2.1/go.mod h1:Ao/+nyNnX5u/hIFPuHl28a+NIkrqK7PRimyKaj4JxVU= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -53,6 +57,8 @@ github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee h1:8Iv5m6xEo1NR1AvpV+7XmhI4r39LGNzwUL4YpMuL5vk= +github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee/go.mod h1:qwtSXrKuJh/zsFQ12yEE89xfCrGKK63Rr7ctU/uCo4g= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/swaggo/files/v2 v2.0.0 h1:hmAt8Dkynw7Ssz46F6pn8ok6YmGZqHSVLZ+HQM7i0kw= diff --git a/local/api/api.go b/local/api/api.go index a14f3bc..61dba0c 100644 --- a/local/api/api.go +++ b/local/api/api.go @@ -31,7 +31,7 @@ func Init(di *dig.Container, app *fiber.App) { StateHistory: serverIdGroup.Group("/state-history"), Membership: groups.Group("/membership"), System: groups.Group("/system"), - Steam2FA: groups.Group("/steam2fa"), + WebSocket: groups.Group("/ws"), } accessKeyMiddleware := middleware.NewAccessKeyMiddleware() diff --git a/local/controller/controller.go b/local/controller/controller.go index 88000a5..583b033 100644 --- a/local/controller/controller.go +++ b/local/controller/controller.go @@ -55,8 +55,8 @@ func InitializeControllers(c *dig.Container) { logging.Panic("unable to initialize membership controller") } - err = c.Invoke(NewSteam2FAController) + err = c.Invoke(NewWebSocketController) if err != nil { - logging.Panic("unable to initialize steam 2fa controller") + logging.Panic("unable to initialize websocket controller") } } diff --git a/local/controller/server.go b/local/controller/server.go index d827f77..1101971 100644 --- a/local/controller/server.go +++ b/local/controller/server.go @@ -139,10 +139,16 @@ func (ac *ServerController) CreateServer(c *fiber.Ctx) error { if err := c.BodyParser(server); err != nil { return ac.errorHandler.HandleParsingError(c, err) } - ac.service.GenerateServerPath(server) - if err := ac.service.CreateServer(c, server); err != nil { + + server.GenerateUUID() + + // Use async server creation to avoid blocking other requests + if err := ac.service.CreateServerAsync(c, server); err != nil { return ac.errorHandler.HandleServiceError(c, err) } + + // Return immediately with server details + // The actual creation will happen in the background with WebSocket updates return c.JSON(server) } diff --git a/local/controller/steam_2fa.go b/local/controller/steam_2fa.go deleted file mode 100644 index 2e7ebf0..0000000 --- a/local/controller/steam_2fa.go +++ /dev/null @@ -1,139 +0,0 @@ -package controller - -import ( - "acc-server-manager/local/middleware" - "acc-server-manager/local/model" - "acc-server-manager/local/utl/common" - "acc-server-manager/local/utl/error_handler" - "acc-server-manager/local/utl/jwt" - - "github.com/gofiber/fiber/v2" -) - -type Steam2FAController struct { - tfaManager *model.Steam2FAManager - errorHandler *error_handler.ControllerErrorHandler - jwtHandler *jwt.OpenJWTHandler -} - -func NewSteam2FAController(tfaManager *model.Steam2FAManager, routeGroups *common.RouteGroups, auth *middleware.AuthMiddleware, jwtHandler *jwt.OpenJWTHandler) *Steam2FAController { - controller := &Steam2FAController{ - tfaManager: tfaManager, - errorHandler: error_handler.NewControllerErrorHandler(), - jwtHandler: jwtHandler, - } - - steam2faRoutes := routeGroups.Steam2FA - steam2faRoutes.Use(auth.AuthenticateOpen) - - // Define routes - steam2faRoutes.Get("/pending", auth.HasPermission(model.ServerView), controller.GetPendingRequests) - steam2faRoutes.Get("/:id", auth.HasPermission(model.ServerView), controller.GetRequest) - steam2faRoutes.Post("/:id/complete", auth.HasPermission(model.ServerUpdate), controller.CompleteRequest) - steam2faRoutes.Post("/:id/cancel", auth.HasPermission(model.ServerUpdate), controller.CancelRequest) - - return controller -} - -// GetPendingRequests gets all pending 2FA requests -// -// @Summary Get pending 2FA requests -// @Description Get all pending Steam 2FA authentication requests -// @Tags Steam 2FA -// @Accept json -// @Produce json -// @Success 200 {array} model.Steam2FARequest -// @Failure 500 {object} error_handler.ErrorResponse -// @Router /steam2fa/pending [get] -func (c *Steam2FAController) GetPendingRequests(ctx *fiber.Ctx) error { - requests := c.tfaManager.GetPendingRequests() - return ctx.JSON(requests) -} - -// GetRequest gets a specific 2FA request by ID -// -// @Summary Get 2FA request -// @Description Get a specific Steam 2FA authentication request by ID -// @Tags Steam 2FA -// @Accept json -// @Produce json -// @Param id path string true "2FA Request ID" -// @Success 200 {object} model.Steam2FARequest -// @Failure 404 {object} error_handler.ErrorResponse -// @Failure 500 {object} error_handler.ErrorResponse -// @Router /steam2fa/{id} [get] -func (c *Steam2FAController) GetRequest(ctx *fiber.Ctx) error { - id := ctx.Params("id") - if id == "" { - return c.errorHandler.HandleError(ctx, fiber.ErrBadRequest, fiber.StatusBadRequest) - } - - request, exists := c.tfaManager.GetRequest(id) - if !exists { - return c.errorHandler.HandleNotFoundError(ctx, "2FA request") - } - - return ctx.JSON(request) -} - -// CompleteRequest marks a 2FA request as completed -// -// @Summary Complete 2FA request -// @Description Mark a Steam 2FA authentication request as completed -// @Tags Steam 2FA -// @Accept json -// @Produce json -// @Param id path string true "2FA Request ID" -// @Success 200 {object} model.Steam2FARequest -// @Failure 400 {object} error_handler.ErrorResponse -// @Failure 404 {object} error_handler.ErrorResponse -// @Failure 500 {object} error_handler.ErrorResponse -// @Router /steam2fa/{id}/complete [post] -func (c *Steam2FAController) CompleteRequest(ctx *fiber.Ctx) error { - id := ctx.Params("id") - if id == "" { - return c.errorHandler.HandleError(ctx, fiber.ErrBadRequest, fiber.StatusBadRequest) - } - - if err := c.tfaManager.CompleteRequest(id); err != nil { - return c.errorHandler.HandleError(ctx, err, fiber.StatusBadRequest) - } - - request, exists := c.tfaManager.GetRequest(id) - if !exists { - return c.errorHandler.HandleNotFoundError(ctx, "2FA request") - } - - return ctx.JSON(request) -} - -// CancelRequest cancels a 2FA request -// -// @Summary Cancel 2FA request -// @Description Cancel a Steam 2FA authentication request -// @Tags Steam 2FA -// @Accept json -// @Produce json -// @Param id path string true "2FA Request ID" -// @Success 200 {object} model.Steam2FARequest -// @Failure 400 {object} error_handler.ErrorResponse -// @Failure 404 {object} error_handler.ErrorResponse -// @Failure 500 {object} error_handler.ErrorResponse -// @Router /steam2fa/{id}/cancel [post] -func (c *Steam2FAController) CancelRequest(ctx *fiber.Ctx) error { - id := ctx.Params("id") - if id == "" { - return c.errorHandler.HandleError(ctx, fiber.ErrBadRequest, fiber.StatusBadRequest) - } - - if err := c.tfaManager.ErrorRequest(id, "cancelled by user"); err != nil { - return c.errorHandler.HandleError(ctx, err, fiber.StatusBadRequest) - } - - request, exists := c.tfaManager.GetRequest(id) - if !exists { - return c.errorHandler.HandleNotFoundError(ctx, "2FA request") - } - - return ctx.JSON(request) -} diff --git a/local/controller/websocket.go b/local/controller/websocket.go new file mode 100644 index 0000000..8ae5c47 --- /dev/null +++ b/local/controller/websocket.go @@ -0,0 +1,168 @@ +package controller + +import ( + "acc-server-manager/local/middleware" + "acc-server-manager/local/service" + "acc-server-manager/local/utl/common" + "acc-server-manager/local/utl/jwt" + "acc-server-manager/local/utl/logging" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/websocket/v2" + "github.com/google/uuid" +) + +type WebSocketController struct { + webSocketService *service.WebSocketService + jwtHandler *jwt.OpenJWTHandler +} + +// NewWebSocketController initializes WebSocketController +func NewWebSocketController( + wsService *service.WebSocketService, + jwtHandler *jwt.OpenJWTHandler, + routeGroups *common.RouteGroups, + auth *middleware.AuthMiddleware, +) *WebSocketController { + wsc := &WebSocketController{ + webSocketService: wsService, + jwtHandler: jwtHandler, + } + + // WebSocket routes + wsRoutes := routeGroups.WebSocket + wsRoutes.Use("/", wsc.upgradeWebSocket) + wsRoutes.Get("/", websocket.New(wsc.handleWebSocket)) + + return wsc +} + +// upgradeWebSocket middleware to upgrade HTTP to WebSocket and validate authentication +func (wsc *WebSocketController) upgradeWebSocket(c *fiber.Ctx) error { + // Check if it's a WebSocket upgrade request + if websocket.IsWebSocketUpgrade(c) { + // Validate JWT token from query parameter or header + token := c.Query("token") + if token == "" { + token = c.Get("Authorization") + if token != "" && len(token) > 7 && token[:7] == "Bearer " { + token = token[7:] + } + } + + if token == "" { + return fiber.NewError(fiber.StatusUnauthorized, "Missing authentication token") + } + + // Validate the token + claims, err := wsc.jwtHandler.ValidateToken(token) + if err != nil { + return fiber.NewError(fiber.StatusUnauthorized, "Invalid authentication token") + } + + // Parse UserID string to UUID + userID, err := uuid.Parse(claims.UserID) + if err != nil { + return fiber.NewError(fiber.StatusUnauthorized, "Invalid user ID in token") + } + + // Store user info in context for use in WebSocket handler + c.Locals("userID", userID) + c.Locals("username", claims.UserID) // Use UserID as username for now + + return c.Next() + } + + return fiber.NewError(fiber.StatusUpgradeRequired, "WebSocket upgrade required") +} + +// handleWebSocket handles WebSocket connections +func (wsc *WebSocketController) handleWebSocket(c *websocket.Conn) { + // Generate a unique connection ID + connID := uuid.New().String() + + // Get user info from locals (set by middleware) + userID, ok := c.Locals("userID").(uuid.UUID) + if !ok { + logging.Error("Failed to get user ID from WebSocket connection") + c.Close() + return + } + + username, _ := c.Locals("username").(string) + logging.Info("WebSocket connection established for user: %s (ID: %s)", username, userID.String()) + + // Add the connection to the service + wsc.webSocketService.AddConnection(connID, c, &userID) + + // Handle connection cleanup + defer func() { + wsc.webSocketService.RemoveConnection(connID) + logging.Info("WebSocket connection closed for user: %s", username) + }() + + // Handle incoming messages from the client + for { + messageType, message, err := c.ReadMessage() + if err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { + logging.Error("WebSocket error for user %s: %v", username, err) + } + break + } + + // Handle different message types + switch messageType { + case websocket.TextMessage: + wsc.handleTextMessage(connID, userID, message) + case websocket.BinaryMessage: + logging.Debug("Received binary message from user %s (not supported)", username) + case websocket.PingMessage: + // Respond with pong + if err := c.WriteMessage(websocket.PongMessage, nil); err != nil { + logging.Error("Failed to send pong to user %s: %v", username, err) + break + } + } + } +} + +// handleTextMessage processes text messages from the client +func (wsc *WebSocketController) handleTextMessage(connID string, userID uuid.UUID, message []byte) { + logging.Debug("Received WebSocket message from user %s: %s", userID.String(), string(message)) + + // Parse the message to handle different types of client requests + // For now, we'll just log it. In the future, you might want to handle: + // - Subscription to specific server creation processes + // - Client heartbeat/keepalive + // - Request for status updates + + // Example: If the message contains a server ID, associate this connection with that server + // This is a simple implementation - you might want to use proper JSON parsing + messageStr := string(message) + if len(messageStr) > 10 && messageStr[:9] == "server_id" { + // Extract server ID from message like "server_id:uuid" + if serverIDStr := messageStr[10:]; len(serverIDStr) > 0 { + if serverID, err := uuid.Parse(serverIDStr); err == nil { + wsc.webSocketService.SetServerID(connID, serverID) + logging.Info("Associated WebSocket connection %s with server %s", connID, serverID.String()) + } + } + } +} + +// GetWebSocketUpgrade returns the WebSocket upgrade handler for use in other controllers +func (wsc *WebSocketController) GetWebSocketUpgrade() fiber.Handler { + return wsc.upgradeWebSocket +} + +// GetWebSocketHandler returns the WebSocket connection handler for use in other controllers +func (wsc *WebSocketController) GetWebSocketHandler() func(*websocket.Conn) { + return wsc.handleWebSocket +} + +// BroadcastServerCreationProgress is a helper method for other services to broadcast progress +func (wsc *WebSocketController) BroadcastServerCreationProgress(serverID uuid.UUID, step string, status string, message string) { + // This can be used by the ServerService during server creation + logging.Info("Broadcasting server creation progress: %s - %s: %s", serverID.String(), step, status) +} diff --git a/local/model/server.go b/local/model/server.go index 5b62176..95a9e8c 100644 --- a/local/model/server.go +++ b/local/model/server.go @@ -104,6 +104,12 @@ func (f *ServerFilter) ApplyFilter(query *gorm.DB) *gorm.DB { return query } +func (s *Server) GenerateUUID() { + if s.ID == uuid.Nil { + s.ID = uuid.New() + } +} + // BeforeCreate is a GORM hook that runs before creating a new server func (s *Server) BeforeCreate(tx *gorm.DB) error { if s.Name == "" { @@ -111,9 +117,7 @@ func (s *Server) BeforeCreate(tx *gorm.DB) error { } // Generate UUID if not set - if s.ID == uuid.Nil { - s.ID = uuid.New() - } + s.GenerateUUID() // Generate service name and config path if not set if s.ServiceName == "" { diff --git a/local/model/websocket.go b/local/model/websocket.go new file mode 100644 index 0000000..3fd6b20 --- /dev/null +++ b/local/model/websocket.go @@ -0,0 +1,89 @@ +package model + +import ( + "github.com/google/uuid" +) + +// ServerCreationStep represents the steps in server creation process +type ServerCreationStep string + +const ( + StepValidation ServerCreationStep = "validation" + StepDirectoryCreation ServerCreationStep = "directory_creation" + StepSteamDownload ServerCreationStep = "steam_download" + StepConfigGeneration ServerCreationStep = "config_generation" + StepServiceCreation ServerCreationStep = "service_creation" + StepFirewallRules ServerCreationStep = "firewall_rules" + StepDatabaseSave ServerCreationStep = "database_save" + StepCompleted ServerCreationStep = "completed" +) + +// StepStatus represents the status of a step +type StepStatus string + +const ( + StatusPending StepStatus = "pending" + StatusInProgress StepStatus = "in_progress" + StatusCompleted StepStatus = "completed" + StatusFailed StepStatus = "failed" +) + +// WebSocketMessageType represents different types of WebSocket messages +type WebSocketMessageType string + +const ( + MessageTypeStep WebSocketMessageType = "step" + MessageTypeSteamOutput WebSocketMessageType = "steam_output" + MessageTypeError WebSocketMessageType = "error" + MessageTypeComplete WebSocketMessageType = "complete" +) + +// WebSocketMessage is the base structure for all WebSocket messages +type WebSocketMessage struct { + Type WebSocketMessageType `json:"type"` + ServerID *uuid.UUID `json:"server_id,omitempty"` + Timestamp int64 `json:"timestamp"` + Data interface{} `json:"data"` +} + +// StepMessage represents a step update message +type StepMessage struct { + Step ServerCreationStep `json:"step"` + Status StepStatus `json:"status"` + Message string `json:"message,omitempty"` + Error string `json:"error,omitempty"` +} + +// SteamOutputMessage represents SteamCMD output +type SteamOutputMessage struct { + Output string `json:"output"` + IsError bool `json:"is_error"` +} + +// ErrorMessage represents an error message +type ErrorMessage struct { + Error string `json:"error"` + Details string `json:"details,omitempty"` +} + +// CompleteMessage represents completion message +type CompleteMessage struct { + ServerID uuid.UUID `json:"server_id"` + Success bool `json:"success"` + Message string `json:"message"` +} + +// GetStepDescription returns a human-readable description for each step +func GetStepDescription(step ServerCreationStep) string { + descriptions := map[ServerCreationStep]string{ + StepValidation: "Validating server configuration", + StepDirectoryCreation: "Creating server directories", + StepSteamDownload: "Downloading server files via Steam", + StepConfigGeneration: "Generating server configuration files", + StepServiceCreation: "Creating Windows service", + StepFirewallRules: "Configuring firewall rules", + StepDatabaseSave: "Saving server to database", + StepCompleted: "Server creation completed", + } + return descriptions[step] +} diff --git a/local/service/server.go b/local/service/server.go index 5c4afec..b193120 100644 --- a/local/service/server.go +++ b/local/service/server.go @@ -31,6 +31,7 @@ type ServerService struct { steamService *SteamService windowsService *WindowsService firewallService *FirewallService + webSocketService *WebSocketService instances sync.Map // Track instances per server lastInsertTimes sync.Map // Track last insert time per server debouncers sync.Map // Track debounce timers per server @@ -68,6 +69,7 @@ func NewServerService( steamService *SteamService, windowsService *WindowsService, firewallService *FirewallService, + webSocketService *WebSocketService, ) *ServerService { service := &ServerService{ repository: repository, @@ -77,6 +79,7 @@ func NewServerService( steamService: steamService, windowsService: windowsService, firewallService: firewallService, + webSocketService: webSocketService, } // Initialize server instances @@ -203,6 +206,7 @@ func (s *ServerService) updateSessionDuration(server *model.Server, sessionType func (s *ServerService) GenerateServerPath(server *model.Server) { // Get the base steamcmd path from environment variable steamCMDPath := env.GetSteamCMDDirPath() + server.FromSteamCMD = true server.Path = server.GenerateServerPath(steamCMDPath) } @@ -330,50 +334,139 @@ func (as *ServerService) GetById(ctx *fiber.Ctx, serverID uuid.UUID) (*model.Ser return server, nil } -func (s *ServerService) CreateServer(ctx *fiber.Ctx, server *model.Server) error { - // Validate basic server configuration +// CreateServerAsync starts server creation asynchronously and returns immediately +func (s *ServerService) CreateServerAsync(ctx *fiber.Ctx, server *model.Server) error { + // Perform basic validation first if err := server.Validate(); err != nil { return err } - // Install server using SteamCMD - if err := s.steamService.InstallServer(ctx.UserContext(), server.GetServerPath(), &server.ID); err != nil { + // Generate server path + s.GenerateServerPath(server) + + // Create a background context that won't be cancelled when the HTTP request ends + bgCtx := context.Background() + + // Start the actual creation process in a goroutine + go func() { + // Create server in background without using fiber.Ctx + if err := s.createServerBackground(bgCtx, server); err != nil { + logging.Error("Async server creation failed for server %s: %v", server.ID, err) + s.webSocketService.BroadcastError(server.ID, "Server creation failed", err.Error()) + s.webSocketService.BroadcastComplete(server.ID, false, fmt.Sprintf("Server creation failed: %v", err)) + } + }() + + return nil +} + +func (s *ServerService) CreateServer(ctx *fiber.Ctx, server *model.Server) error { + // Broadcast step: validation + s.webSocketService.BroadcastStep(server.ID, model.StepValidation, model.StatusInProgress, + model.GetStepDescription(model.StepValidation), "") + + // Validate basic server configuration + if err := server.Validate(); err != nil { + s.webSocketService.BroadcastStep(server.ID, model.StepValidation, model.StatusFailed, + "", fmt.Sprintf("Validation failed: %v", err)) + return err + } + + s.webSocketService.BroadcastStep(server.ID, model.StepValidation, model.StatusCompleted, + "Server configuration validated successfully", "") + + // Broadcast step: directory creation + s.webSocketService.BroadcastStep(server.ID, model.StepDirectoryCreation, model.StatusInProgress, + model.GetStepDescription(model.StepDirectoryCreation), "") + + // Directory creation is handled within InstallServer, so we mark it as completed + s.webSocketService.BroadcastStep(server.ID, model.StepDirectoryCreation, model.StatusCompleted, + "Server directories prepared", "") + + // Broadcast step: Steam download + s.webSocketService.BroadcastStep(server.ID, model.StepSteamDownload, model.StatusInProgress, + model.GetStepDescription(model.StepSteamDownload), "") + + // Install server using SteamCMD with streaming support + if err := s.steamService.InstallServerWithWebSocket(ctx.UserContext(), server.Path, &server.ID, s.webSocketService); err != nil { + s.webSocketService.BroadcastStep(server.ID, model.StepSteamDownload, model.StatusFailed, + "", fmt.Sprintf("Steam installation failed: %v", err)) return fmt.Errorf("failed to install server: %v", err) } - // Create Windows service with correct paths - execPath := filepath.Join(server.GetServerPath(), "accServer.exe") - serverWorkingDir := filepath.Join(server.GetServerPath(), "server") - if err := s.windowsService.CreateService(ctx.UserContext(), server.ServiceName, execPath, serverWorkingDir, nil); err != nil { - // Cleanup on failure - s.steamService.UninstallServer(server.Path) - return fmt.Errorf("failed to create Windows service: %v", err) - } + s.webSocketService.BroadcastStep(server.ID, model.StepSteamDownload, model.StatusCompleted, + "Server files downloaded successfully", "") - s.configureFirewall(server) + // Broadcast step: config generation + s.webSocketService.BroadcastStep(server.ID, model.StepConfigGeneration, model.StatusInProgress, + model.GetStepDescription(model.StepConfigGeneration), "") + + // Find available ports for server ports, err := network.FindAvailablePortRange(DefaultStartPort, RequiredPortCount) if err != nil { + s.webSocketService.BroadcastStep(server.ID, model.StepConfigGeneration, model.StatusFailed, + "", fmt.Sprintf("Failed to find available ports: %v", err)) return fmt.Errorf("failed to find available ports: %v", err) } // Use the first port for both TCP and UDP serverPort := ports[0] + + // Update server configuration with the allocated port + if err := s.updateServerPort(server, serverPort); err != nil { + s.webSocketService.BroadcastStep(server.ID, model.StepConfigGeneration, model.StatusFailed, + "", fmt.Sprintf("Failed to update server configuration: %v", err)) + return fmt.Errorf("failed to update server configuration: %v", err) + } + + s.webSocketService.BroadcastStep(server.ID, model.StepConfigGeneration, model.StatusCompleted, + fmt.Sprintf("Server configuration generated (Port: %d)", serverPort), "") + + // Broadcast step: service creation + s.webSocketService.BroadcastStep(server.ID, model.StepServiceCreation, model.StatusInProgress, + model.GetStepDescription(model.StepServiceCreation), "") + + // Create Windows service with correct paths + execPath := filepath.Join(server.GetServerPath(), "accServer.exe") + serverWorkingDir := filepath.Join(server.GetServerPath(), "server") + if err := s.windowsService.CreateService(ctx.UserContext(), server.ServiceName, execPath, serverWorkingDir, nil); err != nil { + s.webSocketService.BroadcastStep(server.ID, model.StepServiceCreation, model.StatusFailed, + "", fmt.Sprintf("Failed to create Windows service: %v", err)) + // Cleanup on failure + s.steamService.UninstallServer(server.Path) + return fmt.Errorf("failed to create Windows service: %v", err) + } + + s.webSocketService.BroadcastStep(server.ID, model.StepServiceCreation, model.StatusCompleted, + fmt.Sprintf("Windows service '%s' created successfully", server.ServiceName), "") + + // Broadcast step: firewall rules + s.webSocketService.BroadcastStep(server.ID, model.StepFirewallRules, model.StatusInProgress, + model.GetStepDescription(model.StepFirewallRules), "") + + s.configureFirewall(server) tcpPorts := []int{serverPort} udpPorts := []int{serverPort} if err := s.firewallService.CreateServerRules(server.ServiceName, tcpPorts, udpPorts); err != nil { + s.webSocketService.BroadcastStep(server.ID, model.StepFirewallRules, model.StatusFailed, + "", fmt.Sprintf("Failed to create firewall rules: %v", err)) // Cleanup on failure s.windowsService.DeleteService(ctx.UserContext(), server.ServiceName) s.steamService.UninstallServer(server.Path) return fmt.Errorf("failed to create firewall rules: %v", err) } - // Update server configuration with the allocated port - if err := s.updateServerPort(server, serverPort); err != nil { - return fmt.Errorf("failed to update server configuration: %v", err) - } + s.webSocketService.BroadcastStep(server.ID, model.StepFirewallRules, model.StatusCompleted, + fmt.Sprintf("Firewall rules created for port %d", serverPort), "") + + // Broadcast step: database save + s.webSocketService.BroadcastStep(server.ID, model.StepDatabaseSave, model.StatusInProgress, + model.GetStepDescription(model.StepDatabaseSave), "") // Insert server into database if err := s.repository.Insert(ctx.UserContext(), server); err != nil { + s.webSocketService.BroadcastStep(server.ID, model.StepDatabaseSave, model.StatusFailed, + "", fmt.Sprintf("Failed to save server to database: %v", err)) // Cleanup on failure s.firewallService.DeleteServerRules(server.ServiceName, tcpPorts, udpPorts) s.windowsService.DeleteService(ctx.UserContext(), server.ServiceName) @@ -381,9 +474,150 @@ func (s *ServerService) CreateServer(ctx *fiber.Ctx, server *model.Server) error return fmt.Errorf("failed to insert server into database: %v", err) } + s.webSocketService.BroadcastStep(server.ID, model.StepDatabaseSave, model.StatusCompleted, + "Server saved to database successfully", "") + // Initialize server runtime s.StartAccServerRuntime(server) + // Broadcast completion + s.webSocketService.BroadcastStep(server.ID, model.StepCompleted, model.StatusCompleted, + model.GetStepDescription(model.StepCompleted), "") + + s.webSocketService.BroadcastComplete(server.ID, true, + fmt.Sprintf("Server '%s' created successfully on port %d", server.Name, serverPort)) + + return nil +} + +// createServerBackground performs server creation in background without fiber.Ctx +func (s *ServerService) createServerBackground(ctx context.Context, server *model.Server) error { + // Broadcast step: validation + s.webSocketService.BroadcastStep(server.ID, model.StepValidation, model.StatusInProgress, + model.GetStepDescription(model.StepValidation), "") + + // Validate basic server configuration (already done in async method, but double-check) + if err := server.Validate(); err != nil { + s.webSocketService.BroadcastStep(server.ID, model.StepValidation, model.StatusFailed, + "", fmt.Sprintf("Validation failed: %v", err)) + return err + } + + s.webSocketService.BroadcastStep(server.ID, model.StepValidation, model.StatusCompleted, + "Server configuration validated successfully", "") + + // Broadcast step: directory creation + s.webSocketService.BroadcastStep(server.ID, model.StepDirectoryCreation, model.StatusInProgress, + model.GetStepDescription(model.StepDirectoryCreation), "") + + // Directory creation is handled within InstallServer, so we mark it as completed + s.webSocketService.BroadcastStep(server.ID, model.StepDirectoryCreation, model.StatusCompleted, + "Server directories prepared", "") + + // Broadcast step: Steam download + s.webSocketService.BroadcastStep(server.ID, model.StepSteamDownload, model.StatusInProgress, + model.GetStepDescription(model.StepSteamDownload), "") + + // Install server using SteamCMD with streaming support + if err := s.steamService.InstallServerWithWebSocket(ctx, server.GetServerPath(), &server.ID, s.webSocketService); err != nil { + s.webSocketService.BroadcastStep(server.ID, model.StepSteamDownload, model.StatusFailed, + "", fmt.Sprintf("Steam installation failed: %v", err)) + return fmt.Errorf("failed to install server: %v", err) + } + + s.webSocketService.BroadcastStep(server.ID, model.StepSteamDownload, model.StatusCompleted, + "Server files downloaded successfully", "") + + // Broadcast step: config generation + s.webSocketService.BroadcastStep(server.ID, model.StepConfigGeneration, model.StatusInProgress, + model.GetStepDescription(model.StepConfigGeneration), "") + + // Find available ports for server + ports, err := network.FindAvailablePortRange(DefaultStartPort, RequiredPortCount) + if err != nil { + s.webSocketService.BroadcastStep(server.ID, model.StepConfigGeneration, model.StatusFailed, + "", fmt.Sprintf("Failed to find available ports: %v", err)) + return fmt.Errorf("failed to find available ports: %v", err) + } + + // Use the first port for both TCP and UDP + serverPort := ports[0] + + // Update server configuration with the allocated port + if err := s.updateServerPort(server, serverPort); err != nil { + s.webSocketService.BroadcastStep(server.ID, model.StepConfigGeneration, model.StatusFailed, + "", fmt.Sprintf("Failed to update server configuration: %v", err)) + return fmt.Errorf("failed to update server configuration: %v", err) + } + + s.webSocketService.BroadcastStep(server.ID, model.StepConfigGeneration, model.StatusCompleted, + fmt.Sprintf("Server configuration generated (Port: %d)", serverPort), "") + + // Broadcast step: service creation + s.webSocketService.BroadcastStep(server.ID, model.StepServiceCreation, model.StatusInProgress, + model.GetStepDescription(model.StepServiceCreation), "") + + // Create Windows service with correct paths + execPath := filepath.Join(server.GetServerPath(), "accServer.exe") + serverWorkingDir := filepath.Join(server.GetServerPath(), "server") + if err := s.windowsService.CreateService(ctx, server.ServiceName, execPath, serverWorkingDir, nil); err != nil { + s.webSocketService.BroadcastStep(server.ID, model.StepServiceCreation, model.StatusFailed, + "", fmt.Sprintf("Failed to create Windows service: %v", err)) + // Cleanup on failure + s.steamService.UninstallServer(server.Path) + return fmt.Errorf("failed to create Windows service: %v", err) + } + + s.webSocketService.BroadcastStep(server.ID, model.StepServiceCreation, model.StatusCompleted, + fmt.Sprintf("Windows service '%s' created successfully", server.ServiceName), "") + + // Broadcast step: firewall rules + s.webSocketService.BroadcastStep(server.ID, model.StepFirewallRules, model.StatusInProgress, + model.GetStepDescription(model.StepFirewallRules), "") + + s.configureFirewall(server) + tcpPorts := []int{serverPort} + udpPorts := []int{serverPort} + if err := s.firewallService.CreateServerRules(server.ServiceName, tcpPorts, udpPorts); err != nil { + s.webSocketService.BroadcastStep(server.ID, model.StepFirewallRules, model.StatusFailed, + "", fmt.Sprintf("Failed to create firewall rules: %v", err)) + // Cleanup on failure + s.windowsService.DeleteService(ctx, server.ServiceName) + s.steamService.UninstallServer(server.Path) + return fmt.Errorf("failed to create firewall rules: %v", err) + } + + s.webSocketService.BroadcastStep(server.ID, model.StepFirewallRules, model.StatusCompleted, + fmt.Sprintf("Firewall rules created for port %d", serverPort), "") + + // Broadcast step: database save + s.webSocketService.BroadcastStep(server.ID, model.StepDatabaseSave, model.StatusInProgress, + model.GetStepDescription(model.StepDatabaseSave), "") + + // Insert server into database + if err := s.repository.Insert(ctx, server); err != nil { + s.webSocketService.BroadcastStep(server.ID, model.StepDatabaseSave, model.StatusFailed, + "", fmt.Sprintf("Failed to save server to database: %v", err)) + // Cleanup on failure + s.firewallService.DeleteServerRules(server.ServiceName, tcpPorts, udpPorts) + s.windowsService.DeleteService(ctx, server.ServiceName) + s.steamService.UninstallServer(server.Path) + return fmt.Errorf("failed to insert server into database: %v", err) + } + + s.webSocketService.BroadcastStep(server.ID, model.StepDatabaseSave, model.StatusCompleted, + "Server saved to database successfully", "") + + // Initialize server runtime + s.StartAccServerRuntime(server) + + // Broadcast completion + s.webSocketService.BroadcastStep(server.ID, model.StepCompleted, model.StatusCompleted, + model.GetStepDescription(model.StepCompleted), "") + + s.webSocketService.BroadcastComplete(server.ID, true, + fmt.Sprintf("Server '%s' created successfully on port %d", server.Name, serverPort)) + return nil } diff --git a/local/service/service.go b/local/service/service.go index dcc8e22..b368cf3 100644 --- a/local/service/service.go +++ b/local/service/service.go @@ -27,6 +27,7 @@ func InitializeServices(c *dig.Container) { c.Provide(NewWindowsService) c.Provide(NewFirewallService) c.Provide(NewMembershipService) + c.Provide(NewWebSocketService) logging.Debug("Initializing service dependencies") err := c.Invoke(func(server *ServerService, api *ServiceControlService, config *ConfigService) { diff --git a/local/service/steam_service.go b/local/service/steam_service.go index 37b7ca6..ba0ea83 100644 --- a/local/service/steam_service.go +++ b/local/service/steam_service.go @@ -11,6 +11,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "time" "github.com/google/uuid" @@ -131,11 +132,13 @@ func (s *SteamService) InstallServer(ctx context.Context, installPath string, se } if creds != nil && creds.Username != "" { + logging.Info("Using Steam credentials for user: %s", creds.Username) steamCMDArgs = append(steamCMDArgs, creds.Username) if creds.Password != "" { steamCMDArgs = append(steamCMDArgs, creds.Password) } } else { + logging.Info("Using anonymous Steam login") steamCMDArgs = append(steamCMDArgs, "anonymous") } @@ -145,37 +148,196 @@ func (s *SteamService) InstallServer(ctx context.Context, installPath string, se "+quit", ) - // Build PowerShell arguments to execute SteamCMD directly - // This matches the format: powershell -nologo -noprofile c:\steamcmd\steamcmd.exe +args... - args := []string{"-nologo", "-noprofile"} - args = append(args, steamCMDPath) - args = append(args, steamCMDArgs...) + // Execute SteamCMD directly without PowerShell wrapper to get better output capture + args := steamCMDArgs // Use interactive executor to handle potential 2FA prompts with timeout logging.Info("Installing ACC server to %s...", absPath) - + logging.Info("SteamCMD command: %s %s", steamCMDPath, strings.Join(args, " ")) + // Create a context with timeout to prevent hanging indefinitely - timeoutCtx, cancel := context.WithTimeout(ctx, 10*time.Minute) + timeoutCtx, cancel := context.WithTimeout(ctx, 15*time.Minute) // Increased timeout defer cancel() - + + // Update the executor to use SteamCMD directly + originalExePath := s.interactiveExecutor.ExePath + s.interactiveExecutor.ExePath = steamCMDPath + defer func() { + s.interactiveExecutor.ExePath = originalExePath + }() + if err := s.interactiveExecutor.ExecuteInteractive(timeoutCtx, serverID, args...); err != nil { + logging.Error("SteamCMD execution failed: %v", err) if timeoutCtx.Err() == context.DeadlineExceeded { - return fmt.Errorf("SteamCMD operation timed out after 10 minutes") + return fmt.Errorf("SteamCMD operation timed out after 15 minutes - this usually means Steam Guard confirmation is required") } return fmt.Errorf("failed to run SteamCMD: %v", err) } + logging.Info("SteamCMD execution completed successfully, proceeding with verification...") + // Add a delay to allow Steam to properly cleanup logging.Info("Waiting for Steam operations to complete...") time.Sleep(5 * time.Second) // Verify installation exePath := filepath.Join(absPath, "server", "accServer.exe") + logging.Info("Checking for ACC server executable at: %s", exePath) + if _, err := os.Stat(exePath); os.IsNotExist(err) { - return fmt.Errorf("server installation failed: accServer.exe not found in %s", absPath) + // Log directory contents to help debug + logging.Info("accServer.exe not found, checking directory contents...") + if entries, dirErr := os.ReadDir(absPath); dirErr == nil { + logging.Info("Contents of %s:", absPath) + for _, entry := range entries { + logging.Info(" - %s (dir: %v)", entry.Name(), entry.IsDir()) + } + } + + // Check if there's a server subdirectory + serverDir := filepath.Join(absPath, "server") + if entries, dirErr := os.ReadDir(serverDir); dirErr == nil { + logging.Info("Contents of %s:", serverDir) + for _, entry := range entries { + logging.Info(" - %s (dir: %v)", entry.Name(), entry.IsDir()) + } + } else { + logging.Info("Server directory %s does not exist or cannot be read: %v", serverDir, dirErr) + } + + return fmt.Errorf("server installation failed: accServer.exe not found in %s", exePath) } - logging.Info("Server installation completed successfully") + logging.Info("Server installation completed successfully - accServer.exe found at %s", exePath) + return nil +} + +// InstallServerWithWebSocket installs a server with WebSocket output streaming +func (s *SteamService) InstallServerWithWebSocket(ctx context.Context, installPath string, serverID *uuid.UUID, wsService *WebSocketService) error { + if err := s.ensureSteamCMD(ctx); err != nil { + wsService.BroadcastSteamOutput(*serverID, fmt.Sprintf("Error ensuring SteamCMD: %v", err), true) + return err + } + + // Validate installation path for security + if err := s.pathValidator.ValidateInstallPath(installPath); err != nil { + wsService.BroadcastSteamOutput(*serverID, fmt.Sprintf("Invalid installation path: %v", err), true) + return fmt.Errorf("invalid installation path: %v", err) + } + + // Convert to absolute path and ensure proper Windows path format + absPath, err := filepath.Abs(installPath) + if err != nil { + wsService.BroadcastSteamOutput(*serverID, fmt.Sprintf("Failed to get absolute path: %v", err), true) + return fmt.Errorf("failed to get absolute path: %v", err) + } + absPath = filepath.Clean(absPath) + + // Ensure install path exists + if err := os.MkdirAll(absPath, 0755); err != nil { + wsService.BroadcastSteamOutput(*serverID, fmt.Sprintf("Failed to create install directory: %v", err), true) + return fmt.Errorf("failed to create install directory: %v", err) + } + + wsService.BroadcastSteamOutput(*serverID, fmt.Sprintf("Installation directory prepared: %s", absPath), false) + + // Get Steam credentials + creds, err := s.GetCredentials(ctx) + if err != nil { + wsService.BroadcastSteamOutput(*serverID, fmt.Sprintf("Failed to get Steam credentials: %v", err), true) + return fmt.Errorf("failed to get Steam credentials: %v", err) + } + + // Get SteamCMD path from environment variable + steamCMDPath := env.GetSteamCMDPath() + + // Build SteamCMD command arguments + steamCMDArgs := []string{ + "+force_install_dir", absPath, + "+login", + } + + if creds != nil && creds.Username != "" { + wsService.BroadcastSteamOutput(*serverID, fmt.Sprintf("Using Steam credentials for user: %s", creds.Username), false) + steamCMDArgs = append(steamCMDArgs, creds.Username) + if creds.Password != "" { + steamCMDArgs = append(steamCMDArgs, creds.Password) + } + } else { + wsService.BroadcastSteamOutput(*serverID, "Using anonymous Steam login", false) + steamCMDArgs = append(steamCMDArgs, "anonymous") + } + + steamCMDArgs = append(steamCMDArgs, + "+app_update", ACCServerAppID, + "validate", + "+quit", + ) + + // Execute SteamCMD with WebSocket output streaming + args := steamCMDArgs + + wsService.BroadcastSteamOutput(*serverID, fmt.Sprintf("Starting SteamCMD: %s %s", steamCMDPath, strings.Join(args, " ")), false) + + // Create a context with timeout to prevent hanging indefinitely + timeoutCtx, cancel := context.WithTimeout(ctx, 15*time.Minute) + defer cancel() + + // Update the executor to use SteamCMD directly + originalExePath := s.interactiveExecutor.ExePath + s.interactiveExecutor.ExePath = steamCMDPath + defer func() { + s.interactiveExecutor.ExePath = originalExePath + }() + + // Create a modified interactive executor that streams output to WebSocket + wsInteractiveExecutor := command.NewInteractiveCommandExecutorWithWebSocket(s.executor, s.tfaManager, wsService, *serverID) + wsInteractiveExecutor.ExePath = steamCMDPath + + if err := wsInteractiveExecutor.ExecuteInteractive(timeoutCtx, serverID, args...); err != nil { + wsService.BroadcastSteamOutput(*serverID, fmt.Sprintf("SteamCMD execution failed: %v", err), true) + if timeoutCtx.Err() == context.DeadlineExceeded { + return fmt.Errorf("SteamCMD operation timed out after 15 minutes - this usually means Steam Guard confirmation is required") + } + return fmt.Errorf("failed to run SteamCMD: %v", err) + } + + wsService.BroadcastSteamOutput(*serverID, "SteamCMD execution completed successfully, proceeding with verification...", false) + + // Add a delay to allow Steam to properly cleanup + wsService.BroadcastSteamOutput(*serverID, "Waiting for Steam operations to complete...", false) + time.Sleep(5 * time.Second) + + // Verify installation + exePath := filepath.Join(absPath, "server", "accServer.exe") + wsService.BroadcastSteamOutput(*serverID, fmt.Sprintf("Checking for ACC server executable at: %s", exePath), false) + + if _, err := os.Stat(exePath); os.IsNotExist(err) { + wsService.BroadcastSteamOutput(*serverID, "accServer.exe not found, checking directory contents...", false) + + if entries, dirErr := os.ReadDir(absPath); dirErr == nil { + wsService.BroadcastSteamOutput(*serverID, fmt.Sprintf("Contents of %s:", absPath), false) + for _, entry := range entries { + wsService.BroadcastSteamOutput(*serverID, fmt.Sprintf(" - %s (dir: %v)", entry.Name(), entry.IsDir()), false) + } + } + + // Check if there's a server subdirectory + serverDir := filepath.Join(absPath, "server") + if entries, dirErr := os.ReadDir(serverDir); dirErr == nil { + wsService.BroadcastSteamOutput(*serverID, fmt.Sprintf("Contents of %s:", serverDir), false) + for _, entry := range entries { + wsService.BroadcastSteamOutput(*serverID, fmt.Sprintf(" - %s (dir: %v)", entry.Name(), entry.IsDir()), false) + } + } else { + wsService.BroadcastSteamOutput(*serverID, fmt.Sprintf("Server directory %s does not exist or cannot be read: %v", serverDir, dirErr), true) + } + + wsService.BroadcastSteamOutput(*serverID, fmt.Sprintf("Server installation failed: accServer.exe not found in %s", exePath), true) + return fmt.Errorf("server installation failed: accServer.exe not found in %s", exePath) + } + + wsService.BroadcastSteamOutput(*serverID, fmt.Sprintf("Server installation completed successfully - accServer.exe found at %s", exePath), false) return nil } diff --git a/local/service/websocket.go b/local/service/websocket.go new file mode 100644 index 0000000..8e7baca --- /dev/null +++ b/local/service/websocket.go @@ -0,0 +1,186 @@ +package service + +import ( + "acc-server-manager/local/model" + "acc-server-manager/local/utl/logging" + "encoding/json" + "sync" + "time" + + "github.com/gofiber/websocket/v2" + "github.com/google/uuid" +) + +// WebSocketConnection represents a single WebSocket connection +type WebSocketConnection struct { + conn *websocket.Conn + serverID *uuid.UUID // If connected to a specific server creation process + userID *uuid.UUID // User who owns this connection +} + +// WebSocketService manages WebSocket connections and message broadcasting +type WebSocketService struct { + connections sync.Map // map[string]*WebSocketConnection - key is connection ID + mu sync.RWMutex +} + +// NewWebSocketService creates a new WebSocket service +func NewWebSocketService() *WebSocketService { + return &WebSocketService{} +} + +// AddConnection adds a new WebSocket connection +func (ws *WebSocketService) AddConnection(connID string, conn *websocket.Conn, userID *uuid.UUID) { + wsConn := &WebSocketConnection{ + conn: conn, + userID: userID, + } + ws.connections.Store(connID, wsConn) + logging.Info("WebSocket connection added: %s for user: %v", connID, userID) +} + +// RemoveConnection removes a WebSocket connection +func (ws *WebSocketService) RemoveConnection(connID string) { + if conn, exists := ws.connections.LoadAndDelete(connID); exists { + if wsConn, ok := conn.(*WebSocketConnection); ok { + wsConn.conn.Close() + } + } + logging.Info("WebSocket connection removed: %s", connID) +} + +// SetServerID associates a connection with a specific server creation process +func (ws *WebSocketService) SetServerID(connID string, serverID uuid.UUID) { + if conn, exists := ws.connections.Load(connID); exists { + if wsConn, ok := conn.(*WebSocketConnection); ok { + wsConn.serverID = &serverID + } + } +} + +// BroadcastStep sends a step update to all connections associated with a server +func (ws *WebSocketService) BroadcastStep(serverID uuid.UUID, step model.ServerCreationStep, status model.StepStatus, message string, errorMsg string) { + stepMsg := model.StepMessage{ + Step: step, + Status: status, + Message: message, + Error: errorMsg, + } + + wsMsg := model.WebSocketMessage{ + Type: model.MessageTypeStep, + ServerID: &serverID, + Timestamp: time.Now().Unix(), + Data: stepMsg, + } + + ws.broadcastToServer(serverID, wsMsg) +} + +// BroadcastSteamOutput sends Steam command output to all connections associated with a server +func (ws *WebSocketService) BroadcastSteamOutput(serverID uuid.UUID, output string, isError bool) { + steamMsg := model.SteamOutputMessage{ + Output: output, + IsError: isError, + } + + wsMsg := model.WebSocketMessage{ + Type: model.MessageTypeSteamOutput, + ServerID: &serverID, + Timestamp: time.Now().Unix(), + Data: steamMsg, + } + + ws.broadcastToServer(serverID, wsMsg) +} + +// BroadcastError sends an error message to all connections associated with a server +func (ws *WebSocketService) BroadcastError(serverID uuid.UUID, error string, details string) { + errorMsg := model.ErrorMessage{ + Error: error, + Details: details, + } + + wsMsg := model.WebSocketMessage{ + Type: model.MessageTypeError, + ServerID: &serverID, + Timestamp: time.Now().Unix(), + Data: errorMsg, + } + + ws.broadcastToServer(serverID, wsMsg) +} + +// BroadcastComplete sends a completion message to all connections associated with a server +func (ws *WebSocketService) BroadcastComplete(serverID uuid.UUID, success bool, message string) { + completeMsg := model.CompleteMessage{ + ServerID: serverID, + Success: success, + Message: message, + } + + wsMsg := model.WebSocketMessage{ + Type: model.MessageTypeComplete, + ServerID: &serverID, + Timestamp: time.Now().Unix(), + Data: completeMsg, + } + + ws.broadcastToServer(serverID, wsMsg) +} + +// broadcastToServer sends a message to all connections associated with a specific server +func (ws *WebSocketService) broadcastToServer(serverID uuid.UUID, message model.WebSocketMessage) { + data, err := json.Marshal(message) + if err != nil { + logging.Error("Failed to marshal WebSocket message: %v", err) + return + } + + ws.connections.Range(func(key, value interface{}) bool { + if wsConn, ok := value.(*WebSocketConnection); ok { + // Send to connections associated with this server + if wsConn.serverID != nil && *wsConn.serverID == serverID { + if err := wsConn.conn.WriteMessage(websocket.TextMessage, data); err != nil { + logging.Error("Failed to send WebSocket message to connection %s: %v", key, err) + // Remove the connection if it's broken + ws.RemoveConnection(key.(string)) + } + } + } + return true + }) +} + +// BroadcastToUser sends a message to all connections owned by a specific user +func (ws *WebSocketService) BroadcastToUser(userID uuid.UUID, message model.WebSocketMessage) { + data, err := json.Marshal(message) + if err != nil { + logging.Error("Failed to marshal WebSocket message: %v", err) + return + } + + ws.connections.Range(func(key, value interface{}) bool { + if wsConn, ok := value.(*WebSocketConnection); ok { + // Send to connections owned by this user + if wsConn.userID != nil && *wsConn.userID == userID { + if err := wsConn.conn.WriteMessage(websocket.TextMessage, data); err != nil { + logging.Error("Failed to send WebSocket message to connection %s: %v", key, err) + // Remove the connection if it's broken + ws.RemoveConnection(key.(string)) + } + } + } + return true + }) +} + +// GetActiveConnections returns the count of active connections +func (ws *WebSocketService) GetActiveConnections() int { + count := 0 + ws.connections.Range(func(key, value interface{}) bool { + count++ + return true + }) + return count +} diff --git a/local/utl/command/interactive_executor.go b/local/utl/command/interactive_executor.go index 4b4efa5..5b90ba2 100644 --- a/local/utl/command/interactive_executor.go +++ b/local/utl/command/interactive_executor.go @@ -7,7 +7,9 @@ import ( "context" "fmt" "io" + "os" "os/exec" + "reflect" "strings" "time" @@ -56,6 +58,12 @@ func (e *InteractiveCommandExecutor) ExecuteInteractive(ctx context.Context, ser logging.Info("Executing interactive command: %s %s", e.ExePath, strings.Join(args, " ")) + // Enable debug mode if environment variable is set + debugMode := os.Getenv("STEAMCMD_DEBUG") == "true" + if debugMode { + logging.Info("STEAMCMD_DEBUG mode enabled - will log all output and create proactive 2FA requests") + } + if err := cmd.Start(); err != nil { return fmt.Errorf("failed to start command: %v", err) } @@ -111,6 +119,10 @@ func (e *InteractiveCommandExecutor) monitorOutput(ctx context.Context, stdout, outputChan := make(chan string, 100) // Buffered channel to prevent blocking readersDone := make(chan struct{}, 2) + // Track Steam Console startup for this specific execution + steamConsoleStarted := false + tfaRequestCreated := false + // Read from stdout go func() { defer func() { readersDone <- struct{}{} }() @@ -119,6 +131,10 @@ func (e *InteractiveCommandExecutor) monitorOutput(ctx context.Context, stdout, if e.LogOutput { logging.Info("STDOUT: %s", line) } + // Always log Steam CMD output for debugging 2FA issues + if strings.Contains(strings.ToLower(line), "steam") { + logging.Info("STEAM_DEBUG: %s", line) + } select { case outputChan <- line: case <-ctx.Done(): @@ -138,6 +154,10 @@ func (e *InteractiveCommandExecutor) monitorOutput(ctx context.Context, stdout, if e.LogOutput { logging.Info("STDERR: %s", line) } + // Always log Steam CMD errors for debugging 2FA issues + if strings.Contains(strings.ToLower(line), "steam") { + logging.Info("STEAM_DEBUG_ERR: %s", line) + } select { case outputChan <- line: case <-ctx.Done(): @@ -179,20 +199,51 @@ func (e *InteractiveCommandExecutor) monitorOutput(ctx context.Context, stdout, return } + // Check for Steam Console startup + lowerLine := strings.ToLower(line) + if strings.Contains(lowerLine, "steam console client") && strings.Contains(lowerLine, "valve corporation") { + steamConsoleStarted = true + logging.Info("Steam Console Client startup detected - will monitor for 2FA hang") + } + // Check if this line indicates a 2FA prompt if e.is2FAPrompt(line) { - if err := e.handle2FAPrompt(ctx, line, serverID); err != nil { - logging.Error("Failed to handle 2FA prompt: %v", err) + if !tfaRequestCreated { + if err := e.handle2FAPrompt(ctx, line, serverID); err != nil { + logging.Error("Failed to handle 2FA prompt: %v", err) + done <- err + return + } + tfaRequestCreated = true + } + } + + // Check if Steam CMD continued after 2FA (auto-completion) + if tfaRequestCreated && e.isSteamContinuing(line) { + logging.Info("Steam CMD appears to have continued after 2FA confirmation - auto-completing 2FA request") + // Auto-complete any pending 2FA requests for this server + e.autoCompletePendingRequests(serverID) + } + case <-time.After(15 * time.Second): + // If Steam Console has started and we haven't seen output for 15 seconds, + // it's very likely waiting for 2FA confirmation + if steamConsoleStarted && !tfaRequestCreated { + logging.Info("Steam Console started but no output for 15 seconds - likely waiting for Steam Guard 2FA") + if err := e.handle2FAPrompt(ctx, "Steam CMD appears to be waiting for Steam Guard confirmation after startup", serverID); err != nil { + logging.Error("Failed to handle Steam Guard 2FA prompt: %v", err) done <- err return } + tfaRequestCreated = true + } else if !steamConsoleStarted { + logging.Info("No output for 15 seconds (Steam Console not yet started)") } } } } func (e *InteractiveCommandExecutor) is2FAPrompt(line string) bool { - // Common SteamCMD 2FA prompts + // Common SteamCMD 2FA prompts - updated with more comprehensive patterns twoFAKeywords := []string{ "please enter your steam guard code", "steam guard", @@ -200,17 +251,82 @@ func (e *InteractiveCommandExecutor) is2FAPrompt(line string) bool { "authentication code", "please check your steam mobile app", "confirm in application", + "enter the current code from your steam mobile app", + "steam guard mobile authenticator", + "waiting for user info", + "login failure", + "two factor code required", + "enter steam guard code", + "mobile authenticator code", + "authenticator app", + "guard code", + "mobile app", + "confirmation required", } lowerLine := strings.ToLower(line) for _, keyword := range twoFAKeywords { if strings.Contains(lowerLine, keyword) { + logging.Info("2FA keyword match found: '%s' in line: '%s'", keyword, line) + return true + } + } + + // Also check for patterns that might indicate Steam is waiting for input + waitingPatterns := []string{ + "waiting for", + "please enter", + "enter code", + "code:", + "authenticator:", + } + + for _, pattern := range waitingPatterns { + if strings.Contains(lowerLine, pattern) { + logging.Info("Potential 2FA waiting pattern found: '%s' in line: '%s'", pattern, line) + return true + } + } + + return false +} + +func (e *InteractiveCommandExecutor) isSteamContinuing(line string) bool { + lowerLine := strings.ToLower(line) + continuingPatterns := []string{ + "loading steam api", + "logging in user", + "waiting for client config", + "waiting for user info", + "update state", + "success! app", + "fully installed", + } + + for _, pattern := range continuingPatterns { + if strings.Contains(lowerLine, pattern) { return true } } return false } +func (e *InteractiveCommandExecutor) autoCompletePendingRequests(serverID *uuid.UUID) { + if e.tfaManager == nil { + return + } + + pendingRequests := e.tfaManager.GetPendingRequests() + for _, req := range pendingRequests { + if req.ServerID != nil && serverID != nil && *req.ServerID == *serverID { + logging.Info("Auto-completing 2FA request %s for server %s", req.ID, serverID.String()) + if err := e.tfaManager.CompleteRequest(req.ID); err != nil { + logging.Warn("Failed to auto-complete 2FA request %s: %v", req.ID, err) + } + } + } +} + func (e *InteractiveCommandExecutor) handle2FAPrompt(_ context.Context, promptLine string, serverID *uuid.UUID) error { logging.Info("2FA prompt detected: %s", promptLine) @@ -236,3 +352,271 @@ func (e *InteractiveCommandExecutor) handle2FAPrompt(_ context.Context, promptLi logging.Info("2FA completed successfully") return nil } + +// WebSocketInteractiveCommandExecutor extends InteractiveCommandExecutor to stream output via WebSocket +type WebSocketInteractiveCommandExecutor struct { + *InteractiveCommandExecutor + wsService interface{} // Using interface{} to avoid circular import + serverID uuid.UUID +} + +// NewInteractiveCommandExecutorWithWebSocket creates a new WebSocket-enabled interactive command executor +func NewInteractiveCommandExecutorWithWebSocket(baseExecutor *CommandExecutor, tfaManager *model.Steam2FAManager, wsService interface{}, serverID uuid.UUID) *WebSocketInteractiveCommandExecutor { + return &WebSocketInteractiveCommandExecutor{ + InteractiveCommandExecutor: &InteractiveCommandExecutor{ + CommandExecutor: baseExecutor, + tfaManager: tfaManager, + }, + wsService: wsService, + serverID: serverID, + } +} + +// ExecuteInteractive runs a command with WebSocket output streaming +func (e *WebSocketInteractiveCommandExecutor) ExecuteInteractive(ctx context.Context, serverID *uuid.UUID, args ...string) error { + cmd := exec.CommandContext(ctx, e.ExePath, args...) + + if e.WorkDir != "" { + cmd.Dir = e.WorkDir + } + + // Create pipes for stdin, stdout, and stderr + stdin, err := cmd.StdinPipe() + if err != nil { + return fmt.Errorf("failed to create stdin pipe: %v", err) + } + defer stdin.Close() + + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("failed to create stdout pipe: %v", err) + } + defer stdout.Close() + + stderr, err := cmd.StderrPipe() + if err != nil { + return fmt.Errorf("failed to create stderr pipe: %v", err) + } + defer stderr.Close() + + logging.Info("Executing interactive command with WebSocket streaming: %s %s", e.ExePath, strings.Join(args, " ")) + + // Broadcast command start via WebSocket + e.broadcastSteamOutput(fmt.Sprintf("Starting command: %s %s", e.ExePath, strings.Join(args, " ")), false) + + if err := cmd.Start(); err != nil { + e.broadcastSteamOutput(fmt.Sprintf("Failed to start command: %v", err), true) + return fmt.Errorf("failed to start command: %v", err) + } + + // Create channels for output monitoring + outputDone := make(chan error, 1) + cmdDone := make(chan error, 1) + + // Monitor stdout and stderr for 2FA prompts with WebSocket streaming + go e.monitorOutputWithWebSocket(ctx, stdout, stderr, serverID, outputDone) + + // Wait for the command to finish in a separate goroutine + go func() { + cmdDone <- cmd.Wait() + }() + + // Wait for both command and output monitoring to complete + var cmdErr, outputErr error + completedCount := 0 + + for completedCount < 2 { + select { + case cmdErr = <-cmdDone: + completedCount++ + logging.Info("Command execution completed") + e.broadcastSteamOutput("Command execution completed", false) + case outputErr = <-outputDone: + completedCount++ + logging.Info("Output monitoring completed") + case <-ctx.Done(): + e.broadcastSteamOutput("Command execution cancelled", true) + return ctx.Err() + } + } + + if outputErr != nil { + logging.Warn("Output monitoring error: %v", outputErr) + e.broadcastSteamOutput(fmt.Sprintf("Output monitoring error: %v", outputErr), true) + } + + return cmdErr +} + +// broadcastSteamOutput sends output to WebSocket using reflection to avoid circular imports +func (e *WebSocketInteractiveCommandExecutor) broadcastSteamOutput(output string, isError bool) { + if e.wsService == nil { + return + } + + // Use reflection to call BroadcastSteamOutput method + wsServiceVal := reflect.ValueOf(e.wsService) + method := wsServiceVal.MethodByName("BroadcastSteamOutput") + if !method.IsValid() { + logging.Warn("BroadcastSteamOutput method not found on WebSocket service") + return + } + + // Call the method with parameters: serverID, output, isError + args := []reflect.Value{ + reflect.ValueOf(e.serverID), + reflect.ValueOf(output), + reflect.ValueOf(isError), + } + method.Call(args) +} + +// monitorOutputWithWebSocket monitors command output and streams it via WebSocket +func (e *WebSocketInteractiveCommandExecutor) monitorOutputWithWebSocket(ctx context.Context, stdout, stderr io.Reader, serverID *uuid.UUID, done chan error) { + defer func() { + select { + case done <- nil: + default: + } + }() + + // Create scanners for both outputs + stdoutScanner := bufio.NewScanner(stdout) + stderrScanner := bufio.NewScanner(stderr) + + outputChan := make(chan outputLine, 100) // Buffered channel to prevent blocking + readersDone := make(chan struct{}, 2) + + // Track Steam Console startup for this specific execution + steamConsoleStarted := false + tfaRequestCreated := false + + // Read from stdout + go func() { + defer func() { readersDone <- struct{}{} }() + for stdoutScanner.Scan() { + line := stdoutScanner.Text() + if e.LogOutput { + logging.Info("STDOUT: %s", line) + } + // Stream output via WebSocket + e.broadcastSteamOutput(line, false) + + select { + case outputChan <- outputLine{text: line, isError: false}: + case <-ctx.Done(): + return + } + } + if err := stdoutScanner.Err(); err != nil { + logging.Warn("Stdout scanner error: %v", err) + e.broadcastSteamOutput(fmt.Sprintf("Stdout scanner error: %v", err), true) + } + }() + + // Read from stderr + go func() { + defer func() { readersDone <- struct{}{} }() + for stderrScanner.Scan() { + line := stderrScanner.Text() + if e.LogOutput { + logging.Info("STDERR: %s", line) + } + // Stream error output via WebSocket + e.broadcastSteamOutput(line, true) + + select { + case outputChan <- outputLine{text: line, isError: true}: + case <-ctx.Done(): + return + } + } + if err := stderrScanner.Err(); err != nil { + logging.Warn("Stderr scanner error: %v", err) + e.broadcastSteamOutput(fmt.Sprintf("Stderr scanner error: %v", err), true) + } + }() + + // Monitor for completion and 2FA prompts + readersFinished := 0 + for { + select { + case <-ctx.Done(): + done <- ctx.Err() + return + case <-readersDone: + readersFinished++ + if readersFinished == 2 { + // Both readers are done, close output channel and finish monitoring + close(outputChan) + // Drain any remaining output + for lineData := range outputChan { + if e.is2FAPrompt(lineData.text) { + if err := e.handle2FAPrompt(ctx, lineData.text, serverID); err != nil { + logging.Error("Failed to handle 2FA prompt: %v", err) + e.broadcastSteamOutput(fmt.Sprintf("Failed to handle 2FA prompt: %v", err), true) + done <- err + return + } + } + } + return + } + case lineData, ok := <-outputChan: + if !ok { + // Channel closed, we're done + return + } + + // Check for Steam Console startup + lowerLine := strings.ToLower(lineData.text) + if strings.Contains(lowerLine, "steam console client") && strings.Contains(lowerLine, "valve corporation") { + steamConsoleStarted = true + logging.Info("Steam Console Client startup detected - will monitor for 2FA hang") + e.broadcastSteamOutput("Steam Console Client startup detected", false) + } + + // Check if this line indicates a 2FA prompt + if e.is2FAPrompt(lineData.text) { + if !tfaRequestCreated { + e.broadcastSteamOutput("2FA prompt detected - waiting for user confirmation", false) + if err := e.handle2FAPrompt(ctx, lineData.text, serverID); err != nil { + logging.Error("Failed to handle 2FA prompt: %v", err) + e.broadcastSteamOutput(fmt.Sprintf("Failed to handle 2FA prompt: %v", err), true) + done <- err + return + } + tfaRequestCreated = true + } + } + + // Check if Steam CMD continued after 2FA (auto-completion) + if tfaRequestCreated && e.isSteamContinuing(lineData.text) { + logging.Info("Steam CMD appears to have continued after 2FA confirmation") + e.broadcastSteamOutput("Steam CMD continued after 2FA confirmation", false) + // Auto-complete any pending 2FA requests for this server + e.autoCompletePendingRequests(serverID) + } + case <-time.After(15 * time.Second): + // If Steam Console has started and we haven't seen output for 15 seconds, + // it's very likely waiting for 2FA confirmation + if steamConsoleStarted && !tfaRequestCreated { + logging.Info("Steam Console started but no output for 15 seconds - likely waiting for Steam Guard 2FA") + e.broadcastSteamOutput("Waiting for Steam Guard 2FA confirmation...", false) + if err := e.handle2FAPrompt(ctx, "Steam CMD appears to be waiting for Steam Guard confirmation after startup", serverID); err != nil { + logging.Error("Failed to handle Steam Guard 2FA prompt: %v", err) + e.broadcastSteamOutput(fmt.Sprintf("Failed to handle Steam Guard 2FA prompt: %v", err), true) + done <- err + return + } + tfaRequestCreated = true + } + } + } +} + +// outputLine represents a line of output with error status +type outputLine struct { + text string + isError bool +} diff --git a/local/utl/common/common.go b/local/utl/common/common.go index ba1432a..825e4c0 100644 --- a/local/utl/common/common.go +++ b/local/utl/common/common.go @@ -25,7 +25,7 @@ type RouteGroups struct { StateHistory fiber.Router Membership fiber.Router System fiber.Router - Steam2FA fiber.Router + WebSocket fiber.Router } func CheckError(err error) { diff --git a/local/utl/error_handler/controller_error_handler.go b/local/utl/error_handler/controller_error_handler.go index 753a7c7..1d4408a 100644 --- a/local/utl/error_handler/controller_error_handler.go +++ b/local/utl/error_handler/controller_error_handler.go @@ -66,12 +66,34 @@ func (ceh *ControllerErrorHandler) HandleError(c *fiber.Ctx, err error, statusCo if errorResponse.Details == nil { errorResponse.Details = make(map[string]string) } - errorResponse.Details["method"] = c.Method() - errorResponse.Details["path"] = c.Path() - errorResponse.Details["ip"] = c.IP() + + // Safely extract request details + func() { + defer func() { + if r := recover(); r != nil { + // If any of these panic, just skip adding the details + return + } + }() + + errorResponse.Details["method"] = c.Method() + errorResponse.Details["path"] = c.Path() + + // Safely get IP address + if ip := c.IP(); ip != "" { + errorResponse.Details["ip"] = ip + } else { + errorResponse.Details["ip"] = "unknown" + } + }() } // Return appropriate response based on status code + if c == nil { + // If context is nil, we can't return a response + return fmt.Errorf("cannot return HTTP response: context is nil") + } + if statusCode >= 500 { // For server errors, don't expose internal details return c.Status(statusCode).JSON(ErrorResponse{ diff --git a/local/utl/server/server.go b/local/utl/server/server.go index c41aefa..ba5524e 100644 --- a/local/utl/server/server.go +++ b/local/utl/server/server.go @@ -17,9 +17,9 @@ import ( func Start(di *dig.Container) *fiber.App { app := fiber.New(fiber.Config{ EnablePrintRoutes: true, - ReadTimeout: 30 * time.Second, - WriteTimeout: 30 * time.Second, - IdleTimeout: 120 * time.Second, + ReadTimeout: 20 * time.Minute, // Increased for long-running Steam operations + WriteTimeout: 20 * time.Minute, // Increased for long-running Steam operations + IdleTimeout: 25 * time.Minute, // Increased accordingly BodyLimit: 10 * 1024 * 1024, // 10MB }) @@ -29,8 +29,8 @@ func Start(di *dig.Container) *fiber.App { // Add security middleware stack app.Use(securityMW.SecurityHeaders()) app.Use(securityMW.LogSecurityEvents()) - app.Use(securityMW.TimeoutMiddleware(30 * time.Second)) - app.Use(securityMW.RequestContextTimeout(60 * time.Second)) + app.Use(securityMW.TimeoutMiddleware(20 * time.Minute)) // Increased for Steam operations + app.Use(securityMW.RequestContextTimeout(20 * time.Minute)) // Increased for Steam operations app.Use(securityMW.RequestSizeLimit(10 * 1024 * 1024)) // 10MB app.Use(securityMW.ValidateUserAgent()) app.Use(securityMW.ValidateContentType("application/json", "application/x-www-form-urlencoded", "multipart/form-data")) @@ -41,7 +41,7 @@ func Start(di *dig.Container) *fiber.App { allowedOrigin := os.Getenv("CORS_ALLOWED_ORIGIN") if allowedOrigin == "" { - allowedOrigin = "http://localhost:5173" + allowedOrigin = "http://localhost:3000" } app.Use(cors.New(cors.Config{