Use sockets for server creation progress

This commit is contained in:
Fran Jurmanović
2025-09-18 01:06:58 +02:00
parent 760412d7db
commit 901dbe697e
17 changed files with 1314 additions and 188 deletions

3
go.mod
View File

@@ -21,10 +21,12 @@ require (
require ( require (
github.com/KyleBanks/depth v1.2.1 // indirect github.com/KyleBanks/depth v1.2.1 // indirect
github.com/andybalholm/brotli v1.1.0 // 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/jsonpointer v0.21.0 // indirect
github.com/go-openapi/jsonreference 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/spec v0.21.0 // indirect
github.com/go-openapi/swag v0.23.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/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/josharian/intern v1.0.0 // 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-runewidth v0.0.16 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/rivo/uniseg v0.2.0 // 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/swaggo/files/v2 v2.0.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.51.0 // indirect github.com/valyala/fasthttp v1.51.0 // indirect

6
go.sum
View File

@@ -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/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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= 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= 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/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 h1:ff3rg1fB+Rp5JN/N8jfxTiZtMKe/9tB9QDc79fPiJKQ=
github.com/gofiber/swagger v1.1.0/go.mod h1:pRZL0Np35sd+lTODTE5The0G+TMHfNY+oC4hM2/i5m8= 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 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= 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= 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/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 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= 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 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 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= github.com/swaggo/files/v2 v2.0.0 h1:hmAt8Dkynw7Ssz46F6pn8ok6YmGZqHSVLZ+HQM7i0kw=

View File

@@ -31,7 +31,7 @@ func Init(di *dig.Container, app *fiber.App) {
StateHistory: serverIdGroup.Group("/state-history"), StateHistory: serverIdGroup.Group("/state-history"),
Membership: groups.Group("/membership"), Membership: groups.Group("/membership"),
System: groups.Group("/system"), System: groups.Group("/system"),
Steam2FA: groups.Group("/steam2fa"), WebSocket: groups.Group("/ws"),
} }
accessKeyMiddleware := middleware.NewAccessKeyMiddleware() accessKeyMiddleware := middleware.NewAccessKeyMiddleware()

View File

@@ -55,8 +55,8 @@ func InitializeControllers(c *dig.Container) {
logging.Panic("unable to initialize membership controller") logging.Panic("unable to initialize membership controller")
} }
err = c.Invoke(NewSteam2FAController) err = c.Invoke(NewWebSocketController)
if err != nil { if err != nil {
logging.Panic("unable to initialize steam 2fa controller") logging.Panic("unable to initialize websocket controller")
} }
} }

View File

@@ -139,10 +139,16 @@ func (ac *ServerController) CreateServer(c *fiber.Ctx) error {
if err := c.BodyParser(server); err != nil { if err := c.BodyParser(server); err != nil {
return ac.errorHandler.HandleParsingError(c, err) 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 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) return c.JSON(server)
} }

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -104,6 +104,12 @@ func (f *ServerFilter) ApplyFilter(query *gorm.DB) *gorm.DB {
return query 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 // BeforeCreate is a GORM hook that runs before creating a new server
func (s *Server) BeforeCreate(tx *gorm.DB) error { func (s *Server) BeforeCreate(tx *gorm.DB) error {
if s.Name == "" { if s.Name == "" {
@@ -111,9 +117,7 @@ func (s *Server) BeforeCreate(tx *gorm.DB) error {
} }
// Generate UUID if not set // Generate UUID if not set
if s.ID == uuid.Nil { s.GenerateUUID()
s.ID = uuid.New()
}
// Generate service name and config path if not set // Generate service name and config path if not set
if s.ServiceName == "" { if s.ServiceName == "" {

89
local/model/websocket.go Normal file
View File

@@ -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]
}

View File

@@ -31,6 +31,7 @@ type ServerService struct {
steamService *SteamService steamService *SteamService
windowsService *WindowsService windowsService *WindowsService
firewallService *FirewallService firewallService *FirewallService
webSocketService *WebSocketService
instances sync.Map // Track instances per server instances sync.Map // Track instances per server
lastInsertTimes sync.Map // Track last insert time per server lastInsertTimes sync.Map // Track last insert time per server
debouncers sync.Map // Track debounce timers per server debouncers sync.Map // Track debounce timers per server
@@ -68,6 +69,7 @@ func NewServerService(
steamService *SteamService, steamService *SteamService,
windowsService *WindowsService, windowsService *WindowsService,
firewallService *FirewallService, firewallService *FirewallService,
webSocketService *WebSocketService,
) *ServerService { ) *ServerService {
service := &ServerService{ service := &ServerService{
repository: repository, repository: repository,
@@ -77,6 +79,7 @@ func NewServerService(
steamService: steamService, steamService: steamService,
windowsService: windowsService, windowsService: windowsService,
firewallService: firewallService, firewallService: firewallService,
webSocketService: webSocketService,
} }
// Initialize server instances // Initialize server instances
@@ -203,6 +206,7 @@ func (s *ServerService) updateSessionDuration(server *model.Server, sessionType
func (s *ServerService) GenerateServerPath(server *model.Server) { func (s *ServerService) GenerateServerPath(server *model.Server) {
// Get the base steamcmd path from environment variable // Get the base steamcmd path from environment variable
steamCMDPath := env.GetSteamCMDDirPath() steamCMDPath := env.GetSteamCMDDirPath()
server.FromSteamCMD = true
server.Path = server.GenerateServerPath(steamCMDPath) server.Path = server.GenerateServerPath(steamCMDPath)
} }
@@ -330,50 +334,139 @@ func (as *ServerService) GetById(ctx *fiber.Ctx, serverID uuid.UUID) (*model.Ser
return server, nil return server, nil
} }
func (s *ServerService) CreateServer(ctx *fiber.Ctx, server *model.Server) error { // CreateServerAsync starts server creation asynchronously and returns immediately
// Validate basic server configuration func (s *ServerService) CreateServerAsync(ctx *fiber.Ctx, server *model.Server) error {
// Perform basic validation first
if err := server.Validate(); err != nil { if err := server.Validate(); err != nil {
return err return err
} }
// Install server using SteamCMD // Generate server path
if err := s.steamService.InstallServer(ctx.UserContext(), server.GetServerPath(), &server.ID); err != nil { 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) return fmt.Errorf("failed to install server: %v", err)
} }
// Create Windows service with correct paths s.webSocketService.BroadcastStep(server.ID, model.StepSteamDownload, model.StatusCompleted,
execPath := filepath.Join(server.GetServerPath(), "accServer.exe") "Server files downloaded successfully", "")
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.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) ports, err := network.FindAvailablePortRange(DefaultStartPort, RequiredPortCount)
if err != nil { 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) return fmt.Errorf("failed to find available ports: %v", err)
} }
// Use the first port for both TCP and UDP // Use the first port for both TCP and UDP
serverPort := ports[0] 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} tcpPorts := []int{serverPort}
udpPorts := []int{serverPort} udpPorts := []int{serverPort}
if err := s.firewallService.CreateServerRules(server.ServiceName, tcpPorts, udpPorts); err != nil { 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 // Cleanup on failure
s.windowsService.DeleteService(ctx.UserContext(), server.ServiceName) s.windowsService.DeleteService(ctx.UserContext(), server.ServiceName)
s.steamService.UninstallServer(server.Path) s.steamService.UninstallServer(server.Path)
return fmt.Errorf("failed to create firewall rules: %v", err) return fmt.Errorf("failed to create firewall rules: %v", err)
} }
// Update server configuration with the allocated port s.webSocketService.BroadcastStep(server.ID, model.StepFirewallRules, model.StatusCompleted,
if err := s.updateServerPort(server, serverPort); err != nil { fmt.Sprintf("Firewall rules created for port %d", serverPort), "")
return fmt.Errorf("failed to update server configuration: %v", err)
} // Broadcast step: database save
s.webSocketService.BroadcastStep(server.ID, model.StepDatabaseSave, model.StatusInProgress,
model.GetStepDescription(model.StepDatabaseSave), "")
// Insert server into database // Insert server into database
if err := s.repository.Insert(ctx.UserContext(), server); err != nil { 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 // Cleanup on failure
s.firewallService.DeleteServerRules(server.ServiceName, tcpPorts, udpPorts) s.firewallService.DeleteServerRules(server.ServiceName, tcpPorts, udpPorts)
s.windowsService.DeleteService(ctx.UserContext(), server.ServiceName) 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) 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 // Initialize server runtime
s.StartAccServerRuntime(server) 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 return nil
} }

View File

@@ -27,6 +27,7 @@ func InitializeServices(c *dig.Container) {
c.Provide(NewWindowsService) c.Provide(NewWindowsService)
c.Provide(NewFirewallService) c.Provide(NewFirewallService)
c.Provide(NewMembershipService) c.Provide(NewMembershipService)
c.Provide(NewWebSocketService)
logging.Debug("Initializing service dependencies") logging.Debug("Initializing service dependencies")
err := c.Invoke(func(server *ServerService, api *ServiceControlService, config *ConfigService) { err := c.Invoke(func(server *ServerService, api *ServiceControlService, config *ConfigService) {

View File

@@ -11,6 +11,7 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
@@ -131,11 +132,13 @@ func (s *SteamService) InstallServer(ctx context.Context, installPath string, se
} }
if creds != nil && creds.Username != "" { if creds != nil && creds.Username != "" {
logging.Info("Using Steam credentials for user: %s", creds.Username)
steamCMDArgs = append(steamCMDArgs, creds.Username) steamCMDArgs = append(steamCMDArgs, creds.Username)
if creds.Password != "" { if creds.Password != "" {
steamCMDArgs = append(steamCMDArgs, creds.Password) steamCMDArgs = append(steamCMDArgs, creds.Password)
} }
} else { } else {
logging.Info("Using anonymous Steam login")
steamCMDArgs = append(steamCMDArgs, "anonymous") steamCMDArgs = append(steamCMDArgs, "anonymous")
} }
@@ -145,37 +148,196 @@ func (s *SteamService) InstallServer(ctx context.Context, installPath string, se
"+quit", "+quit",
) )
// Build PowerShell arguments to execute SteamCMD directly // Execute SteamCMD directly without PowerShell wrapper to get better output capture
// This matches the format: powershell -nologo -noprofile c:\steamcmd\steamcmd.exe +args... args := steamCMDArgs
args := []string{"-nologo", "-noprofile"}
args = append(args, steamCMDPath)
args = append(args, steamCMDArgs...)
// Use interactive executor to handle potential 2FA prompts with timeout // Use interactive executor to handle potential 2FA prompts with timeout
logging.Info("Installing ACC server to %s...", absPath) 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 // 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() 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 { if err := s.interactiveExecutor.ExecuteInteractive(timeoutCtx, serverID, args...); err != nil {
logging.Error("SteamCMD execution failed: %v", err)
if timeoutCtx.Err() == context.DeadlineExceeded { 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) 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 // Add a delay to allow Steam to properly cleanup
logging.Info("Waiting for Steam operations to complete...") logging.Info("Waiting for Steam operations to complete...")
time.Sleep(5 * time.Second) time.Sleep(5 * time.Second)
// Verify installation // Verify installation
exePath := filepath.Join(absPath, "server", "accServer.exe") 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) { 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 return nil
} }

186
local/service/websocket.go Normal file
View File

@@ -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
}

View File

@@ -7,7 +7,9 @@ import (
"context" "context"
"fmt" "fmt"
"io" "io"
"os"
"os/exec" "os/exec"
"reflect"
"strings" "strings"
"time" "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, " ")) 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 { if err := cmd.Start(); err != nil {
return fmt.Errorf("failed to start command: %v", err) 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 outputChan := make(chan string, 100) // Buffered channel to prevent blocking
readersDone := make(chan struct{}, 2) readersDone := make(chan struct{}, 2)
// Track Steam Console startup for this specific execution
steamConsoleStarted := false
tfaRequestCreated := false
// Read from stdout // Read from stdout
go func() { go func() {
defer func() { readersDone <- struct{}{} }() defer func() { readersDone <- struct{}{} }()
@@ -119,6 +131,10 @@ func (e *InteractiveCommandExecutor) monitorOutput(ctx context.Context, stdout,
if e.LogOutput { if e.LogOutput {
logging.Info("STDOUT: %s", line) 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 { select {
case outputChan <- line: case outputChan <- line:
case <-ctx.Done(): case <-ctx.Done():
@@ -138,6 +154,10 @@ func (e *InteractiveCommandExecutor) monitorOutput(ctx context.Context, stdout,
if e.LogOutput { if e.LogOutput {
logging.Info("STDERR: %s", line) 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 { select {
case outputChan <- line: case outputChan <- line:
case <-ctx.Done(): case <-ctx.Done():
@@ -179,20 +199,51 @@ func (e *InteractiveCommandExecutor) monitorOutput(ctx context.Context, stdout,
return 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 // Check if this line indicates a 2FA prompt
if e.is2FAPrompt(line) { if e.is2FAPrompt(line) {
if err := e.handle2FAPrompt(ctx, line, serverID); err != nil { if !tfaRequestCreated {
logging.Error("Failed to handle 2FA prompt: %v", err) 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 done <- err
return 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 { func (e *InteractiveCommandExecutor) is2FAPrompt(line string) bool {
// Common SteamCMD 2FA prompts // Common SteamCMD 2FA prompts - updated with more comprehensive patterns
twoFAKeywords := []string{ twoFAKeywords := []string{
"please enter your steam guard code", "please enter your steam guard code",
"steam guard", "steam guard",
@@ -200,17 +251,82 @@ func (e *InteractiveCommandExecutor) is2FAPrompt(line string) bool {
"authentication code", "authentication code",
"please check your steam mobile app", "please check your steam mobile app",
"confirm in application", "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) lowerLine := strings.ToLower(line)
for _, keyword := range twoFAKeywords { for _, keyword := range twoFAKeywords {
if strings.Contains(lowerLine, keyword) { 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 true
} }
} }
return false 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 { func (e *InteractiveCommandExecutor) handle2FAPrompt(_ context.Context, promptLine string, serverID *uuid.UUID) error {
logging.Info("2FA prompt detected: %s", promptLine) logging.Info("2FA prompt detected: %s", promptLine)
@@ -236,3 +352,271 @@ func (e *InteractiveCommandExecutor) handle2FAPrompt(_ context.Context, promptLi
logging.Info("2FA completed successfully") logging.Info("2FA completed successfully")
return nil 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
}

View File

@@ -25,7 +25,7 @@ type RouteGroups struct {
StateHistory fiber.Router StateHistory fiber.Router
Membership fiber.Router Membership fiber.Router
System fiber.Router System fiber.Router
Steam2FA fiber.Router WebSocket fiber.Router
} }
func CheckError(err error) { func CheckError(err error) {

View File

@@ -66,12 +66,34 @@ func (ceh *ControllerErrorHandler) HandleError(c *fiber.Ctx, err error, statusCo
if errorResponse.Details == nil { if errorResponse.Details == nil {
errorResponse.Details = make(map[string]string) errorResponse.Details = make(map[string]string)
} }
errorResponse.Details["method"] = c.Method()
errorResponse.Details["path"] = c.Path() // Safely extract request details
errorResponse.Details["ip"] = c.IP() 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 // 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 { if statusCode >= 500 {
// For server errors, don't expose internal details // For server errors, don't expose internal details
return c.Status(statusCode).JSON(ErrorResponse{ return c.Status(statusCode).JSON(ErrorResponse{

View File

@@ -17,9 +17,9 @@ import (
func Start(di *dig.Container) *fiber.App { func Start(di *dig.Container) *fiber.App {
app := fiber.New(fiber.Config{ app := fiber.New(fiber.Config{
EnablePrintRoutes: true, EnablePrintRoutes: true,
ReadTimeout: 30 * time.Second, ReadTimeout: 20 * time.Minute, // Increased for long-running Steam operations
WriteTimeout: 30 * time.Second, WriteTimeout: 20 * time.Minute, // Increased for long-running Steam operations
IdleTimeout: 120 * time.Second, IdleTimeout: 25 * time.Minute, // Increased accordingly
BodyLimit: 10 * 1024 * 1024, // 10MB BodyLimit: 10 * 1024 * 1024, // 10MB
}) })
@@ -29,8 +29,8 @@ func Start(di *dig.Container) *fiber.App {
// Add security middleware stack // Add security middleware stack
app.Use(securityMW.SecurityHeaders()) app.Use(securityMW.SecurityHeaders())
app.Use(securityMW.LogSecurityEvents()) app.Use(securityMW.LogSecurityEvents())
app.Use(securityMW.TimeoutMiddleware(30 * time.Second)) app.Use(securityMW.TimeoutMiddleware(20 * time.Minute)) // Increased for Steam operations
app.Use(securityMW.RequestContextTimeout(60 * time.Second)) app.Use(securityMW.RequestContextTimeout(20 * time.Minute)) // Increased for Steam operations
app.Use(securityMW.RequestSizeLimit(10 * 1024 * 1024)) // 10MB app.Use(securityMW.RequestSizeLimit(10 * 1024 * 1024)) // 10MB
app.Use(securityMW.ValidateUserAgent()) app.Use(securityMW.ValidateUserAgent())
app.Use(securityMW.ValidateContentType("application/json", "application/x-www-form-urlencoded", "multipart/form-data")) 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") allowedOrigin := os.Getenv("CORS_ALLOWED_ORIGIN")
if allowedOrigin == "" { if allowedOrigin == "" {
allowedOrigin = "http://localhost:5173" allowedOrigin = "http://localhost:3000"
} }
app.Use(cors.New(cors.Config{ app.Use(cors.New(cors.Config{