resolve server creation issues

This commit is contained in:
2026-03-19 00:25:25 +01:00
parent 9172f0256f
commit d4b16d80f3
17 changed files with 179 additions and 121 deletions

View File

@@ -64,12 +64,9 @@ PASSWORD=change-this-default-admin-password
# Docker image name for ACC server containers (built from docker/acc-server/Dockerfile)
# ACC_IMAGE=acc-wine:latest
# Host-side path for the shared ACC game installation volume.
# IMPORTANT: must be the HOST path — ACC containers are Docker siblings of the manager.
# ACC_GAME_VOLUME=/data/acc-game
# Host-side base path for per-server config directories.
# ACC_CONFIG_BASE_PATH=/data/configs
# Base path where per-server game installations are stored.
# Each server gets its own subdirectory: ACC_SERVERS_PATH/<service-name>/
# ACC_SERVERS_PATH=/data/servers
# Port pool for Docker-mode ACC server containers (one port per server).
# PORT_POOL_MIN=9600

View File

@@ -50,14 +50,13 @@ WORKDIR /app
COPY --from=builder /acc-server-manager /app/acc-server-manager
# Persistent data directories.
RUN mkdir -p /data/configs /data/acc-game /data/db
RUN mkdir -p /data/servers /data/db
ENV PLATFORM=docker
ENV PORT=3000
ENV DB_NAME=/data/db/acc.db
ENV STEAMCMD_LINUX_PATH=/usr/games/steamcmd
ENV ACC_GAME_VOLUME=/data/acc-game
ENV ACC_CONFIG_BASE_PATH=/data/configs
ENV ACC_SERVERS_PATH=/data/servers
EXPOSE 3000

View File

@@ -11,25 +11,22 @@ services:
environment:
PLATFORM: docker
DB_NAME: /data/db/acc.db
ACC_GAME_VOLUME: /var/lib/docker/volumes/acc-server-manager_acc-game/_data
ACC_CONFIG_BASE_PATH: /var/lib/docker/volumes/acc-server-manager_acc-configs/_data
STEAMCMD_LINUX_PATH: /usr/games/steamcmd
PORT_POOL_MIN: "9600"
PORT_POOL_MAX: "9700"
ACC_IMAGE: acc-wine:latest
ACC_SERVERS_PATH: /data/servers
env_file:
- .env
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- acc-game:/data/acc-game
- acc-configs:/data/configs
- acc-servers:/data/servers
- acc-db:/data/db
networks:
- acc-net
volumes:
acc-game:
acc-configs:
acc-servers:
acc-db:
networks:

View File

@@ -24,10 +24,8 @@ WORKDIR /home/accserver
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Volumes (bind-mounted by DockerRuntime.Create):
# /acc/game — shared game files, mounted read-only (ACC_GAME_VOLUME)
# /acc/cfg — per-server config dir, mounted read-write (server.GetConfigPath())
VOLUME ["/acc/game", "/acc/cfg"]
# Bind-mounted by DockerRuntime.Create: per-server game install (rw)
VOLUME ["/acc/game"]
# Default server port — overridden at runtime via SERVER_PORT env var.
ENV SERVER_PORT=9600

View File

@@ -12,6 +12,5 @@ trap cleanup SIGTERM SIGINT
echo "Starting ACC server on port ${SERVER_PORT}..."
exec wine /acc/game/server/accServer.exe \
-serverCfgPath /acc/cfg \
exec wine /acc/game/accServer.exe \
-port "${SERVER_PORT}"

View File

@@ -33,6 +33,7 @@ func Init(di *dig.Container, app *fiber.App) {
System: groups.Group("/system"),
WebSocket: groups.Group("/ws"),
Leaderboard: serverIdGroup.Group("/leaderboard"),
Steam: groups.Group("/steam"),
}
accessKeyMiddleware := middleware.NewAccessKeyMiddleware()

View File

@@ -64,4 +64,9 @@ func InitializeControllers(c *dig.Container) {
if err != nil {
logging.Panic("unable to initialize leaderboard controller")
}
err = c.Invoke(NewSteamController)
if err != nil {
logging.Panic("unable to initialize steam controller")
}
}

57
local/controller/steam.go Normal file
View File

@@ -0,0 +1,57 @@
package controller
import (
"acc-server-manager/local/middleware"
"acc-server-manager/local/model"
"acc-server-manager/local/service"
"acc-server-manager/local/utl/common"
"acc-server-manager/local/utl/error_handler"
"github.com/gofiber/fiber/v2"
)
type SteamController struct {
steamService *service.SteamService
auth *middleware.AuthMiddleware
errorHandler *error_handler.ControllerErrorHandler
}
func NewSteamController(steamService *service.SteamService, auth *middleware.AuthMiddleware, routeGroups *common.RouteGroups) *SteamController {
sc := &SteamController{
steamService: steamService,
auth: auth,
errorHandler: error_handler.NewControllerErrorHandler(),
}
routeGroups.Steam.Use(auth.Authenticate, auth.RequireSuperAdmin())
routeGroups.Steam.Post("/credentials", sc.SetCredentials)
return sc
}
func (sc *SteamController) SetCredentials(c *fiber.Ctx) error {
type request struct {
Username string `json:"username"`
Password string `json:"password"`
}
var req request
if err := c.BodyParser(&req); err != nil {
return sc.errorHandler.HandleParsingError(c, err)
}
creds := &model.SteamCredentials{
Username: req.Username,
Password: req.Password,
}
if err := creds.Validate(); err != nil {
return sc.errorHandler.HandleServiceError(c, err)
}
if err := sc.steamService.SaveCredentials(c.UserContext(), creds); err != nil {
return sc.errorHandler.HandleServiceError(c, err)
}
return c.Status(fiber.StatusCreated).JSON(fiber.Map{"id": creds.ID})
}

View File

@@ -178,6 +178,23 @@ func (m *AuthMiddleware) HasPermission(requiredPermission string) fiber.Handler
}
}
func (m *AuthMiddleware) RequireSuperAdmin() fiber.Handler {
return func(ctx *fiber.Ctx) error {
if os.Getenv("TESTING_ENV") == "true" {
return ctx.Next()
}
userInfo, ok := ctx.Locals("userInfo").(*CachedUserInfo)
if !ok || userInfo.RoleName != "Super Admin" {
return ctx.Status(fiber.StatusForbidden).JSON(fiber.Map{
"error": "Forbidden",
})
}
return ctx.Next()
}
}
func (m *AuthMiddleware) AuthRateLimit() fiber.Handler {
return m.securityMW.AuthRateLimit()
}

View File

@@ -31,9 +31,6 @@ func NewDockerRuntime(client *dockerclient.Client, repo *repository.ServerReposi
func (r *DockerRuntime) Create(ctx context.Context, server *model.Server) error {
image := env.GetACCImage()
gameVolume := env.GetACCGameVolume()
configPath := server.GetConfigPath()
portStr := strconv.Itoa(server.Port)
natPort, err := nat.NewPort("tcp", portStr)
if err != nil {
@@ -54,8 +51,7 @@ func (r *DockerRuntime) Create(ctx context.Context, server *model.Server) error
}
binds := []string{
fmt.Sprintf("%s:/acc/game:ro", gameVolume),
fmt.Sprintf("%s:/acc/cfg:rw", configPath),
fmt.Sprintf("%s:/acc/game:rw", server.GetServerPath()),
}
resp, err := r.client.ContainerCreate(ctx, &container.Config{

View File

@@ -200,8 +200,8 @@ func (s *ServerService) GenerateServerPath(server *model.Server) {
}
if env.IsDockerPlatform() {
server.Path = filepath.Join(env.GetACCConfigBasePath(), server.ServiceName)
server.FromSteamCMD = false
server.Path = filepath.Join(env.GetACCServersPath(), server.ServiceName)
server.FromSteamCMD = true
} else {
steamCMDPath := env.GetSteamCMDDirPath()
server.Path = server.GenerateServerPath(steamCMDPath)
@@ -369,11 +369,7 @@ func (s *ServerService) createServerBackground(ctx context.Context, server *mode
important: true,
description: "Server files downloaded successfully",
callback: func() (string, error) {
installPath := server.Path
if env.IsDockerPlatform() {
installPath = env.GetACCGameVolume()
}
if err := s.steamService.InstallServerWithWebSocket(ctx, installPath, &server.ID, s.webSocketService); err != nil {
if err := s.steamService.InstallServerWithWebSocket(ctx, server.Path, &server.ID, s.webSocketService); err != nil {
return "", fmt.Errorf("failed to install server: %v", err)
}
return "Server files downloaded successfully", nil

View File

@@ -105,9 +105,11 @@ func (s *SteamService) InstallServerWithWebSocket(ctx context.Context, installPa
return err
}
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)
if !env.IsDockerPlatform() {
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)
}
}
absPath, err := filepath.Abs(installPath)
@@ -148,6 +150,10 @@ func (s *SteamService) InstallServerWithWebSocket(ctx context.Context, installPa
steamCMDArgs = append(steamCMDArgs, "anonymous")
}
if env.IsDockerPlatform() {
steamCMDArgs = append(steamCMDArgs, "+@sSteamCmdForcePlatformType", "windows")
}
steamCMDArgs = append(steamCMDArgs,
"+app_update", ACCServerAppID,
"validate",
@@ -229,9 +235,11 @@ func (s *SteamService) InstallServerWithCallbacks(ctx context.Context, installPa
return err
}
if err := s.pathValidator.ValidateInstallPath(installPath); err != nil {
outputCallback(*serverID, fmt.Sprintf("Invalid installation path: %v", err), true)
return fmt.Errorf("invalid installation path: %v", err)
if !env.IsDockerPlatform() {
if err := s.pathValidator.ValidateInstallPath(installPath); err != nil {
outputCallback(*serverID, fmt.Sprintf("Invalid installation path: %v", err), true)
return fmt.Errorf("invalid installation path: %v", err)
}
}
absPath, err := filepath.Abs(installPath)
@@ -272,6 +280,10 @@ func (s *SteamService) InstallServerWithCallbacks(ctx context.Context, installPa
steamCMDArgs = append(steamCMDArgs, "anonymous")
}
if env.IsDockerPlatform() {
steamCMDArgs = append(steamCMDArgs, "+@sSteamCmdForcePlatformType", "windows")
}
steamCMDArgs = append(steamCMDArgs,
"+app_update", ACCServerAppID,
"validate",

View File

@@ -15,6 +15,7 @@ type WebSocketConnection struct {
conn *websocket.Conn
serverID *uuid.UUID
userID *uuid.UUID
mu sync.Mutex
}
type WebSocketService struct {
@@ -131,7 +132,7 @@ func (ws *WebSocketService) broadcastToServer(serverID uuid.UUID, message model.
ws.connections.Range(func(key, value interface{}) bool {
if wsConn, ok := value.(*WebSocketConnection); ok {
if wsConn.serverID != nil && *wsConn.serverID == serverID {
if err := wsConn.conn.WriteMessage(websocket.TextMessage, data); err != nil {
if err := wsConn.writeMessage(websocket.TextMessage, data); err != nil {
logging.Error("Failed to send WebSocket message to connection %s: %v", key, err)
ws.RemoveConnection(key.(string))
} else {
@@ -145,7 +146,7 @@ func (ws *WebSocketService) broadcastToServer(serverID uuid.UUID, message model.
if !sentToAssociatedConnections && (message.Type == model.MessageTypeStep || message.Type == model.MessageTypeError || message.Type == model.MessageTypeComplete) {
ws.connections.Range(func(key, value interface{}) bool {
if wsConn, ok := value.(*WebSocketConnection); ok {
if err := wsConn.conn.WriteMessage(websocket.TextMessage, data); err != nil {
if err := wsConn.writeMessage(websocket.TextMessage, data); err != nil {
logging.Error("Failed to send WebSocket message to connection %s: %v", key, err)
ws.RemoveConnection(key.(string))
}
@@ -165,7 +166,7 @@ func (ws *WebSocketService) BroadcastToUser(userID uuid.UUID, message model.WebS
ws.connections.Range(func(key, value interface{}) bool {
if wsConn, ok := value.(*WebSocketConnection); ok {
if wsConn.userID != nil && *wsConn.userID == userID {
if err := wsConn.conn.WriteMessage(websocket.TextMessage, data); err != nil {
if err := wsConn.writeMessage(websocket.TextMessage, data); err != nil {
logging.Error("Failed to send WebSocket message to connection %s: %v", key, err)
ws.RemoveConnection(key.(string))
}
@@ -175,6 +176,12 @@ func (ws *WebSocketService) BroadcastToUser(userID uuid.UUID, message model.WebS
})
}
func (wc *WebSocketConnection) writeMessage(messageType int, data []byte) error {
wc.mu.Lock()
defer wc.mu.Unlock()
return wc.conn.WriteMessage(messageType, data)
}
func (ws *WebSocketService) GetActiveConnections() int {
count := 0
ws.connections.Range(func(key, value interface{}) bool {

View File

@@ -123,11 +123,12 @@ func (e *CallbackInteractiveCommandExecutor) monitorOutputWithCallbacks(ctx cont
stdoutScanner := bufio.NewScanner(stdout)
stderrScanner := bufio.NewScanner(stderr)
outputChan := make(chan outputLine, 100)
outputChan := make(chan outputLine, 10000)
readersDone := make(chan struct{}, 2)
steamConsoleStarted := false
tfaRequestCreated := false
tfaAutoCompleted := false
go func() {
defer func() { readersDone <- struct{}{} }()
@@ -182,13 +183,14 @@ func (e *CallbackInteractiveCommandExecutor) monitorOutputWithCallbacks(ctx cont
if readersFinished == 2 {
close(outputChan)
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.callbacks.OnOutput(e.serverID, fmt.Sprintf("Failed to handle 2FA prompt: %v", err), true)
done <- err
return
}
if e.is2FAPrompt(lineData.text) && !tfaRequestCreated {
tfaRequestCreated = true
go func(prompt string) {
if err := e.handle2FAPrompt(ctx, prompt, serverID); err != nil {
logging.Error("Failed to handle 2FA prompt: %v", err)
e.callbacks.OnOutput(e.serverID, fmt.Sprintf("Failed to handle 2FA prompt: %v", err), true)
}
}(lineData.text)
}
}
return
@@ -207,33 +209,34 @@ func (e *CallbackInteractiveCommandExecutor) monitorOutputWithCallbacks(ctx cont
if e.is2FAPrompt(lineData.text) {
if !tfaRequestCreated {
e.callbacks.OnOutput(e.serverID, "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.callbacks.OnOutput(e.serverID, fmt.Sprintf("Failed to handle 2FA prompt: %v", err), true)
done <- err
return
}
tfaRequestCreated = true
e.callbacks.OnOutput(e.serverID, "2FA prompt detected - waiting for user confirmation", false)
go func(prompt string) {
if err := e.handle2FAPrompt(ctx, prompt, serverID); err != nil {
logging.Error("Failed to handle 2FA prompt: %v", err)
e.callbacks.OnOutput(e.serverID, fmt.Sprintf("Failed to handle 2FA prompt: %v", err), true)
}
}(lineData.text)
}
}
if tfaRequestCreated && e.isSteamContinuing(lineData.text) {
if tfaRequestCreated && !tfaAutoCompleted && e.isSteamContinuing(lineData.text) {
logging.Info("Steam CMD appears to have continued after 2FA confirmation")
tfaAutoCompleted = true
e.callbacks.OnOutput(e.serverID, "Steam CMD continued after 2FA confirmation", false)
e.autoCompletePendingRequests(serverID)
}
case <-time.After(15 * time.Second):
if steamConsoleStarted && !tfaRequestCreated {
tfaRequestCreated = true
logging.Info("Steam Console started but no output for 15 seconds - likely waiting for Steam Guard 2FA")
e.callbacks.OnOutput(e.serverID, "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.callbacks.OnOutput(e.serverID, fmt.Sprintf("Failed to handle Steam Guard 2FA prompt: %v", err), true)
done <- err
return
}
tfaRequestCreated = true
go func() {
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.callbacks.OnOutput(e.serverID, fmt.Sprintf("Failed to handle Steam Guard 2FA prompt: %v", err), true)
}
}()
}
}
}

View File

@@ -106,11 +106,12 @@ func (e *InteractiveCommandExecutor) monitorOutput(ctx context.Context, stdout,
stdoutScanner := bufio.NewScanner(stdout)
stderrScanner := bufio.NewScanner(stderr)
outputChan := make(chan string, 100)
outputChan := make(chan string, 10000)
readersDone := make(chan struct{}, 2)
steamConsoleStarted := false
tfaRequestCreated := false
tfaAutoCompleted := false
go func() {
defer func() { readersDone <- struct{}{} }()
@@ -165,12 +166,13 @@ func (e *InteractiveCommandExecutor) monitorOutput(ctx context.Context, stdout,
if readersFinished == 2 {
close(outputChan)
for line := range outputChan {
if e.is2FAPrompt(line) {
if err := e.handle2FAPrompt(ctx, line, serverID); err != nil {
logging.Error("Failed to handle 2FA prompt: %v", err)
done <- err
return
}
if e.is2FAPrompt(line) && !tfaRequestCreated {
tfaRequestCreated = true
go func(prompt string) {
if err := e.handle2FAPrompt(ctx, prompt, serverID); err != nil {
logging.Error("Failed to handle 2FA prompt: %v", err)
}
}(line)
}
}
return
@@ -188,28 +190,29 @@ func (e *InteractiveCommandExecutor) monitorOutput(ctx context.Context, stdout,
if e.is2FAPrompt(line) {
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
go func(prompt string) {
if err := e.handle2FAPrompt(ctx, prompt, serverID); err != nil {
logging.Error("Failed to handle 2FA prompt: %v", err)
}
}(line)
}
}
if tfaRequestCreated && e.isSteamContinuing(line) {
if tfaRequestCreated && !tfaAutoCompleted && e.isSteamContinuing(line) {
logging.Info("Steam CMD appears to have continued after 2FA confirmation - auto-completing 2FA request")
tfaAutoCompleted = true
e.autoCompletePendingRequests(serverID)
}
case <-time.After(15 * time.Second):
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
logging.Info("Steam Console started but no output for 15 seconds - likely waiting for Steam Guard 2FA")
go func() {
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)
}
}()
} else if !steamConsoleStarted {
logging.Info("No output for 15 seconds (Steam Console not yet started)")
}
@@ -220,22 +223,15 @@ func (e *InteractiveCommandExecutor) monitorOutput(ctx context.Context, stdout,
func (e *InteractiveCommandExecutor) is2FAPrompt(line string) bool {
twoFAKeywords := []string{
"please enter your steam guard code",
"steam guard",
"two-factor",
"authentication code",
"steam guard code",
"two-factor authentication",
"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",
"2fa prompt detected",
}
lowerLine := strings.ToLower(line)
@@ -246,21 +242,6 @@ func (e *InteractiveCommandExecutor) is2FAPrompt(line string) bool {
}
}
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
}

View File

@@ -27,6 +27,7 @@ type RouteGroups struct {
System fiber.Router
WebSocket fiber.Router
Leaderboard fiber.Router
Steam fiber.Router
}
func CheckError(err error) {

16
local/utl/env/env.go vendored
View File

@@ -10,8 +10,7 @@ const (
DefaultNSSMPath = ".\\nssm.exe"
DefaultPlatform = "windows"
DefaultACCImage = "acc-wine:latest"
DefaultACCGameVolume = "/data/acc-game"
DefaultACCConfigBasePath = "/data/configs"
DefaultACCServersPath = "/data/servers"
DefaultLinuxSteamCMDPath = "/usr/games/steamcmd"
)
@@ -59,18 +58,11 @@ func GetACCImage() string {
return DefaultACCImage
}
func GetACCGameVolume() string {
if v := os.Getenv("ACC_GAME_VOLUME"); v != "" {
return v
}
return DefaultACCGameVolume
}
func GetACCConfigBasePath() string {
if p := os.Getenv("ACC_CONFIG_BASE_PATH"); p != "" {
func GetACCServersPath() string {
if p := os.Getenv("ACC_SERVERS_PATH"); p != "" {
return p
}
return DefaultACCConfigBasePath
return DefaultACCServersPath
}
func ValidatePaths() map[string]error {