From 49dfa3847533fae0184bd79bcc9dee68c8647b9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=20Jurmanovi=C4=87?= Date: Sun, 22 Mar 2026 13:19:33 +0100 Subject: [PATCH 01/21] feat: sync server name with config --- local/service/config.go | 19 +++++++++++++++++++ local/service/server.go | 4 ++++ 2 files changed, 23 insertions(+) diff --git a/local/service/config.go b/local/service/config.go index b632854..dbc5832 100644 --- a/local/service/config.go +++ b/local/service/config.go @@ -229,6 +229,17 @@ func (as *ConfigService) updateConfigInternal(ctx context.Context, serverID stri return nil, err } + if configFile == SettingsJson { + if nameVal, ok := (*body)["serverName"]; ok { + if nameStr, ok := nameVal.(string); ok && nameStr != "" && nameStr != server.Name { + server.Name = nameStr + if err := as.serverRepository.Update(ctx, server); err != nil { + logging.Error("Failed to update server name in DB: %v", err) + } + } + } + } + as.configCache.InvalidateServerCache(serverID) as.serverService.StartAccServerRuntime(server) @@ -489,3 +500,11 @@ func (as *ConfigService) SaveConfiguration(server *model.Server, config *model.C _, _, err = as.updateConfigFiles(context.Background(), server, ConfigurationJson, &configMap, true) return err } + +func (as *ConfigService) SetServerName(server *model.Server, name string) error { + configMap := map[string]interface{}{ + "serverName": name, + } + _, _, err := as.updateConfigFiles(context.Background(), server, SettingsJson, &configMap, false) + return err +} diff --git a/local/service/server.go b/local/service/server.go index 00adc45..934f0e4 100644 --- a/local/service/server.go +++ b/local/service/server.go @@ -388,6 +388,10 @@ func (s *ServerService) createServerBackground(ctx context.Context, server *mode return "", fmt.Errorf("failed to update server configuration: %v", err) } + if err := s.configService.SetServerName(server, server.Name); err != nil { + logging.Error("Failed to set server name in config: %v", err) + } + return fmt.Sprintf("Server configuration generated (Port: %d)", serverPort), nil }, }, From 110007224580d2e395d4e0e6cfc49814bb82ef6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=20Jurmanovi=C4=87?= Date: Sun, 22 Mar 2026 13:22:46 +0100 Subject: [PATCH 02/21] feat: sync server name when updating all configs --- local/service/config.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/local/service/config.go b/local/service/config.go index dbc5832..675e432 100644 --- a/local/service/config.go +++ b/local/service/config.go @@ -129,6 +129,16 @@ func (as *ConfigService) UpdateAllConfigs(ctx *fiber.Ctx, req *model.Configurati if err != nil { return nil, err } + if entry.fileName == SettingsJson { + if nameVal, ok := (*entry.body)["serverName"]; ok { + if nameStr, ok := nameVal.(string); ok && nameStr != "" && nameStr != server.Name { + server.Name = nameStr + if err := as.serverRepository.Update(ctx.UserContext(), server); err != nil { + logging.Error("Failed to update server name in DB: %v", err) + } + } + } + } result := as.repository.UpdateConfig(ctx.UserContext(), &model.Config{ ServerID: serverUUID, ConfigFile: entry.fileName, From 4aa67db3fd8de645210c592945b517ac6cb79dab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=20Jurmanovi=C4=87?= Date: Wed, 18 Mar 2026 19:15:19 +0100 Subject: [PATCH 03/21] add docker specific columns to server table --- .../001_upgrade_password_security.go | 0 local/migrations/002_migrate_to_uuid.go | 0 .../003_update_state_history_sessions.go | 0 local/migrations/004_fix_ddl_comments.go | 0 local/migrations/005_add_platform_columns.go | 33 +++++++++ local/model/server.go | 2 + local/utl/env/env.go | 64 ++++++++++++++--- local/utl/network/port_pool.go | 72 +++++++++++++++++++ local/utl/server/server.go | 2 +- 9 files changed, 163 insertions(+), 10 deletions(-) mode change 100644 => 100755 local/migrations/001_upgrade_password_security.go mode change 100644 => 100755 local/migrations/002_migrate_to_uuid.go mode change 100644 => 100755 local/migrations/003_update_state_history_sessions.go mode change 100644 => 100755 local/migrations/004_fix_ddl_comments.go create mode 100644 local/migrations/005_add_platform_columns.go mode change 100644 => 100755 local/model/server.go mode change 100644 => 100755 local/utl/env/env.go create mode 100644 local/utl/network/port_pool.go diff --git a/local/migrations/001_upgrade_password_security.go b/local/migrations/001_upgrade_password_security.go old mode 100644 new mode 100755 diff --git a/local/migrations/002_migrate_to_uuid.go b/local/migrations/002_migrate_to_uuid.go old mode 100644 new mode 100755 diff --git a/local/migrations/003_update_state_history_sessions.go b/local/migrations/003_update_state_history_sessions.go old mode 100644 new mode 100755 diff --git a/local/migrations/004_fix_ddl_comments.go b/local/migrations/004_fix_ddl_comments.go old mode 100644 new mode 100755 diff --git a/local/migrations/005_add_platform_columns.go b/local/migrations/005_add_platform_columns.go new file mode 100644 index 0000000..113df0f --- /dev/null +++ b/local/migrations/005_add_platform_columns.go @@ -0,0 +1,33 @@ +package migrations + +import ( + "acc-server-manager/local/utl/logging" + "fmt" + + "gorm.io/gorm" +) + +func RunAddPlatformColumnsMigration(db *gorm.DB) error { + if err := db.AutoMigrate(&MigrationRecord{}); err != nil { + return fmt.Errorf("failed to ensure migration_records table: %v", err) + } + + var record MigrationRecord + if err := db.Where("migration_name = ?", "005_add_platform_columns").First(&record).Error; err == nil { + logging.Info("Platform columns migration already applied, skipping") + return nil + } + + record = MigrationRecord{ + MigrationName: "005_add_platform_columns", + AppliedAt: "datetime('now')", + Success: true, + Notes: "Added Platform and ContainerID columns to servers table via GORM AutoMigrate", + } + if err := db.Create(&record).Error; err != nil { + return fmt.Errorf("failed to record migration 005: %v", err) + } + + logging.Info("Platform columns migration recorded") + return nil +} diff --git a/local/model/server.go b/local/model/server.go old mode 100644 new mode 100755 index bab75d6..4693d6c --- a/local/model/server.go +++ b/local/model/server.go @@ -42,6 +42,8 @@ type Server struct { Port int `gorm:"not null" json:"-"` Path string `gorm:"not null" json:"path"` ServiceName string `gorm:"not null" json:"serviceName"` + Platform string `gorm:"not null;default:'windows'" json:"platform"` + ContainerID string `gorm:"default:''" json:"-"` State *ServerState `gorm:"-" json:"state"` DateCreated time.Time `json:"dateCreated"` FromSteamCMD bool `gorm:"not null; default:true" json:"-"` diff --git a/local/utl/env/env.go b/local/utl/env/env.go old mode 100644 new mode 100755 index 0f41b84..d1e50cd --- a/local/utl/env/env.go +++ b/local/utl/env/env.go @@ -6,10 +6,26 @@ import ( ) const ( - DefaultSteamCMDPath = "c:\\steamcmd\\steamcmd.exe" - DefaultNSSMPath = ".\\nssm.exe" + DefaultSteamCMDPath = "c:\\steamcmd\\steamcmd.exe" + DefaultNSSMPath = ".\\nssm.exe" + DefaultPlatform = "windows" + DefaultACCImage = "acc-wine:latest" + DefaultACCGameVolume = "/data/acc-game" + DefaultACCConfigBasePath = "/data/configs" + DefaultLinuxSteamCMDPath = "/usr/games/steamcmd" ) +func GetPlatform() string { + if p := os.Getenv("PLATFORM"); p != "" { + return p + } + return DefaultPlatform +} + +func IsDockerPlatform() bool { + return GetPlatform() == "docker" +} + func GetSteamCMDPath() string { if path := os.Getenv("STEAMCMD_PATH"); path != "" { return path @@ -17,6 +33,13 @@ func GetSteamCMDPath() string { return DefaultSteamCMDPath } +func GetLinuxSteamCMDPath() string { + if path := os.Getenv("STEAMCMD_LINUX_PATH"); path != "" { + return path + } + return DefaultLinuxSteamCMDPath +} + func GetSteamCMDDirPath() string { steamCMDPath := GetSteamCMDPath() return filepath.Dir(steamCMDPath) @@ -29,17 +52,40 @@ func GetNSSMPath() string { return DefaultNSSMPath } +func GetACCImage() string { + if img := os.Getenv("ACC_IMAGE"); img != "" { + return img + } + 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 != "" { + return p + } + return DefaultACCConfigBasePath +} + func ValidatePaths() map[string]error { errors := make(map[string]error) - steamCMDPath := GetSteamCMDPath() - if _, err := os.Stat(steamCMDPath); os.IsNotExist(err) { - errors["STEAMCMD_PATH"] = err - } + if !IsDockerPlatform() { + steamCMDPath := GetSteamCMDPath() + if _, err := os.Stat(steamCMDPath); os.IsNotExist(err) { + errors["STEAMCMD_PATH"] = err + } - nssmPath := GetNSSMPath() - if _, err := os.Stat(nssmPath); os.IsNotExist(err) { - errors["NSSM_PATH"] = err + nssmPath := GetNSSMPath() + if _, err := os.Stat(nssmPath); os.IsNotExist(err) { + errors["NSSM_PATH"] = err + } } return errors diff --git a/local/utl/network/port_pool.go b/local/utl/network/port_pool.go new file mode 100644 index 0000000..c43e58f --- /dev/null +++ b/local/utl/network/port_pool.go @@ -0,0 +1,72 @@ +package network + +import ( + "context" + "fmt" + "os" + "strconv" + "sync" + + "gorm.io/gorm" +) + +const ( + defaultPortPoolMin = 9600 + defaultPortPoolMax = 9700 +) + +type PortPoolManager struct { + mu sync.Mutex + min int + max int + inUse map[int]struct{} +} + +func NewPortPoolManager(db *gorm.DB) (*PortPoolManager, error) { + min := portEnvInt("PORT_POOL_MIN", defaultPortPoolMin) + max := portEnvInt("PORT_POOL_MAX", defaultPortPoolMax) + + var ports []int + if err := db.Raw("SELECT port FROM servers WHERE platform = 'docker' AND port > 0").Scan(&ports).Error; err != nil { + return nil, fmt.Errorf("failed to seed port pool from database: %v", err) + } + + inUse := make(map[int]struct{}, len(ports)) + for _, p := range ports { + inUse[p] = struct{}{} + } + + return &PortPoolManager{ + min: min, + max: max, + inUse: inUse, + }, nil +} + +func (p *PortPoolManager) Acquire(_ context.Context) (int, error) { + p.mu.Lock() + defer p.mu.Unlock() + + for port := p.min; port <= p.max; port++ { + if _, used := p.inUse[port]; !used { + p.inUse[port] = struct{}{} + return port, nil + } + } + return 0, fmt.Errorf("no available ports in pool [%d-%d]", p.min, p.max) +} + +func (p *PortPoolManager) Release(_ context.Context, port int) { + p.mu.Lock() + defer p.mu.Unlock() + delete(p.inUse, port) +} + +func portEnvInt(key string, defaultVal int) int { + if v := os.Getenv(key); v != "" { + if n, err := strconv.Atoi(v); err == nil { + return n + } + } + return defaultVal +} diff --git a/local/utl/server/server.go b/local/utl/server/server.go index 1571486..d5da435 100755 --- a/local/utl/server/server.go +++ b/local/utl/server/server.go @@ -44,7 +44,7 @@ func Start(di *dig.Container) *fiber.App { app.Use(cors.New(cors.Config{ AllowOrigins: allowedOrigin, - AllowHeaders: "Origin, Content-Type, Accept, Authorization, Access-Token", + AllowHeaders: "Origin, Content-Type, Accept, Authorization, Access-Key", AllowMethods: "GET, POST, PUT, DELETE, OPTIONS", AllowCredentials: true, MaxAge: 86400, // 24 hours From beb9b479dff5bdfc68d1f429e298d68798ed3e5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=20Jurmanovi=C4=87?= Date: Wed, 18 Mar 2026 19:16:49 +0100 Subject: [PATCH 04/21] add platform interface according to env --- local/platform/interfaces.go | 28 ++++++ local/service/file_log_streamer.go | 38 ++++++++ local/service/server.go | 150 +++++++++++++++++------------ local/service/service.go | 70 +++++++++++++- local/service/service_control.go | 52 ++++++---- local/service/steam_service.go | 17 +++- local/service/windows_runtime.go | 68 +++++++++++++ local/utl/db/db.go | 4 + 8 files changed, 339 insertions(+), 88 deletions(-) create mode 100644 local/platform/interfaces.go create mode 100644 local/service/file_log_streamer.go mode change 100644 => 100755 local/service/server.go mode change 100644 => 100755 local/service/service.go mode change 100644 => 100755 local/service/service_control.go mode change 100644 => 100755 local/service/steam_service.go create mode 100644 local/service/windows_runtime.go mode change 100644 => 100755 local/utl/db/db.go diff --git a/local/platform/interfaces.go b/local/platform/interfaces.go new file mode 100644 index 0000000..47de4ed --- /dev/null +++ b/local/platform/interfaces.go @@ -0,0 +1,28 @@ +package platform + +import ( + "acc-server-manager/local/model" + "context" + + "github.com/google/uuid" +) + +type ServerRuntime interface { + Create(ctx context.Context, server *model.Server) error + Delete(ctx context.Context, serverID uuid.UUID) error + Start(ctx context.Context, serverID uuid.UUID) (string, error) + Stop(ctx context.Context, serverID uuid.UUID) (string, error) + Restart(ctx context.Context, serverID uuid.UUID) (string, error) + Status(ctx context.Context, serverID uuid.UUID) (string, error) +} + +type FirewallManager interface { + CreateServerRules(serverName string, tcpPorts, udpPorts []int) error + DeleteServerRules(serverName string, tcpPorts, udpPorts []int) error + UpdateServerRules(serverName string, tcpPorts, udpPorts []int) error +} + +type LogStreamer interface { + Start(ctx context.Context, server *model.Server, handleLine func(string)) error + Stop(serverID uuid.UUID) +} diff --git a/local/service/file_log_streamer.go b/local/service/file_log_streamer.go new file mode 100644 index 0000000..62bd3e6 --- /dev/null +++ b/local/service/file_log_streamer.go @@ -0,0 +1,38 @@ +package service + +import ( + "acc-server-manager/local/model" + "acc-server-manager/local/utl/tracking" + "context" + "path/filepath" + "sync" + + "github.com/google/uuid" +) + +type FileLogStreamer struct { + tailers sync.Map // uuid.UUID → *tracking.LogTailer +} + +func NewFileLogStreamer() *FileLogStreamer { + return &FileLogStreamer{} +} + +func (s *FileLogStreamer) Start(_ context.Context, server *model.Server, handleLine func(string)) error { + if _, exists := s.tailers.Load(server.ID); exists { + return nil + } + + logPath := filepath.Join(server.GetLogPath(), "server.log") + tailer := tracking.NewLogTailer(logPath, handleLine) + s.tailers.Store(server.ID, tailer) + + go tailer.Start() + return nil +} + +func (s *FileLogStreamer) Stop(serverID uuid.UUID) { + if v, exists := s.tailers.LoadAndDelete(serverID); exists { + v.(*tracking.LogTailer).Stop() + } +} diff --git a/local/service/server.go b/local/service/server.go old mode 100644 new mode 100755 index 00adc45..2cf5555 --- a/local/service/server.go +++ b/local/service/server.go @@ -2,9 +2,11 @@ package service import ( "acc-server-manager/local/model" + "acc-server-manager/local/platform" "acc-server-manager/local/repository" "acc-server-manager/local/utl/env" "acc-server-manager/local/utl/logging" + "acc-server-manager/local/utl/network" "acc-server-manager/local/utl/tracking" "context" "fmt" @@ -12,8 +14,6 @@ import ( "sync" "time" - "acc-server-manager/local/utl/network" - "github.com/gofiber/fiber/v2" "github.com/google/uuid" ) @@ -29,14 +29,15 @@ type ServerService struct { apiService *ServiceControlService configService *ConfigService steamService *SteamService - windowsService *WindowsService - firewallService *FirewallService + runtime platform.ServerRuntime + firewall platform.FirewallManager + logStreamer platform.LogStreamer + portPool *network.PortPoolManager 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 - logTailers sync.Map // Track log tailers per server - sessionIDs sync.Map // Track current session ID per server + instances sync.Map // uuid.UUID → *tracking.AccServerInstance + lastInsertTimes sync.Map // uuid.UUID → time.Time + debouncers sync.Map // uuid.UUID → *pendingState + sessionIDs sync.Map // uuid.UUID → uuid.UUID } type pendingState struct { @@ -45,16 +46,10 @@ type pendingState struct { } func (s *ServerService) ensureLogTailing(server *model.Server, instance *tracking.AccServerInstance) { - if _, exists := s.logTailers.Load(server.ID); exists { - return - } - go func() { - logPath := filepath.Join(server.GetLogPath(), "server.log") - tailer := tracking.NewLogTailer(logPath, instance.HandleLogLine) - s.logTailers.Store(server.ID, tailer) - - tailer.Start() + if err := s.logStreamer.Start(context.Background(), server, instance.HandleLogLine); err != nil { + logging.Error("Failed to start log streaming for server %s: %v", server.ID, err) + } }() } @@ -64,8 +59,10 @@ func NewServerService( apiService *ServiceControlService, configService *ConfigService, steamService *SteamService, - windowsService *WindowsService, - firewallService *FirewallService, + runtime platform.ServerRuntime, + firewall platform.FirewallManager, + logStreamer platform.LogStreamer, + portPool *network.PortPoolManager, webSocketService *WebSocketService, ) *ServerService { service := &ServerService{ @@ -74,8 +71,10 @@ func NewServerService( apiService: apiService, configService: configService, steamService: steamService, - windowsService: windowsService, - firewallService: firewallService, + runtime: runtime, + firewall: firewall, + logStreamer: logStreamer, + portPool: portPool, webSocketService: webSocketService, } @@ -195,9 +194,19 @@ func (s *ServerService) updateSessionDuration(server *model.Server, sessionType } func (s *ServerService) GenerateServerPath(server *model.Server) { - steamCMDPath := env.GetSteamCMDDirPath() - server.Path = server.GenerateServerPath(steamCMDPath) - server.FromSteamCMD = true + server.GenerateUUID() + if server.ServiceName == "" { + server.ServiceName = server.GenerateServiceName() + } + + if env.IsDockerPlatform() { + server.Path = filepath.Join(env.GetACCConfigBasePath(), server.ServiceName) + server.FromSteamCMD = false + } else { + steamCMDPath := env.GetSteamCMDDirPath() + server.Path = server.GenerateServerPath(steamCMDPath) + server.FromSteamCMD = true + } } func (s *ServerService) handleStateChange(server *model.Server, state *model.ServerState) { @@ -250,9 +259,6 @@ func (s *ServerService) StartAccServerRuntime(server *model.Server) { s.ensureLogTailing(server, instance) } -// context.Context: Application context -// Returns: -// string: Application version func (s *ServerService) GetAll(ctx *fiber.Ctx, filter *model.ServerFilter) (*[]model.Server, error) { servers, err := s.repository.GetAll(ctx.UserContext(), filter) if err != nil { @@ -281,9 +287,6 @@ func (s *ServerService) GetAll(ctx *fiber.Ctx, filter *model.ServerFilter) (*[]m return servers, nil } -// context.Context: Application context -// Returns: -// string: Application version func (as *ServerService) GetById(ctx *fiber.Ctx, serverID uuid.UUID) (*model.Server, error) { server, err := as.repository.GetByID(ctx.UserContext(), serverID) if err != nil { @@ -291,7 +294,7 @@ func (as *ServerService) GetById(ctx *fiber.Ctx, serverID uuid.UUID) (*model.Ser } status, err := as.apiService.GetCachedStatus(server.ServiceName) if err != nil { - logging.Error(err.Error()) + logging.Error("Failed to get cached status: %v", err) } server.Status = model.ParseServiceStatus(status) instance, ok := as.instances.Load(server.ID) @@ -366,7 +369,11 @@ func (s *ServerService) createServerBackground(ctx context.Context, server *mode important: true, description: "Server files downloaded successfully", callback: func() (string, error) { - if err := s.steamService.InstallServerWithWebSocket(ctx, server.Path, &server.ID, s.webSocketService); err != nil { + installPath := server.Path + if env.IsDockerPlatform() { + installPath = env.GetACCGameVolume() + } + if err := s.steamService.InstallServerWithWebSocket(ctx, installPath, &server.ID, s.webSocketService); err != nil { return "", fmt.Errorf("failed to install server: %v", err) } return "Server files downloaded successfully", nil @@ -377,12 +384,24 @@ func (s *ServerService) createServerBackground(ctx context.Context, server *mode important: true, description: "", callback: func() (string, error) { - ports, err := network.FindAvailablePortRange(DefaultStartPort, RequiredPortCount) - if err != nil { - return "", fmt.Errorf("failed to find available ports: %v", err) + var port int + var err error + + if env.IsDockerPlatform() { + port, err = s.portPool.Acquire(ctx) + if err != nil { + return "", fmt.Errorf("failed to acquire port from pool: %v", err) + } + } else { + ports, err := network.FindAvailablePortRange(DefaultStartPort, RequiredPortCount) + if err != nil { + return "", fmt.Errorf("failed to find available ports: %v", err) + } + port = ports[0] } - serverPort = ports[0] + serverPort = port + server.Port = port if err := s.updateServerPort(server, serverPort); err != nil { return "", fmt.Errorf("failed to update server configuration: %v", err) @@ -396,12 +415,11 @@ func (s *ServerService) createServerBackground(ctx context.Context, server *mode important: true, description: "", callback: func() (string, error) { - 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 { - return "", fmt.Errorf("failed to create Windows service: %v", err) + server.Platform = env.GetPlatform() + if err := s.runtime.Create(ctx, server); err != nil { + return "", fmt.Errorf("failed to create server instance: %v", err) } - return fmt.Sprintf("Windows service '%s' created successfully", server.ServiceName), nil + return fmt.Sprintf("Server instance '%s' created successfully", server.ServiceName), nil }, }, { @@ -409,13 +427,12 @@ func (s *ServerService) createServerBackground(ctx context.Context, server *mode important: false, description: "", callback: func() (string, error) { - s.configureFirewall(server) tcpPorts = []int{serverPort} udpPorts = []int{serverPort} - if err := s.firewallService.CreateServerRules(server.ServiceName, tcpPorts, udpPorts); err != nil { + if err := s.firewall.CreateServerRules(server.ServiceName, tcpPorts, udpPorts); err != nil { return "", fmt.Errorf("failed to create firewall rules: %v", err) } - return fmt.Sprintf("Firewall rules created for port %d", serverPort), nil + return fmt.Sprintf("Firewall rules configured for port %d", serverPort), nil }, }, { @@ -469,12 +486,18 @@ func (s *ServerService) rollbackSteps(ctx context.Context, server *model.Server, s.repository.Delete(ctx, server.ID) case model.StepFirewallRules: if len(tcpPorts) > 0 && len(udpPorts) > 0 { - s.firewallService.DeleteServerRules(server.ServiceName, tcpPorts, udpPorts) + s.firewall.DeleteServerRules(server.ServiceName, tcpPorts, udpPorts) } case model.StepServiceCreation: - s.windowsService.DeleteService(ctx, server.ServiceName) + s.runtime.Delete(ctx, server.ID) case model.StepSteamDownload: - s.steamService.UninstallServer(server.Path) + if !env.IsDockerPlatform() { + s.steamService.UninstallServer(server.Path) + } + case model.StepConfigGeneration: + if env.IsDockerPlatform() && server.Port > 0 { + s.portPool.Release(ctx, server.Port) + } } } } @@ -485,32 +508,37 @@ func (s *ServerService) DeleteServer(ctx *fiber.Ctx, serverID uuid.UUID) error { return fmt.Errorf("failed to get server details: %v", err) } - if err := s.windowsService.DeleteService(ctx.UserContext(), server.ServiceName); err != nil { - logging.Error("Failed to delete Windows service: %v", err) + if err := s.runtime.Delete(ctx.UserContext(), server.ID); err != nil { + logging.Error("Failed to delete server instance: %v", err) } configuration, err := s.configService.GetConfiguration(server) if err != nil { logging.Error("Failed to get configuration for server %d: %v", server.ID, err) } - tcpPorts := []int{configuration.TcpPort.ToInt()} - udpPorts := []int{configuration.UdpPort.ToInt()} - if err := s.firewallService.DeleteServerRules(server.ServiceName, tcpPorts, udpPorts); err != nil { - logging.Error("Failed to delete firewall rules: %v", err) + if configuration != nil { + tcpPorts := []int{configuration.TcpPort.ToInt()} + udpPorts := []int{configuration.UdpPort.ToInt()} + if err := s.firewall.DeleteServerRules(server.ServiceName, tcpPorts, udpPorts); err != nil { + logging.Error("Failed to delete firewall rules: %v", err) + } } - if err := s.steamService.UninstallServer(server.Path); err != nil { - logging.Error("Failed to uninstall server: %v", err) + if server.Platform != "docker" { + if err := s.steamService.UninstallServer(server.Path); err != nil { + logging.Error("Failed to uninstall server: %v", err) + } + } + + if env.IsDockerPlatform() && server.Port > 0 { + s.portPool.Release(ctx.UserContext(), server.Port) } if err := s.repository.Delete(ctx.UserContext(), serverID); err != nil { return fmt.Errorf("failed to delete server from database: %v", err) } - if tailer, exists := s.logTailers.Load(server.ID); exists { - tailer.(*tracking.LogTailer).Stop() - s.logTailers.Delete(server.ID) - } + s.logStreamer.Stop(server.ID) s.instances.Delete(server.ID) s.lastInsertTimes.Delete(server.ID) s.debouncers.Delete(server.ID) @@ -533,7 +561,7 @@ func (s *ServerService) configureFirewall(server *model.Server) error { logging.Info("Configuring firewall for server %d with port %d", server.ID, serverPort) - if err := s.firewallService.UpdateServerRules(server.Name, tcpPorts, udpPorts); err != nil { + if err := s.firewall.UpdateServerRules(server.Name, tcpPorts, udpPorts); err != nil { return fmt.Errorf("failed to configure firewall: %v", err) } diff --git a/local/service/service.go b/local/service/service.go old mode 100644 new mode 100755 index 64f3e9e..d95639f --- a/local/service/service.go +++ b/local/service/service.go @@ -1,13 +1,22 @@ package service import ( + "acc-server-manager/local/platform" "acc-server-manager/local/repository" + "acc-server-manager/local/utl/docker" + "acc-server-manager/local/utl/env" "acc-server-manager/local/utl/logging" + "acc-server-manager/local/utl/network" + "fmt" + dockerclient "github.com/docker/docker/client" "go.uber.org/dig" + "gorm.io/gorm" ) -// *dig.Container: Dig Container +// InitializeServices registers all service providers into the DI container. +// Platform-specific components (ServerRuntime, FirewallManager, LogStreamer) +// are conditionally provided based on the PLATFORM environment variable. func InitializeServices(c *dig.Container) { logging.Debug("Initializing repositories") repository.InitializeRepositories(c) @@ -19,21 +28,74 @@ func InitializeServices(c *dig.Container) { c.Provide(NewServiceControlService) c.Provide(NewConfigService) c.Provide(NewLookupService) - c.Provide(NewWindowsService) - c.Provide(NewFirewallService) c.Provide(NewMembershipService) c.Provide(NewWebSocketService) c.Provide(NewLeaderboardService) + // Always provide the PortPoolManager; in Windows mode it seeds from an + // empty result set and sits idle. + c.Provide(func(db *gorm.DB) (*network.PortPoolManager, error) { + return network.NewPortPoolManager(db) + }) + + if env.IsDockerPlatform() { + registerDockerProviders(c) + } else { + registerWindowsProviders(c) + } + logging.Debug("Initializing service dependencies") err := c.Invoke(func(server *ServerService, api *ServiceControlService, config *ConfigService) { logging.Debug("Setting up service cross-references") api.SetServerService(server) config.SetServerService(server) - }) if err != nil { logging.Panic("unable to initialize services: " + err.Error()) } logging.Debug("Completed service initialization") } + +func registerWindowsProviders(c *dig.Container) { + logging.Debug("Registering Windows platform providers") + + c.Provide(NewWindowsService) + + c.Provide(func(ws *WindowsService, repo *repository.ServerRepository) platform.ServerRuntime { + return NewWindowsRuntime(ws, repo) + }) + + c.Provide(func() platform.FirewallManager { + return NewFirewallService() + }) + + c.Provide(func() platform.LogStreamer { + return NewFileLogStreamer() + }) +} + +func registerDockerProviders(c *dig.Container) { + logging.Debug("Registering Docker platform providers") + + if err := c.Provide(func() (*dockerclient.Client, error) { + client, err := docker.NewDockerClient() + if err != nil { + return nil, fmt.Errorf("failed to connect to Docker daemon: %v", err) + } + return client, nil + }); err != nil { + logging.Panic("failed to provide Docker client: " + err.Error()) + } + + c.Provide(func(client *dockerclient.Client, repo *repository.ServerRepository) platform.ServerRuntime { + return NewDockerRuntime(client, repo) + }) + + c.Provide(func() platform.FirewallManager { + return &NoOpFirewall{} + }) + + c.Provide(func(client *dockerclient.Client) platform.LogStreamer { + return NewDockerLogStreamer(client) + }) +} diff --git a/local/service/service_control.go b/local/service/service_control.go old mode 100644 new mode 100755 index b1794d5..7641357 --- a/local/service/service_control.go +++ b/local/service/service_control.go @@ -2,6 +2,7 @@ package service import ( "acc-server-manager/local/model" + "acc-server-manager/local/platform" "acc-server-manager/local/repository" "context" "errors" @@ -15,20 +16,23 @@ type ServiceControlService struct { serverRepository *repository.ServerRepository serverService *ServerService statusCache *model.ServerStatusCache - windowsService *WindowsService + runtime platform.ServerRuntime } -func NewServiceControlService(repository *repository.ServiceControlRepository, - serverRepository *repository.ServerRepository) *ServiceControlService { +func NewServiceControlService( + repository *repository.ServiceControlRepository, + serverRepository *repository.ServerRepository, + runtime platform.ServerRuntime, +) *ServiceControlService { return &ServiceControlService{ repository: repository, serverRepository: serverRepository, + runtime: runtime, statusCache: model.NewServerStatusCache(model.CacheConfig{ ExpirationTime: 30 * time.Second, ThrottleTime: 5 * time.Second, DefaultStatus: model.StatusRunning, }), - windowsService: NewWindowsService(), } } @@ -123,7 +127,11 @@ func (as *ServiceControlService) ServiceControlRestartServer(ctx *fiber.Ctx) (st } func (as *ServiceControlService) StatusServer(serviceName string) (string, error) { - return as.windowsService.Status(context.Background(), serviceName) + server, err := as.serverRepository.GetFirstByServiceName(context.Background(), serviceName) + if err != nil { + return "", err + } + return as.runtime.Status(context.Background(), server.ID) } func (as *ServiceControlService) GetCachedStatus(serviceName string) (string, error) { @@ -142,44 +150,46 @@ func (as *ServiceControlService) GetCachedStatus(serviceName string) (string, er } func (as *ServiceControlService) StartServer(serviceName string) (string, error) { - status, err := as.windowsService.Start(context.Background(), serviceName) - if err != nil { - return "", err - } - server, err := as.serverRepository.GetFirstByServiceName(context.Background(), serviceName) if err != nil { return "", err } + + status, err := as.runtime.Start(context.Background(), server.ID) + if err != nil { + return "", err + } + as.serverService.StartAccServerRuntime(server) return status, err } func (as *ServiceControlService) StopServer(serviceName string) (string, error) { - status, err := as.windowsService.Stop(context.Background(), serviceName) - if err != nil { - return "", err - } - server, err := as.serverRepository.GetFirstByServiceName(context.Background(), serviceName) if err != nil { return "", err } - as.serverService.instances.Delete(server.ID) + status, err := as.runtime.Stop(context.Background(), server.ID) + if err != nil { + return "", err + } + + as.serverService.instances.Delete(server.ID) return status, err } func (as *ServiceControlService) RestartServer(serviceName string) (string, error) { - status, err := as.windowsService.Restart(context.Background(), serviceName) - if err != nil { - return "", err - } - server, err := as.serverRepository.GetFirstByServiceName(context.Background(), serviceName) if err != nil { return "", err } + + status, err := as.runtime.Restart(context.Background(), server.ID) + if err != nil { + return "", err + } + as.serverService.StartAccServerRuntime(server) return status, err } diff --git a/local/service/steam_service.go b/local/service/steam_service.go old mode 100644 new mode 100755 index f6ef459..2af06e4 --- a/local/service/steam_service.go +++ b/local/service/steam_service.go @@ -44,6 +44,14 @@ func NewSteamService(repository *repository.SteamCredentialsRepository, tfaManag } } +// getSteamCMDPath returns the platform-appropriate SteamCMD binary path. +func getSteamCMDPath() string { + if env.IsDockerPlatform() { + return env.GetLinuxSteamCMDPath() + } + return env.GetSteamCMDPath() +} + func (s *SteamService) GetCredentials(ctx context.Context) (*model.SteamCredentials, error) { return s.repository.GetCurrent(ctx) } @@ -56,6 +64,11 @@ func (s *SteamService) SaveCredentials(ctx context.Context, creds *model.SteamCr } func (s *SteamService) ensureSteamCMD(_ context.Context) error { + // In Docker mode, SteamCMD is pre-installed in the manager container image. + if env.IsDockerPlatform() { + return nil + } + steamCMDPath := env.GetSteamCMDPath() steamCMDDir := filepath.Dir(steamCMDPath) @@ -117,7 +130,7 @@ func (s *SteamService) InstallServerWithWebSocket(ctx context.Context, installPa return fmt.Errorf("failed to get Steam credentials: %v", err) } - steamCMDPath := env.GetSteamCMDPath() + steamCMDPath := getSteamCMDPath() steamCMDArgs := []string{ "+force_install_dir", absPath, @@ -241,7 +254,7 @@ func (s *SteamService) InstallServerWithCallbacks(ctx context.Context, installPa return fmt.Errorf("failed to get Steam credentials: %v", err) } - steamCMDPath := env.GetSteamCMDPath() + steamCMDPath := getSteamCMDPath() steamCMDArgs := []string{ "+force_install_dir", absPath, diff --git a/local/service/windows_runtime.go b/local/service/windows_runtime.go new file mode 100644 index 0000000..9eb13c5 --- /dev/null +++ b/local/service/windows_runtime.go @@ -0,0 +1,68 @@ +package service + +import ( + "acc-server-manager/local/model" + "acc-server-manager/local/repository" + "context" + "path/filepath" + + "github.com/google/uuid" +) + +type WindowsRuntime struct { + windowsService *WindowsService + repo *repository.ServerRepository +} + +func NewWindowsRuntime(windowsService *WindowsService, repo *repository.ServerRepository) *WindowsRuntime { + return &WindowsRuntime{ + windowsService: windowsService, + repo: repo, + } +} + +func (r *WindowsRuntime) Create(ctx context.Context, server *model.Server) error { + execPath := filepath.Join(server.GetServerPath(), "accServer.exe") + serverWorkingDir := server.GetServerPath() + return r.windowsService.CreateService(ctx, server.ServiceName, execPath, serverWorkingDir, nil) +} + +func (r *WindowsRuntime) Delete(ctx context.Context, serverID uuid.UUID) error { + server, err := r.repo.GetByID(ctx, serverID) + if err != nil { + return err + } + return r.windowsService.DeleteService(ctx, server.ServiceName) +} + +func (r *WindowsRuntime) Start(ctx context.Context, serverID uuid.UUID) (string, error) { + server, err := r.repo.GetByID(ctx, serverID) + if err != nil { + return "", err + } + return r.windowsService.Start(ctx, server.ServiceName) +} + +func (r *WindowsRuntime) Stop(ctx context.Context, serverID uuid.UUID) (string, error) { + server, err := r.repo.GetByID(ctx, serverID) + if err != nil { + return "", err + } + return r.windowsService.Stop(ctx, server.ServiceName) +} + +func (r *WindowsRuntime) Restart(ctx context.Context, serverID uuid.UUID) (string, error) { + server, err := r.repo.GetByID(ctx, serverID) + if err != nil { + return "", err + } + return r.windowsService.Restart(ctx, server.ServiceName) +} + +func (r *WindowsRuntime) Status(ctx context.Context, serverID uuid.UUID) (string, error) { + server, err := r.repo.GetByID(ctx, serverID) + if err != nil { + return "", err + } + return r.windowsService.Status(ctx, server.ServiceName) +} diff --git a/local/utl/db/db.go b/local/utl/db/db.go old mode 100644 new mode 100755 index 55ca071..0500181 --- a/local/utl/db/db.go +++ b/local/utl/db/db.go @@ -76,6 +76,10 @@ func runMigrations(db *gorm.DB) { logging.Error("Failed to run password security migration: %v", err) } + if err := migrations.RunAddPlatformColumnsMigration(db); err != nil { + logging.Error("Failed to run platform columns migration: %v", err) + } + logging.Info("Custom database migrations completed") } From 9172f0256f65fa776b205cc2d020ca04d4f9c1bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=20Jurmanovi=C4=87?= Date: Wed, 18 Mar 2026 19:17:10 +0100 Subject: [PATCH 05/21] add docker specifics --- .env.example | 28 +++ .gitea/workflows/release.yml | 0 .gitignore | 0 Dockerfile | 64 ++++++ README.md | 0 cmd/api/main.go | 0 cmd/api/swagger.go | 0 cmd/migrate/main.go | 0 cmd/steam-crypt/main.go | 0 docker-compose.yml | 37 ++++ docker/acc-server/Dockerfile | 41 ++++ docker/acc-server/entrypoint.sh | 17 ++ docs/API.md | 0 docs/CONFIG.md | 0 docs/DEPLOYMENT.md | 0 docs/MIGRATION.md | 0 docs/SETUP.md | 0 docs/STEAM_2FA_IMPLEMENTATION.md | 0 docs/SWAGGER.md | 0 docs/TROUBLESHOOTING.md | 0 go.mod | 24 ++- go.sum | 76 ++++++- local/api/api.go | 0 local/controller/config.go | 0 local/controller/controller.go | 0 local/controller/leaderboard.go | 0 local/controller/lookup.go | 0 local/controller/membership.go | 0 local/controller/server.go | 0 local/controller/service_control.go | 0 local/controller/state_history.go | 0 local/controller/system.go | 0 local/controller/websocket.go | 0 local/middleware/access_key.go | 0 local/middleware/auth.go | 0 local/middleware/logging/request_logging.go | 0 local/middleware/security/security.go | 0 local/model/cache.go | 0 local/model/config.go | 0 local/model/filter.go | 0 local/model/leaderboard.go | 0 local/model/lookup.go | 0 local/model/model.go | 0 local/model/permission.go | 0 local/model/permissions.go | 0 local/model/role.go | 0 local/model/service_control.go | 0 local/model/state_history.go | 0 local/model/state_history_stats.go | 0 local/model/steam_2fa.go | 0 local/model/steam_credentials.go | 0 local/model/user.go | 0 local/model/websocket.go | 0 local/repository/base.go | 0 local/repository/config.go | 0 local/repository/leaderboard.go | 0 local/repository/lookup.go | 0 local/repository/membership.go | 0 local/repository/repository.go | 0 local/repository/server.go | 0 local/repository/service_control.go | 0 local/repository/state_history.go | 0 local/repository/steam_credentials.go | 0 local/service/config.go | 0 local/service/docker_log_streamer.go | 74 +++++++ local/service/docker_runtime.go | 194 ++++++++++++++++++ local/service/firewall_service.go | 0 local/service/leaderboard.go | 0 local/service/lookup.go | 0 local/service/membership.go | 0 local/service/noop_firewall.go | 7 + local/service/service_manager.go | 0 local/service/state_history.go | 0 local/service/websocket.go | 0 local/service/windows_service.go | 0 local/utl/audit/audit.go | 0 local/utl/cache/cache.go | 0 local/utl/command/callback_executor.go | 0 local/utl/command/callbacks.go | 0 local/utl/command/executor.go | 0 local/utl/command/interactive_executor.go | 0 local/utl/common/common.go | 0 local/utl/configs/configs.go | 0 local/utl/docker/client.go | 12 ++ .../error_handler/controller_error_handler.go | 0 local/utl/errors/safe_error.go | 0 local/utl/graceful/shutdown.go | 0 local/utl/jwt/jwt.go | 0 local/utl/logging/base.go | 0 local/utl/logging/debug.go | 0 local/utl/logging/error.go | 0 local/utl/logging/info.go | 0 local/utl/logging/logger.go | 0 local/utl/logging/warn.go | 0 local/utl/network/port.go | 0 local/utl/password/password.go | 0 local/utl/regex_handler/regex_handler.go | 0 local/utl/security/download_verifier.go | 0 local/utl/security/path_validator.go | 0 local/utl/tracking/log_tailer.go | 0 local/utl/tracking/position_tracker.go | 0 local/utl/tracking/tracking.go | 0 local/utl/websocket/websocket.go | 0 scripts/README.md | 0 scripts/build.ps1 | 0 scripts/deploy.bat | 0 scripts/deploy.config.example | 0 scripts/deploy.ps1 | 0 scripts/generate-secrets.ps1 | 0 scripts/generate-secrets.sh | 0 swagger/docs.go | 0 swagger/swagger.json | 0 swagger/swagger.yaml | 0 tests/auth_helper.go | 0 tests/mocks/auth_middleware_mock.go | 0 tests/mocks/repository_mock.go | 0 tests/mocks/state_history_mock.go | 0 tests/test_helper.go | 0 tests/testdata/state_history_data.go | 0 .../unit/controller/controller_simple_test.go | 0 tests/unit/controller/helper.go | 0 .../state_history_controller_test.go | 0 .../state_history_repository_test.go | 0 tests/unit/service/auth_simple_test.go | 0 tests/unit/service/cache_service_test.go | 0 tests/unit/service/config_service_test.go | 0 .../service/state_history_service_test.go | 0 127 files changed, 564 insertions(+), 10 deletions(-) mode change 100644 => 100755 .env.example mode change 100644 => 100755 .gitea/workflows/release.yml mode change 100644 => 100755 .gitignore create mode 100644 Dockerfile mode change 100644 => 100755 README.md mode change 100644 => 100755 cmd/api/main.go mode change 100644 => 100755 cmd/api/swagger.go mode change 100644 => 100755 cmd/migrate/main.go mode change 100644 => 100755 cmd/steam-crypt/main.go create mode 100644 docker-compose.yml create mode 100644 docker/acc-server/Dockerfile create mode 100644 docker/acc-server/entrypoint.sh mode change 100644 => 100755 docs/API.md mode change 100644 => 100755 docs/CONFIG.md mode change 100644 => 100755 docs/DEPLOYMENT.md mode change 100644 => 100755 docs/MIGRATION.md mode change 100644 => 100755 docs/SETUP.md mode change 100644 => 100755 docs/STEAM_2FA_IMPLEMENTATION.md mode change 100644 => 100755 docs/SWAGGER.md mode change 100644 => 100755 docs/TROUBLESHOOTING.md mode change 100644 => 100755 go.mod mode change 100644 => 100755 go.sum mode change 100644 => 100755 local/api/api.go mode change 100644 => 100755 local/controller/config.go mode change 100644 => 100755 local/controller/controller.go mode change 100644 => 100755 local/controller/leaderboard.go mode change 100644 => 100755 local/controller/lookup.go mode change 100644 => 100755 local/controller/membership.go mode change 100644 => 100755 local/controller/server.go mode change 100644 => 100755 local/controller/service_control.go mode change 100644 => 100755 local/controller/state_history.go mode change 100644 => 100755 local/controller/system.go mode change 100644 => 100755 local/controller/websocket.go mode change 100644 => 100755 local/middleware/access_key.go mode change 100644 => 100755 local/middleware/auth.go mode change 100644 => 100755 local/middleware/logging/request_logging.go mode change 100644 => 100755 local/middleware/security/security.go mode change 100644 => 100755 local/model/cache.go mode change 100644 => 100755 local/model/config.go mode change 100644 => 100755 local/model/filter.go mode change 100644 => 100755 local/model/leaderboard.go mode change 100644 => 100755 local/model/lookup.go mode change 100644 => 100755 local/model/model.go mode change 100644 => 100755 local/model/permission.go mode change 100644 => 100755 local/model/permissions.go mode change 100644 => 100755 local/model/role.go mode change 100644 => 100755 local/model/service_control.go mode change 100644 => 100755 local/model/state_history.go mode change 100644 => 100755 local/model/state_history_stats.go mode change 100644 => 100755 local/model/steam_2fa.go mode change 100644 => 100755 local/model/steam_credentials.go mode change 100644 => 100755 local/model/user.go mode change 100644 => 100755 local/model/websocket.go mode change 100644 => 100755 local/repository/base.go mode change 100644 => 100755 local/repository/config.go mode change 100644 => 100755 local/repository/leaderboard.go mode change 100644 => 100755 local/repository/lookup.go mode change 100644 => 100755 local/repository/membership.go mode change 100644 => 100755 local/repository/repository.go mode change 100644 => 100755 local/repository/server.go mode change 100644 => 100755 local/repository/service_control.go mode change 100644 => 100755 local/repository/state_history.go mode change 100644 => 100755 local/repository/steam_credentials.go mode change 100644 => 100755 local/service/config.go create mode 100644 local/service/docker_log_streamer.go create mode 100644 local/service/docker_runtime.go mode change 100644 => 100755 local/service/firewall_service.go mode change 100644 => 100755 local/service/leaderboard.go mode change 100644 => 100755 local/service/lookup.go mode change 100644 => 100755 local/service/membership.go create mode 100644 local/service/noop_firewall.go mode change 100644 => 100755 local/service/service_manager.go mode change 100644 => 100755 local/service/state_history.go mode change 100644 => 100755 local/service/websocket.go mode change 100644 => 100755 local/service/windows_service.go mode change 100644 => 100755 local/utl/audit/audit.go mode change 100644 => 100755 local/utl/cache/cache.go mode change 100644 => 100755 local/utl/command/callback_executor.go mode change 100644 => 100755 local/utl/command/callbacks.go mode change 100644 => 100755 local/utl/command/executor.go mode change 100644 => 100755 local/utl/command/interactive_executor.go mode change 100644 => 100755 local/utl/common/common.go mode change 100644 => 100755 local/utl/configs/configs.go create mode 100644 local/utl/docker/client.go mode change 100644 => 100755 local/utl/error_handler/controller_error_handler.go mode change 100644 => 100755 local/utl/errors/safe_error.go mode change 100644 => 100755 local/utl/graceful/shutdown.go mode change 100644 => 100755 local/utl/jwt/jwt.go mode change 100644 => 100755 local/utl/logging/base.go mode change 100644 => 100755 local/utl/logging/debug.go mode change 100644 => 100755 local/utl/logging/error.go mode change 100644 => 100755 local/utl/logging/info.go mode change 100644 => 100755 local/utl/logging/logger.go mode change 100644 => 100755 local/utl/logging/warn.go mode change 100644 => 100755 local/utl/network/port.go mode change 100644 => 100755 local/utl/password/password.go mode change 100644 => 100755 local/utl/regex_handler/regex_handler.go mode change 100644 => 100755 local/utl/security/download_verifier.go mode change 100644 => 100755 local/utl/security/path_validator.go mode change 100644 => 100755 local/utl/tracking/log_tailer.go mode change 100644 => 100755 local/utl/tracking/position_tracker.go mode change 100644 => 100755 local/utl/tracking/tracking.go mode change 100644 => 100755 local/utl/websocket/websocket.go mode change 100644 => 100755 scripts/README.md mode change 100644 => 100755 scripts/build.ps1 mode change 100644 => 100755 scripts/deploy.bat mode change 100644 => 100755 scripts/deploy.config.example mode change 100644 => 100755 scripts/deploy.ps1 mode change 100644 => 100755 scripts/generate-secrets.ps1 mode change 100644 => 100755 scripts/generate-secrets.sh mode change 100644 => 100755 swagger/docs.go mode change 100644 => 100755 swagger/swagger.json mode change 100644 => 100755 swagger/swagger.yaml mode change 100644 => 100755 tests/auth_helper.go mode change 100644 => 100755 tests/mocks/auth_middleware_mock.go mode change 100644 => 100755 tests/mocks/repository_mock.go mode change 100644 => 100755 tests/mocks/state_history_mock.go mode change 100644 => 100755 tests/test_helper.go mode change 100644 => 100755 tests/testdata/state_history_data.go mode change 100644 => 100755 tests/unit/controller/controller_simple_test.go mode change 100644 => 100755 tests/unit/controller/helper.go mode change 100644 => 100755 tests/unit/controller/state_history_controller_test.go mode change 100644 => 100755 tests/unit/repository/state_history_repository_test.go mode change 100644 => 100755 tests/unit/service/auth_simple_test.go mode change 100644 => 100755 tests/unit/service/cache_service_test.go mode change 100644 => 100755 tests/unit/service/config_service_test.go mode change 100644 => 100755 tests/unit/service/state_history_service_test.go diff --git a/.env.example b/.env.example old mode 100644 new mode 100755 index 7592a3a..ab37138 --- a/.env.example +++ b/.env.example @@ -50,6 +50,34 @@ PASSWORD=change-this-default-admin-password # 4. NEVER commit actual secrets to version control! +# ============================================================================= +# PLATFORM SELECTION +# ============================================================================= + +# Platform mode: "windows" (default) or "docker" +# PLATFORM=windows + +# ============================================================================= +# DOCKER MODE SETTINGS (required when PLATFORM=docker) +# ============================================================================= + +# 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 + +# Port pool for Docker-mode ACC server containers (one port per server). +# PORT_POOL_MIN=9600 +# PORT_POOL_MAX=9700 + +# Linux SteamCMD binary path (pre-installed in the manager Docker image). +# STEAMCMD_LINUX_PATH=/usr/games/steamcmd + # ============================================================================= # OPTIONAL SETTINGS (These are handled by system config in database) # ============================================================================= diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml old mode 100644 new mode 100755 diff --git a/.gitignore b/.gitignore old mode 100644 new mode 100755 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ff2b854 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,64 @@ +# Multi-stage build for the acc-server-manager Go backend. +# CGO_ENABLED=1 is required for mattn/go-sqlite3 (uses cgo). +# The binary is compiled for Linux (Docker mode default). + +# ─── Builder ────────────────────────────────────────────────────────────────── +FROM golang:1.25-bookworm AS builder + +WORKDIR /build + +# Install build dependencies for CGO + sqlite3. +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + libc6-dev \ + && rm -rf /var/lib/apt/lists/* + +# Download Go modules first (layer cache). +COPY go.mod go.sum ./ +RUN go mod download + +# Copy source and build. +COPY . . +RUN CGO_ENABLED=1 GOOS=linux GOARCH=amd64 \ + go build -ldflags="-s -w" -o /acc-server-manager ./cmd/api + +# ─── Runtime ────────────────────────────────────────────────────────────────── +FROM debian:bookworm-slim AS runtime + +# Install runtime dependencies and SteamCMD. +# steamcmd is not in the standard Debian repo — download directly from Valve. +RUN dpkg --add-architecture i386 && \ + apt-get update && \ + apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + lib32gcc-s1 \ + && \ + rm -rf /var/lib/apt/lists/* + +RUN mkdir -p /usr/games && \ + curl -sSL --retry 3 --retry-delay 5 \ + -o /tmp/steamcmd.tar.gz \ + "https://steamcdn-a.akamaihd.net/client/installer/steamcmd_linux.tar.gz" && \ + tar -xzf /tmp/steamcmd.tar.gz -C /usr/games && \ + rm /tmp/steamcmd.tar.gz && \ + chmod +x /usr/games/steamcmd.sh && \ + ln -sf /usr/games/steamcmd.sh /usr/games/steamcmd + +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 + +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 + +EXPOSE 3000 + +ENTRYPOINT ["/app/acc-server-manager"] diff --git a/README.md b/README.md old mode 100644 new mode 100755 diff --git a/cmd/api/main.go b/cmd/api/main.go old mode 100644 new mode 100755 diff --git a/cmd/api/swagger.go b/cmd/api/swagger.go old mode 100644 new mode 100755 diff --git a/cmd/migrate/main.go b/cmd/migrate/main.go old mode 100644 new mode 100755 diff --git a/cmd/steam-crypt/main.go b/cmd/steam-crypt/main.go old mode 100644 new mode 100755 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8d06305 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,37 @@ +services: + manager: + build: + context: . + dockerfile: Dockerfile + image: acc-server-manager:latest + container_name: acc-server-manager + restart: unless-stopped + ports: + - "3000:3000" + 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 + env_file: + - .env + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - acc-game:/data/acc-game + - acc-configs:/data/configs + - acc-db:/data/db + networks: + - acc-net + +volumes: + acc-game: + acc-configs: + acc-db: + +networks: + acc-net: + driver: bridge diff --git a/docker/acc-server/Dockerfile b/docker/acc-server/Dockerfile new file mode 100644 index 0000000..5a8001a --- /dev/null +++ b/docker/acc-server/Dockerfile @@ -0,0 +1,41 @@ +# ACC Server container: Wine + accServer.exe +# Game files are mounted read-only from the shared acc-game volume. +# Per-server config files are mounted read-write from the manager's config volume. +FROM debian:bookworm-slim + +# Install Wine, Xvfb (headless display for Wine), and minimal runtime deps. +RUN dpkg --add-architecture i386 && \ + apt-get update && \ + apt-get install -y --no-install-recommends \ + wine \ + wine32 \ + wine64 \ + xvfb \ + ca-certificates \ + && \ + rm -rf /var/lib/apt/lists/* + +# Create a non-root user for running the server. +RUN useradd -ms /bin/bash accserver + +WORKDIR /home/accserver + +# Copy entrypoint script. +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"] + +# Default server port — overridden at runtime via SERVER_PORT env var. +ENV SERVER_PORT=9600 +ENV WINEDEBUG=-all +ENV DISPLAY=:99 + +EXPOSE ${SERVER_PORT}/tcp +EXPOSE ${SERVER_PORT}/udp + +USER accserver +ENTRYPOINT ["/entrypoint.sh"] diff --git a/docker/acc-server/entrypoint.sh b/docker/acc-server/entrypoint.sh new file mode 100644 index 0000000..ed2db8b --- /dev/null +++ b/docker/acc-server/entrypoint.sh @@ -0,0 +1,17 @@ +#!/bin/bash +set -e + +Xvfb :99 -screen 0 1024x768x16 & +XVFB_PID=$! + +cleanup() { + echo "Stopping ACC server..." + kill $XVFB_PID 2>/dev/null || true +} +trap cleanup SIGTERM SIGINT + +echo "Starting ACC server on port ${SERVER_PORT}..." + +exec wine /acc/game/server/accServer.exe \ + -serverCfgPath /acc/cfg \ + -port "${SERVER_PORT}" diff --git a/docs/API.md b/docs/API.md old mode 100644 new mode 100755 diff --git a/docs/CONFIG.md b/docs/CONFIG.md old mode 100644 new mode 100755 diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md old mode 100644 new mode 100755 diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md old mode 100644 new mode 100755 diff --git a/docs/SETUP.md b/docs/SETUP.md old mode 100644 new mode 100755 diff --git a/docs/STEAM_2FA_IMPLEMENTATION.md b/docs/STEAM_2FA_IMPLEMENTATION.md old mode 100644 new mode 100755 diff --git a/docs/SWAGGER.md b/docs/SWAGGER.md old mode 100644 new mode 100755 diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md old mode 100644 new mode 100755 diff --git a/go.mod b/go.mod old mode 100644 new mode 100755 index 13282e2..50acfa1 --- a/go.mod +++ b/go.mod @@ -1,15 +1,19 @@ module acc-server-manager -go 1.23.0 +go 1.25.0 require ( + github.com/docker/docker v24.0.9+incompatible + github.com/docker/go-connections v0.6.0 github.com/gofiber/fiber/v2 v2.52.8 github.com/gofiber/swagger v1.1.0 + github.com/gofiber/websocket/v2 v2.2.1 github.com/golang-jwt/jwt/v4 v4.5.2 github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 github.com/qjebbs/go-jsons v0.0.0-20221222033332-a534c5fc1c4c github.com/swaggo/swag v1.16.3 + github.com/valyala/fasthttp v1.51.0 go.uber.org/dig v1.17.1 golang.org/x/crypto v0.39.0 golang.org/x/sync v0.15.0 @@ -20,13 +24,17 @@ require ( require ( github.com/KyleBanks/depth v1.2.1 // indirect + github.com/Microsoft/go-winio v0.4.21 // indirect github.com/andybalholm/brotli v1.1.0 // indirect + github.com/docker/distribution v2.7.1+incompatible // indirect + github.com/docker/go-units v0.5.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/gogo/protobuf v1.3.2 // indirect + github.com/google/go-cmp v0.7.0 // 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 @@ -36,13 +44,21 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mattn/go-sqlite3 v1.14.22 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/morikuni/aec v1.1.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/rivo/uniseg v0.2.0 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee // indirect + github.com/stretchr/testify v1.11.1 // 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 github.com/valyala/tcplisten v1.0.0 // indirect - golang.org/x/sys v0.33.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/time v0.9.0 // indirect golang.org/x/tools v0.33.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + gotest.tools/v3 v3.5.2 // indirect ) diff --git a/go.sum b/go.sum old mode 100644 new mode 100755 index ceaa612..563e7b0 --- a/go.sum +++ b/go.sum @@ -1,9 +1,21 @@ +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/Microsoft/go-winio v0.4.21 h1:+6mVbXh4wPzUrl1COX9A+ZCvEpYsOBZ6/+kwDnvLyro= +github.com/Microsoft/go-winio v0.4.21/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84= github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= 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/docker/distribution v2.7.1+incompatible h1:a5mlkVzth6W5A4fOsS3D2EO5BUmsJpcB+cRlLU7cSug= +github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= +github.com/docker/docker v24.0.9+incompatible h1:HPGzNmwfLZWdxHqK9/II92pyi1EpYKsAqcl4G0Of9v0= +github.com/docker/docker v24.0.9+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= 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= @@ -20,8 +32,12 @@ 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/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 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/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= @@ -32,6 +48,8 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -49,18 +67,31 @@ github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6T github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ= +github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/qjebbs/go-jsons v0.0.0-20221222033332-a534c5fc1c4c h1:kmzxiX+OB0knCo1V0dkEkdPelzCdAzCURCfmFArn2/A= github.com/qjebbs/go-jsons v0.0.0-20221222033332-a534c5fc1c4c/go.mod h1:wNJrtinHyC3YSf6giEh4FJN8+yZV7nXBjvmfjhBIcw4= 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/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 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/sirupsen/logrus v1.7.0 h1:ShrD1U9pZB12TX0cVy0DtePoCH97K8EtX+mg7ZARUtM= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/swaggo/files/v2 v2.0.0 h1:hmAt8Dkynw7Ssz46F6pn8ok6YmGZqHSVLZ+HQM7i0kw= github.com/swaggo/files/v2 v2.0.0/go.mod h1:24kk2Y9NYEJ5lHuCra6iVwkMjIekMCaFq/0JQj66kyM= github.com/swaggo/swag v1.16.3 h1:PnCYjPCah8FK4I26l2F/KQ4yz3sILcVUN3cTlBFA9Pg= @@ -71,22 +102,53 @@ github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1S github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= go.uber.org/dig v1.17.1 h1:Tga8Lz8PcYNsWsyHMZ1Vm0OQOUaJNDyvPImgbAu9YSc= go.uber.org/dig v1.17.1/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= +golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= @@ -96,3 +158,5 @@ gorm.io/driver/sqlite v1.5.6 h1:fO/X46qn5NUEEOZtnjJRWRzZMe8nqJiQ9E+0hi+hKQE= gorm.io/driver/sqlite v1.5.6/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4= gorm.io/gorm v1.25.11 h1:/Wfyg1B/je1hnDx3sMkX+gAlxrlZpn6X0BXRlwXlvHg= gorm.io/gorm v1.25.11/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= diff --git a/local/api/api.go b/local/api/api.go old mode 100644 new mode 100755 diff --git a/local/controller/config.go b/local/controller/config.go old mode 100644 new mode 100755 diff --git a/local/controller/controller.go b/local/controller/controller.go old mode 100644 new mode 100755 diff --git a/local/controller/leaderboard.go b/local/controller/leaderboard.go old mode 100644 new mode 100755 diff --git a/local/controller/lookup.go b/local/controller/lookup.go old mode 100644 new mode 100755 diff --git a/local/controller/membership.go b/local/controller/membership.go old mode 100644 new mode 100755 diff --git a/local/controller/server.go b/local/controller/server.go old mode 100644 new mode 100755 diff --git a/local/controller/service_control.go b/local/controller/service_control.go old mode 100644 new mode 100755 diff --git a/local/controller/state_history.go b/local/controller/state_history.go old mode 100644 new mode 100755 diff --git a/local/controller/system.go b/local/controller/system.go old mode 100644 new mode 100755 diff --git a/local/controller/websocket.go b/local/controller/websocket.go old mode 100644 new mode 100755 diff --git a/local/middleware/access_key.go b/local/middleware/access_key.go old mode 100644 new mode 100755 diff --git a/local/middleware/auth.go b/local/middleware/auth.go old mode 100644 new mode 100755 diff --git a/local/middleware/logging/request_logging.go b/local/middleware/logging/request_logging.go old mode 100644 new mode 100755 diff --git a/local/middleware/security/security.go b/local/middleware/security/security.go old mode 100644 new mode 100755 diff --git a/local/model/cache.go b/local/model/cache.go old mode 100644 new mode 100755 diff --git a/local/model/config.go b/local/model/config.go old mode 100644 new mode 100755 diff --git a/local/model/filter.go b/local/model/filter.go old mode 100644 new mode 100755 diff --git a/local/model/leaderboard.go b/local/model/leaderboard.go old mode 100644 new mode 100755 diff --git a/local/model/lookup.go b/local/model/lookup.go old mode 100644 new mode 100755 diff --git a/local/model/model.go b/local/model/model.go old mode 100644 new mode 100755 diff --git a/local/model/permission.go b/local/model/permission.go old mode 100644 new mode 100755 diff --git a/local/model/permissions.go b/local/model/permissions.go old mode 100644 new mode 100755 diff --git a/local/model/role.go b/local/model/role.go old mode 100644 new mode 100755 diff --git a/local/model/service_control.go b/local/model/service_control.go old mode 100644 new mode 100755 diff --git a/local/model/state_history.go b/local/model/state_history.go old mode 100644 new mode 100755 diff --git a/local/model/state_history_stats.go b/local/model/state_history_stats.go old mode 100644 new mode 100755 diff --git a/local/model/steam_2fa.go b/local/model/steam_2fa.go old mode 100644 new mode 100755 diff --git a/local/model/steam_credentials.go b/local/model/steam_credentials.go old mode 100644 new mode 100755 diff --git a/local/model/user.go b/local/model/user.go old mode 100644 new mode 100755 diff --git a/local/model/websocket.go b/local/model/websocket.go old mode 100644 new mode 100755 diff --git a/local/repository/base.go b/local/repository/base.go old mode 100644 new mode 100755 diff --git a/local/repository/config.go b/local/repository/config.go old mode 100644 new mode 100755 diff --git a/local/repository/leaderboard.go b/local/repository/leaderboard.go old mode 100644 new mode 100755 diff --git a/local/repository/lookup.go b/local/repository/lookup.go old mode 100644 new mode 100755 diff --git a/local/repository/membership.go b/local/repository/membership.go old mode 100644 new mode 100755 diff --git a/local/repository/repository.go b/local/repository/repository.go old mode 100644 new mode 100755 diff --git a/local/repository/server.go b/local/repository/server.go old mode 100644 new mode 100755 diff --git a/local/repository/service_control.go b/local/repository/service_control.go old mode 100644 new mode 100755 diff --git a/local/repository/state_history.go b/local/repository/state_history.go old mode 100644 new mode 100755 diff --git a/local/repository/steam_credentials.go b/local/repository/steam_credentials.go old mode 100644 new mode 100755 diff --git a/local/service/config.go b/local/service/config.go old mode 100644 new mode 100755 diff --git a/local/service/docker_log_streamer.go b/local/service/docker_log_streamer.go new file mode 100644 index 0000000..e84ad40 --- /dev/null +++ b/local/service/docker_log_streamer.go @@ -0,0 +1,74 @@ +package service + +import ( + "acc-server-manager/local/model" + "acc-server-manager/local/utl/logging" + "bufio" + "context" + "io" + "sync" + + "github.com/docker/docker/api/types" + dockerclient "github.com/docker/docker/client" + "github.com/docker/docker/pkg/stdcopy" + "github.com/google/uuid" +) + +type DockerLogStreamer struct { + client *dockerclient.Client + cancels sync.Map // uuid.UUID → context.CancelFunc +} + +func NewDockerLogStreamer(client *dockerclient.Client) *DockerLogStreamer { + return &DockerLogStreamer{client: client} +} + +func (s *DockerLogStreamer) Start(ctx context.Context, server *model.Server, handleLine func(string)) error { + if _, exists := s.cancels.Load(server.ID); exists { + return nil + } + if server.ContainerID == "" { + logging.Warn("DockerLogStreamer.Start: no container ID for server %s, skipping", server.ID) + return nil + } + + streamCtx, cancel := context.WithCancel(ctx) + s.cancels.Store(server.ID, cancel) + + go func() { + defer s.cancels.Delete(server.ID) + + rc, err := s.client.ContainerLogs(streamCtx, server.ContainerID, types.ContainerLogsOptions{ + ShowStdout: true, + ShowStderr: true, + Follow: true, + Timestamps: false, + }) + if err != nil { + logging.Error("DockerLogStreamer: failed to attach to container %s: %v", server.ContainerID[:12], err) + return + } + defer rc.Close() + + pr, pw := io.Pipe() + go func() { + defer pw.Close() + if _, err := stdcopy.StdCopy(pw, pw, rc); err != nil && err != io.EOF { + logging.Warn("DockerLogStreamer: stdcopy error for %s: %v", server.ServiceName, err) + } + }() + + scanner := bufio.NewScanner(pr) + for scanner.Scan() { + handleLine(scanner.Text()) + } + }() + + return nil +} + +func (s *DockerLogStreamer) Stop(serverID uuid.UUID) { + if v, ok := s.cancels.LoadAndDelete(serverID); ok { + v.(context.CancelFunc)() + } +} diff --git a/local/service/docker_runtime.go b/local/service/docker_runtime.go new file mode 100644 index 0000000..7b37544 --- /dev/null +++ b/local/service/docker_runtime.go @@ -0,0 +1,194 @@ +package service + +import ( + "acc-server-manager/local/model" + "acc-server-manager/local/repository" + "acc-server-manager/local/utl/env" + "acc-server-manager/local/utl/logging" + "context" + "fmt" + "strconv" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/filters" + dockerclient "github.com/docker/docker/client" + "github.com/docker/go-connections/nat" + "github.com/google/uuid" +) + +type DockerRuntime struct { + client *dockerclient.Client + repo *repository.ServerRepository +} + +func NewDockerRuntime(client *dockerclient.Client, repo *repository.ServerRepository) *DockerRuntime { + return &DockerRuntime{ + client: client, + repo: repo, + } +} + +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 { + return fmt.Errorf("invalid port %d: %v", server.Port, err) + } + natPortUDP, err := nat.NewPort("udp", portStr) + if err != nil { + return fmt.Errorf("invalid udp port %d: %v", server.Port, err) + } + + portBindings := nat.PortMap{ + natPort: []nat.PortBinding{{HostPort: portStr}}, + natPortUDP: []nat.PortBinding{{HostPort: portStr}}, + } + exposedPorts := nat.PortSet{ + natPort: struct{}{}, + natPortUDP: struct{}{}, + } + + binds := []string{ + fmt.Sprintf("%s:/acc/game:ro", gameVolume), + fmt.Sprintf("%s:/acc/cfg:rw", configPath), + } + + resp, err := r.client.ContainerCreate(ctx, &container.Config{ + Image: image, + ExposedPorts: exposedPorts, + Env: []string{ + fmt.Sprintf("SERVER_PORT=%d", server.Port), + }, + }, &container.HostConfig{ + Binds: binds, + PortBindings: portBindings, + RestartPolicy: container.RestartPolicy{Name: "unless-stopped"}, + }, nil, nil, server.ServiceName) + if err != nil { + return fmt.Errorf("failed to create container %s: %v", server.ServiceName, err) + } + + server.ContainerID = resp.ID + logging.Info("Created Docker container %s (ID: %s) for server %s", server.ServiceName, resp.ID[:12], server.ID) + return nil +} + +func (r *DockerRuntime) Delete(ctx context.Context, serverID uuid.UUID) error { + containerID, err := r.resolveContainerID(ctx, serverID) + if err != nil { + return err + } + if containerID == "" { + return nil + } + + err = r.client.ContainerRemove(ctx, containerID, types.ContainerRemoveOptions{ + Force: true, + RemoveVolumes: false, + }) + if err != nil { + return fmt.Errorf("failed to remove container %s: %v", containerID[:12], err) + } + logging.Info("Removed Docker container %s for server %s", containerID[:12], serverID) + return nil +} + +func (r *DockerRuntime) Start(ctx context.Context, serverID uuid.UUID) (string, error) { + containerID, err := r.resolveContainerID(ctx, serverID) + if err != nil { + return "", err + } + if err := r.client.ContainerStart(ctx, containerID, types.ContainerStartOptions{}); err != nil { + return "", fmt.Errorf("failed to start container: %v", err) + } + return model.StatusRunning.String(), nil +} + +func (r *DockerRuntime) Stop(ctx context.Context, serverID uuid.UUID) (string, error) { + containerID, err := r.resolveContainerID(ctx, serverID) + if err != nil { + return "", err + } + if err := r.client.ContainerStop(ctx, containerID, container.StopOptions{}); err != nil { + return "", fmt.Errorf("failed to stop container: %v", err) + } + return model.StatusStopped.String(), nil +} + +func (r *DockerRuntime) Restart(ctx context.Context, serverID uuid.UUID) (string, error) { + containerID, err := r.resolveContainerID(ctx, serverID) + if err != nil { + return "", err + } + if err := r.client.ContainerRestart(ctx, containerID, container.StopOptions{}); err != nil { + return "", fmt.Errorf("failed to restart container: %v", err) + } + return model.StatusRunning.String(), nil +} + +func (r *DockerRuntime) Status(ctx context.Context, serverID uuid.UUID) (string, error) { + server, err := r.repo.GetByID(ctx, serverID) + if err != nil || server == nil { + return model.StatusUnknown.String(), err + } + + containerID := server.ContainerID + if containerID == "" { + containerID, err = r.findContainerByName(ctx, server.ServiceName) + if err != nil || containerID == "" { + return model.StatusStopped.String(), nil + } + } + + info, err := r.client.ContainerInspect(ctx, containerID) + if err != nil { + return model.StatusUnknown.String(), nil + } + + return dockerStateToStatus(info.State.Status), nil +} + +func (r *DockerRuntime) resolveContainerID(ctx context.Context, serverID uuid.UUID) (string, error) { + server, err := r.repo.GetByID(ctx, serverID) + if err != nil { + return "", fmt.Errorf("failed to look up server %s: %v", serverID, err) + } + if server == nil { + return "", fmt.Errorf("server %s not found", serverID) + } + return server.ContainerID, nil +} + +func (r *DockerRuntime) findContainerByName(ctx context.Context, name string) (string, error) { + containers, err := r.client.ContainerList(ctx, types.ContainerListOptions{ + All: true, + Filters: filters.NewArgs(filters.Arg("name", "/"+name)), + }) + if err != nil { + return "", err + } + if len(containers) == 0 { + return "", nil + } + return containers[0].ID, nil +} + +func dockerStateToStatus(state string) string { + switch state { + case "running": + return model.StatusRunning.String() + case "exited", "dead": + return model.StatusStopped.String() + case "restarting": + return model.StatusRestarting.String() + case "paused": + return model.StatusStopped.String() + default: + return model.StatusUnknown.String() + } +} diff --git a/local/service/firewall_service.go b/local/service/firewall_service.go old mode 100644 new mode 100755 diff --git a/local/service/leaderboard.go b/local/service/leaderboard.go old mode 100644 new mode 100755 diff --git a/local/service/lookup.go b/local/service/lookup.go old mode 100644 new mode 100755 diff --git a/local/service/membership.go b/local/service/membership.go old mode 100644 new mode 100755 diff --git a/local/service/noop_firewall.go b/local/service/noop_firewall.go new file mode 100644 index 0000000..e57e598 --- /dev/null +++ b/local/service/noop_firewall.go @@ -0,0 +1,7 @@ +package service + +type NoOpFirewall struct{} + +func (f *NoOpFirewall) CreateServerRules(_ string, _, _ []int) error { return nil } +func (f *NoOpFirewall) DeleteServerRules(_ string, _, _ []int) error { return nil } +func (f *NoOpFirewall) UpdateServerRules(_ string, _, _ []int) error { return nil } diff --git a/local/service/service_manager.go b/local/service/service_manager.go old mode 100644 new mode 100755 diff --git a/local/service/state_history.go b/local/service/state_history.go old mode 100644 new mode 100755 diff --git a/local/service/websocket.go b/local/service/websocket.go old mode 100644 new mode 100755 diff --git a/local/service/windows_service.go b/local/service/windows_service.go old mode 100644 new mode 100755 diff --git a/local/utl/audit/audit.go b/local/utl/audit/audit.go old mode 100644 new mode 100755 diff --git a/local/utl/cache/cache.go b/local/utl/cache/cache.go old mode 100644 new mode 100755 diff --git a/local/utl/command/callback_executor.go b/local/utl/command/callback_executor.go old mode 100644 new mode 100755 diff --git a/local/utl/command/callbacks.go b/local/utl/command/callbacks.go old mode 100644 new mode 100755 diff --git a/local/utl/command/executor.go b/local/utl/command/executor.go old mode 100644 new mode 100755 diff --git a/local/utl/command/interactive_executor.go b/local/utl/command/interactive_executor.go old mode 100644 new mode 100755 diff --git a/local/utl/common/common.go b/local/utl/common/common.go old mode 100644 new mode 100755 diff --git a/local/utl/configs/configs.go b/local/utl/configs/configs.go old mode 100644 new mode 100755 diff --git a/local/utl/docker/client.go b/local/utl/docker/client.go new file mode 100644 index 0000000..87be22f --- /dev/null +++ b/local/utl/docker/client.go @@ -0,0 +1,12 @@ +package docker + +import ( + dockerclient "github.com/docker/docker/client" +) + +func NewDockerClient() (*dockerclient.Client, error) { + return dockerclient.NewClientWithOpts( + dockerclient.FromEnv, + dockerclient.WithAPIVersionNegotiation(), + ) +} diff --git a/local/utl/error_handler/controller_error_handler.go b/local/utl/error_handler/controller_error_handler.go old mode 100644 new mode 100755 diff --git a/local/utl/errors/safe_error.go b/local/utl/errors/safe_error.go old mode 100644 new mode 100755 diff --git a/local/utl/graceful/shutdown.go b/local/utl/graceful/shutdown.go old mode 100644 new mode 100755 diff --git a/local/utl/jwt/jwt.go b/local/utl/jwt/jwt.go old mode 100644 new mode 100755 diff --git a/local/utl/logging/base.go b/local/utl/logging/base.go old mode 100644 new mode 100755 diff --git a/local/utl/logging/debug.go b/local/utl/logging/debug.go old mode 100644 new mode 100755 diff --git a/local/utl/logging/error.go b/local/utl/logging/error.go old mode 100644 new mode 100755 diff --git a/local/utl/logging/info.go b/local/utl/logging/info.go old mode 100644 new mode 100755 diff --git a/local/utl/logging/logger.go b/local/utl/logging/logger.go old mode 100644 new mode 100755 diff --git a/local/utl/logging/warn.go b/local/utl/logging/warn.go old mode 100644 new mode 100755 diff --git a/local/utl/network/port.go b/local/utl/network/port.go old mode 100644 new mode 100755 diff --git a/local/utl/password/password.go b/local/utl/password/password.go old mode 100644 new mode 100755 diff --git a/local/utl/regex_handler/regex_handler.go b/local/utl/regex_handler/regex_handler.go old mode 100644 new mode 100755 diff --git a/local/utl/security/download_verifier.go b/local/utl/security/download_verifier.go old mode 100644 new mode 100755 diff --git a/local/utl/security/path_validator.go b/local/utl/security/path_validator.go old mode 100644 new mode 100755 diff --git a/local/utl/tracking/log_tailer.go b/local/utl/tracking/log_tailer.go old mode 100644 new mode 100755 diff --git a/local/utl/tracking/position_tracker.go b/local/utl/tracking/position_tracker.go old mode 100644 new mode 100755 diff --git a/local/utl/tracking/tracking.go b/local/utl/tracking/tracking.go old mode 100644 new mode 100755 diff --git a/local/utl/websocket/websocket.go b/local/utl/websocket/websocket.go old mode 100644 new mode 100755 diff --git a/scripts/README.md b/scripts/README.md old mode 100644 new mode 100755 diff --git a/scripts/build.ps1 b/scripts/build.ps1 old mode 100644 new mode 100755 diff --git a/scripts/deploy.bat b/scripts/deploy.bat old mode 100644 new mode 100755 diff --git a/scripts/deploy.config.example b/scripts/deploy.config.example old mode 100644 new mode 100755 diff --git a/scripts/deploy.ps1 b/scripts/deploy.ps1 old mode 100644 new mode 100755 diff --git a/scripts/generate-secrets.ps1 b/scripts/generate-secrets.ps1 old mode 100644 new mode 100755 diff --git a/scripts/generate-secrets.sh b/scripts/generate-secrets.sh old mode 100644 new mode 100755 diff --git a/swagger/docs.go b/swagger/docs.go old mode 100644 new mode 100755 diff --git a/swagger/swagger.json b/swagger/swagger.json old mode 100644 new mode 100755 diff --git a/swagger/swagger.yaml b/swagger/swagger.yaml old mode 100644 new mode 100755 diff --git a/tests/auth_helper.go b/tests/auth_helper.go old mode 100644 new mode 100755 diff --git a/tests/mocks/auth_middleware_mock.go b/tests/mocks/auth_middleware_mock.go old mode 100644 new mode 100755 diff --git a/tests/mocks/repository_mock.go b/tests/mocks/repository_mock.go old mode 100644 new mode 100755 diff --git a/tests/mocks/state_history_mock.go b/tests/mocks/state_history_mock.go old mode 100644 new mode 100755 diff --git a/tests/test_helper.go b/tests/test_helper.go old mode 100644 new mode 100755 diff --git a/tests/testdata/state_history_data.go b/tests/testdata/state_history_data.go old mode 100644 new mode 100755 diff --git a/tests/unit/controller/controller_simple_test.go b/tests/unit/controller/controller_simple_test.go old mode 100644 new mode 100755 diff --git a/tests/unit/controller/helper.go b/tests/unit/controller/helper.go old mode 100644 new mode 100755 diff --git a/tests/unit/controller/state_history_controller_test.go b/tests/unit/controller/state_history_controller_test.go old mode 100644 new mode 100755 diff --git a/tests/unit/repository/state_history_repository_test.go b/tests/unit/repository/state_history_repository_test.go old mode 100644 new mode 100755 diff --git a/tests/unit/service/auth_simple_test.go b/tests/unit/service/auth_simple_test.go old mode 100644 new mode 100755 diff --git a/tests/unit/service/cache_service_test.go b/tests/unit/service/cache_service_test.go old mode 100644 new mode 100755 diff --git a/tests/unit/service/config_service_test.go b/tests/unit/service/config_service_test.go old mode 100644 new mode 100755 diff --git a/tests/unit/service/state_history_service_test.go b/tests/unit/service/state_history_service_test.go old mode 100644 new mode 100755 From d4b16d80f3f25924e3e9e77f2f3e97d6e2bb30f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=20Jurmanovi=C4=87?= Date: Thu, 19 Mar 2026 00:25:25 +0100 Subject: [PATCH 06/21] resolve server creation issues --- .env.example | 9 +-- Dockerfile | 5 +- docker-compose.yml | 9 +-- docker/acc-server/Dockerfile | 6 +- docker/acc-server/entrypoint.sh | 3 +- local/api/api.go | 1 + local/controller/controller.go | 5 ++ local/controller/steam.go | 57 +++++++++++++++++++ local/middleware/auth.go | 17 ++++++ local/service/docker_runtime.go | 6 +- local/service/server.go | 10 +--- local/service/steam_service.go | 24 ++++++-- local/service/websocket.go | 13 ++++- local/utl/command/callback_executor.go | 49 ++++++++-------- local/utl/command/interactive_executor.go | 69 ++++++++--------------- local/utl/common/common.go | 1 + local/utl/env/env.go | 16 ++---- 17 files changed, 179 insertions(+), 121 deletions(-) create mode 100644 local/controller/steam.go diff --git a/.env.example b/.env.example index ab37138..0897b48 100755 --- a/.env.example +++ b/.env.example @@ -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// +# ACC_SERVERS_PATH=/data/servers # Port pool for Docker-mode ACC server containers (one port per server). # PORT_POOL_MIN=9600 diff --git a/Dockerfile b/Dockerfile index ff2b854..4354d63 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 8d06305..45e7260 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/docker/acc-server/Dockerfile b/docker/acc-server/Dockerfile index 5a8001a..861bdb2 100644 --- a/docker/acc-server/Dockerfile +++ b/docker/acc-server/Dockerfile @@ -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 diff --git a/docker/acc-server/entrypoint.sh b/docker/acc-server/entrypoint.sh index ed2db8b..f3ce7f0 100644 --- a/docker/acc-server/entrypoint.sh +++ b/docker/acc-server/entrypoint.sh @@ -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}" diff --git a/local/api/api.go b/local/api/api.go index f8b6114..0f3f775 100755 --- a/local/api/api.go +++ b/local/api/api.go @@ -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() diff --git a/local/controller/controller.go b/local/controller/controller.go index 96c5692..79af86b 100755 --- a/local/controller/controller.go +++ b/local/controller/controller.go @@ -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") + } } diff --git a/local/controller/steam.go b/local/controller/steam.go new file mode 100644 index 0000000..16832da --- /dev/null +++ b/local/controller/steam.go @@ -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}) +} diff --git a/local/middleware/auth.go b/local/middleware/auth.go index 467d966..7abc5fb 100755 --- a/local/middleware/auth.go +++ b/local/middleware/auth.go @@ -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() } diff --git a/local/service/docker_runtime.go b/local/service/docker_runtime.go index 7b37544..5e2b6e9 100644 --- a/local/service/docker_runtime.go +++ b/local/service/docker_runtime.go @@ -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{ diff --git a/local/service/server.go b/local/service/server.go index 2cf5555..b74ecdd 100755 --- a/local/service/server.go +++ b/local/service/server.go @@ -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 diff --git a/local/service/steam_service.go b/local/service/steam_service.go index 2af06e4..b14e113 100755 --- a/local/service/steam_service.go +++ b/local/service/steam_service.go @@ -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", diff --git a/local/service/websocket.go b/local/service/websocket.go index 9ecacca..1f2cbd2 100755 --- a/local/service/websocket.go +++ b/local/service/websocket.go @@ -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 { diff --git a/local/utl/command/callback_executor.go b/local/utl/command/callback_executor.go index 2125317..6547810 100755 --- a/local/utl/command/callback_executor.go +++ b/local/utl/command/callback_executor.go @@ -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) + } + }() } } } diff --git a/local/utl/command/interactive_executor.go b/local/utl/command/interactive_executor.go index 1e6ff47..f3bd369 100755 --- a/local/utl/command/interactive_executor.go +++ b/local/utl/command/interactive_executor.go @@ -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 } diff --git a/local/utl/common/common.go b/local/utl/common/common.go index 8e94bbe..0ad0917 100755 --- a/local/utl/common/common.go +++ b/local/utl/common/common.go @@ -27,6 +27,7 @@ type RouteGroups struct { System fiber.Router WebSocket fiber.Router Leaderboard fiber.Router + Steam fiber.Router } func CheckError(err error) { diff --git a/local/utl/env/env.go b/local/utl/env/env.go index d1e50cd..f258c0a 100755 --- a/local/utl/env/env.go +++ b/local/utl/env/env.go @@ -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 { From c03234d33901845cf02672db47f74aa94256f2ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=20Jurmanovi=C4=87?= Date: Thu, 19 Mar 2026 18:24:19 +0100 Subject: [PATCH 07/21] update server app path to host's --- .env.example | 6 +++++- docker-compose.yml | 4 ++-- local/service/docker_runtime.go | 9 ++++++++- local/service/server.go | 12 +++++------- local/utl/env/env.go | 7 +++++++ 5 files changed, 27 insertions(+), 11 deletions(-) diff --git a/.env.example b/.env.example index 0897b48..95f73c6 100755 --- a/.env.example +++ b/.env.example @@ -64,10 +64,14 @@ PASSWORD=change-this-default-admin-password # Docker image name for ACC server containers (built from docker/acc-server/Dockerfile) # ACC_IMAGE=acc-wine:latest -# Base path where per-server game installations are stored. +# Base path where per-server game installations are stored (inside the manager container). # Each server gets its own subdirectory: ACC_SERVERS_PATH// # ACC_SERVERS_PATH=/data/servers +# Host-side path for the same directory — used when creating sibling ACC server containers. +# Must match the host path bound to ACC_SERVERS_PATH in docker-compose.yml. +# ACC_SERVERS_HOST_PATH=/opt/acc-servers + # Port pool for Docker-mode ACC server containers (one port per server). # PORT_POOL_MIN=9600 # PORT_POOL_MAX=9700 diff --git a/docker-compose.yml b/docker-compose.yml index 45e7260..82f653a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,17 +16,17 @@ services: PORT_POOL_MAX: "9700" ACC_IMAGE: acc-wine:latest ACC_SERVERS_PATH: /data/servers + ACC_SERVERS_HOST_PATH: /opt/acc-servers env_file: - .env volumes: - /var/run/docker.sock:/var/run/docker.sock - - acc-servers:/data/servers + - /opt/acc-servers:/data/servers - acc-db:/data/db networks: - acc-net volumes: - acc-servers: acc-db: networks: diff --git a/local/service/docker_runtime.go b/local/service/docker_runtime.go index 5e2b6e9..b129666 100644 --- a/local/service/docker_runtime.go +++ b/local/service/docker_runtime.go @@ -7,7 +7,9 @@ import ( "acc-server-manager/local/utl/logging" "context" "fmt" + "path/filepath" "strconv" + "strings" "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/container" @@ -50,8 +52,13 @@ func (r *DockerRuntime) Create(ctx context.Context, server *model.Server) error natPortUDP: struct{}{}, } + containerBase := env.GetACCServersPath() + hostBase := env.GetACCServersHostPath() + serverSubPath := strings.TrimPrefix(server.GetServerPath(), containerBase) + hostServerPath := filepath.Join(hostBase, serverSubPath) + binds := []string{ - fmt.Sprintf("%s:/acc/game:rw", server.GetServerPath()), + fmt.Sprintf("%s:/acc/game:rw", hostServerPath), } resp, err := r.client.ContainerCreate(ctx, &container.Config{ diff --git a/local/service/server.go b/local/service/server.go index b74ecdd..3318111 100755 --- a/local/service/server.go +++ b/local/service/server.go @@ -487,9 +487,9 @@ func (s *ServerService) rollbackSteps(ctx context.Context, server *model.Server, case model.StepServiceCreation: s.runtime.Delete(ctx, server.ID) case model.StepSteamDownload: - if !env.IsDockerPlatform() { - s.steamService.UninstallServer(server.Path) - } + s.steamService.UninstallServer(server.Path) + case model.StepDirectoryCreation: + s.steamService.UninstallServer(server.Path) case model.StepConfigGeneration: if env.IsDockerPlatform() && server.Port > 0 { s.portPool.Release(ctx, server.Port) @@ -520,10 +520,8 @@ func (s *ServerService) DeleteServer(ctx *fiber.Ctx, serverID uuid.UUID) error { } } - if server.Platform != "docker" { - if err := s.steamService.UninstallServer(server.Path); err != nil { - logging.Error("Failed to uninstall server: %v", err) - } + if err := s.steamService.UninstallServer(server.Path); err != nil { + logging.Error("Failed to uninstall server: %v", err) } if env.IsDockerPlatform() && server.Port > 0 { diff --git a/local/utl/env/env.go b/local/utl/env/env.go index f258c0a..a176d11 100755 --- a/local/utl/env/env.go +++ b/local/utl/env/env.go @@ -65,6 +65,13 @@ func GetACCServersPath() string { return DefaultACCServersPath } +func GetACCServersHostPath() string { + if p := os.Getenv("ACC_SERVERS_HOST_PATH"); p != "" { + return p + } + return GetACCServersPath() +} + func ValidatePaths() map[string]error { errors := make(map[string]error) From 4441524447364ce099254bc1b0a87101f7dd6d09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=20Jurmanovi=C4=87?= Date: Thu, 19 Mar 2026 19:23:33 +0100 Subject: [PATCH 08/21] fix acc server container restarting --- docker-compose.yml | 4 ++-- docker/acc-server/Dockerfile | 7 ++----- docker/acc-server/entrypoint.sh | 16 +++++++++++++--- local/service/docker_runtime.go | 20 +++++++++++--------- 4 files changed, 28 insertions(+), 19 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 82f653a..6ac7862 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,8 +12,8 @@ services: PLATFORM: docker DB_NAME: /data/db/acc.db STEAMCMD_LINUX_PATH: /usr/games/steamcmd - PORT_POOL_MIN: "9600" - PORT_POOL_MAX: "9700" + PORT_POOL_MIN: "9601" + PORT_POOL_MAX: "9699" ACC_IMAGE: acc-wine:latest ACC_SERVERS_PATH: /data/servers ACC_SERVERS_HOST_PATH: /opt/acc-servers diff --git a/docker/acc-server/Dockerfile b/docker/acc-server/Dockerfile index 861bdb2..47e0bd0 100644 --- a/docker/acc-server/Dockerfile +++ b/docker/acc-server/Dockerfile @@ -15,10 +15,9 @@ RUN dpkg --add-architecture i386 && \ && \ rm -rf /var/lib/apt/lists/* -# Create a non-root user for running the server. -RUN useradd -ms /bin/bash accserver +RUN mkdir -p /tmp/.X11-unix && chmod 1777 /tmp/.X11-unix -WORKDIR /home/accserver +WORKDIR /root # Copy entrypoint script. COPY entrypoint.sh /entrypoint.sh @@ -30,10 +29,8 @@ VOLUME ["/acc/game"] # Default server port — overridden at runtime via SERVER_PORT env var. ENV SERVER_PORT=9600 ENV WINEDEBUG=-all -ENV DISPLAY=:99 EXPOSE ${SERVER_PORT}/tcp EXPOSE ${SERVER_PORT}/udp -USER accserver ENTRYPOINT ["/entrypoint.sh"] diff --git a/docker/acc-server/entrypoint.sh b/docker/acc-server/entrypoint.sh index f3ce7f0..67d6e1b 100644 --- a/docker/acc-server/entrypoint.sh +++ b/docker/acc-server/entrypoint.sh @@ -1,7 +1,15 @@ #!/bin/bash set -e -Xvfb :99 -screen 0 1024x768x16 & +# Use a display number derived from the server port to avoid conflicts +# between containers (each server gets its own unique display). +DISPLAY_NUM=$((SERVER_PORT % 1000 + 100)) +export DISPLAY=:${DISPLAY_NUM} + +# Clean up any stale X lock from a previous container run. +rm -f /tmp/.X${DISPLAY_NUM}-lock /tmp/.X11-unix/X${DISPLAY_NUM} 2>/dev/null || true + +Xvfb ${DISPLAY} -screen 0 1024x768x16 -nolisten tcp & XVFB_PID=$! cleanup() { @@ -10,7 +18,9 @@ cleanup() { } trap cleanup SIGTERM SIGINT +mkdir -p /acc/game/log + echo "Starting ACC server on port ${SERVER_PORT}..." -exec wine /acc/game/accServer.exe \ - -port "${SERVER_PORT}" +cd /acc/game +exec wine /acc/game/accServer.exe diff --git a/local/service/docker_runtime.go b/local/service/docker_runtime.go index b129666..7ecd23d 100644 --- a/local/service/docker_runtime.go +++ b/local/service/docker_runtime.go @@ -33,23 +33,25 @@ func NewDockerRuntime(client *dockerclient.Client, repo *repository.ServerReposi func (r *DockerRuntime) Create(ctx context.Context, server *model.Server) error { image := env.GetACCImage() - portStr := strconv.Itoa(server.Port) - natPort, err := nat.NewPort("tcp", portStr) + tcpPortStr := strconv.Itoa(server.Port) + udpPortStr := strconv.Itoa(server.Port - 1) + + natTCP, err := nat.NewPort("tcp", tcpPortStr) if err != nil { - return fmt.Errorf("invalid port %d: %v", server.Port, err) + return fmt.Errorf("invalid tcp port %d: %v", server.Port, err) } - natPortUDP, err := nat.NewPort("udp", portStr) + natUDP, err := nat.NewPort("udp", udpPortStr) if err != nil { - return fmt.Errorf("invalid udp port %d: %v", server.Port, err) + return fmt.Errorf("invalid udp port %d: %v", server.Port-1, err) } portBindings := nat.PortMap{ - natPort: []nat.PortBinding{{HostPort: portStr}}, - natPortUDP: []nat.PortBinding{{HostPort: portStr}}, + natTCP: []nat.PortBinding{{HostPort: tcpPortStr}}, + natUDP: []nat.PortBinding{{HostPort: udpPortStr}}, } exposedPorts := nat.PortSet{ - natPort: struct{}{}, - natPortUDP: struct{}{}, + natTCP: struct{}{}, + natUDP: struct{}{}, } containerBase := env.GetACCServersPath() From 85c5874bc759ceeef0afa5b0c9f1442ca325c873 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=20Jurmanovi=C4=87?= Date: Sat, 21 Mar 2026 19:58:55 +0100 Subject: [PATCH 09/21] update container port mapping --- docker-compose.yml | 2 +- docker/acc-server/Dockerfile | 13 ++-- docker/acc-server/entrypoint.sh | 68 ++++++++++++++++--- local/service/config.go | 8 +++ local/service/docker_runtime.go | 59 +++++++++++++--- local/service/server.go | 7 ++ tests/test_helper.go | 4 +- .../unit/controller/controller_simple_test.go | 4 +- tests/unit/service/cache_service_test.go | 2 +- tests/unit/service/config_service_test.go | 6 +- 10 files changed, 139 insertions(+), 34 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 6ac7862..aae6f03 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,7 +12,7 @@ services: PLATFORM: docker DB_NAME: /data/db/acc.db STEAMCMD_LINUX_PATH: /usr/games/steamcmd - PORT_POOL_MIN: "9601" + PORT_POOL_MIN: "9600" PORT_POOL_MAX: "9699" ACC_IMAGE: acc-wine:latest ACC_SERVERS_PATH: /data/servers diff --git a/docker/acc-server/Dockerfile b/docker/acc-server/Dockerfile index 47e0bd0..0e67a18 100644 --- a/docker/acc-server/Dockerfile +++ b/docker/acc-server/Dockerfile @@ -1,6 +1,5 @@ # ACC Server container: Wine + accServer.exe -# Game files are mounted read-only from the shared acc-game volume. -# Per-server config files are mounted read-write from the manager's config volume. +# Game files are bind-mounted from the host at /acc/game. FROM debian:bookworm-slim # Install Wine, Xvfb (headless display for Wine), and minimal runtime deps. @@ -12,6 +11,7 @@ RUN dpkg --add-architecture i386 && \ wine64 \ xvfb \ ca-certificates \ + tcpdump \ && \ rm -rf /var/lib/apt/lists/* @@ -19,6 +19,10 @@ RUN mkdir -p /tmp/.X11-unix && chmod 1777 /tmp/.X11-unix WORKDIR /root +ENV WINEARCH=win64 +ENV WINEPREFIX=/root/.wine +ENV WINEDEBUG=-all + # Copy entrypoint script. COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh @@ -26,11 +30,6 @@ RUN chmod +x /entrypoint.sh # 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 -ENV WINEDEBUG=-all - -EXPOSE ${SERVER_PORT}/tcp -EXPOSE ${SERVER_PORT}/udp ENTRYPOINT ["/entrypoint.sh"] diff --git a/docker/acc-server/entrypoint.sh b/docker/acc-server/entrypoint.sh index 67d6e1b..784273d 100644 --- a/docker/acc-server/entrypoint.sh +++ b/docker/acc-server/entrypoint.sh @@ -1,26 +1,78 @@ #!/bin/bash set -e -# Use a display number derived from the server port to avoid conflicts -# between containers (each server gets its own unique display). DISPLAY_NUM=$((SERVER_PORT % 1000 + 100)) export DISPLAY=:${DISPLAY_NUM} -# Clean up any stale X lock from a previous container run. rm -f /tmp/.X${DISPLAY_NUM}-lock /tmp/.X11-unix/X${DISPLAY_NUM} 2>/dev/null || true Xvfb ${DISPLAY} -screen 0 1024x768x16 -nolisten tcp & XVFB_PID=$! +TCPDUMP_PID="" + cleanup() { echo "Stopping ACC server..." - kill $XVFB_PID 2>/dev/null || true + [ -n "${TCPDUMP_PID}" ] && kill "${TCPDUMP_PID}" 2>/dev/null || true + kill "${XVFB_PID}" 2>/dev/null || true } -trap cleanup SIGTERM SIGINT +trap cleanup SIGTERM SIGINT EXIT + +# Initialize Wine prefix (no-op if already done) +wineboot -u 2>/dev/null mkdir -p /acc/game/log -echo "Starting ACC server on port ${SERVER_PORT}..." +# ─── Debug mode ─────────────────────────────────────────────────────────────── +# Set DEBUG_MODE=1 on the container to enable extra diagnostics: +# +# docker run -e DEBUG_MODE=1 ... +# or in docker-compose: +# environment: +# DEBUG_MODE: "1" +# +# Produces: +# /acc/game/log/tcpdump_.pcap — raw packet capture (open in Wireshark) +# /acc/game/log/wine_debug.log — Wine debug output +# Stderr: Wine version, WINEDEBUG channel, prefix state +# +# You can also override which Wine debug channels are enabled: +# WINEDEBUG_CHANNELS="+loaddll,+seh,+relay" (relay is very verbose) +# ───────────────────────────────────────────────────────────────────────────── +if [ "${DEBUG_MODE:-0}" = "1" ]; then + echo "[debug] Debug mode enabled for port ${SERVER_PORT}" + echo "[debug] Wine: $(wine --version 2>/dev/null || echo unknown)" + echo "[debug] WINEPREFIX: ${WINEPREFIX}" -cd /acc/game -exec wine /acc/game/accServer.exe + # Configure Wine debug channels (default: loader + exception + module info) + export WINEDEBUG="${WINEDEBUG_CHANNELS:-+loaddll,+module,+seh,+ntdll}" + echo "[debug] WINEDEBUG=${WINEDEBUG}" + + # Start packet capture on the server port — captures both TCP and UDP. + # The pcap file records every byte Kunos sends/receives so you can inspect + # the 2-byte "pakSizeMax" packet and anything that follows. + # + # NOTE: requires CAP_NET_RAW capability on the container: + # docker-compose: cap_add: [NET_RAW] + # docker run: --cap-add=NET_RAW + # Without it tcpdump will be skipped (no crash, just no capture). + CAPTURE_FILE="/acc/game/log/tcpdump_${SERVER_PORT}.pcap" + echo "[debug] Starting packet capture → ${CAPTURE_FILE}" + echo "[debug] (requires cap_add: [NET_RAW] in docker-compose — will exit silently if missing)" + tcpdump -i any -nn -s 0 -w "${CAPTURE_FILE}" \ + "port ${SERVER_PORT}" 2>/acc/game/log/tcpdump.err & + TCPDUMP_PID=$! + + # Redirect Wine debug output to a log file (Wine writes to stderr). + WINE_LOG="/acc/game/log/wine_debug.log" + echo "[debug] Wine debug log → ${WINE_LOG}" + echo "Starting ACC server on port ${SERVER_PORT} (DEBUG)..." + cd /acc/game + exec wine /acc/game/accServer.exe 2>"${WINE_LOG}" +else + # Production: suppress all Wine debug output. + export WINEDEBUG=-all + echo "Starting ACC server on port ${SERVER_PORT}..." + cd /acc/game + exec wine /acc/game/accServer.exe +fi diff --git a/local/service/config.go b/local/service/config.go index b632854..4b9c8ea 100755 --- a/local/service/config.go +++ b/local/service/config.go @@ -476,6 +476,14 @@ func (as *ConfigService) GetConfiguration(server *model.Server) (*model.Configur return &config, nil } +func (as *ConfigService) SetIgnorePrematureDisconnects(server *model.Server, value int) error { + configMap := map[string]interface{}{ + "ignorePrematureDisconnects": value, + } + _, _, err := as.updateConfigFiles(context.Background(), server, SettingsJson, &configMap, false) + return err +} + func (as *ConfigService) SaveConfiguration(server *model.Server, config *model.Configuration) error { configMap := make(map[string]interface{}) configBytes, err := json.Marshal(config) diff --git a/local/service/docker_runtime.go b/local/service/docker_runtime.go index 7ecd23d..addcc16 100644 --- a/local/service/docker_runtime.go +++ b/local/service/docker_runtime.go @@ -33,21 +33,21 @@ func NewDockerRuntime(client *dockerclient.Client, repo *repository.ServerReposi func (r *DockerRuntime) Create(ctx context.Context, server *model.Server) error { image := env.GetACCImage() - tcpPortStr := strconv.Itoa(server.Port) - udpPortStr := strconv.Itoa(server.Port - 1) + // ACC uses the same port number for both TCP (client connections) and UDP (car positions/ping). + portStr := strconv.Itoa(server.Port) - natTCP, err := nat.NewPort("tcp", tcpPortStr) + natTCP, err := nat.NewPort("tcp", portStr) if err != nil { - return fmt.Errorf("invalid tcp port %d: %v", server.Port, err) + return fmt.Errorf("invalid port %d: %v", server.Port, err) } - natUDP, err := nat.NewPort("udp", udpPortStr) + natUDP, err := nat.NewPort("udp", portStr) if err != nil { - return fmt.Errorf("invalid udp port %d: %v", server.Port-1, err) + return fmt.Errorf("invalid port %d: %v", server.Port, err) } portBindings := nat.PortMap{ - natTCP: []nat.PortBinding{{HostPort: tcpPortStr}}, - natUDP: []nat.PortBinding{{HostPort: udpPortStr}}, + natTCP: []nat.PortBinding{{HostPort: portStr}}, + natUDP: []nat.PortBinding{{HostPort: portStr}}, } exposedPorts := nat.PortSet{ natTCP: struct{}{}, @@ -73,6 +73,8 @@ func (r *DockerRuntime) Create(ctx context.Context, server *model.Server) error Binds: binds, PortBindings: portBindings, RestartPolicy: container.RestartPolicy{Name: "unless-stopped"}, + // NET_RAW is required for tcpdump packet capture in debug mode (DEBUG_MODE=1). + CapAdd: []string{"NET_RAW"}, }, nil, nil, server.ServiceName) if err != nil { return fmt.Errorf("failed to create container %s: %v", server.ServiceName, err) @@ -104,10 +106,19 @@ func (r *DockerRuntime) Delete(ctx context.Context, serverID uuid.UUID) error { } func (r *DockerRuntime) Start(ctx context.Context, serverID uuid.UUID) (string, error) { - containerID, err := r.resolveContainerID(ctx, serverID) + server, err := r.repo.GetByID(ctx, serverID) + if err != nil { + return "", fmt.Errorf("failed to look up server %s: %v", serverID, err) + } + if server == nil { + return "", fmt.Errorf("server %s not found", serverID) + } + + containerID, err := r.ensureContainer(ctx, server) if err != nil { return "", err } + if err := r.client.ContainerStart(ctx, containerID, types.ContainerStartOptions{}); err != nil { return "", fmt.Errorf("failed to start container: %v", err) } @@ -126,16 +137,44 @@ func (r *DockerRuntime) Stop(ctx context.Context, serverID uuid.UUID) (string, e } func (r *DockerRuntime) Restart(ctx context.Context, serverID uuid.UUID) (string, error) { - containerID, err := r.resolveContainerID(ctx, serverID) + server, err := r.repo.GetByID(ctx, serverID) + if err != nil { + return "", fmt.Errorf("failed to look up server %s: %v", serverID, err) + } + if server == nil { + return "", fmt.Errorf("server %s not found", serverID) + } + + containerID, err := r.ensureContainer(ctx, server) if err != nil { return "", err } + if err := r.client.ContainerRestart(ctx, containerID, container.StopOptions{}); err != nil { return "", fmt.Errorf("failed to restart container: %v", err) } return model.StatusRunning.String(), nil } +// ensureContainer returns the container ID for a server, recreating the container +// from the server's current DB record if it no longer exists in Docker. +func (r *DockerRuntime) ensureContainer(ctx context.Context, server *model.Server) (string, error) { + if server.ContainerID != "" { + if _, err := r.client.ContainerInspect(ctx, server.ContainerID); err == nil { + return server.ContainerID, nil + } + logging.Info("Container %s for server %s no longer exists, recreating", server.ContainerID[:min(12, len(server.ContainerID))], server.ID) + } + + if err := r.Create(ctx, server); err != nil { + return "", fmt.Errorf("failed to recreate container: %v", err) + } + if err := r.repo.Update(ctx, server); err != nil { + return "", fmt.Errorf("container recreated but failed to persist container ID: %v", err) + } + return server.ContainerID, nil +} + func (r *DockerRuntime) Status(ctx context.Context, serverID uuid.UUID) (string, error) { server, err := r.repo.GetByID(ctx, serverID) if err != nil || server == nil { diff --git a/local/service/server.go b/local/service/server.go index 3318111..84e8325 100755 --- a/local/service/server.go +++ b/local/service/server.go @@ -574,10 +574,17 @@ func (s *ServerService) updateServerPort(server *model.Server, port int) error { config.TcpPort = model.IntString(port) config.UdpPort = model.IntString(port) + config.RegisterToLobby = model.IntString(1) if err := s.configService.SaveConfiguration(server, config); err != nil { return fmt.Errorf("failed to save server configuration: %v", err) } + if env.IsDockerPlatform() { + if err := s.configService.SetIgnorePrematureDisconnects(server, 0); err != nil { + logging.Error("Failed to set ignorePrematureDisconnects for Docker server: %v", err) + } + } + return nil } diff --git a/tests/test_helper.go b/tests/test_helper.go index b6d0132..b029921 100755 --- a/tests/test_helper.go +++ b/tests/test_helper.go @@ -109,7 +109,7 @@ func createTestData(t *testing.T, tempDir string) *TestData { configFiles := map[string]string{ "configuration.json": `{ "udpPort": "9231", - "tcpPort": "9232", + "tcpPort": "9231", "maxConnections": "30", "lanDiscovery": "1", "registerToLobby": "1", @@ -198,7 +198,7 @@ func createTestData(t *testing.T, tempDir string) *TestData { sampleConfig := &model.Configuration{ UdpPort: model.IntString(9231), - TcpPort: model.IntString(9232), + TcpPort: model.IntString(9231), MaxConnections: model.IntString(30), LanDiscovery: model.IntString(1), RegisterToLobby: model.IntString(1), diff --git a/tests/unit/controller/controller_simple_test.go b/tests/unit/controller/controller_simple_test.go index e01581c..cd14601 100755 --- a/tests/unit/controller/controller_simple_test.go +++ b/tests/unit/controller/controller_simple_test.go @@ -294,7 +294,7 @@ func TestController_ConfigurationModel_JSONSerialization(t *testing.T) { app.Get("/config", func(c *fiber.Ctx) error { config := &model.Configuration{ UdpPort: model.IntString(9231), - TcpPort: model.IntString(9232), + TcpPort: model.IntString(9231), MaxConnections: model.IntString(30), LanDiscovery: model.IntString(1), RegisterToLobby: model.IntString(1), @@ -316,7 +316,7 @@ func TestController_ConfigurationModel_JSONSerialization(t *testing.T) { tests.AssertNoError(t, err) tests.AssertEqual(t, model.IntString(9231), response.UdpPort) - tests.AssertEqual(t, model.IntString(9232), response.TcpPort) + tests.AssertEqual(t, model.IntString(9231), response.TcpPort) tests.AssertEqual(t, model.IntString(30), response.MaxConnections) tests.AssertEqual(t, model.IntString(1), response.LanDiscovery) tests.AssertEqual(t, model.IntString(1), response.RegisterToLobby) diff --git a/tests/unit/service/cache_service_test.go b/tests/unit/service/cache_service_test.go index 95862af..2d69942 100755 --- a/tests/unit/service/cache_service_test.go +++ b/tests/unit/service/cache_service_test.go @@ -464,7 +464,7 @@ func TestServerConfigCache_Configuration(t *testing.T) { serverID := uuid.New().String() configuration := model.Configuration{ UdpPort: model.IntString(9231), - TcpPort: model.IntString(9232), + TcpPort: model.IntString(9231), MaxConnections: model.IntString(30), LanDiscovery: model.IntString(1), RegisterToLobby: model.IntString(1), diff --git a/tests/unit/service/config_service_test.go b/tests/unit/service/config_service_test.go index 3d441e8..ecc3d41 100755 --- a/tests/unit/service/config_service_test.go +++ b/tests/unit/service/config_service_test.go @@ -27,7 +27,7 @@ func TestConfigService_GetConfiguration_ValidFile(t *testing.T) { tests.AssertNotNil(t, config) tests.AssertEqual(t, model.IntString(9231), config.UdpPort) - tests.AssertEqual(t, model.IntString(9232), config.TcpPort) + tests.AssertEqual(t, model.IntString(9231), config.TcpPort) tests.AssertEqual(t, model.IntString(30), config.MaxConnections) tests.AssertEqual(t, model.IntString(1), config.LanDiscovery) tests.AssertEqual(t, model.IntString(1), config.RegisterToLobby) @@ -122,7 +122,7 @@ func TestConfigService_LoadConfigs_Success(t *testing.T) { tests.AssertNotNil(t, configs) tests.AssertEqual(t, model.IntString(9231), configs.Configuration.UdpPort) - tests.AssertEqual(t, model.IntString(9232), configs.Configuration.TcpPort) + tests.AssertEqual(t, model.IntString(9231), configs.Configuration.TcpPort) tests.AssertEqual(t, "Test ACC Server", configs.Settings.ServerName) tests.AssertEqual(t, "admin123", configs.Settings.AdminPassword) tests.AssertEqual(t, "spa", configs.Event.Track) @@ -264,7 +264,7 @@ func TestConfigService_Caching_Configuration(t *testing.T) { tests.AssertNotNil(t, config2) tests.AssertEqual(t, model.IntString(9231), config2.UdpPort) - tests.AssertEqual(t, model.IntString(9232), config2.TcpPort) + tests.AssertEqual(t, model.IntString(9231), config2.TcpPort) } func TestConfigService_Caching_EventConfig(t *testing.T) { From e637b5e6490a8f3e7cd99554a9440a9de7549854 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=20Jurmanovi=C4=87?= Date: Sun, 22 Mar 2026 13:38:00 +0100 Subject: [PATCH 10/21] feat: add server log endpoint and streaming --- local/api/api.go | 1 + local/controller/controller.go | 5 +++ local/controller/log.go | 62 ++++++++++++++++++++++++++++ local/model/websocket.go | 5 +++ local/platform/interfaces.go | 1 + local/service/docker_log_streamer.go | 32 ++++++++++++++ local/service/file_log_streamer.go | 30 ++++++++++++++ local/service/server.go | 17 +++++++- local/service/websocket.go | 10 +++++ local/utl/common/common.go | 1 + 10 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 local/controller/log.go diff --git a/local/api/api.go b/local/api/api.go index 0f3f775..a2487d2 100755 --- a/local/api/api.go +++ b/local/api/api.go @@ -34,6 +34,7 @@ func Init(di *dig.Container, app *fiber.App) { WebSocket: groups.Group("/ws"), Leaderboard: serverIdGroup.Group("/leaderboard"), Steam: groups.Group("/steam"), + Log: serverIdGroup.Group("/log"), } accessKeyMiddleware := middleware.NewAccessKeyMiddleware() diff --git a/local/controller/controller.go b/local/controller/controller.go index 79af86b..b304313 100755 --- a/local/controller/controller.go +++ b/local/controller/controller.go @@ -69,4 +69,9 @@ func InitializeControllers(c *dig.Container) { if err != nil { logging.Panic("unable to initialize steam controller") } + + err = c.Invoke(NewLogController) + if err != nil { + logging.Panic("unable to initialize log controller") + } } diff --git a/local/controller/log.go b/local/controller/log.go new file mode 100644 index 0000000..9d27487 --- /dev/null +++ b/local/controller/log.go @@ -0,0 +1,62 @@ +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" + "strconv" + + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" +) + +type LogController struct { + service *service.ServerService + errorHandler *error_handler.ControllerErrorHandler +} + +func NewLogController(ss *service.ServerService, routeGroups *common.RouteGroups, auth *middleware.AuthMiddleware) *LogController { + lc := &LogController{ + service: ss, + errorHandler: error_handler.NewControllerErrorHandler(), + } + + logRoutes := routeGroups.Log + logRoutes.Use(auth.Authenticate) + logRoutes.Get("/", auth.HasPermission(model.ServerView), lc.GetLogLines) + + return lc +} + +// GetLogLines godoc +// @Summary Get last N lines of server log +// @Tags Server +// @Param id path string true "Server ID" +// @Param lines query int false "Number of lines to return (default 100)" +// @Success 200 {object} object{lines=[]string} +// @Security BearerAuth +// @Router /server/{id}/log [get] +func (lc *LogController) GetLogLines(c *fiber.Ctx) error { + serverIDStr := c.Params("id") + serverID, err := uuid.Parse(serverIDStr) + if err != nil { + return lc.errorHandler.HandleUUIDError(c, "server ID") + } + + n, _ := strconv.Atoi(c.Query("lines", "100")) + if n <= 0 { + n = 100 + } + if n > 10000 { + n = 10000 + } + + lines, err := lc.service.GetLastLogLines(c, serverID, n) + if err != nil { + return lc.errorHandler.HandleServiceError(c, err) + } + + return c.JSON(fiber.Map{"lines": lines}) +} diff --git a/local/model/websocket.go b/local/model/websocket.go index f894a9e..6db5a26 100755 --- a/local/model/websocket.go +++ b/local/model/websocket.go @@ -33,6 +33,7 @@ const ( MessageTypeSteamOutput WebSocketMessageType = "steam_output" MessageTypeError WebSocketMessageType = "error" MessageTypeComplete WebSocketMessageType = "complete" + MessageTypeLogLine WebSocketMessageType = "log_line" ) type WebSocketMessage struct { @@ -65,6 +66,10 @@ type CompleteMessage struct { Message string `json:"message"` } +type LogLineMessage struct { + Line string `json:"line"` +} + func GetStepDescription(step ServerCreationStep) string { descriptions := map[ServerCreationStep]string{ StepValidation: "Validating server configuration", diff --git a/local/platform/interfaces.go b/local/platform/interfaces.go index 47de4ed..d292260 100644 --- a/local/platform/interfaces.go +++ b/local/platform/interfaces.go @@ -25,4 +25,5 @@ type FirewallManager interface { type LogStreamer interface { Start(ctx context.Context, server *model.Server, handleLine func(string)) error Stop(serverID uuid.UUID) + GetLastLines(ctx context.Context, server *model.Server, n int) ([]string, error) } diff --git a/local/service/docker_log_streamer.go b/local/service/docker_log_streamer.go index e84ad40..347ef8b 100644 --- a/local/service/docker_log_streamer.go +++ b/local/service/docker_log_streamer.go @@ -6,6 +6,7 @@ import ( "bufio" "context" "io" + "strconv" "sync" "github.com/docker/docker/api/types" @@ -72,3 +73,34 @@ func (s *DockerLogStreamer) Stop(serverID uuid.UUID) { v.(context.CancelFunc)() } } + +func (s *DockerLogStreamer) GetLastLines(ctx context.Context, server *model.Server, n int) ([]string, error) { + if n <= 0 || server.ContainerID == "" { + return []string{}, nil + } + + rc, err := s.client.ContainerLogs(ctx, server.ContainerID, types.ContainerLogsOptions{ + ShowStdout: true, + ShowStderr: true, + Tail: strconv.Itoa(n), + }) + if err != nil { + return nil, err + } + defer rc.Close() + + pr, pw := io.Pipe() + go func() { + defer pw.Close() + if _, err := stdcopy.StdCopy(pw, pw, rc); err != nil && err != io.EOF { + logging.Warn("DockerLogStreamer.GetLastLines: stdcopy error for %s: %v", server.ServiceName, err) + } + }() + + var lines []string + scanner := bufio.NewScanner(pr) + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + return lines, scanner.Err() +} diff --git a/local/service/file_log_streamer.go b/local/service/file_log_streamer.go index 62bd3e6..2474565 100644 --- a/local/service/file_log_streamer.go +++ b/local/service/file_log_streamer.go @@ -3,7 +3,9 @@ package service import ( "acc-server-manager/local/model" "acc-server-manager/local/utl/tracking" + "bufio" "context" + "os" "path/filepath" "sync" @@ -36,3 +38,31 @@ func (s *FileLogStreamer) Stop(serverID uuid.UUID) { v.(*tracking.LogTailer).Stop() } } + +func (s *FileLogStreamer) GetLastLines(_ context.Context, server *model.Server, n int) ([]string, error) { + if n <= 0 { + return []string{}, nil + } + logPath := filepath.Join(server.GetLogPath(), "server.log") + f, err := os.Open(logPath) + if err != nil { + if os.IsNotExist(err) { + return []string{}, nil + } + return nil, err + } + defer f.Close() + + var lines []string + scanner := bufio.NewScanner(f) + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + if err := scanner.Err(); err != nil { + return nil, err + } + if len(lines) <= n { + return lines, nil + } + return lines[len(lines)-n:], nil +} diff --git a/local/service/server.go b/local/service/server.go index 84e8325..84d83a7 100755 --- a/local/service/server.go +++ b/local/service/server.go @@ -47,12 +47,27 @@ type pendingState struct { func (s *ServerService) ensureLogTailing(server *model.Server, instance *tracking.AccServerInstance) { go func() { - if err := s.logStreamer.Start(context.Background(), server, instance.HandleLogLine); err != nil { + handleFn := func(line string) { + instance.HandleLogLine(line) + s.webSocketService.BroadcastLogLine(server.ID, line) + } + if err := s.logStreamer.Start(context.Background(), server, handleFn); err != nil { logging.Error("Failed to start log streaming for server %s: %v", server.ID, err) } }() } +func (s *ServerService) GetLastLogLines(ctx *fiber.Ctx, serverID uuid.UUID, n int) ([]string, error) { + server, err := s.repository.GetByID(ctx.UserContext(), serverID) + if err != nil { + return nil, err + } + if n <= 0 { + n = 100 + } + return s.logStreamer.GetLastLines(ctx.UserContext(), server, n) +} + func NewServerService( repository *repository.ServerRepository, stateHistoryRepo *repository.StateHistoryRepository, diff --git a/local/service/websocket.go b/local/service/websocket.go index 1f2cbd2..6394050 100755 --- a/local/service/websocket.go +++ b/local/service/websocket.go @@ -103,6 +103,16 @@ func (ws *WebSocketService) BroadcastError(serverID uuid.UUID, error string, det ws.broadcastToServer(serverID, wsMsg) } +func (ws *WebSocketService) BroadcastLogLine(serverID uuid.UUID, line string) { + msg := model.WebSocketMessage{ + Type: model.MessageTypeLogLine, + ServerID: &serverID, + Timestamp: time.Now().Unix(), + Data: model.LogLineMessage{Line: line}, + } + ws.broadcastToServer(serverID, msg) +} + func (ws *WebSocketService) BroadcastComplete(serverID uuid.UUID, success bool, message string) { completeMsg := model.CompleteMessage{ ServerID: serverID, diff --git a/local/utl/common/common.go b/local/utl/common/common.go index 0ad0917..4f9ca07 100755 --- a/local/utl/common/common.go +++ b/local/utl/common/common.go @@ -28,6 +28,7 @@ type RouteGroups struct { WebSocket fiber.Router Leaderboard fiber.Router Steam fiber.Router + Log fiber.Router } func CheckError(err error) { From a6f67dd652d8e88d21174922d1c34eacc422822a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=20Jurmanovi=C4=87?= Date: Sun, 22 Mar 2026 13:39:31 +0100 Subject: [PATCH 11/21] fix: use ring buffer for log reading --- local/service/file_log_streamer.go | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/local/service/file_log_streamer.go b/local/service/file_log_streamer.go index 2474565..7b0fa7e 100644 --- a/local/service/file_log_streamer.go +++ b/local/service/file_log_streamer.go @@ -53,16 +53,25 @@ func (s *FileLogStreamer) GetLastLines(_ context.Context, server *model.Server, } defer f.Close() - var lines []string + ring := make([]string, n) + head := 0 + count := 0 scanner := bufio.NewScanner(f) for scanner.Scan() { - lines = append(lines, scanner.Text()) + ring[head] = scanner.Text() + head = (head + 1) % n + if count < n { + count++ + } } if err := scanner.Err(); err != nil { return nil, err } - if len(lines) <= n { - return lines, nil + if count < n { + return ring[:count], nil } - return lines[len(lines)-n:], nil + out := make([]string, n) + copy(out, ring[head:]) + copy(out[n-head:], ring[:head]) + return out, nil } From e96b8bca12358671e7634ab76ba655bd924aa095 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=20Jurmanovi=C4=87?= Date: Sun, 22 Mar 2026 13:52:15 +0100 Subject: [PATCH 12/21] feat: add activity logs --- local/api/api.go | 2 + local/controller/activity_log.go | 72 +++++++++++++++++++++ local/controller/controller.go | 5 ++ local/controller/leaderboard.go | 10 ++- local/migrations/006_add_activity_log.go | 33 ++++++++++ local/model/activity_log.go | 81 ++++++++++++++++++++++++ local/model/server.go | 25 ++++---- local/repository/activity_log.go | 75 ++++++++++++++++++++++ local/repository/repository.go | 1 + local/service/activity_log.go | 62 ++++++++++++++++++ local/service/config.go | 47 ++++++++++++-- local/service/leaderboard.go | 32 ++++++++-- local/service/server.go | 20 +++++- local/service/service.go | 1 + local/service/service_control.go | 70 +++++++++++++++++++- local/utl/common/common.go | 2 + local/utl/db/db.go | 5 ++ 17 files changed, 517 insertions(+), 26 deletions(-) create mode 100644 local/controller/activity_log.go create mode 100644 local/migrations/006_add_activity_log.go create mode 100644 local/model/activity_log.go create mode 100644 local/repository/activity_log.go create mode 100644 local/service/activity_log.go diff --git a/local/api/api.go b/local/api/api.go index 0f3f775..f835917 100755 --- a/local/api/api.go +++ b/local/api/api.go @@ -34,6 +34,8 @@ func Init(di *dig.Container, app *fiber.App) { WebSocket: groups.Group("/ws"), Leaderboard: serverIdGroup.Group("/leaderboard"), Steam: groups.Group("/steam"), + ActivityLog: serverIdGroup.Group("/activity-log"), + Groups: groups, } accessKeyMiddleware := middleware.NewAccessKeyMiddleware() diff --git a/local/controller/activity_log.go b/local/controller/activity_log.go new file mode 100644 index 0000000..145eace --- /dev/null +++ b/local/controller/activity_log.go @@ -0,0 +1,72 @@ +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 ActivityLogController struct { + service *service.ActivityLogService + errorHandler *error_handler.ControllerErrorHandler +} + +func NewActivityLogController(as *service.ActivityLogService, routeGroups *common.RouteGroups, auth *middleware.AuthMiddleware) *ActivityLogController { + ac := &ActivityLogController{ + service: as, + errorHandler: error_handler.NewControllerErrorHandler(), + } + + // Per-server activity log: GET /server/:id/activity-log + routeGroups.ActivityLog.Use(auth.Authenticate) + routeGroups.ActivityLog.Get("/", ac.GetServerLogs) + + // Global activity log: GET /activity-log (admin use) + routeGroups.Groups.Group("/activity-log").Use(auth.Authenticate).Get("/", ac.GetAllLogs) + + return ac +} + +// GetServerLogs returns activity logs for a specific server. +// +// @Summary Return activity logs for a server +// @Tags ActivityLog +// @Success 200 {array} model.ActivityLog +// @Router /server/{id}/activity-log [get] +func (ac *ActivityLogController) GetServerLogs(c *fiber.Ctx) error { + var filter model.ActivityLogFilter + if err := common.ParseQueryFilter(c, &filter); err != nil { + return ac.errorHandler.HandleValidationError(c, err, "query_filter") + } + + result, err := ac.service.GetAll(c.UserContext(), &filter) + if err != nil { + return ac.errorHandler.HandleServiceError(c, err) + } + + return c.JSON(result) +} + +// GetAllLogs returns activity logs across all servers (admin). +// +// @Summary Return all activity logs +// @Tags ActivityLog +// @Success 200 {array} model.ActivityLog +// @Router /activity-log [get] +func (ac *ActivityLogController) GetAllLogs(c *fiber.Ctx) error { + var filter model.ActivityLogFilter + if err := common.ParseQueryFilter(c, &filter); err != nil { + return ac.errorHandler.HandleValidationError(c, err, "query_filter") + } + + result, err := ac.service.GetAll(c.UserContext(), &filter) + if err != nil { + return ac.errorHandler.HandleServiceError(c, err) + } + + return c.JSON(result) +} diff --git a/local/controller/controller.go b/local/controller/controller.go index 79af86b..7348963 100755 --- a/local/controller/controller.go +++ b/local/controller/controller.go @@ -69,4 +69,9 @@ func InitializeControllers(c *dig.Container) { if err != nil { logging.Panic("unable to initialize steam controller") } + + err = c.Invoke(NewActivityLogController) + if err != nil { + logging.Panic("unable to initialize activity log controller") + } } diff --git a/local/controller/leaderboard.go b/local/controller/leaderboard.go index d985fd9..a9074da 100755 --- a/local/controller/leaderboard.go +++ b/local/controller/leaderboard.go @@ -11,6 +11,7 @@ import ( "github.com/google/uuid" ) + type LeaderboardController struct { service *service.LeaderboardService errorHandler *error_handler.ControllerErrorHandler @@ -60,7 +61,14 @@ func (lc *LeaderboardController) Update(c *fiber.Ctx) error { return lc.errorHandler.HandleParsingError(c, err) } - data, err := lc.service.Update(c.UserContext(), serverID, &input) + userInfo, _ := c.Locals("userInfo").(*middleware.CachedUserInfo) + actorID, actorUsername := "", "system" + if userInfo != nil { + actorID = userInfo.UserID + actorUsername = userInfo.Username + } + + data, err := lc.service.Update(c.UserContext(), serverID, &input, actorID, actorUsername) if err != nil { return lc.errorHandler.HandleServiceError(c, err) } diff --git a/local/migrations/006_add_activity_log.go b/local/migrations/006_add_activity_log.go new file mode 100644 index 0000000..952f80c --- /dev/null +++ b/local/migrations/006_add_activity_log.go @@ -0,0 +1,33 @@ +package migrations + +import ( + "acc-server-manager/local/utl/logging" + "fmt" + + "gorm.io/gorm" +) + +func RunAddActivityLogMigration(db *gorm.DB) error { + if err := db.AutoMigrate(&MigrationRecord{}); err != nil { + return fmt.Errorf("failed to ensure migration_records table: %v", err) + } + + var record MigrationRecord + if err := db.Where("migration_name = ?", "006_add_activity_log").First(&record).Error; err == nil { + logging.Info("Activity log migration already applied, skipping") + return nil + } + + record = MigrationRecord{ + MigrationName: "006_add_activity_log", + AppliedAt: "datetime('now')", + Success: true, + Notes: "Added ActivityLog table via GORM AutoMigrate", + } + if err := db.Create(&record).Error; err != nil { + return fmt.Errorf("failed to record migration 006: %v", err) + } + + logging.Info("Activity log migration recorded") + return nil +} diff --git a/local/model/activity_log.go b/local/model/activity_log.go new file mode 100644 index 0000000..110bbaa --- /dev/null +++ b/local/model/activity_log.go @@ -0,0 +1,81 @@ +package model + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type ActionType string + +const ( + ActionConfigUpdate ActionType = "config_update" + ActionServerStart ActionType = "server_start" + ActionServerStop ActionType = "server_stop" + ActionServerRestart ActionType = "server_restart" + ActionLeaderboardUpdate ActionType = "leaderboard_update" +) + +type ActivityLog struct { + ID uuid.UUID `json:"id" gorm:"type:uuid;primary_key;"` + ServerID uuid.UUID `json:"serverId" gorm:"not null;type:uuid;index"` + UserID string `json:"userId" gorm:"not null"` + Username string `json:"username" gorm:"not null"` + Action ActionType `json:"action" gorm:"not null"` + Details string `json:"details" gorm:"type:text"` + CreatedAt time.Time `json:"createdAt"` +} + +func (a *ActivityLog) BeforeCreate(tx *gorm.DB) error { + if a.ID == uuid.Nil { + a.ID = uuid.New() + } + if a.CreatedAt.IsZero() { + a.CreatedAt = time.Now().UTC() + } + return nil +} + +type ActivityLogFilter struct { + BaseFilter + ServerBasedFilter + DateRangeFilter + UserID string `query:"user_id"` + Action ActionType `query:"action"` +} + +func (f *ActivityLogFilter) ApplyFilter(query *gorm.DB) *gorm.DB { + if f.ServerID != "" { + if serverUUID, err := uuid.Parse(f.ServerID); err == nil { + query = query.Where("server_id = ?", serverUUID) + } + } + if f.UserID != "" { + query = query.Where("user_id = ?", f.UserID) + } + if f.Action != "" { + query = query.Where("action = ?", f.Action) + } + timeZero := time.Time{} + if f.StartDate != timeZero { + query = query.Where("created_at >= ?", f.StartDate) + } + if f.EndDate != timeZero { + query = query.Where("created_at <= ?", f.EndDate) + } + return query +} + +func (f *ActivityLogFilter) GetSorting() (field string, desc bool) { + if f.SortBy == "" { + return "created_at", true + } + return f.SortBy, f.SortDesc +} + +type LastActivityInfo struct { + UpdatedAt time.Time `json:"updatedAt"` + Username string `json:"username"` + Action ActionType `json:"action"` +} diff --git a/local/model/server.go b/local/model/server.go index 4693d6c..2c0cb18 100755 --- a/local/model/server.go +++ b/local/model/server.go @@ -35,18 +35,19 @@ func (s *Server) ToServerAPI() *ServerAPI { } type Server struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"` - Name string `gorm:"not null" json:"name"` - Status ServiceStatus `json:"status" gorm:"-"` - IP string `gorm:"not null" json:"-"` - Port int `gorm:"not null" json:"-"` - Path string `gorm:"not null" json:"path"` - ServiceName string `gorm:"not null" json:"serviceName"` - Platform string `gorm:"not null;default:'windows'" json:"platform"` - ContainerID string `gorm:"default:''" json:"-"` - State *ServerState `gorm:"-" json:"state"` - DateCreated time.Time `json:"dateCreated"` - FromSteamCMD bool `gorm:"not null; default:true" json:"-"` + ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"` + Name string `gorm:"not null" json:"name"` + Status ServiceStatus `json:"status" gorm:"-"` + IP string `gorm:"not null" json:"-"` + Port int `gorm:"not null" json:"-"` + Path string `gorm:"not null" json:"path"` + ServiceName string `gorm:"not null" json:"serviceName"` + Platform string `gorm:"not null;default:'windows'" json:"platform"` + ContainerID string `gorm:"default:''" json:"-"` + State *ServerState `gorm:"-" json:"state"` + LastActivity *LastActivityInfo `gorm:"-" json:"lastActivity,omitempty"` + DateCreated time.Time `json:"dateCreated"` + FromSteamCMD bool `gorm:"not null; default:true" json:"-"` } type PlayerState struct { diff --git a/local/repository/activity_log.go b/local/repository/activity_log.go new file mode 100644 index 0000000..9c27f91 --- /dev/null +++ b/local/repository/activity_log.go @@ -0,0 +1,75 @@ +package repository + +import ( + "acc-server-manager/local/model" + "context" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type ActivityLogRepository struct { + *BaseRepository[model.ActivityLog, model.ActivityLogFilter] +} + +func NewActivityLogRepository(db *gorm.DB) *ActivityLogRepository { + return &ActivityLogRepository{ + BaseRepository: NewBaseRepository[model.ActivityLog, model.ActivityLogFilter](db, model.ActivityLog{}), + } +} + +func (r *ActivityLogRepository) Insert(ctx context.Context, entry *model.ActivityLog) error { + return r.BaseRepository.Insert(ctx, entry) +} + +func (r *ActivityLogRepository) GetAll(ctx context.Context, filter *model.ActivityLogFilter) (*[]model.ActivityLog, error) { + return r.BaseRepository.GetAll(ctx, filter) +} + +type lastActivityRow struct { + ServerID string `gorm:"column:server_id"` + CreatedAt time.Time `gorm:"column:created_at"` + Username string `gorm:"column:username"` + Action model.ActionType `gorm:"column:action"` +} + +// GetLastActivityByServerIDs returns the most recent activity entry per server. +// Uses a single GROUP BY query with MAX(created_at); SQLite selects the other +// columns from the row that holds the max value — documented SQLite behaviour. +func (r *ActivityLogRepository) GetLastActivityByServerIDs(ctx context.Context, serverIDs []uuid.UUID) (map[uuid.UUID]*model.LastActivityInfo, error) { + out := make(map[uuid.UUID]*model.LastActivityInfo) + if len(serverIDs) == 0 { + return out, nil + } + + // Convert to string slice so the IN clause works with SQLite UUID strings. + ids := make([]string, len(serverIDs)) + for i, id := range serverIDs { + ids[i] = id.String() + } + + var rows []lastActivityRow + err := r.BaseRepository.db.WithContext(ctx). + Model(&model.ActivityLog{}). + Select("server_id, MAX(created_at) as created_at, username, action"). + Where("server_id IN ?", ids). + Group("server_id"). + Scan(&rows).Error + if err != nil { + return nil, err + } + + for _, row := range rows { + serverUUID, err := uuid.Parse(row.ServerID) + if err != nil { + continue + } + out[serverUUID] = &model.LastActivityInfo{ + UpdatedAt: row.CreatedAt, + Username: row.Username, + Action: row.Action, + } + } + return out, nil +} diff --git a/local/repository/repository.go b/local/repository/repository.go index c8feb17..66833c9 100755 --- a/local/repository/repository.go +++ b/local/repository/repository.go @@ -20,6 +20,7 @@ func InitializeRepositories(c *dig.Container) { c.Provide(NewSteamCredentialsRepository) c.Provide(NewMembershipRepository) c.Provide(NewLeaderboardRepository) + c.Provide(NewActivityLogRepository) if err := c.Provide(func() *model.Steam2FAManager { manager := model.NewSteam2FAManager() diff --git a/local/service/activity_log.go b/local/service/activity_log.go new file mode 100644 index 0000000..7883efb --- /dev/null +++ b/local/service/activity_log.go @@ -0,0 +1,62 @@ +package service + +import ( + "acc-server-manager/local/model" + "acc-server-manager/local/repository" + "acc-server-manager/local/utl/logging" + "context" + "encoding/json" + "fmt" +) + +type ActivityLogService struct { + repo *repository.ActivityLogRepository +} + +func NewActivityLogService(repo *repository.ActivityLogRepository) *ActivityLogService { + return &ActivityLogService{repo: repo} +} + +// Log persists an activity log entry. Errors are logged but not returned so +// callers can treat this as fire-and-forget. +func (s *ActivityLogService) Log(ctx context.Context, entry *model.ActivityLog) error { + if err := s.repo.Insert(ctx, entry); err != nil { + logging.Error("Failed to insert activity log: %v", err) + return err + } + return nil +} + +func (s *ActivityLogService) GetAll(ctx context.Context, filter *model.ActivityLogFilter) (*[]model.ActivityLog, error) { + return s.repo.GetAll(ctx, filter) +} + +// BuildConfigDetails produces a JSON string summarising which top-level keys +// changed between oldJSON and newJSON. +func BuildConfigDetails(file string, oldJSON, newJSON []byte) string { + var oldMap, newMap map[string]interface{} + + if err := json.Unmarshal(oldJSON, &oldMap); err != nil { + oldMap = map[string]interface{}{} + } + if err := json.Unmarshal(newJSON, &newMap); err != nil { + newMap = map[string]interface{}{} + } + + var changedKeys []string + for key, newVal := range newMap { + oldVal, exists := oldMap[key] + if !exists { + changedKeys = append(changedKeys, key) + continue + } + oldBytes, _ := json.Marshal(oldVal) + newBytes, _ := json.Marshal(newVal) + if string(oldBytes) != string(newBytes) { + changedKeys = append(changedKeys, key) + } + } + + keysJSON, _ := json.Marshal(changedKeys) + return fmt.Sprintf(`{"file":%q,"changed_keys":%s}`, file, string(keysJSON)) +} diff --git a/local/service/config.go b/local/service/config.go index 4b9c8ea..bc5a841 100755 --- a/local/service/config.go +++ b/local/service/config.go @@ -1,6 +1,7 @@ package service import ( + "acc-server-manager/local/middleware" "acc-server-manager/local/model" "acc-server-manager/local/repository" "acc-server-manager/local/utl/common" @@ -68,14 +69,16 @@ type ConfigService struct { repository *repository.ConfigRepository serverRepository *repository.ServerRepository serverService *ServerService + activityLog *ActivityLogService configCache *model.ServerConfigCache } -func NewConfigService(repository *repository.ConfigRepository, serverRepository *repository.ServerRepository) *ConfigService { +func NewConfigService(repository *repository.ConfigRepository, serverRepository *repository.ServerRepository, activityLog *ActivityLogService) *ConfigService { logging.Debug("Initializing ConfigService with 5m expiration and 1s throttle") return &ConfigService{ repository: repository, serverRepository: serverRepository, + activityLog: activityLog, configCache: model.NewServerConfigCache(model.CacheConfig{ ExpirationTime: 5 * time.Minute, ThrottleTime: 1 * time.Second, @@ -120,6 +123,13 @@ func (as *ConfigService) UpdateAllConfigs(ctx *fiber.Ctx, req *model.Configurati {SettingsJson, req.Settings}, } + userInfo, _ := ctx.Locals("userInfo").(*middleware.CachedUserInfo) + actorID, actorUsername := "", "system" + if userInfo != nil { + actorID = userInfo.UserID + actorUsername = userInfo.Username + } + var results []*model.Config for _, entry := range entries { if entry.body == nil { @@ -137,6 +147,17 @@ func (as *ConfigService) UpdateAllConfigs(ctx *fiber.Ctx, req *model.Configurati ChangedAt: time.Now(), }) results = append(results, result) + + details := BuildConfigDetails(entry.fileName, oldDataUTF8, newData) + go func(details string) { + _ = as.activityLog.Log(context.Background(), &model.ActivityLog{ + ServerID: serverUUID, + UserID: actorID, + Username: actorUsername, + Action: model.ActionConfigUpdate, + Details: details, + }) + }(details) } if len(results) > 0 { @@ -152,7 +173,14 @@ func (as *ConfigService) UpdateConfig(ctx *fiber.Ctx, body *map[string]interface configFile := ctx.Params("file") override := ctx.QueryBool("override", false) - return as.updateConfigInternal(ctx.UserContext(), serverID, configFile, body, override) + userInfo, _ := ctx.Locals("userInfo").(*middleware.CachedUserInfo) + actorID, actorUsername := "", "system" + if userInfo != nil { + actorID = userInfo.UserID + actorUsername = userInfo.Username + } + + return as.updateConfigInternal(ctx.UserContext(), serverID, configFile, body, override, actorID, actorUsername) } func (as *ConfigService) updateConfigFiles(ctx context.Context, server *model.Server, configFile string, body *map[string]interface{}, override bool) ([]byte, []byte, error) { @@ -211,7 +239,7 @@ func (as *ConfigService) updateConfigFiles(ctx context.Context, server *model.Se return oldDataUTF8, newData, nil } -func (as *ConfigService) updateConfigInternal(ctx context.Context, serverID string, configFile string, body *map[string]interface{}, override bool) (*model.Config, error) { +func (as *ConfigService) updateConfigInternal(ctx context.Context, serverID string, configFile string, body *map[string]interface{}, override bool, actorID, actorUsername string) (*model.Config, error) { serverUUID, err := uuid.Parse(serverID) if err != nil { logging.Error("Invalid server ID format: %v", err) @@ -230,8 +258,19 @@ func (as *ConfigService) updateConfigInternal(ctx context.Context, serverID stri } as.configCache.InvalidateServerCache(serverID) - as.serverService.StartAccServerRuntime(server) + + details := BuildConfigDetails(configFile, oldDataUTF8, newData) + go func() { + _ = as.activityLog.Log(context.Background(), &model.ActivityLog{ + ServerID: serverUUID, + UserID: actorID, + Username: actorUsername, + Action: model.ActionConfigUpdate, + Details: details, + }) + }() + return as.repository.UpdateConfig(ctx, &model.Config{ ServerID: serverUUID, ConfigFile: configFile, diff --git a/local/service/leaderboard.go b/local/service/leaderboard.go index 64cdae0..f43437d 100755 --- a/local/service/leaderboard.go +++ b/local/service/leaderboard.go @@ -3,6 +3,7 @@ package service import ( "acc-server-manager/local/model" "acc-server-manager/local/repository" + "acc-server-manager/local/utl/logging" "context" "fmt" @@ -10,11 +11,12 @@ import ( ) type LeaderboardService struct { - repo *repository.LeaderboardRepository + repo *repository.LeaderboardRepository + activityLog *ActivityLogService } -func NewLeaderboardService(repo *repository.LeaderboardRepository) *LeaderboardService { - return &LeaderboardService{repo: repo} +func NewLeaderboardService(repo *repository.LeaderboardRepository, activityLog *ActivityLogService) *LeaderboardService { + return &LeaderboardService{repo: repo, activityLog: activityLog} } // Get returns the leaderboard for a server. @@ -23,7 +25,7 @@ func (s *LeaderboardService) Get(ctx context.Context, serverID uuid.UUID) (*mode } // Update replaces the leaderboard for a server. -func (s *LeaderboardService) Update(ctx context.Context, serverID uuid.UUID, lb *model.Leaderboard) (*model.Leaderboard, error) { +func (s *LeaderboardService) Update(ctx context.Context, serverID uuid.UUID, lb *model.Leaderboard, actorID, actorUsername string) (*model.Leaderboard, error) { if lb == nil { return nil, fmt.Errorf("input is required") } @@ -33,5 +35,25 @@ func (s *LeaderboardService) Update(ctx context.Context, serverID uuid.UUID, lb for i := range lb.Races { lb.Races[i].Position = i } - return s.repo.FullReplace(ctx, serverID, lb) + result, err := s.repo.FullReplace(ctx, serverID, lb) + if err != nil { + return nil, err + } + + driverCount := len(lb.Drivers) + raceCount := len(lb.Races) + details := fmt.Sprintf(`{"driver_count":%d,"race_count":%d}`, driverCount, raceCount) + go func() { + if logErr := s.activityLog.Log(context.Background(), &model.ActivityLog{ + ServerID: serverID, + UserID: actorID, + Username: actorUsername, + Action: model.ActionLeaderboardUpdate, + Details: details, + }); logErr != nil { + logging.Error("Failed to log leaderboard update: %v", logErr) + } + }() + + return result, nil } diff --git a/local/service/server.go b/local/service/server.go index 84e8325..9b4b761 100755 --- a/local/service/server.go +++ b/local/service/server.go @@ -26,12 +26,13 @@ const ( type ServerService struct { repository *repository.ServerRepository stateHistoryRepo *repository.StateHistoryRepository + activityLogRepo *repository.ActivityLogRepository apiService *ServiceControlService configService *ConfigService steamService *SteamService runtime platform.ServerRuntime firewall platform.FirewallManager - logStreamer platform.LogStreamer + logStreamer platform.LogStreamer portPool *network.PortPoolManager webSocketService *WebSocketService instances sync.Map // uuid.UUID → *tracking.AccServerInstance @@ -56,6 +57,7 @@ func (s *ServerService) ensureLogTailing(server *model.Server, instance *trackin func NewServerService( repository *repository.ServerRepository, stateHistoryRepo *repository.StateHistoryRepository, + activityLogRepo *repository.ActivityLogRepository, apiService *ServiceControlService, configService *ConfigService, steamService *SteamService, @@ -68,6 +70,7 @@ func NewServerService( service := &ServerService{ repository: repository, stateHistoryRepo: stateHistoryRepo, + activityLogRepo: activityLogRepo, apiService: apiService, configService: configService, steamService: steamService, @@ -266,6 +269,16 @@ func (s *ServerService) GetAll(ctx *fiber.Ctx, filter *model.ServerFilter) (*[]m return nil, err } + // Collect server IDs for a single last-activity lookup. + serverIDs := make([]uuid.UUID, len(*servers)) + for i, srv := range *servers { + serverIDs[i] = srv.ID + } + lastActivities, laErr := s.activityLogRepo.GetLastActivityByServerIDs(ctx.UserContext(), serverIDs) + if laErr != nil { + logging.Error("Failed to get last activities for server list: %v", laErr) + } + for i := range *servers { server := &(*servers)[i] status, err := s.apiService.GetCachedStatus(server.ServiceName) @@ -282,6 +295,11 @@ func (s *ServerService) GetAll(ctx *fiber.Ctx, filter *model.ServerFilter) (*[]m server.State = serverInstance.State } } + if lastActivities != nil { + if info, ok := lastActivities[server.ID]; ok { + server.LastActivity = info + } + } } return servers, nil diff --git a/local/service/service.go b/local/service/service.go index d95639f..2f51d4e 100755 --- a/local/service/service.go +++ b/local/service/service.go @@ -23,6 +23,7 @@ func InitializeServices(c *dig.Container) { logging.Debug("Registering services") c.Provide(NewSteamService) + c.Provide(NewActivityLogService) c.Provide(NewServerService) c.Provide(NewStateHistoryService) c.Provide(NewServiceControlService) diff --git a/local/service/service_control.go b/local/service/service_control.go index 7641357..95c6816 100755 --- a/local/service/service_control.go +++ b/local/service/service_control.go @@ -1,6 +1,7 @@ package service import ( + "acc-server-manager/local/middleware" "acc-server-manager/local/model" "acc-server-manager/local/platform" "acc-server-manager/local/repository" @@ -15,6 +16,7 @@ type ServiceControlService struct { repository *repository.ServiceControlRepository serverRepository *repository.ServerRepository serverService *ServerService + activityLog *ActivityLogService statusCache *model.ServerStatusCache runtime platform.ServerRuntime } @@ -23,11 +25,13 @@ func NewServiceControlService( repository *repository.ServiceControlRepository, serverRepository *repository.ServerRepository, runtime platform.ServerRuntime, + activityLog *ActivityLogService, ) *ServiceControlService { return &ServiceControlService{ repository: repository, serverRepository: serverRepository, runtime: runtime, + activityLog: activityLog, statusCache: model.NewServerStatusCache(model.CacheConfig{ ExpirationTime: 30 * time.Second, ThrottleTime: 5 * time.Second, @@ -36,6 +40,27 @@ func NewServiceControlService( } } +// getServerFromCtx resolves the server from ctx locals (serverId or service name). +func (as *ServiceControlService) getServerFromCtx(ctx *fiber.Ctx) (*model.Server, error) { + serviceName, ok := ctx.Locals("service").(string) + if !ok || serviceName == "" { + serverId, ok2 := ctx.Locals("serverId").(string) + if !ok2 || serverId == "" { + return nil, errors.New("service name missing") + } + return as.serverRepository.GetByID(ctx.UserContext(), serverId) + } + return as.serverRepository.GetFirstByServiceName(ctx.UserContext(), serviceName) +} + +func extractActor(ctx *fiber.Ctx) (actorID, actorUsername string) { + userInfo, _ := ctx.Locals("userInfo").(*middleware.CachedUserInfo) + if userInfo != nil { + return userInfo.UserID, userInfo.Username + } + return "", "system" +} + func (as *ServiceControlService) SetServerService(serverService *ServerService) { as.serverService = serverService } @@ -61,10 +86,11 @@ func (as *ServiceControlService) GetStatus(ctx *fiber.Ctx) (string, error) { } func (as *ServiceControlService) ServiceControlStartServer(ctx *fiber.Ctx) (string, error) { - serviceName, err := as.GetServiceName(ctx) + server, err := as.getServerFromCtx(ctx) if err != nil { return "", err } + serviceName := server.ServiceName as.statusCache.UpdateStatus(serviceName, model.StatusStarting) @@ -79,14 +105,27 @@ func (as *ServiceControlService) ServiceControlStartServer(ctx *fiber.Ctx) (stri status := model.ParseServiceStatus(statusStr) as.statusCache.UpdateStatus(serviceName, status) + + actorID, actorUsername := extractActor(ctx) + go func() { + _ = as.activityLog.Log(context.Background(), &model.ActivityLog{ + ServerID: server.ID, + UserID: actorID, + Username: actorUsername, + Action: model.ActionServerStart, + Details: `{"service":"` + serviceName + `"}`, + }) + }() + return status.String(), nil } func (as *ServiceControlService) ServiceControlStopServer(ctx *fiber.Ctx) (string, error) { - serviceName, err := as.GetServiceName(ctx) + server, err := as.getServerFromCtx(ctx) if err != nil { return "", err } + serviceName := server.ServiceName as.statusCache.UpdateStatus(serviceName, model.StatusStopping) @@ -101,14 +140,27 @@ func (as *ServiceControlService) ServiceControlStopServer(ctx *fiber.Ctx) (strin status := model.ParseServiceStatus(statusStr) as.statusCache.UpdateStatus(serviceName, status) + + actorID, actorUsername := extractActor(ctx) + go func() { + _ = as.activityLog.Log(context.Background(), &model.ActivityLog{ + ServerID: server.ID, + UserID: actorID, + Username: actorUsername, + Action: model.ActionServerStop, + Details: `{"service":"` + serviceName + `"}`, + }) + }() + return status.String(), nil } func (as *ServiceControlService) ServiceControlRestartServer(ctx *fiber.Ctx) (string, error) { - serviceName, err := as.GetServiceName(ctx) + server, err := as.getServerFromCtx(ctx) if err != nil { return "", err } + serviceName := server.ServiceName as.statusCache.UpdateStatus(serviceName, model.StatusRestarting) @@ -123,6 +175,18 @@ func (as *ServiceControlService) ServiceControlRestartServer(ctx *fiber.Ctx) (st status := model.ParseServiceStatus(statusStr) as.statusCache.UpdateStatus(serviceName, status) + + actorID, actorUsername := extractActor(ctx) + go func() { + _ = as.activityLog.Log(context.Background(), &model.ActivityLog{ + ServerID: server.ID, + UserID: actorID, + Username: actorUsername, + Action: model.ActionServerRestart, + Details: `{"service":"` + serviceName + `"}`, + }) + }() + return status.String(), nil } diff --git a/local/utl/common/common.go b/local/utl/common/common.go index 0ad0917..04014f6 100755 --- a/local/utl/common/common.go +++ b/local/utl/common/common.go @@ -28,6 +28,8 @@ type RouteGroups struct { WebSocket fiber.Router Leaderboard fiber.Router Steam fiber.Router + ActivityLog fiber.Router + Groups fiber.Router } func CheckError(err error) { diff --git a/local/utl/db/db.go b/local/utl/db/db.go index 0500181..aefcf60 100755 --- a/local/utl/db/db.go +++ b/local/utl/db/db.go @@ -54,6 +54,7 @@ func Migrate(db *gorm.DB) { &model.LeaderboardRace{}, &model.LeaderboardResult{}, &model.LeaderboardPointRow{}, + &model.ActivityLog{}, ) if err != nil { @@ -80,6 +81,10 @@ func runMigrations(db *gorm.DB) { logging.Error("Failed to run platform columns migration: %v", err) } + if err := migrations.RunAddActivityLogMigration(db); err != nil { + logging.Error("Failed to run activity log migration: %v", err) + } + logging.Info("Custom database migrations completed") } From 14aa8a84e53e6043fa31a0e34cf122d02677624f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=20Jurmanovi=C4=87?= Date: Sun, 22 Mar 2026 13:54:43 +0100 Subject: [PATCH 13/21] feat: add logging to existing services --- local/middleware/auth.go | 2 + local/service/config.go | 63 ++++++++++--------- local/service/service_control.go | 76 ++++++++++++----------- tests/unit/service/config_service_test.go | 14 ++--- 4 files changed, 82 insertions(+), 73 deletions(-) diff --git a/local/middleware/auth.go b/local/middleware/auth.go index 7abc5fb..fbb0ec3 100755 --- a/local/middleware/auth.go +++ b/local/middleware/auth.go @@ -115,6 +115,7 @@ func (m *AuthMiddleware) AuthenticateWithHandler(jwtHandler *jwt.JWTHandler, isO if os.Getenv("TESTING_ENV") == "true" { userInfo := CachedUserInfo{UserID: uuid.New().String(), Username: "test@example.com", RoleName: "Admin", Permissions: make(map[string]bool), CachedAt: time.Now()} ctx.Locals("userID", userInfo.UserID) + ctx.Locals("actorUsername", userInfo.Username) ctx.Locals("userInfo", userInfo) ctx.Locals("authTime", time.Now()) } else { @@ -127,6 +128,7 @@ func (m *AuthMiddleware) AuthenticateWithHandler(jwtHandler *jwt.JWTHandler, isO } ctx.Locals("userID", claims.UserID) + ctx.Locals("actorUsername", userInfo.Username) ctx.Locals("userInfo", userInfo) ctx.Locals("authTime", time.Now()) } diff --git a/local/service/config.go b/local/service/config.go index bc5a841..9a190a1 100755 --- a/local/service/config.go +++ b/local/service/config.go @@ -1,7 +1,6 @@ package service import ( - "acc-server-manager/local/middleware" "acc-server-manager/local/model" "acc-server-manager/local/repository" "acc-server-manager/local/utl/common" @@ -123,11 +122,10 @@ func (as *ConfigService) UpdateAllConfigs(ctx *fiber.Ctx, req *model.Configurati {SettingsJson, req.Settings}, } - userInfo, _ := ctx.Locals("userInfo").(*middleware.CachedUserInfo) - actorID, actorUsername := "", "system" - if userInfo != nil { - actorID = userInfo.UserID - actorUsername = userInfo.Username + actorID, _ := ctx.Locals("userID").(string) + actorUsername, _ := ctx.Locals("actorUsername").(string) + if actorUsername == "" { + actorUsername = "system" } var results []*model.Config @@ -148,16 +146,18 @@ func (as *ConfigService) UpdateAllConfigs(ctx *fiber.Ctx, req *model.Configurati }) results = append(results, result) - details := BuildConfigDetails(entry.fileName, oldDataUTF8, newData) - go func(details string) { - _ = as.activityLog.Log(context.Background(), &model.ActivityLog{ - ServerID: serverUUID, - UserID: actorID, - Username: actorUsername, - Action: model.ActionConfigUpdate, - Details: details, - }) - }(details) + if as.activityLog != nil { + details := BuildConfigDetails(entry.fileName, oldDataUTF8, newData) + go func(details string) { + _ = as.activityLog.Log(context.Background(), &model.ActivityLog{ + ServerID: serverUUID, + UserID: actorID, + Username: actorUsername, + Action: model.ActionConfigUpdate, + Details: details, + }) + }(details) + } } if len(results) > 0 { @@ -173,11 +173,10 @@ func (as *ConfigService) UpdateConfig(ctx *fiber.Ctx, body *map[string]interface configFile := ctx.Params("file") override := ctx.QueryBool("override", false) - userInfo, _ := ctx.Locals("userInfo").(*middleware.CachedUserInfo) - actorID, actorUsername := "", "system" - if userInfo != nil { - actorID = userInfo.UserID - actorUsername = userInfo.Username + actorID, _ := ctx.Locals("userID").(string) + actorUsername, _ := ctx.Locals("actorUsername").(string) + if actorUsername == "" { + actorUsername = "system" } return as.updateConfigInternal(ctx.UserContext(), serverID, configFile, body, override, actorID, actorUsername) @@ -260,16 +259,18 @@ func (as *ConfigService) updateConfigInternal(ctx context.Context, serverID stri as.configCache.InvalidateServerCache(serverID) as.serverService.StartAccServerRuntime(server) - details := BuildConfigDetails(configFile, oldDataUTF8, newData) - go func() { - _ = as.activityLog.Log(context.Background(), &model.ActivityLog{ - ServerID: serverUUID, - UserID: actorID, - Username: actorUsername, - Action: model.ActionConfigUpdate, - Details: details, - }) - }() + if as.activityLog != nil { + details := BuildConfigDetails(configFile, oldDataUTF8, newData) + go func() { + _ = as.activityLog.Log(context.Background(), &model.ActivityLog{ + ServerID: serverUUID, + UserID: actorID, + Username: actorUsername, + Action: model.ActionConfigUpdate, + Details: details, + }) + }() + } return as.repository.UpdateConfig(ctx, &model.Config{ ServerID: serverUUID, diff --git a/local/service/service_control.go b/local/service/service_control.go index 95c6816..89ad683 100755 --- a/local/service/service_control.go +++ b/local/service/service_control.go @@ -1,7 +1,6 @@ package service import ( - "acc-server-manager/local/middleware" "acc-server-manager/local/model" "acc-server-manager/local/platform" "acc-server-manager/local/repository" @@ -54,11 +53,12 @@ func (as *ServiceControlService) getServerFromCtx(ctx *fiber.Ctx) (*model.Server } func extractActor(ctx *fiber.Ctx) (actorID, actorUsername string) { - userInfo, _ := ctx.Locals("userInfo").(*middleware.CachedUserInfo) - if userInfo != nil { - return userInfo.UserID, userInfo.Username + actorID, _ = ctx.Locals("userID").(string) + actorUsername, _ = ctx.Locals("actorUsername").(string) + if actorUsername == "" { + actorUsername = "system" } - return "", "system" + return } func (as *ServiceControlService) SetServerService(serverService *ServerService) { @@ -106,16 +106,18 @@ func (as *ServiceControlService) ServiceControlStartServer(ctx *fiber.Ctx) (stri status := model.ParseServiceStatus(statusStr) as.statusCache.UpdateStatus(serviceName, status) - actorID, actorUsername := extractActor(ctx) - go func() { - _ = as.activityLog.Log(context.Background(), &model.ActivityLog{ - ServerID: server.ID, - UserID: actorID, - Username: actorUsername, - Action: model.ActionServerStart, - Details: `{"service":"` + serviceName + `"}`, - }) - }() + if as.activityLog != nil { + actorID, actorUsername := extractActor(ctx) + go func() { + _ = as.activityLog.Log(context.Background(), &model.ActivityLog{ + ServerID: server.ID, + UserID: actorID, + Username: actorUsername, + Action: model.ActionServerStart, + Details: `{"service":"` + serviceName + `"}`, + }) + }() + } return status.String(), nil } @@ -141,16 +143,18 @@ func (as *ServiceControlService) ServiceControlStopServer(ctx *fiber.Ctx) (strin status := model.ParseServiceStatus(statusStr) as.statusCache.UpdateStatus(serviceName, status) - actorID, actorUsername := extractActor(ctx) - go func() { - _ = as.activityLog.Log(context.Background(), &model.ActivityLog{ - ServerID: server.ID, - UserID: actorID, - Username: actorUsername, - Action: model.ActionServerStop, - Details: `{"service":"` + serviceName + `"}`, - }) - }() + if as.activityLog != nil { + actorID, actorUsername := extractActor(ctx) + go func() { + _ = as.activityLog.Log(context.Background(), &model.ActivityLog{ + ServerID: server.ID, + UserID: actorID, + Username: actorUsername, + Action: model.ActionServerStop, + Details: `{"service":"` + serviceName + `"}`, + }) + }() + } return status.String(), nil } @@ -176,16 +180,18 @@ func (as *ServiceControlService) ServiceControlRestartServer(ctx *fiber.Ctx) (st status := model.ParseServiceStatus(statusStr) as.statusCache.UpdateStatus(serviceName, status) - actorID, actorUsername := extractActor(ctx) - go func() { - _ = as.activityLog.Log(context.Background(), &model.ActivityLog{ - ServerID: server.ID, - UserID: actorID, - Username: actorUsername, - Action: model.ActionServerRestart, - Details: `{"service":"` + serviceName + `"}`, - }) - }() + if as.activityLog != nil { + actorID, actorUsername := extractActor(ctx) + go func() { + _ = as.activityLog.Log(context.Background(), &model.ActivityLog{ + ServerID: server.ID, + UserID: actorID, + Username: actorUsername, + Action: model.ActionServerRestart, + Details: `{"service":"` + serviceName + `"}`, + }) + }() + } return status.String(), nil } diff --git a/tests/unit/service/config_service_test.go b/tests/unit/service/config_service_test.go index ecc3d41..5169a0d 100755 --- a/tests/unit/service/config_service_test.go +++ b/tests/unit/service/config_service_test.go @@ -20,7 +20,7 @@ func TestConfigService_GetConfiguration_ValidFile(t *testing.T) { configRepo := repository.NewConfigRepository(helper.DB) serverRepo := repository.NewServerRepository(helper.DB) - configService := service.NewConfigService(configRepo, serverRepo) + configService := service.NewConfigService(configRepo, serverRepo, nil) config, err := configService.GetConfiguration(helper.TestData.Server) tests.AssertNoError(t, err) @@ -43,7 +43,7 @@ func TestConfigService_GetEventConfig_ValidFile(t *testing.T) { configRepo := repository.NewConfigRepository(helper.DB) serverRepo := repository.NewServerRepository(helper.DB) - configService := service.NewConfigService(configRepo, serverRepo) + configService := service.NewConfigService(configRepo, serverRepo, nil) eventConfig, err := configService.GetEventConfig(helper.TestData.Server) tests.AssertNoError(t, err) @@ -73,7 +73,7 @@ func TestConfigService_SaveConfiguration_Success(t *testing.T) { configRepo := repository.NewConfigRepository(helper.DB) serverRepo := repository.NewServerRepository(helper.DB) - configService := service.NewConfigService(configRepo, serverRepo) + configService := service.NewConfigService(configRepo, serverRepo, nil) newConfig := &model.Configuration{ UdpPort: model.IntString(9999), @@ -115,7 +115,7 @@ func TestConfigService_LoadConfigs_Success(t *testing.T) { configRepo := repository.NewConfigRepository(helper.DB) serverRepo := repository.NewServerRepository(helper.DB) - configService := service.NewConfigService(configRepo, serverRepo) + configService := service.NewConfigService(configRepo, serverRepo, nil) configs, err := configService.LoadConfigs(helper.TestData.Server) tests.AssertNoError(t, err) @@ -142,7 +142,7 @@ func TestConfigService_MalformedJSON(t *testing.T) { configRepo := repository.NewConfigRepository(helper.DB) serverRepo := repository.NewServerRepository(helper.DB) - configService := service.NewConfigService(configRepo, serverRepo) + configService := service.NewConfigService(configRepo, serverRepo, nil) config, err := configService.GetConfiguration(helper.TestData.Server) if err == nil { @@ -245,7 +245,7 @@ func TestConfigService_Caching_Configuration(t *testing.T) { configRepo := repository.NewConfigRepository(helper.DB) serverRepo := repository.NewServerRepository(helper.DB) - configService := service.NewConfigService(configRepo, serverRepo) + configService := service.NewConfigService(configRepo, serverRepo, nil) config1, err := configService.GetConfiguration(helper.TestData.Server) tests.AssertNoError(t, err) @@ -276,7 +276,7 @@ func TestConfigService_Caching_EventConfig(t *testing.T) { configRepo := repository.NewConfigRepository(helper.DB) serverRepo := repository.NewServerRepository(helper.DB) - configService := service.NewConfigService(configRepo, serverRepo) + configService := service.NewConfigService(configRepo, serverRepo, nil) event1, err := configService.GetEventConfig(helper.TestData.Server) tests.AssertNoError(t, err) From a490a7339261bc858ae134038a2616d0db651d36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=20Jurmanovi=C4=87?= Date: Mon, 23 Mar 2026 21:16:59 +0100 Subject: [PATCH 14/21] fix: revert debug mode --- docker/acc-server/entrypoint.sh | 68 ++++----------------------------- 1 file changed, 8 insertions(+), 60 deletions(-) diff --git a/docker/acc-server/entrypoint.sh b/docker/acc-server/entrypoint.sh index 784273d..67d6e1b 100644 --- a/docker/acc-server/entrypoint.sh +++ b/docker/acc-server/entrypoint.sh @@ -1,78 +1,26 @@ #!/bin/bash set -e +# Use a display number derived from the server port to avoid conflicts +# between containers (each server gets its own unique display). DISPLAY_NUM=$((SERVER_PORT % 1000 + 100)) export DISPLAY=:${DISPLAY_NUM} +# Clean up any stale X lock from a previous container run. rm -f /tmp/.X${DISPLAY_NUM}-lock /tmp/.X11-unix/X${DISPLAY_NUM} 2>/dev/null || true Xvfb ${DISPLAY} -screen 0 1024x768x16 -nolisten tcp & XVFB_PID=$! -TCPDUMP_PID="" - cleanup() { echo "Stopping ACC server..." - [ -n "${TCPDUMP_PID}" ] && kill "${TCPDUMP_PID}" 2>/dev/null || true - kill "${XVFB_PID}" 2>/dev/null || true + kill $XVFB_PID 2>/dev/null || true } -trap cleanup SIGTERM SIGINT EXIT - -# Initialize Wine prefix (no-op if already done) -wineboot -u 2>/dev/null +trap cleanup SIGTERM SIGINT mkdir -p /acc/game/log -# ─── Debug mode ─────────────────────────────────────────────────────────────── -# Set DEBUG_MODE=1 on the container to enable extra diagnostics: -# -# docker run -e DEBUG_MODE=1 ... -# or in docker-compose: -# environment: -# DEBUG_MODE: "1" -# -# Produces: -# /acc/game/log/tcpdump_.pcap — raw packet capture (open in Wireshark) -# /acc/game/log/wine_debug.log — Wine debug output -# Stderr: Wine version, WINEDEBUG channel, prefix state -# -# You can also override which Wine debug channels are enabled: -# WINEDEBUG_CHANNELS="+loaddll,+seh,+relay" (relay is very verbose) -# ───────────────────────────────────────────────────────────────────────────── -if [ "${DEBUG_MODE:-0}" = "1" ]; then - echo "[debug] Debug mode enabled for port ${SERVER_PORT}" - echo "[debug] Wine: $(wine --version 2>/dev/null || echo unknown)" - echo "[debug] WINEPREFIX: ${WINEPREFIX}" +echo "Starting ACC server on port ${SERVER_PORT}..." - # Configure Wine debug channels (default: loader + exception + module info) - export WINEDEBUG="${WINEDEBUG_CHANNELS:-+loaddll,+module,+seh,+ntdll}" - echo "[debug] WINEDEBUG=${WINEDEBUG}" - - # Start packet capture on the server port — captures both TCP and UDP. - # The pcap file records every byte Kunos sends/receives so you can inspect - # the 2-byte "pakSizeMax" packet and anything that follows. - # - # NOTE: requires CAP_NET_RAW capability on the container: - # docker-compose: cap_add: [NET_RAW] - # docker run: --cap-add=NET_RAW - # Without it tcpdump will be skipped (no crash, just no capture). - CAPTURE_FILE="/acc/game/log/tcpdump_${SERVER_PORT}.pcap" - echo "[debug] Starting packet capture → ${CAPTURE_FILE}" - echo "[debug] (requires cap_add: [NET_RAW] in docker-compose — will exit silently if missing)" - tcpdump -i any -nn -s 0 -w "${CAPTURE_FILE}" \ - "port ${SERVER_PORT}" 2>/acc/game/log/tcpdump.err & - TCPDUMP_PID=$! - - # Redirect Wine debug output to a log file (Wine writes to stderr). - WINE_LOG="/acc/game/log/wine_debug.log" - echo "[debug] Wine debug log → ${WINE_LOG}" - echo "Starting ACC server on port ${SERVER_PORT} (DEBUG)..." - cd /acc/game - exec wine /acc/game/accServer.exe 2>"${WINE_LOG}" -else - # Production: suppress all Wine debug output. - export WINEDEBUG=-all - echo "Starting ACC server on port ${SERVER_PORT}..." - cd /acc/game - exec wine /acc/game/accServer.exe -fi +cd /acc/game +exec wine /acc/game/accServer.exe From 6c1e9b236e316cf897774a4413941958883bdce5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=20Jurmanovi=C4=87?= Date: Mon, 23 Mar 2026 21:18:02 +0100 Subject: [PATCH 15/21] fix: build errors --- local/controller/controller.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/local/controller/controller.go b/local/controller/controller.go index cd9fe4c..9984e65 100755 --- a/local/controller/controller.go +++ b/local/controller/controller.go @@ -73,7 +73,8 @@ func InitializeControllers(c *dig.Container) { err = c.Invoke(NewActivityLogController) if err != nil { logging.Panic("unable to initialize activity log controller") - + } + err = c.Invoke(NewLogController) if err != nil { logging.Panic("unable to initialize log controller") From 908adf4468e3fd02265e45020a13375f4039e433 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=20Jurmanovi=C4=87?= Date: Mon, 23 Mar 2026 21:31:32 +0100 Subject: [PATCH 16/21] fix: stream only new logs back to client after starting server --- local/service/docker_log_streamer.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/local/service/docker_log_streamer.go b/local/service/docker_log_streamer.go index 347ef8b..ba47bef 100644 --- a/local/service/docker_log_streamer.go +++ b/local/service/docker_log_streamer.go @@ -8,6 +8,7 @@ import ( "io" "strconv" "sync" + "time" "github.com/docker/docker/api/types" dockerclient "github.com/docker/docker/client" @@ -25,14 +26,14 @@ func NewDockerLogStreamer(client *dockerclient.Client) *DockerLogStreamer { } func (s *DockerLogStreamer) Start(ctx context.Context, server *model.Server, handleLine func(string)) error { - if _, exists := s.cancels.Load(server.ID); exists { - return nil - } + s.Stop(server.ID) + if server.ContainerID == "" { logging.Warn("DockerLogStreamer.Start: no container ID for server %s, skipping", server.ID) return nil } + since := time.Now().UTC().Format(time.RFC3339) streamCtx, cancel := context.WithCancel(ctx) s.cancels.Store(server.ID, cancel) @@ -44,6 +45,7 @@ func (s *DockerLogStreamer) Start(ctx context.Context, server *model.Server, han ShowStderr: true, Follow: true, Timestamps: false, + Since: since, }) if err != nil { logging.Error("DockerLogStreamer: failed to attach to container %s: %v", server.ContainerID[:12], err) From 0e04da031b380d884d5416990b9d5005842bbf38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=20Jurmanovi=C4=87?= Date: Mon, 23 Mar 2026 22:13:19 +0100 Subject: [PATCH 17/21] fix: resolve empty configs logging --- local/controller/activity_log.go | 24 ----------------- local/controller/service_control.go | 1 + local/model/activity_log.go | 7 ++--- local/repository/activity_log.go | 23 +++++++++++++++- local/service/activity_log.go | 3 +++ local/service/config.go | 42 +++++++++++++++-------------- 6 files changed, 52 insertions(+), 48 deletions(-) diff --git a/local/controller/activity_log.go b/local/controller/activity_log.go index 145eace..0711bb4 100644 --- a/local/controller/activity_log.go +++ b/local/controller/activity_log.go @@ -21,36 +21,12 @@ func NewActivityLogController(as *service.ActivityLogService, routeGroups *commo errorHandler: error_handler.NewControllerErrorHandler(), } - // Per-server activity log: GET /server/:id/activity-log - routeGroups.ActivityLog.Use(auth.Authenticate) - routeGroups.ActivityLog.Get("/", ac.GetServerLogs) - // Global activity log: GET /activity-log (admin use) routeGroups.Groups.Group("/activity-log").Use(auth.Authenticate).Get("/", ac.GetAllLogs) return ac } -// GetServerLogs returns activity logs for a specific server. -// -// @Summary Return activity logs for a server -// @Tags ActivityLog -// @Success 200 {array} model.ActivityLog -// @Router /server/{id}/activity-log [get] -func (ac *ActivityLogController) GetServerLogs(c *fiber.Ctx) error { - var filter model.ActivityLogFilter - if err := common.ParseQueryFilter(c, &filter); err != nil { - return ac.errorHandler.HandleValidationError(c, err, "query_filter") - } - - result, err := ac.service.GetAll(c.UserContext(), &filter) - if err != nil { - return ac.errorHandler.HandleServiceError(c, err) - } - - return c.JSON(result) -} - // GetAllLogs returns activity logs across all servers (admin). // // @Summary Return all activity logs diff --git a/local/controller/service_control.go b/local/controller/service_control.go index d010e35..eb3eca2 100755 --- a/local/controller/service_control.go +++ b/local/controller/service_control.go @@ -29,6 +29,7 @@ func NewServiceControlController(as *service.ServiceControlService, routeGroups } serviceRoutes := routeGroups.Server.Group("/:id/service") + serviceRoutes.Use(auth.Authenticate) serviceRoutes.Get("/:service", ac.getStatus) serviceRoutes.Post("/start", ac.startServer) serviceRoutes.Post("/stop", ac.stopServer) diff --git a/local/model/activity_log.go b/local/model/activity_log.go index 110bbaa..b925b1a 100644 --- a/local/model/activity_log.go +++ b/local/model/activity_log.go @@ -20,6 +20,7 @@ const ( type ActivityLog struct { ID uuid.UUID `json:"id" gorm:"type:uuid;primary_key;"` ServerID uuid.UUID `json:"serverId" gorm:"not null;type:uuid;index"` + Server *Server `json:"server,omitempty" gorm:"foreignKey:ServerID"` UserID string `json:"userId" gorm:"not null"` Username string `json:"username" gorm:"not null"` Action ActionType `json:"action" gorm:"not null"` @@ -39,10 +40,10 @@ func (a *ActivityLog) BeforeCreate(tx *gorm.DB) error { type ActivityLogFilter struct { BaseFilter - ServerBasedFilter DateRangeFilter - UserID string `query:"user_id"` - Action ActionType `query:"action"` + UserID string `query:"user_id"` + Action ActionType `query:"action"` + ServerID string `query:"server_id"` } func (f *ActivityLogFilter) ApplyFilter(query *gorm.DB) *gorm.DB { diff --git a/local/repository/activity_log.go b/local/repository/activity_log.go index 9c27f91..f71f577 100644 --- a/local/repository/activity_log.go +++ b/local/repository/activity_log.go @@ -3,6 +3,7 @@ package repository import ( "acc-server-manager/local/model" "context" + "fmt" "time" "github.com/google/uuid" @@ -24,7 +25,27 @@ func (r *ActivityLogRepository) Insert(ctx context.Context, entry *model.Activit } func (r *ActivityLogRepository) GetAll(ctx context.Context, filter *model.ActivityLogFilter) (*[]model.ActivityLog, error) { - return r.BaseRepository.GetAll(ctx, filter) + result := &[]model.ActivityLog{} + query := r.BaseRepository.db.WithContext(ctx). + Model(&model.ActivityLog{}). + Preload("Server") + + query = filter.ApplyFilter(query) + + offset, limit := filter.Pagination() + query = query.Offset(offset).Limit(limit) + + field, desc := filter.GetSorting() + if desc { + query = query.Order(field + " DESC") + } else { + query = query.Order(field) + } + + if err := query.Find(result).Error; err != nil { + return nil, fmt.Errorf("error getting activity logs: %w", err) + } + return result, nil } type lastActivityRow struct { diff --git a/local/service/activity_log.go b/local/service/activity_log.go index 7883efb..7ff0b00 100644 --- a/local/service/activity_log.go +++ b/local/service/activity_log.go @@ -57,6 +57,9 @@ func BuildConfigDetails(file string, oldJSON, newJSON []byte) string { } } + if len(changedKeys) == 0 { + return "" + } keysJSON, _ := json.Marshal(changedKeys) return fmt.Sprintf(`{"file":%q,"changed_keys":%s}`, file, string(keysJSON)) } diff --git a/local/service/config.go b/local/service/config.go index 1188b29..0bc67d0 100755 --- a/local/service/config.go +++ b/local/service/config.go @@ -157,16 +157,17 @@ func (as *ConfigService) UpdateAllConfigs(ctx *fiber.Ctx, req *model.Configurati results = append(results, result) if as.activityLog != nil { - details := BuildConfigDetails(entry.fileName, oldDataUTF8, newData) - go func(details string) { - _ = as.activityLog.Log(context.Background(), &model.ActivityLog{ - ServerID: serverUUID, - UserID: actorID, - Username: actorUsername, - Action: model.ActionConfigUpdate, - Details: details, - }) - }(details) + if details := BuildConfigDetails(entry.fileName, oldDataUTF8, newData); details != "" { + go func(details string) { + _ = as.activityLog.Log(context.Background(), &model.ActivityLog{ + ServerID: serverUUID, + UserID: actorID, + Username: actorUsername, + Action: model.ActionConfigUpdate, + Details: details, + }) + }(details) + } } } @@ -281,16 +282,17 @@ func (as *ConfigService) updateConfigInternal(ctx context.Context, serverID stri as.serverService.StartAccServerRuntime(server) if as.activityLog != nil { - details := BuildConfigDetails(configFile, oldDataUTF8, newData) - go func() { - _ = as.activityLog.Log(context.Background(), &model.ActivityLog{ - ServerID: serverUUID, - UserID: actorID, - Username: actorUsername, - Action: model.ActionConfigUpdate, - Details: details, - }) - }() + if details := BuildConfigDetails(configFile, oldDataUTF8, newData); details != "" { + go func() { + _ = as.activityLog.Log(context.Background(), &model.ActivityLog{ + ServerID: serverUUID, + UserID: actorID, + Username: actorUsername, + Action: model.ActionConfigUpdate, + Details: details, + }) + }() + } } return as.repository.UpdateConfig(ctx, &model.Config{ From efc6b728381ca27a0083cc8f67d0f3cbb7203c74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=20Jurmanovi=C4=87?= Date: Mon, 23 Mar 2026 22:14:55 +0100 Subject: [PATCH 18/21] bump version --- local/utl/configs/configs.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/local/utl/configs/configs.go b/local/utl/configs/configs.go index e4e011e..3b0bfaa 100755 --- a/local/utl/configs/configs.go +++ b/local/utl/configs/configs.go @@ -8,7 +8,7 @@ import ( ) var ( - Version = "0.12.3" + Version = "0.13.0" Prefix = "v1" Secret string SecretCode string From 180555c90334eb20f39ea4681aad8effe0e08354 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=20Jurmanovi=C4=87?= Date: Thu, 26 Mar 2026 21:45:15 +0100 Subject: [PATCH 19/21] feat: config presets --- local/api/api.go | 1 + local/controller/config_preset.go | 240 +++++++++++++++++++++ local/controller/controller.go | 5 + local/migrations/007_add_config_presets.go | 51 +++++ local/model/config_preset.go | 48 +++++ local/model/filter.go | 12 ++ local/model/permissions.go | 9 + local/repository/config_preset.go | 17 ++ local/repository/repository.go | 1 + local/service/config.go | 6 + local/service/config_preset.go | 176 +++++++++++++++ local/service/service.go | 1 + local/utl/common/common.go | 1 + local/utl/db/db.go | 5 + 14 files changed, 573 insertions(+) create mode 100644 local/controller/config_preset.go create mode 100644 local/migrations/007_add_config_presets.go create mode 100644 local/model/config_preset.go create mode 100644 local/repository/config_preset.go create mode 100644 local/service/config_preset.go diff --git a/local/api/api.go b/local/api/api.go index aca6eb3..d98697f 100755 --- a/local/api/api.go +++ b/local/api/api.go @@ -37,6 +37,7 @@ func Init(di *dig.Container, app *fiber.App) { ActivityLog: serverIdGroup.Group("/activity-log"), Groups: groups, Log: serverIdGroup.Group("/log"), + Presets: groups.Group("/presets"), } accessKeyMiddleware := middleware.NewAccessKeyMiddleware() diff --git a/local/controller/config_preset.go b/local/controller/config_preset.go new file mode 100644 index 0000000..7fb12e5 --- /dev/null +++ b/local/controller/config_preset.go @@ -0,0 +1,240 @@ +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" + "acc-server-manager/local/utl/logging" + + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" +) + +type ConfigPresetController struct { + service *service.ConfigPresetService + apiService *service.ServiceControlService + errorHandler *error_handler.ControllerErrorHandler +} + +// NewConfigPresetController initializes ConfigPresetController. +func NewConfigPresetController(s *service.ConfigPresetService, apiService *service.ServiceControlService, routeGroups *common.RouteGroups, auth *middleware.AuthMiddleware) *ConfigPresetController { + c := &ConfigPresetController{ + service: s, + apiService: apiService, + errorHandler: error_handler.NewControllerErrorHandler(), + } + + presetRoutes := routeGroups.Presets + presetRoutes.Use(auth.Authenticate) + presetRoutes.Get("/", auth.HasPermission(model.PresetView), c.ListPresets) + presetRoutes.Post("/", auth.HasPermission(model.PresetCreate), c.CreatePreset) + presetRoutes.Get("/:presetId", auth.HasPermission(model.PresetView), c.GetPreset) + presetRoutes.Put("/:presetId", auth.HasPermission(model.PresetUpdate), c.UpdatePreset) + presetRoutes.Delete("/:presetId", auth.HasPermission(model.PresetDelete), c.DeletePreset) + + configRoutes := routeGroups.Config + configRoutes.Post("/presets/:presetId/apply", auth.HasPermission(model.ConfigUpdate), c.ApplyPreset) + + return c +} + +// ListPresets returns all config presets. +// +// @Summary List config presets +// @Description Get a list of all saved configuration presets +// @Tags Config Presets +// @Accept json +// @Produce json +// @Param name query string false "Filter by preset name (partial match)" +// @Success 200 {array} model.ConfigPreset "List of presets" +// @Failure 401 {object} error_handler.ErrorResponse "Unauthorized" +// @Failure 403 {object} error_handler.ErrorResponse "Insufficient permissions" +// @Failure 500 {object} error_handler.ErrorResponse "Internal server error" +// @Security BearerAuth +// @Router /presets [get] +func (c *ConfigPresetController) ListPresets(ctx *fiber.Ctx) error { + var filter model.ConfigPresetFilter + if err := common.ParseQueryFilter(ctx, &filter); err != nil { + return c.errorHandler.HandleParsingError(ctx, err) + } + + presets, err := c.service.ListPresets(ctx.UserContext(), &filter) + if err != nil { + return c.errorHandler.HandleServiceError(ctx, err) + } + return ctx.JSON(presets) +} + +// GetPreset returns a single config preset by ID. +// +// @Summary Get config preset +// @Description Retrieve a single configuration preset by ID +// @Tags Config Presets +// @Accept json +// @Produce json +// @Param presetId path string true "Preset ID (UUID format)" +// @Success 200 {object} model.ConfigPreset "Preset" +// @Failure 400 {object} error_handler.ErrorResponse "Invalid preset ID" +// @Failure 401 {object} error_handler.ErrorResponse "Unauthorized" +// @Failure 403 {object} error_handler.ErrorResponse "Insufficient permissions" +// @Failure 404 {object} error_handler.ErrorResponse "Preset not found" +// @Failure 500 {object} error_handler.ErrorResponse "Internal server error" +// @Security BearerAuth +// @Router /presets/{presetId} [get] +func (c *ConfigPresetController) GetPreset(ctx *fiber.Ctx) error { + id, err := uuid.Parse(ctx.Params("presetId")) + if err != nil { + return c.errorHandler.HandleUUIDError(ctx, "preset ID") + } + + preset, err := c.service.GetPreset(ctx.UserContext(), id) + if err != nil { + return c.errorHandler.HandleServiceError(ctx, err) + } + return ctx.JSON(preset) +} + +// CreatePreset creates a new config preset. +// +// @Summary Create config preset +// @Description Save a new configuration preset with one or more ACC config sections +// @Tags Config Presets +// @Accept json +// @Produce json +// @Param content body model.ConfigPresetCreateRequest true "Preset data" +// @Success 201 {object} model.ConfigPreset "Created preset" +// @Failure 400 {object} error_handler.ErrorResponse "Invalid request" +// @Failure 401 {object} error_handler.ErrorResponse "Unauthorized" +// @Failure 403 {object} error_handler.ErrorResponse "Insufficient permissions" +// @Failure 500 {object} error_handler.ErrorResponse "Internal server error" +// @Security BearerAuth +// @Router /presets [post] +func (c *ConfigPresetController) CreatePreset(ctx *fiber.Ctx) error { + var req model.ConfigPresetCreateRequest + if err := ctx.BodyParser(&req); err != nil { + return c.errorHandler.HandleParsingError(ctx, err) + } + + preset, err := c.service.CreatePreset(ctx.UserContext(), &req) + if err != nil { + return c.errorHandler.HandleServiceError(ctx, err) + } + return ctx.Status(fiber.StatusCreated).JSON(preset) +} + +// UpdatePreset updates an existing config preset. +// +// @Summary Update config preset +// @Description Update name, description, or any config sections of an existing preset +// @Tags Config Presets +// @Accept json +// @Produce json +// @Param presetId path string true "Preset ID (UUID format)" +// @Param content body model.ConfigPresetUpdateRequest true "Fields to update (omit to keep existing)" +// @Success 200 {object} model.ConfigPreset "Updated preset" +// @Failure 400 {object} error_handler.ErrorResponse "Invalid request or preset ID" +// @Failure 401 {object} error_handler.ErrorResponse "Unauthorized" +// @Failure 403 {object} error_handler.ErrorResponse "Insufficient permissions" +// @Failure 404 {object} error_handler.ErrorResponse "Preset not found" +// @Failure 500 {object} error_handler.ErrorResponse "Internal server error" +// @Security BearerAuth +// @Router /presets/{presetId} [put] +func (c *ConfigPresetController) UpdatePreset(ctx *fiber.Ctx) error { + id, err := uuid.Parse(ctx.Params("presetId")) + if err != nil { + return c.errorHandler.HandleUUIDError(ctx, "preset ID") + } + + var req model.ConfigPresetUpdateRequest + if err := ctx.BodyParser(&req); err != nil { + return c.errorHandler.HandleParsingError(ctx, err) + } + + preset, err := c.service.UpdatePreset(ctx.UserContext(), id, &req) + if err != nil { + return c.errorHandler.HandleServiceError(ctx, err) + } + return ctx.JSON(preset) +} + +// DeletePreset deletes a config preset. +// +// @Summary Delete config preset +// @Description Permanently delete a configuration preset +// @Tags Config Presets +// @Accept json +// @Produce json +// @Param presetId path string true "Preset ID (UUID format)" +// @Success 204 "No Content" +// @Failure 400 {object} error_handler.ErrorResponse "Invalid preset ID" +// @Failure 401 {object} error_handler.ErrorResponse "Unauthorized" +// @Failure 403 {object} error_handler.ErrorResponse "Insufficient permissions" +// @Failure 500 {object} error_handler.ErrorResponse "Internal server error" +// @Security BearerAuth +// @Router /presets/{presetId} [delete] +func (c *ConfigPresetController) DeletePreset(ctx *fiber.Ctx) error { + id, err := uuid.Parse(ctx.Params("presetId")) + if err != nil { + return c.errorHandler.HandleUUIDError(ctx, "preset ID") + } + + if err := c.service.DeletePreset(ctx.UserContext(), id); err != nil { + return c.errorHandler.HandleServiceError(ctx, err) + } + return ctx.SendStatus(fiber.StatusNoContent) +} + +// ApplyPreset applies a saved preset to a server's config files. +// +// @Summary Apply config preset to server +// @Description Apply a saved configuration preset to an ACC server, writing only the sections stored in the preset +// @Tags Server Configuration +// @Accept json +// @Produce json +// @Param id path string true "Server ID (UUID format)" +// @Param presetId path string true "Preset ID (UUID format)" +// @Param restart query bool false "Restart the server after applying the preset" +// @Success 200 {array} model.Config "List of updated config audit records" +// @Failure 400 {object} error_handler.ErrorResponse "Invalid server or preset ID" +// @Failure 401 {object} error_handler.ErrorResponse "Unauthorized" +// @Failure 403 {object} error_handler.ErrorResponse "Insufficient permissions" +// @Failure 404 {object} error_handler.ErrorResponse "Server or preset not found" +// @Failure 500 {object} error_handler.ErrorResponse "Internal server error" +// @Security BearerAuth +// @Router /server/{id}/config/presets/{presetId}/apply [post] +func (c *ConfigPresetController) ApplyPreset(ctx *fiber.Ctx) error { + restart := ctx.QueryBool("restart") + serverID := ctx.Params("id") + + if _, err := uuid.Parse(serverID); err != nil { + return c.errorHandler.HandleUUIDError(ctx, "server ID") + } + + ctx.Locals("serverId", serverID) + + presetID, err := uuid.Parse(ctx.Params("presetId")) + if err != nil { + return c.errorHandler.HandleUUIDError(ctx, "preset ID") + } + + actorID, _ := ctx.Locals("userID").(string) + actorUsername, _ := ctx.Locals("actorUsername").(string) + if actorUsername == "" { + actorUsername = "system" + } + + configs, err := c.service.ApplyPreset(ctx.UserContext(), serverID, presetID, actorID, actorUsername) + if err != nil { + return c.errorHandler.HandleServiceError(ctx, err) + } + + if restart { + if _, err := c.apiService.ServiceControlRestartServer(ctx); err != nil { + logging.ErrorWithContext("PRESET_APPLY_RESTART", "Failed to restart server after preset apply: %v", err) + } + } + + return ctx.JSON(configs) +} diff --git a/local/controller/controller.go b/local/controller/controller.go index 9984e65..185d511 100755 --- a/local/controller/controller.go +++ b/local/controller/controller.go @@ -35,6 +35,11 @@ func InitializeControllers(c *dig.Container) { logging.Panic("unable to initialize config controller") } + err = c.Invoke(NewConfigPresetController) + if err != nil { + logging.Panic("unable to initialize config preset controller") + } + err = c.Invoke(NewServerController) if err != nil { logging.Panic("unable to initialize server controller") diff --git a/local/migrations/007_add_config_presets.go b/local/migrations/007_add_config_presets.go new file mode 100644 index 0000000..574e102 --- /dev/null +++ b/local/migrations/007_add_config_presets.go @@ -0,0 +1,51 @@ +package migrations + +import ( + "acc-server-manager/local/utl/logging" + "fmt" + "time" + + "gorm.io/gorm" +) + +type ConfigPresetMigration struct { + ID uint `gorm:"primaryKey"` + Name string `gorm:"not null"` + Description string + Configuration string `gorm:"type:text"` + AssistRules string `gorm:"type:text"` + Event string `gorm:"type:text"` + EventRules string `gorm:"type:text"` + Settings string `gorm:"type:text"` + CreatedAt string + UpdatedAt string +} + +func (ConfigPresetMigration) TableName() string { + return "config_presets" +} + +func RunAddConfigPresetsMigration(db *gorm.DB) error { + if err := db.AutoMigrate(&MigrationRecord{}); err != nil { + return fmt.Errorf("failed to ensure migration_records table: %v", err) + } + + var record MigrationRecord + if err := db.Where("migration_name = ?", "007_add_config_presets").First(&record).Error; err == nil { + logging.Info("Config presets migration already applied, skipping") + return nil + } + + record = MigrationRecord{ + MigrationName: "007_add_config_presets", + AppliedAt: time.Now().UTC().Format(time.RFC3339), + Success: true, + Notes: "Added ConfigPreset table via GORM AutoMigrate", + } + if err := db.Create(&record).Error; err != nil { + return fmt.Errorf("failed to record migration 007: %v", err) + } + + logging.Info("Config presets migration recorded") + return nil +} diff --git a/local/model/config_preset.go b/local/model/config_preset.go new file mode 100644 index 0000000..fe62b36 --- /dev/null +++ b/local/model/config_preset.go @@ -0,0 +1,48 @@ +package model + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type ConfigPreset struct { + ID uuid.UUID `json:"id" gorm:"type:uuid;primary_key;"` + Name string `json:"name" gorm:"not null"` + Description string `json:"description"` + Configuration string `json:"configuration" gorm:"type:text"` + AssistRules string `json:"assistRules" gorm:"type:text"` + Event string `json:"event" gorm:"type:text"` + EventRules string `json:"eventRules" gorm:"type:text"` + Settings string `json:"settings" gorm:"type:text"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func (p *ConfigPreset) BeforeCreate(tx *gorm.DB) error { + if p.ID == uuid.Nil { + p.ID = uuid.New() + } + return nil +} + +type ConfigPresetCreateRequest struct { + Name string `json:"name"` + Description string `json:"description"` + Configuration *map[string]interface{} `json:"configuration"` + AssistRules *map[string]interface{} `json:"assistRules"` + Event *map[string]interface{} `json:"event"` + EventRules *map[string]interface{} `json:"eventRules"` + Settings *map[string]interface{} `json:"settings"` +} + +type ConfigPresetUpdateRequest struct { + Name *string `json:"name"` + Description *string `json:"description"` + Configuration *map[string]interface{} `json:"configuration"` + AssistRules *map[string]interface{} `json:"assistRules"` + Event *map[string]interface{} `json:"event"` + EventRules *map[string]interface{} `json:"eventRules"` + Settings *map[string]interface{} `json:"settings"` +} diff --git a/local/model/filter.go b/local/model/filter.go index b9df064..138afd0 100755 --- a/local/model/filter.go +++ b/local/model/filter.go @@ -30,6 +30,18 @@ type ConfigFilter struct { ChangedAt time.Time `query:"changed_at" time_format:"2006-01-02T15:04:05Z07:00"` } +type ConfigPresetFilter struct { + BaseFilter + Name string `query:"name"` +} + +func (f *ConfigPresetFilter) ApplyFilter(query *gorm.DB) *gorm.DB { + if f.Name != "" { + query = query.Where("name LIKE ?", "%"+f.Name+"%") + } + return query +} + type ServiceControlFilter struct { BaseFilter ServiceControl string `query:"serviceControl"` diff --git a/local/model/permissions.go b/local/model/permissions.go index d57c097..8748d25 100755 --- a/local/model/permissions.go +++ b/local/model/permissions.go @@ -24,6 +24,11 @@ const ( MembershipCreate = "membership.create" MembershipView = "membership.view" MembershipEdit = "membership.edit" + + PresetView = "preset.view" + PresetCreate = "preset.create" + PresetUpdate = "preset.update" + PresetDelete = "preset.delete" ) func AllPermissions() []string { @@ -47,5 +52,9 @@ func AllPermissions() []string { MembershipCreate, MembershipView, MembershipEdit, + PresetView, + PresetCreate, + PresetUpdate, + PresetDelete, } } diff --git a/local/repository/config_preset.go b/local/repository/config_preset.go new file mode 100644 index 0000000..264c03a --- /dev/null +++ b/local/repository/config_preset.go @@ -0,0 +1,17 @@ +package repository + +import ( + "acc-server-manager/local/model" + + "gorm.io/gorm" +) + +type ConfigPresetRepository struct { + *BaseRepository[model.ConfigPreset, model.ConfigPresetFilter] +} + +func NewConfigPresetRepository(db *gorm.DB) *ConfigPresetRepository { + return &ConfigPresetRepository{ + BaseRepository: NewBaseRepository[model.ConfigPreset, model.ConfigPresetFilter](db, model.ConfigPreset{}), + } +} diff --git a/local/repository/repository.go b/local/repository/repository.go index 66833c9..6305709 100755 --- a/local/repository/repository.go +++ b/local/repository/repository.go @@ -21,6 +21,7 @@ func InitializeRepositories(c *dig.Container) { c.Provide(NewMembershipRepository) c.Provide(NewLeaderboardRepository) c.Provide(NewActivityLogRepository) + c.Provide(NewConfigPresetRepository) if err := c.Provide(func() *model.Steam2FAManager { manager := model.NewSteam2FAManager() diff --git a/local/service/config.go b/local/service/config.go index 0bc67d0..68f593f 100755 --- a/local/service/config.go +++ b/local/service/config.go @@ -561,6 +561,12 @@ func (as *ConfigService) SaveConfiguration(server *model.Server, config *model.C return err } +// ApplySection writes a single config section to the server's config file and +// records the change. It is used by ConfigPresetService to apply preset sections. +func (as *ConfigService) ApplySection(ctx context.Context, serverID, configFile string, body *map[string]interface{}, actorID, actorUsername string) (*model.Config, error) { + return as.updateConfigInternal(ctx, serverID, configFile, body, false, actorID, actorUsername) +} + func (as *ConfigService) SetServerName(server *model.Server, name string) error { configMap := map[string]interface{}{ "serverName": name, diff --git a/local/service/config_preset.go b/local/service/config_preset.go new file mode 100644 index 0000000..0b7d2e6 --- /dev/null +++ b/local/service/config_preset.go @@ -0,0 +1,176 @@ +package service + +import ( + "acc-server-manager/local/model" + "acc-server-manager/local/repository" + "context" + "encoding/json" + "fmt" + + "github.com/google/uuid" +) + +const ( + presetMaxNameLen = 100 + presetMaxDescLen = 500 + presetMaxSectionBytes = 64 * 1024 // 64 KB per config section +) + +type ConfigPresetService struct { + repository *repository.ConfigPresetRepository + configService *ConfigService +} + +func NewConfigPresetService(repo *repository.ConfigPresetRepository, configService *ConfigService) *ConfigPresetService { + return &ConfigPresetService{ + repository: repo, + configService: configService, + } +} + +func (s *ConfigPresetService) ListPresets(ctx context.Context, filter *model.ConfigPresetFilter) (*[]model.ConfigPreset, error) { + return s.repository.GetAll(ctx, filter) +} + +func (s *ConfigPresetService) GetPreset(ctx context.Context, id uuid.UUID) (*model.ConfigPreset, error) { + preset, err := s.repository.GetByID(ctx, id) + if err != nil { + return nil, err + } + if preset == nil { + return nil, fmt.Errorf("preset not found") + } + return preset, nil +} + +func (s *ConfigPresetService) CreatePreset(ctx context.Context, req *model.ConfigPresetCreateRequest) (*model.ConfigPreset, error) { + if req.Name == "" { + return nil, fmt.Errorf("preset name is required") + } + if len(req.Name) > presetMaxNameLen { + return nil, fmt.Errorf("preset name exceeds maximum length of %d characters", presetMaxNameLen) + } + if len(req.Description) > presetMaxDescLen { + return nil, fmt.Errorf("preset description exceeds maximum length of %d characters", presetMaxDescLen) + } + + preset := &model.ConfigPreset{ + Name: req.Name, + Description: req.Description, + } + + if err := marshalSections(req.Configuration, req.AssistRules, req.Event, req.EventRules, req.Settings, preset); err != nil { + return nil, err + } + + if err := s.repository.Insert(ctx, preset); err != nil { + return nil, err + } + return preset, nil +} + +func (s *ConfigPresetService) UpdatePreset(ctx context.Context, id uuid.UUID, req *model.ConfigPresetUpdateRequest) (*model.ConfigPreset, error) { + preset, err := s.GetPreset(ctx, id) + if err != nil { + return nil, err + } + + if req.Name != nil { + if len(*req.Name) == 0 { + return nil, fmt.Errorf("preset name cannot be empty") + } + if len(*req.Name) > presetMaxNameLen { + return nil, fmt.Errorf("preset name exceeds maximum length of %d characters", presetMaxNameLen) + } + preset.Name = *req.Name + } + if req.Description != nil { + if len(*req.Description) > presetMaxDescLen { + return nil, fmt.Errorf("preset description exceeds maximum length of %d characters", presetMaxDescLen) + } + preset.Description = *req.Description + } + if err := marshalSections(req.Configuration, req.AssistRules, req.Event, req.EventRules, req.Settings, preset); err != nil { + return nil, err + } + + if err := s.repository.Update(ctx, preset); err != nil { + return nil, err + } + return preset, nil +} + +func (s *ConfigPresetService) DeletePreset(ctx context.Context, id uuid.UUID) error { + return s.repository.Delete(ctx, id) +} + +func (s *ConfigPresetService) ApplyPreset(ctx context.Context, serverID string, presetID uuid.UUID, actorID, actorUsername string) ([]*model.Config, error) { + preset, err := s.GetPreset(ctx, presetID) + if err != nil { + return nil, err + } + + type sectionEntry struct { + fileName string + raw string + } + + sections := []sectionEntry{ + {ConfigurationJson, preset.Configuration}, + {AssistRulesJson, preset.AssistRules}, + {EventJson, preset.Event}, + {EventRulesJson, preset.EventRules}, + {SettingsJson, preset.Settings}, + } + + var results []*model.Config + for _, section := range sections { + if section.raw == "" { + continue + } + var body map[string]interface{} + if err := json.Unmarshal([]byte(section.raw), &body); err != nil { + return nil, fmt.Errorf("failed to unmarshal preset section %s: %v", section.fileName, err) + } + result, err := s.configService.ApplySection(ctx, serverID, section.fileName, &body, actorID, actorUsername) + if err != nil { + return nil, fmt.Errorf("failed to apply preset section %s: %v", section.fileName, err) + } + if result != nil { + results = append(results, result) + } + } + + return results, nil +} + +// marshalSections marshals each non-nil config section into the preset's JSON string fields, +// enforcing the per-section byte size limit. +func marshalSections(configuration, assistRules, event, eventRules, settings *map[string]interface{}, preset *model.ConfigPreset) error { + type entry struct { + src *map[string]interface{} + name string + dest *string + } + entries := []entry{ + {configuration, "configuration", &preset.Configuration}, + {assistRules, "assistRules", &preset.AssistRules}, + {event, "event", &preset.Event}, + {eventRules, "eventRules", &preset.EventRules}, + {settings, "settings", &preset.Settings}, + } + for _, e := range entries { + if e.src == nil { + continue + } + b, err := json.Marshal(e.src) + if err != nil { + return fmt.Errorf("failed to marshal %s: %v", e.name, err) + } + if len(b) > presetMaxSectionBytes { + return fmt.Errorf("%s section exceeds maximum size of %d bytes", e.name, presetMaxSectionBytes) + } + *e.dest = string(b) + } + return nil +} diff --git a/local/service/service.go b/local/service/service.go index 2f51d4e..43271b0 100755 --- a/local/service/service.go +++ b/local/service/service.go @@ -32,6 +32,7 @@ func InitializeServices(c *dig.Container) { c.Provide(NewMembershipService) c.Provide(NewWebSocketService) c.Provide(NewLeaderboardService) + c.Provide(NewConfigPresetService) // Always provide the PortPoolManager; in Windows mode it seeds from an // empty result set and sits idle. diff --git a/local/utl/common/common.go b/local/utl/common/common.go index 7c844f1..47f963a 100755 --- a/local/utl/common/common.go +++ b/local/utl/common/common.go @@ -31,6 +31,7 @@ type RouteGroups struct { ActivityLog fiber.Router Groups fiber.Router Log fiber.Router + Presets fiber.Router } func CheckError(err error) { diff --git a/local/utl/db/db.go b/local/utl/db/db.go index aefcf60..5a02000 100755 --- a/local/utl/db/db.go +++ b/local/utl/db/db.go @@ -55,6 +55,7 @@ func Migrate(db *gorm.DB) { &model.LeaderboardResult{}, &model.LeaderboardPointRow{}, &model.ActivityLog{}, + &model.ConfigPreset{}, ) if err != nil { @@ -85,6 +86,10 @@ func runMigrations(db *gorm.DB) { logging.Error("Failed to run activity log migration: %v", err) } + if err := migrations.RunAddConfigPresetsMigration(db); err != nil { + logging.Error("Failed to run config presets migration: %v", err) + } + logging.Info("Custom database migrations completed") } From f23f276ee5a745ae0f63ef8f58436586c98812dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=20Jurmanovi=C4=87?= Date: Thu, 26 Mar 2026 22:32:24 +0100 Subject: [PATCH 20/21] fix: preset logging --- local/model/activity_log.go | 2 ++ local/service/config.go | 2 +- local/service/config_preset.go | 25 ++++++++++++++++++++++++- 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/local/model/activity_log.go b/local/model/activity_log.go index b925b1a..e23523b 100644 --- a/local/model/activity_log.go +++ b/local/model/activity_log.go @@ -15,6 +15,8 @@ const ( ActionServerStop ActionType = "server_stop" ActionServerRestart ActionType = "server_restart" ActionLeaderboardUpdate ActionType = "leaderboard_update" + ActionPresetCreate ActionType = "preset_create" + ActionPresetApply ActionType = "preset_apply" ) type ActivityLog struct { diff --git a/local/service/config.go b/local/service/config.go index 68f593f..95601b4 100755 --- a/local/service/config.go +++ b/local/service/config.go @@ -564,7 +564,7 @@ func (as *ConfigService) SaveConfiguration(server *model.Server, config *model.C // ApplySection writes a single config section to the server's config file and // records the change. It is used by ConfigPresetService to apply preset sections. func (as *ConfigService) ApplySection(ctx context.Context, serverID, configFile string, body *map[string]interface{}, actorID, actorUsername string) (*model.Config, error) { - return as.updateConfigInternal(ctx, serverID, configFile, body, false, actorID, actorUsername) + return as.updateConfigInternal(ctx, serverID, configFile, body, true, actorID, actorUsername) } func (as *ConfigService) SetServerName(server *model.Server, name string) error { diff --git a/local/service/config_preset.go b/local/service/config_preset.go index 0b7d2e6..be0f4c4 100644 --- a/local/service/config_preset.go +++ b/local/service/config_preset.go @@ -3,6 +3,7 @@ package service import ( "acc-server-manager/local/model" "acc-server-manager/local/repository" + "acc-server-manager/local/utl/logging" "context" "encoding/json" "fmt" @@ -19,12 +20,14 @@ const ( type ConfigPresetService struct { repository *repository.ConfigPresetRepository configService *ConfigService + activityLog *ActivityLogService } -func NewConfigPresetService(repo *repository.ConfigPresetRepository, configService *ConfigService) *ConfigPresetService { +func NewConfigPresetService(repo *repository.ConfigPresetRepository, configService *ConfigService, activityLog *ActivityLogService) *ConfigPresetService { return &ConfigPresetService{ repository: repo, configService: configService, + activityLog: activityLog, } } @@ -66,6 +69,8 @@ func (s *ConfigPresetService) CreatePreset(ctx context.Context, req *model.Confi if err := s.repository.Insert(ctx, preset); err != nil { return nil, err } + + logging.Info("Preset created: id=%s name=%q", preset.ID, preset.Name) return preset, nil } @@ -110,6 +115,11 @@ func (s *ConfigPresetService) ApplyPreset(ctx context.Context, serverID string, return nil, err } + serverUUID, err := uuid.Parse(serverID) + if err != nil { + return nil, fmt.Errorf("invalid server ID: %v", err) + } + type sectionEntry struct { fileName string raw string @@ -141,6 +151,19 @@ func (s *ConfigPresetService) ApplyPreset(ctx context.Context, serverID string, } } + details := fmt.Sprintf(`{"preset_id":%q,"preset_name":%q,"sections_applied":%d}`, presetID, preset.Name, len(results)) + go func() { + if logErr := s.activityLog.Log(context.Background(), &model.ActivityLog{ + ServerID: serverUUID, + UserID: actorID, + Username: actorUsername, + Action: model.ActionPresetApply, + Details: details, + }); logErr != nil { + logging.Error("Failed to log preset apply: %v", logErr) + } + }() + return results, nil } From f2580b6de0532c720a5ff693194ca292324fe295 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=20Jurmanovi=C4=87?= Date: Thu, 26 Mar 2026 22:48:59 +0100 Subject: [PATCH 21/21] fix: nullable server id in activity log and fix preset logging --- local/controller/config_preset.go | 8 +++++++- local/model/activity_log.go | 2 +- local/service/config.go | 4 ++-- local/service/config_preset.go | 23 +++++++++++++++++------ local/service/leaderboard.go | 2 +- local/service/service_control.go | 6 +++--- 6 files changed, 31 insertions(+), 14 deletions(-) diff --git a/local/controller/config_preset.go b/local/controller/config_preset.go index 7fb12e5..fb792b1 100644 --- a/local/controller/config_preset.go +++ b/local/controller/config_preset.go @@ -117,7 +117,13 @@ func (c *ConfigPresetController) CreatePreset(ctx *fiber.Ctx) error { return c.errorHandler.HandleParsingError(ctx, err) } - preset, err := c.service.CreatePreset(ctx.UserContext(), &req) + actorID, _ := ctx.Locals("userID").(string) + actorUsername, _ := ctx.Locals("actorUsername").(string) + if actorUsername == "" { + actorUsername = "system" + } + + preset, err := c.service.CreatePreset(ctx.UserContext(), &req, actorID, actorUsername) if err != nil { return c.errorHandler.HandleServiceError(ctx, err) } diff --git a/local/model/activity_log.go b/local/model/activity_log.go index e23523b..66c79e8 100644 --- a/local/model/activity_log.go +++ b/local/model/activity_log.go @@ -21,7 +21,7 @@ const ( type ActivityLog struct { ID uuid.UUID `json:"id" gorm:"type:uuid;primary_key;"` - ServerID uuid.UUID `json:"serverId" gorm:"not null;type:uuid;index"` + ServerID *uuid.UUID `json:"serverId" gorm:"type:uuid;index"` Server *Server `json:"server,omitempty" gorm:"foreignKey:ServerID"` UserID string `json:"userId" gorm:"not null"` Username string `json:"username" gorm:"not null"` diff --git a/local/service/config.go b/local/service/config.go index 95601b4..84ddeaa 100755 --- a/local/service/config.go +++ b/local/service/config.go @@ -160,7 +160,7 @@ func (as *ConfigService) UpdateAllConfigs(ctx *fiber.Ctx, req *model.Configurati if details := BuildConfigDetails(entry.fileName, oldDataUTF8, newData); details != "" { go func(details string) { _ = as.activityLog.Log(context.Background(), &model.ActivityLog{ - ServerID: serverUUID, + ServerID: &serverUUID, UserID: actorID, Username: actorUsername, Action: model.ActionConfigUpdate, @@ -285,7 +285,7 @@ func (as *ConfigService) updateConfigInternal(ctx context.Context, serverID stri if details := BuildConfigDetails(configFile, oldDataUTF8, newData); details != "" { go func() { _ = as.activityLog.Log(context.Background(), &model.ActivityLog{ - ServerID: serverUUID, + ServerID: &serverUUID, UserID: actorID, Username: actorUsername, Action: model.ActionConfigUpdate, diff --git a/local/service/config_preset.go b/local/service/config_preset.go index be0f4c4..caada5d 100644 --- a/local/service/config_preset.go +++ b/local/service/config_preset.go @@ -12,9 +12,9 @@ import ( ) const ( - presetMaxNameLen = 100 - presetMaxDescLen = 500 - presetMaxSectionBytes = 64 * 1024 // 64 KB per config section + presetMaxNameLen = 100 + presetMaxDescLen = 500 + presetMaxSectionBytes = 64 * 1024 // 64 KB per config section ) type ConfigPresetService struct { @@ -46,7 +46,7 @@ func (s *ConfigPresetService) GetPreset(ctx context.Context, id uuid.UUID) (*mod return preset, nil } -func (s *ConfigPresetService) CreatePreset(ctx context.Context, req *model.ConfigPresetCreateRequest) (*model.ConfigPreset, error) { +func (s *ConfigPresetService) CreatePreset(ctx context.Context, req *model.ConfigPresetCreateRequest, actorID, actorUsername string) (*model.ConfigPreset, error) { if req.Name == "" { return nil, fmt.Errorf("preset name is required") } @@ -70,7 +70,18 @@ func (s *ConfigPresetService) CreatePreset(ctx context.Context, req *model.Confi return nil, err } - logging.Info("Preset created: id=%s name=%q", preset.ID, preset.Name) + details := fmt.Sprintf(`{"preset_id":%q,"preset_name":%q}`, preset.ID, preset.Name) + go func() { + if logErr := s.activityLog.Log(context.Background(), &model.ActivityLog{ + UserID: actorID, + Username: actorUsername, + Action: model.ActionPresetCreate, + Details: details, + }); logErr != nil { + logging.Error("Failed to log preset create: %v", logErr) + } + }() + return preset, nil } @@ -154,7 +165,7 @@ func (s *ConfigPresetService) ApplyPreset(ctx context.Context, serverID string, details := fmt.Sprintf(`{"preset_id":%q,"preset_name":%q,"sections_applied":%d}`, presetID, preset.Name, len(results)) go func() { if logErr := s.activityLog.Log(context.Background(), &model.ActivityLog{ - ServerID: serverUUID, + ServerID: &serverUUID, UserID: actorID, Username: actorUsername, Action: model.ActionPresetApply, diff --git a/local/service/leaderboard.go b/local/service/leaderboard.go index f43437d..c1407e0 100755 --- a/local/service/leaderboard.go +++ b/local/service/leaderboard.go @@ -45,7 +45,7 @@ func (s *LeaderboardService) Update(ctx context.Context, serverID uuid.UUID, lb details := fmt.Sprintf(`{"driver_count":%d,"race_count":%d}`, driverCount, raceCount) go func() { if logErr := s.activityLog.Log(context.Background(), &model.ActivityLog{ - ServerID: serverID, + ServerID: &serverID, UserID: actorID, Username: actorUsername, Action: model.ActionLeaderboardUpdate, diff --git a/local/service/service_control.go b/local/service/service_control.go index 89ad683..06dc391 100755 --- a/local/service/service_control.go +++ b/local/service/service_control.go @@ -110,7 +110,7 @@ func (as *ServiceControlService) ServiceControlStartServer(ctx *fiber.Ctx) (stri actorID, actorUsername := extractActor(ctx) go func() { _ = as.activityLog.Log(context.Background(), &model.ActivityLog{ - ServerID: server.ID, + ServerID: &server.ID, UserID: actorID, Username: actorUsername, Action: model.ActionServerStart, @@ -147,7 +147,7 @@ func (as *ServiceControlService) ServiceControlStopServer(ctx *fiber.Ctx) (strin actorID, actorUsername := extractActor(ctx) go func() { _ = as.activityLog.Log(context.Background(), &model.ActivityLog{ - ServerID: server.ID, + ServerID: &server.ID, UserID: actorID, Username: actorUsername, Action: model.ActionServerStop, @@ -184,7 +184,7 @@ func (as *ServiceControlService) ServiceControlRestartServer(ctx *fiber.Ctx) (st actorID, actorUsername := extractActor(ctx) go func() { _ = as.activityLog.Log(context.Background(), &model.ActivityLog{ - ServerID: server.ID, + ServerID: &server.ID, UserID: actorID, Username: actorUsername, Action: model.ActionServerRestart,