Use sockets for server creation progress
This commit is contained in:
3
go.mod
3
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
|
||||
|
||||
6
go.sum
6
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=
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
168
local/controller/websocket.go
Normal file
168
local/controller/websocket.go
Normal 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)
|
||||
}
|
||||
@@ -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 == "" {
|
||||
|
||||
89
local/model/websocket.go
Normal file
89
local/model/websocket.go
Normal 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]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
186
local/service/websocket.go
Normal file
186
local/service/websocket.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user