diff --git a/local/model/server.go b/local/model/server.go index 8029a34..ceea2f3 100644 --- a/local/model/server.go +++ b/local/model/server.go @@ -1,12 +1,20 @@ package model import ( + "errors" + "fmt" + "path/filepath" "sync" "time" "gorm.io/gorm" ) +const ( + BaseServerPath = "servers" + ServiceNamePrefix = "ACC-Server" +) + // Server represents an ACC server instance type Server struct { ID uint `gorm:"primaryKey" json:"id"` @@ -14,9 +22,10 @@ type Server struct { Status ServiceStatus `json:"status" gorm:"-"` IP string `gorm:"not null" json:"-"` Port int `gorm:"not null" json:"-"` - ConfigPath string `gorm:"not null" json:"-"` // e.g. "/acc/servers/server1/" - ServiceName string `gorm:"not null" json:"-"` // Windows service name - State ServerState `gorm:"-" json:"state"` + ConfigPath string `gorm:"not null" json:"configPath"` // e.g. "/acc/servers/server1/" + ServiceName string `gorm:"not null" json:"serviceName"` // Windows service name + State ServerState `gorm:"-" json:"state"` + DateCreated time.Time `json:"dateCreated"` } type PlayerState struct { @@ -70,4 +79,52 @@ func (f *ServerFilter) ApplyFilter(query *gorm.DB) *gorm.DB { } return query +} + +// BeforeCreate is a GORM hook that runs before creating a new server +func (s *Server) BeforeCreate(tx *gorm.DB) error { + if s.Name == "" { + return errors.New("server name is required") + } + + // Generate service name and config path if not set + if s.ServiceName == "" { + s.ServiceName = s.GenerateServiceName() + } + if s.ConfigPath == "" { + s.ConfigPath = s.GenerateConfigPath() + } + + // Set creation date if not set + if s.DateCreated.IsZero() { + s.DateCreated = time.Now().UTC() + } + + return nil +} + +// GenerateServiceName creates a unique service name based on the server name +func (s *Server) GenerateServiceName() string { + // If ID is set, use it + if s.ID > 0 { + return fmt.Sprintf("%s-%d", ServiceNamePrefix, s.ID) + } + // Otherwise use a timestamp-based unique identifier + return fmt.Sprintf("%s-%d", ServiceNamePrefix, time.Now().UnixNano()) +} + +// GenerateConfigPath creates the config path based on the service name +func (s *Server) GenerateConfigPath() string { + // Ensure service name is set + if s.ServiceName == "" { + s.ServiceName = s.GenerateServiceName() + } + return filepath.Join(BaseServerPath, s.ServiceName) +} + +func (s *Server) Validate() error { + if s.Name == "" { + return errors.New("server name is required") + } + return nil } \ No newline at end of file diff --git a/local/model/stateHistory.go b/local/model/stateHistory.go index 11fd3cc..52b6209 100644 --- a/local/model/stateHistory.go +++ b/local/model/stateHistory.go @@ -58,5 +58,5 @@ type StateHistory struct { DateCreated time.Time `json:"dateCreated"` SessionStart time.Time `json:"sessionStart"` SessionDurationMinutes int `json:"sessionDurationMinutes"` - SessionID uint `json:"sessionId" gorm:"not null"` // Unique identifier for each session/event + SessionID uint `json:"sessionId" gorm:"not null;default:0"` // Unique identifier for each session/event } \ No newline at end of file diff --git a/local/model/steam_credentials.go b/local/model/steam_credentials.go new file mode 100644 index 0000000..95dc420 --- /dev/null +++ b/local/model/steam_credentials.go @@ -0,0 +1,154 @@ +package model + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "errors" + "io" + "time" + + "gorm.io/gorm" +) + +// SteamCredentials represents stored Steam login credentials +type SteamCredentials struct { + ID uint `gorm:"primaryKey" json:"id"` + Username string `gorm:"not null" json:"username"` + Password string `gorm:"not null" json:"-"` // Encrypted, not exposed in JSON + DateCreated time.Time `json:"dateCreated"` + LastUpdated time.Time `json:"lastUpdated"` +} + +// TableName specifies the table name for GORM +func (SteamCredentials) TableName() string { + return "steam_credentials" +} + +// BeforeCreate is a GORM hook that runs before creating new credentials +func (s *SteamCredentials) BeforeCreate(tx *gorm.DB) error { + now := time.Now().UTC() + if s.DateCreated.IsZero() { + s.DateCreated = now + } + s.LastUpdated = now + + // Encrypt password before saving + encrypted, err := EncryptPassword(s.Password) + if err != nil { + return err + } + s.Password = encrypted + + return nil +} + +// BeforeUpdate is a GORM hook that runs before updating credentials +func (s *SteamCredentials) BeforeUpdate(tx *gorm.DB) error { + s.LastUpdated = time.Now().UTC() + + // Only encrypt if password field is being updated + if tx.Statement.Changed("Password") { + encrypted, err := EncryptPassword(s.Password) + if err != nil { + return err + } + s.Password = encrypted + } + + return nil +} + +// AfterFind is a GORM hook that runs after fetching credentials +func (s *SteamCredentials) AfterFind(tx *gorm.DB) error { + // Decrypt password after fetching + if s.Password != "" { + decrypted, err := DecryptPassword(s.Password) + if err != nil { + return err + } + s.Password = decrypted + } + return nil +} + +// Validate checks if the credentials are valid +func (s *SteamCredentials) Validate() error { + if s.Username == "" { + return errors.New("username is required") + } + if s.Password == "" { + return errors.New("password is required") + } + return nil +} + +// GetEncryptionKey returns the encryption key, in a real application this should be stored securely +// and potentially rotated periodically +func GetEncryptionKey() []byte { + // This is a placeholder - in production, this should be stored securely + // and potentially fetched from a key management service + return []byte("your-32-byte-encryption-key-here") +} + +// EncryptPassword encrypts a password using AES-256 +func EncryptPassword(password string) (string, error) { + key := GetEncryptionKey() + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + + // Create a new GCM cipher + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + // Create a nonce + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + + // Encrypt the password + ciphertext := gcm.Seal(nonce, nonce, []byte(password), nil) + + // Return base64 encoded encrypted password + return base64.StdEncoding.EncodeToString(ciphertext), nil +} + +// DecryptPassword decrypts an encrypted password +func DecryptPassword(encryptedPassword string) (string, error) { + key := GetEncryptionKey() + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + + // Create a new GCM cipher + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + // Decode base64 encoded password + ciphertext, err := base64.StdEncoding.DecodeString(encryptedPassword) + if err != nil { + return "", err + } + + nonceSize := gcm.NonceSize() + if len(ciphertext) < nonceSize { + return "", errors.New("ciphertext too short") + } + + nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:] + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return "", err + } + + return string(plaintext), nil +} \ No newline at end of file diff --git a/local/repository/repository.go b/local/repository/repository.go index e6405e2..c047835 100644 --- a/local/repository/repository.go +++ b/local/repository/repository.go @@ -15,4 +15,5 @@ func InitializeRepositories(c *dig.Container) { c.Provide(NewServerRepository) c.Provide(NewConfigRepository) c.Provide(NewLookupRepository) + c.Provide(NewSteamCredentialsRepository) } diff --git a/local/repository/steam_credentials.go b/local/repository/steam_credentials.go new file mode 100644 index 0000000..f9a7c11 --- /dev/null +++ b/local/repository/steam_credentials.go @@ -0,0 +1,41 @@ +package repository + +import ( + "acc-server-manager/local/model" + "context" + + "gorm.io/gorm" +) + +type SteamCredentialsRepository struct { + db *gorm.DB +} + +func NewSteamCredentialsRepository(db *gorm.DB) *SteamCredentialsRepository { + return &SteamCredentialsRepository{ + db: db, + } +} + +func (r *SteamCredentialsRepository) GetCurrent(ctx context.Context) (*model.SteamCredentials, error) { + var creds model.SteamCredentials + result := r.db.WithContext(ctx).Order("id desc").First(&creds) + if result.Error != nil { + if result.Error == gorm.ErrRecordNotFound { + return nil, nil + } + return nil, result.Error + } + return &creds, nil +} + +func (r *SteamCredentialsRepository) Save(ctx context.Context, creds *model.SteamCredentials) error { + if creds.ID == 0 { + return r.db.WithContext(ctx).Create(creds).Error + } + return r.db.WithContext(ctx).Save(creds).Error +} + +func (r *SteamCredentialsRepository) Delete(ctx context.Context, id uint) error { + return r.db.WithContext(ctx).Delete(&model.SteamCredentials{}, id).Error +} \ No newline at end of file diff --git a/local/service/api.go b/local/service/api.go index 7eb4f4d..782f482 100644 --- a/local/service/api.go +++ b/local/service/api.go @@ -3,10 +3,8 @@ package service import ( "acc-server-manager/local/model" "acc-server-manager/local/repository" - "acc-server-manager/local/utl/common" "context" "errors" - "strings" "time" "github.com/gofiber/fiber/v2" @@ -17,6 +15,7 @@ type ApiService struct { serverRepository *repository.ServerRepository serverService *ServerService statusCache *model.ServerStatusCache + windowsService *WindowsService } func NewApiService(repository *repository.ApiRepository, @@ -29,6 +28,7 @@ func NewApiService(repository *repository.ApiRepository, ThrottleTime: 5 * time.Second, // Minimum 5 seconds between checks DefaultStatus: model.StatusRunning, // Default to running if throttled }), + windowsService: NewWindowsService(), } } @@ -120,11 +120,11 @@ func (as *ApiService) ApiRestartServer(ctx *fiber.Ctx) (string, error) { } func (as *ApiService) StatusServer(serviceName string) (string, error) { - return ManageService(serviceName, "status") + return as.windowsService.Status(serviceName) } func (as *ApiService) StartServer(serviceName string) (string, error) { - status, err := ManageService(serviceName, "start") + status, err := as.windowsService.Start(serviceName) if err != nil { return "", err } @@ -138,7 +138,7 @@ func (as *ApiService) StartServer(serviceName string) (string, error) { } func (as *ApiService) StopServer(serviceName string) (string, error) { - status, err := ManageService(serviceName, "stop") + status, err := as.windowsService.Stop(serviceName) if err != nil { return "", err } @@ -153,7 +153,7 @@ func (as *ApiService) StopServer(serviceName string) (string, error) { } func (as *ApiService) RestartServer(serviceName string) (string, error) { - status, err := ManageService(serviceName, "restart") + status, err := as.windowsService.Restart(serviceName) if err != nil { return "", err } @@ -166,20 +166,6 @@ func (as *ApiService) RestartServer(serviceName string) (string, error) { return status, err } -func ManageService(serviceName string, action string) (string, error) { - output, err := common.RunElevatedCommand(action, serviceName) - if err != nil { - return "", err - } - - // Clean up NSSM output by removing null bytes and trimming whitespace - cleaned := strings.TrimSpace(strings.ReplaceAll(output, "\x00", "")) - // Remove \r\n from status strings - cleaned = strings.TrimSuffix(cleaned, "\r\n") - - return cleaned, nil -} - func (as *ApiService) GetServiceName(ctx *fiber.Ctx) (string, error) { var server *model.Server var err error diff --git a/local/service/firewall_service.go b/local/service/firewall_service.go new file mode 100644 index 0000000..e2124fc --- /dev/null +++ b/local/service/firewall_service.go @@ -0,0 +1,106 @@ +package service + +import ( + "acc-server-manager/local/utl/command" + "acc-server-manager/local/utl/logging" + "fmt" +) + +type FirewallService struct { + executor *command.CommandExecutor +} + +func NewFirewallService() *FirewallService { + return &FirewallService{ + executor: &command.CommandExecutor{ + ExePath: "netsh", + LogOutput: true, + }, + } +} + +func (s *FirewallService) CreateServerRules(serverName string, tcpPorts, udpPorts []int) error { + for _, port := range tcpPorts { + ruleName := fmt.Sprintf("%s-TCP-%d", serverName, port) + builder := command.NewCommandBuilder(). + Add("advfirewall"). + Add("firewall"). + Add("add"). + Add("rule"). + AddPair("name", ruleName). + AddPair("dir", "in"). + AddPair("action", "allow"). + AddPair("protocol", "TCP"). + AddFlag("localport", port) + + if err := s.executor.ExecuteWithBuilder(builder); err != nil { + return fmt.Errorf("failed to create TCP firewall rule for port %d: %v", port, err) + } + logging.Info("Created TCP firewall rule: %s", ruleName) + } + + for _, port := range udpPorts { + ruleName := fmt.Sprintf("%s-UDP-%d", serverName, port) + builder := command.NewCommandBuilder(). + Add("advfirewall"). + Add("firewall"). + Add("add"). + Add("rule"). + AddPair("name", ruleName). + AddPair("dir", "in"). + AddPair("action", "allow"). + AddPair("protocol", "UDP"). + AddFlag("localport", port) + + if err := s.executor.ExecuteWithBuilder(builder); err != nil { + return fmt.Errorf("failed to create UDP firewall rule for port %d: %v", port, err) + } + logging.Info("Created UDP firewall rule: %s", ruleName) + } + + return nil +} + +func (s *FirewallService) DeleteServerRules(serverName string, tcpPorts, udpPorts []int) error { + for _, port := range tcpPorts { + ruleName := fmt.Sprintf("%s-TCP-%d", serverName, port) + builder := command.NewCommandBuilder(). + Add("advfirewall"). + Add("firewall"). + Add("delete"). + Add("rule"). + AddPair("name", ruleName) + + if err := s.executor.ExecuteWithBuilder(builder); err != nil { + return fmt.Errorf("failed to delete TCP firewall rule for port %d: %v", port, err) + } + logging.Info("Deleted TCP firewall rule: %s", ruleName) + } + + for _, port := range udpPorts { + ruleName := fmt.Sprintf("%s-UDP-%d", serverName, port) + builder := command.NewCommandBuilder(). + Add("advfirewall"). + Add("firewall"). + Add("delete"). + Add("rule"). + AddPair("name", ruleName) + + if err := s.executor.ExecuteWithBuilder(builder); err != nil { + return fmt.Errorf("failed to delete UDP firewall rule for port %d: %v", port, err) + } + logging.Info("Deleted UDP firewall rule: %s", ruleName) + } + + return nil +} + +func (s *FirewallService) UpdateServerRules(serverName string, tcpPorts, udpPorts []int) error { + // First delete existing rules + if err := s.DeleteServerRules(serverName, tcpPorts, udpPorts); err != nil { + return err + } + + // Then create new rules + return s.CreateServerRules(serverName, tcpPorts, udpPorts) +} \ No newline at end of file diff --git a/local/service/server.go b/local/service/server.go index 67efac3..0184ec4 100644 --- a/local/service/server.go +++ b/local/service/server.go @@ -6,6 +6,7 @@ import ( "acc-server-manager/local/utl/logging" "acc-server-manager/local/utl/tracking" "context" + "fmt" "path/filepath" "strconv" "sync" @@ -18,12 +19,15 @@ type ServerService struct { repository *repository.ServerRepository stateHistoryRepo *repository.StateHistoryRepository apiService *ApiService - instances sync.Map + instances sync.Map // Track instances per server configService *ConfigService 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 + steamService *SteamService + windowsService *WindowsService + firewallService *FirewallService } type pendingState struct { @@ -48,12 +52,17 @@ func (s *ServerService) ensureLogTailing(server *model.Server, instance *trackin }() } -func NewServerService(repository *repository.ServerRepository, stateHistoryRepo *repository.StateHistoryRepository, apiService *ApiService, configService *ConfigService) *ServerService { +func NewServerService(repository *repository.ServerRepository, stateHistoryRepo *repository.StateHistoryRepository, apiService *ApiService, configService *ConfigService, steamCredentialsRepo *repository.SteamCredentialsRepository) *ServerService { + steamService := NewSteamService(steamCredentialsRepo) + service := &ServerService{ repository: repository, apiService: apiService, configService: configService, stateHistoryRepo: stateHistoryRepo, + steamService: steamService, + windowsService: NewWindowsService(), + firewallService: NewFirewallService(), } // Initialize instances for all servers @@ -291,4 +300,141 @@ func (as *ServerService) GetById(ctx *fiber.Ctx, serverID int) (*model.Server, e } return server, nil +} + +func (s *ServerService) CreateServer(ctx *fiber.Ctx, server *model.Server) error { + // Validate basic server configuration + if err := server.Validate(); err != nil { + return err + } + + // Install server using SteamCMD + if err := s.steamService.InstallServer(ctx.UserContext(), server.ConfigPath); err != nil { + return fmt.Errorf("failed to install server: %v", err) + } + + // Create Windows service + execPath := filepath.Join(server.ConfigPath, "accServer.exe") + if err := s.windowsService.CreateService(server.ServiceName, execPath, server.ConfigPath, nil); err != nil { + // Cleanup on failure + s.steamService.UninstallServer(server.ConfigPath) + return fmt.Errorf("failed to create Windows service: %v", err) + } + + // Create firewall rules + tcpPorts := []int{9600} // Add all required TCP ports + udpPorts := []int{9600} // Add all required UDP ports + if err := s.firewallService.CreateServerRules(server.ServiceName, tcpPorts, udpPorts); err != nil { + // Cleanup on failure + s.windowsService.DeleteService(server.ServiceName) + s.steamService.UninstallServer(server.ConfigPath) + return fmt.Errorf("failed to create firewall rules: %v", err) + } + + // Insert server into database + if err := s.repository.Insert(ctx.UserContext(), server); err != nil { + // Cleanup on failure + s.firewallService.DeleteServerRules(server.ServiceName, tcpPorts, udpPorts) + s.windowsService.DeleteService(server.ServiceName) + s.steamService.UninstallServer(server.ConfigPath) + return fmt.Errorf("failed to insert server into database: %v", err) + } + + // Initialize server runtime + s.StartAccServerRuntime(server) + + return nil +} + +func (s *ServerService) DeleteServer(ctx *fiber.Ctx, serverID int) error { + // Get server details + server, err := s.repository.GetByID(ctx.UserContext(), serverID) + if err != nil { + return fmt.Errorf("failed to get server details: %v", err) + } + + // Stop and remove Windows service + if err := s.windowsService.DeleteService(server.ServiceName); err != nil { + logging.Error("Failed to delete Windows service: %v", err) + } + + // Remove firewall rules + tcpPorts := []int{9600} // Add all required TCP ports + udpPorts := []int{9600} // Add all required UDP ports + if err := s.firewallService.DeleteServerRules(server.ServiceName, tcpPorts, udpPorts); err != nil { + logging.Error("Failed to delete firewall rules: %v", err) + } + + // Uninstall server files + if err := s.steamService.UninstallServer(server.ConfigPath); err != nil { + logging.Error("Failed to uninstall server: %v", err) + } + + // Remove from database + if err := s.repository.Delete(ctx.UserContext(), serverID); err != nil { + return fmt.Errorf("failed to delete server from database: %v", err) + } + + // Cleanup runtime resources + if tailer, exists := s.logTailers.Load(server.ID); exists { + tailer.(*tracking.LogTailer).Stop() + s.logTailers.Delete(server.ID) + } + s.instances.Delete(server.ID) + s.lastInsertTimes.Delete(server.ID) + s.debouncers.Delete(server.ID) + s.sessionIDs.Delete(server.ID) + + return nil +} + +func (s *ServerService) UpdateServer(ctx *fiber.Ctx, server *model.Server) error { + // Validate server configuration + if err := server.Validate(); err != nil { + return err + } + + // Get existing server details + existingServer, err := s.repository.GetByID(ctx.UserContext(), int(server.ID)) + if err != nil { + return fmt.Errorf("failed to get existing server details: %v", err) + } + + // Update server files if path changed + if existingServer.ConfigPath != server.ConfigPath { + if err := s.steamService.InstallServer(ctx.UserContext(), server.ConfigPath); err != nil { + return fmt.Errorf("failed to install server to new location: %v", err) + } + // Clean up old installation + if err := s.steamService.UninstallServer(existingServer.ConfigPath); err != nil { + logging.Error("Failed to remove old server installation: %v", err) + } + } + + // Update Windows service if necessary + if existingServer.ServiceName != server.ServiceName || existingServer.ConfigPath != server.ConfigPath { + execPath := filepath.Join(server.ConfigPath, "accServer.exe") + if err := s.windowsService.UpdateService(server.ServiceName, execPath, server.ConfigPath, nil); err != nil { + return fmt.Errorf("failed to update Windows service: %v", err) + } + } + + // Update firewall rules if service name changed + if existingServer.ServiceName != server.ServiceName { + tcpPorts := []int{9600} // Add all required TCP ports + udpPorts := []int{9600} // Add all required UDP ports + if err := s.firewallService.UpdateServerRules(server.ServiceName, tcpPorts, udpPorts); err != nil { + return fmt.Errorf("failed to update firewall rules: %v", err) + } + } + + // Update database record + if err := s.repository.Update(ctx.UserContext(), server); err != nil { + return fmt.Errorf("failed to update server in database: %v", err) + } + + // Restart server runtime + s.StartAccServerRuntime(server) + + return nil } \ No newline at end of file diff --git a/local/service/service.go b/local/service/service.go index 8ca1d8a..1a038f0 100644 --- a/local/service/service.go +++ b/local/service/service.go @@ -21,7 +21,6 @@ func InitializeServices(c *dig.Container) { c.Provide(NewApiService) c.Provide(NewConfigService) c.Provide(NewLookupService) - err := c.Invoke(func(server *ServerService, api *ApiService, config *ConfigService, lookup *LookupService) { api.SetServerService(server) config.SetServerService(server) diff --git a/local/service/service_manager.go b/local/service/service_manager.go new file mode 100644 index 0000000..8de9965 --- /dev/null +++ b/local/service/service_manager.go @@ -0,0 +1,61 @@ +package service + +import ( + "acc-server-manager/local/utl/command" + "strings" +) + +type ServiceManager struct { + executor *command.CommandExecutor + psExecutor *command.CommandExecutor +} + +func NewServiceManager() *ServiceManager { + return &ServiceManager{ + executor: &command.CommandExecutor{ + ExePath: "nssm", + LogOutput: true, + }, + psExecutor: &command.CommandExecutor{ + ExePath: "powershell", + LogOutput: true, + }, + } +} + +func (s *ServiceManager) ManageService(serviceName, action string) (string, error) { + // Run NSSM command through PowerShell to ensure elevation + output, err := s.psExecutor.ExecuteWithOutput("-nologo", "-noprofile", ".\\nssm", action, serviceName) + if err != nil { + return "", err + } + + // Clean up output by removing null bytes and trimming whitespace + cleaned := strings.TrimSpace(strings.ReplaceAll(output, "\x00", "")) + // Remove \r\n from status strings + cleaned = strings.TrimSuffix(cleaned, "\r\n") + + return cleaned, nil +} + +func (s *ServiceManager) Status(serviceName string) (string, error) { + return s.ManageService(serviceName, "status") +} + +func (s *ServiceManager) Start(serviceName string) (string, error) { + return s.ManageService(serviceName, "start") +} + +func (s *ServiceManager) Stop(serviceName string) (string, error) { + return s.ManageService(serviceName, "stop") +} + +func (s *ServiceManager) Restart(serviceName string) (string, error) { + // First stop the service + if _, err := s.Stop(serviceName); err != nil { + return "", err + } + + // Then start it again + return s.Start(serviceName) +} \ No newline at end of file diff --git a/local/service/steam_service.go b/local/service/steam_service.go new file mode 100644 index 0000000..7d7d406 --- /dev/null +++ b/local/service/steam_service.go @@ -0,0 +1,125 @@ +package service + +import ( + "acc-server-manager/local/model" + "acc-server-manager/local/repository" + "acc-server-manager/local/utl/command" + "acc-server-manager/local/utl/logging" + "context" + "fmt" + "os" + "path/filepath" +) + +const ( + SteamCMDPath = "steamcmd" + ACCServerAppID = "1430110" +) + +type SteamService struct { + executor *command.CommandExecutor + repository *repository.SteamCredentialsRepository +} + +func NewSteamService(repository *repository.SteamCredentialsRepository) *SteamService { + return &SteamService{ + executor: &command.CommandExecutor{ + ExePath: "powershell", + LogOutput: true, + }, + repository: repository, + } +} + +func (s *SteamService) GetCredentials(ctx context.Context) (*model.SteamCredentials, error) { + return s.repository.GetCurrent(ctx) +} + +func (s *SteamService) SaveCredentials(ctx context.Context, creds *model.SteamCredentials) error { + if err := creds.Validate(); err != nil { + return err + } + return s.repository.Save(ctx, creds) +} + +func (s *SteamService) ensureSteamCMD() error { + // Check if SteamCMD exists + if _, err := os.Stat(SteamCMDPath); !os.IsNotExist(err) { + return nil + } + + // Download and install SteamCMD + logging.Info("Downloading SteamCMD...") + if err := s.executor.Execute("-Command", + "Invoke-WebRequest -Uri 'https://steamcdn-a.akamaihd.net/client/installer/steamcmd.zip' -OutFile 'steamcmd.zip'"); err != nil { + return fmt.Errorf("failed to download SteamCMD: %v", err) + } + + // Extract SteamCMD + logging.Info("Extracting SteamCMD...") + if err := s.executor.Execute("-Command", + "Expand-Archive -Path 'steamcmd.zip' -DestinationPath 'steamcmd'"); err != nil { + return fmt.Errorf("failed to extract SteamCMD: %v", err) + } + + // Clean up zip file + os.Remove("steamcmd.zip") + return nil +} + +func (s *SteamService) InstallServer(ctx context.Context, installPath string) error { + if err := s.ensureSteamCMD(); err != nil { + return err + } + + // Ensure install path exists + if err := os.MkdirAll(installPath, 0755); err != nil { + return fmt.Errorf("failed to create install directory: %v", err) + } + + // Get Steam credentials + creds, err := s.GetCredentials(ctx) + if err != nil { + return fmt.Errorf("failed to get Steam credentials: %v", err) + } + + // Build SteamCMD command + args := []string{ + "-nologo", + "-noprofile", + filepath.Join(SteamCMDPath, "steamcmd.exe"), + "+force_install_dir", installPath, + "+login", + } + + if creds != nil && creds.Username != "" { + args = append(args, creds.Username) + if creds.Password != "" { + args = append(args, creds.Password) + } + } else { + args = append(args, "anonymous") + } + + args = append(args, + "+app_update", ACCServerAppID, + "validate", + "+quit", + ) + + // Run SteamCMD + logging.Info("Installing ACC server to %s...", installPath) + if err := s.executor.Execute(args...); err != nil { + return fmt.Errorf("failed to install server: %v", err) + } + + return nil +} + +func (s *SteamService) UpdateServer(ctx context.Context, installPath string) error { + return s.InstallServer(ctx, installPath) // Same process as install +} + +func (s *SteamService) UninstallServer(installPath string) error { + return os.RemoveAll(installPath) +} \ No newline at end of file diff --git a/local/service/windows_service.go b/local/service/windows_service.go new file mode 100644 index 0000000..0d0fac9 --- /dev/null +++ b/local/service/windows_service.go @@ -0,0 +1,123 @@ +package service + +import ( + "acc-server-manager/local/utl/command" + "acc-server-manager/local/utl/logging" + "fmt" + "path/filepath" + "strings" +) + +const ( + NSSMPath = ".\\nssm" +) + +type WindowsService struct { + executor *command.CommandExecutor +} + +func NewWindowsService() *WindowsService { + return &WindowsService{ + executor: &command.CommandExecutor{ + ExePath: "powershell", + LogOutput: true, + }, + } +} + +// executeNSSM runs an NSSM command through PowerShell with elevation +func (s *WindowsService) executeNSSM(args ...string) (string, error) { + // Prepend NSSM path to arguments + nssmArgs := append([]string{"-nologo", "-noprofile", NSSMPath}, args...) + + output, err := s.executor.ExecuteWithOutput(nssmArgs...) + if err != nil { + return "", err + } + + // Clean up output by removing null bytes and trimming whitespace + cleaned := strings.TrimSpace(strings.ReplaceAll(output, "\x00", "")) + // Remove \r\n from status strings + cleaned = strings.TrimSuffix(cleaned, "\r\n") + + return cleaned, nil +} + +// Service Installation/Configuration Methods + +func (s *WindowsService) CreateService(serviceName, execPath, workingDir string, args []string) error { + // Ensure paths are absolute + absExecPath, err := filepath.Abs(execPath) + if err != nil { + return fmt.Errorf("failed to get absolute path for executable: %v", err) + } + + absWorkingDir, err := filepath.Abs(workingDir) + if err != nil { + return fmt.Errorf("failed to get absolute path for working directory: %v", err) + } + + // Install service + if _, err := s.executeNSSM("install", serviceName, absExecPath); err != nil { + return fmt.Errorf("failed to install service: %v", err) + } + + // Set working directory + if _, err := s.executeNSSM("set", serviceName, "AppDirectory", absWorkingDir); err != nil { + return fmt.Errorf("failed to set working directory: %v", err) + } + + // Set arguments if provided + if len(args) > 0 { + cmdArgs := append([]string{"set", serviceName, "AppParameters"}, args...) + if _, err := s.executeNSSM(cmdArgs...); err != nil { + return fmt.Errorf("failed to set arguments: %v", err) + } + } + + logging.Info("Created Windows service: %s", serviceName) + return nil +} + +func (s *WindowsService) DeleteService(serviceName string) error { + if _, err := s.executeNSSM("remove", serviceName, "confirm"); err != nil { + return fmt.Errorf("failed to remove service: %v", err) + } + + logging.Info("Removed Windows service: %s", serviceName) + return nil +} + +func (s *WindowsService) UpdateService(serviceName, execPath, workingDir string, args []string) error { + // First remove the existing service + if err := s.DeleteService(serviceName); err != nil { + return err + } + + // Then create it again with new parameters + return s.CreateService(serviceName, execPath, workingDir, args) +} + +// Service Control Methods + +func (s *WindowsService) Status(serviceName string) (string, error) { + return s.executeNSSM("status", serviceName) +} + +func (s *WindowsService) Start(serviceName string) (string, error) { + return s.executeNSSM("start", serviceName) +} + +func (s *WindowsService) Stop(serviceName string) (string, error) { + return s.executeNSSM("stop", serviceName) +} + +func (s *WindowsService) Restart(serviceName string) (string, error) { + // First stop the service + if _, err := s.Stop(serviceName); err != nil { + return "", err + } + + // Then start it again + return s.Start(serviceName) +} \ No newline at end of file diff --git a/local/utl/command/executor.go b/local/utl/command/executor.go new file mode 100644 index 0000000..75c0d07 --- /dev/null +++ b/local/utl/command/executor.go @@ -0,0 +1,103 @@ +package command + +import ( + "acc-server-manager/local/utl/logging" + "fmt" + "os" + "os/exec" + "strings" +) + +// CommandExecutor provides a base structure for executing commands +type CommandExecutor struct { + // Base executable path + ExePath string + // Working directory for commands + WorkDir string + // Whether to capture and log output + LogOutput bool +} + +// CommandBuilder helps build command arguments +type CommandBuilder struct { + args []string +} + +func NewCommandBuilder() *CommandBuilder { + return &CommandBuilder{ + args: make([]string, 0), + } +} + +func (b *CommandBuilder) Add(arg string) *CommandBuilder { + b.args = append(b.args, arg) + return b +} + +func (b *CommandBuilder) AddPair(key, value string) *CommandBuilder { + b.args = append(b.args, key, value) + return b +} + +func (b *CommandBuilder) AddFlag(flag string, value interface{}) *CommandBuilder { + b.args = append(b.args, fmt.Sprintf("%s=%v", flag, value)) + return b +} + +func (b *CommandBuilder) Build() []string { + return b.args +} + +// Execute runs a command with the given arguments +func (e *CommandExecutor) Execute(args ...string) error { + cmd := exec.Command(e.ExePath, args...) + + if e.WorkDir != "" { + cmd.Dir = e.WorkDir + } + + if e.LogOutput { + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + } + + logging.Info("Executing command: %s %s", e.ExePath, strings.Join(args, " ")) + return cmd.Run() +} + +// ExecuteWithBuilder runs a command using a CommandBuilder +func (e *CommandExecutor) ExecuteWithBuilder(builder *CommandBuilder) error { + return e.Execute(builder.Build()...) +} + +// ExecuteWithOutput runs a command and returns its output +func (e *CommandExecutor) ExecuteWithOutput(args ...string) (string, error) { + cmd := exec.Command(e.ExePath, args...) + + if e.WorkDir != "" { + cmd.Dir = e.WorkDir + } + + logging.Info("Executing command: %s %s", e.ExePath, strings.Join(args, " ")) + output, err := cmd.CombinedOutput() + return string(output), err +} + +// ExecuteWithEnv runs a command with custom environment variables +func (e *CommandExecutor) ExecuteWithEnv(env []string, args ...string) error { + cmd := exec.Command(e.ExePath, args...) + + if e.WorkDir != "" { + cmd.Dir = e.WorkDir + } + + if e.LogOutput { + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + } + + cmd.Env = append(os.Environ(), env...) + + logging.Info("Executing command: %s %s", e.ExePath, strings.Join(args, " ")) + return cmd.Run() +} \ No newline at end of file diff --git a/local/utl/common/common.go b/local/utl/common/common.go index 0d93710..675de32 100644 --- a/local/utl/common/common.go +++ b/local/utl/common/common.go @@ -7,7 +7,6 @@ import ( "fmt" "net" "os" - "os/exec" "reflect" "regexp" "strconv" @@ -66,16 +65,6 @@ func Find[T any](lst *[]T, callback func(item *T) bool) *T { return nil } -func RunElevatedCommand(command string, service string) (string, error) { - cmd := exec.Command("powershell", "-nologo", "-noprofile", ".\\nssm", command, service) - // cmd := exec.Command("powershell", "-nologo", "-noprofile", "-File", "run_sc.ps1", command, service) - output, err := cmd.CombinedOutput() - if err != nil { - return "", fmt.Errorf("error: %v, output: %s", err, string(output)) - } - return string(output), nil -} - func IndentJson(body []byte) ([]byte, error) { newBody := new([]byte) unmarshaledBody := bytes.NewBuffer(*newBody)