diff --git a/README.md b/README.md new file mode 100644 index 0000000..7a050e4 --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +# VSSM (VintageStory Server Manager) +*A server manager for VintageStory* + +## IMPORTANT INFORMATION +This project is currently in development, and thus everything is subject to change + +## About +VSSM allows you to create and manage multiple VS servers + +## Installation and Usage +There are no binaries just yet, so you have to download the source files. + +Setup is easy from source, all you need is: +- Linux +- Go +- .NET 10 Runtime + +**If you are using NixOS, a flake.nix is provided, just run `nix develop` for a full development enviroment** + +- Once you have the source code downloaded or cloned, you can run `go build` to generate an executable. +- Run `./vssm daemon` to start the daemon - *(For now, theres no background service created by default, but you can run it in the background with `vssm daemon &` and kill the process with `pkill vssm`)* +- Run `./vssm` with no arguments to show usage + +## Configuration +**For now, the configuration file is stored in `~/.config/vssm/config.json`, but this will be configurable later.** + + + +## Roadmap + +- [-] Configuration + - [x] Application data path + - [x] Declarative Servers + +- [-] Server management + - [x] Create servers + - [x] Start/Stop servers + - [x] Multiple servers support + - [x] List servers and their status + - [ ] Delete servers + - [ ] Automated server backups + +- [ ] Binary releases + +- [-] Other + - [-] First class NixOS support + - [x] Patch downloaded server binaries for NixOS + - [ ] Official nix package or flake + - [ ] All configuration options diff --git a/config.go b/config.go index 9a927b6..5e15c98 100644 --- a/config.go +++ b/config.go @@ -16,13 +16,16 @@ type AppConfig struct { } `json:"storage"` Daemon struct { - UseNixOs bool `json:"use_nixos"` + // UseNixOs bool `json:"use_nixos"` + ListenAddress string `json:"listen_address"` } + + Instances map[string]VsServerConfigOptions `json:"instances"` } func DefaultConfig() *AppConfig { home, _ := os.UserHomeDir() - basePath := filepath.Join(home, ".local", "share", "vs-manager") + basePath := filepath.Join(home, ".local", "share", "vssm") cfg := &AppConfig{} cfg.Storage.InstallDir = filepath.Join(basePath, "installs") @@ -30,6 +33,10 @@ func DefaultConfig() *AppConfig { cfg.Storage.BackupDir = filepath.Join(basePath, "backups") cfg.Storage.ConfigTemplatesDir = filepath.Join(basePath, "config_templates") + cfg.Daemon.ListenAddress = "127.0.0.1:12345" + + cfg.Instances = make(map[string]VsServerConfigOptions) + return cfg } diff --git a/daemon.go b/daemon.go new file mode 100644 index 0000000..292cb27 --- /dev/null +++ b/daemon.go @@ -0,0 +1,298 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "os/signal" + "path/filepath" + "sync" + "syscall" + "time" +) + +type CommandPayload struct { + Command string `json:"command"` +} + +type InstanceStatusResponse struct { + Name string `json:"name"` + Version string `json:"version"` + Port int `json:"port"` + Status string `json:"status"` +} + +type DaemonServer struct { + cfg *AppConfig + procManager *ProcessManager +} + +func StartDaemon(cfg *AppConfig) error { + ds := &DaemonServer{ + cfg: cfg, + procManager: NewProcessManager(), + } + + mux := http.NewServeMux() + mux.HandleFunc("/instances/create", ds.handleCreate) + mux.HandleFunc("/instances/start", ds.handleStart) + mux.HandleFunc("/instances/stop", ds.handleStop) + mux.HandleFunc("/instances/command", ds.handleCommand) + mux.HandleFunc("/instances/list", ds.handleList) + + corsWrappedHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusOK) + return + } + + mux.ServeHTTP(w, r) + }) + + server := &http.Server{ + Addr: cfg.Daemon.ListenAddress, + Handler: corsWrappedHandler, + } + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + + go func() { + fmt.Printf("Engine daemon actively listening on http://%s\n", cfg.Daemon.ListenAddress) + if err := server.ListenAndServe(); err != http.ErrServerClosed { + fmt.Printf("Daemon runtime failure: %v\n", err) + } + }() + + <-sigChan + fmt.Println("\n[Daemon] Shutdown signal caught! Initializing graceful teardown sequence...") + + _ = server.Close() + ds.shutdownAllRunningServers() + + fmt.Println("[Daemon] All threads gracefully shut down. Exiting supervisor cleanly.") + return nil +} + +func (ds *DaemonServer) shutdownAllRunningServers() { + ds.procManager.Lock() + + var activeNames []string + for name := range ds.procManager.ActiveInstances { + activeNames = append(activeNames, name) + } + ds.procManager.Unlock() + + if len(activeNames) == 0 { + fmt.Println("[Daemon] No active server instances to tear down.") + return + } + + fmt.Printf("[Daemon] Flushing stop instructions to %d running instance(s)...\n", len(activeNames)) + + var wg sync.WaitGroup + for _, name := range activeNames { + wg.Add(1) + go func(instanceName string) { + defer wg.Done() + + fmt.Printf("[Daemon] Sending graceful /stop to instance '%s'...\n", instanceName) + err := ds.procManager.SendCommand(instanceName, "/stop") + if err != nil { + fmt.Printf("[Daemon Error] Could not send stop to %s: %v\n", instanceName, err) + return + } + + ticker := time.NewTicker(250 * time.Millisecond) + defer ticker.Stop() + timeout := time.After(15 * time.Second) + + for { + select { + case <-ticker.C: + ds.procManager.RLock() + _, running := ds.procManager.ActiveInstances[instanceName] + ds.procManager.RUnlock() + + if !running { + fmt.Printf("[Daemon] Instance '%s' has successfully exited.\n", instanceName) + return + } + case <-timeout: + fmt.Printf("[Daemon Warning] Instance '%s' timed out while trying to stop safely.\n", instanceName) + return + } + } + }(name) + } + + wg.Wait() +} + +func (ds *DaemonServer) handleCreate(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + name := r.URL.Query().Get("name") + version := r.URL.Query().Get("version") + if name == "" || version == "" { + http.Error(w, "Missing name or version parameters", http.StatusBadRequest) + return + } + + if _, exists := ds.cfg.Instances[name]; exists { + http.Error(w, fmt.Sprintf("Instance '%s' already exists in configuration", name), http.StatusConflict) + return + } + + options := VsServerConfigOptions{ + Version: version, + ServerName: name, + Port: 42424, + MaxClients: 10, + } + + err := DownloadAndExtractServer(version, ds.cfg.Storage.InstallDir) + if err != nil { + http.Error(w, fmt.Sprintf("Installation failed: %v", err), http.StatusInternalServerError) + return + } + + err = CreateNewInstance(name, version, options, ds.cfg) + if err != nil { + http.Error(w, fmt.Sprintf("Instance provisioning failed: %v", err), http.StatusInternalServerError) + return + } + + ds.cfg.Instances[name] = options + + home, _ := os.UserHomeDir() + configPath := filepath.Join(home, ".config", "vs-manager", "config.json") + data, _ := json.MarshalIndent(ds.cfg, "", " ") + _ = os.WriteFile(configPath, data, 0644) + + w.WriteHeader(http.StatusCreated) + fmt.Fprintf(w, "Successfully created and stored profile for instance %s", name) +} + +func (ds *DaemonServer) handleStart(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + name := r.URL.Query().Get("name") + if name == "" { + http.Error(w, "Missing name parameter", http.StatusBadRequest) + return + } + + options, exists := ds.cfg.Instances[name] + if !exists { + http.Error(w, fmt.Sprintf("Instance '%s' does not exist. Run 'create' first", name), http.StatusNotFound) + return + } + + instanceConfigPath := filepath.Join(ds.cfg.Storage.InstancesDir, name, "serverconfig.json") + err := SyncInstanceConfig(options.Version, instanceConfigPath, options, ds.cfg) + if err != nil { + http.Error(w, "Failed to sync config: "+err.Error(), http.StatusInternalServerError) + return + } + + err = ds.procManager.StartInstance(name, options.Version, options, ds.cfg) + if err != nil { + http.Error(w, fmt.Sprintf("Process startup failed: %v", err), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, "Successfully started instance %s", name) +} + +func (ds *DaemonServer) handleStop(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + name := r.URL.Query().Get("name") + if name == "" { + http.Error(w, "Missing name parameter", http.StatusBadRequest) + return + } + + err := ds.procManager.SendCommand(name, "/stop") + if err != nil { + http.Error(w, fmt.Sprintf("Failed to dispatch stop command: %v", err), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, "Termination signal routed to instance %s", name) +} + +func (ds *DaemonServer) handleCommand(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + name := r.URL.Query().Get("name") + if name == "" { + http.Error(w, "Missing name parameter", http.StatusBadRequest) + return + } + + var payload CommandPayload + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + http.Error(w, "Malformed JSON body", http.StatusBadRequest) + return + } + + err := ds.procManager.SendCommand(name, payload.Command) + if err != nil { + http.Error(w, fmt.Sprintf("Command delivery failed: %v", err), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, "Command delivered successfully to %s", name) +} + +func (ds *DaemonServer) handleList(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + ds.procManager.RLock() + defer ds.procManager.RUnlock() + + var responseList []InstanceStatusResponse + + for name, options := range ds.cfg.Instances { + status := "STOPPED" + if _, running := ds.procManager.ActiveInstances[name]; running { + status = "RUNNING" + } + + responseList = append(responseList, InstanceStatusResponse{ + Name: name, + Version: options.Version, + Port: options.Port, + Status: status, + }) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(responseList) +} diff --git a/go.mod b/go.mod index 047dd0b..3bbbc00 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ -module vs-manager +module vssm go 1.26.3 diff --git a/instance_config.go b/instance_config.go index faa9b61..1494ea5 100644 --- a/instance_config.go +++ b/instance_config.go @@ -10,6 +10,7 @@ import ( ) type VsServerConfigOptions struct { + Version string `json:"Version"` ServerName string `json:"ServerName"` Port int `json:"Port"` IpAddress string `json:"IpAddress"` @@ -20,61 +21,39 @@ type VsServerConfigOptions struct { PreApprovedRole string `json:"PreApprovedRole"` } -// func WriteInstanceConfig(instanceDir string, settings VsServerConfig) error { -// configPath := filepath.Join(instanceDir, "serverconfig.json") +func PrepareInstanceConfig(templateVersion string, instanceConfigPath string, options VsServerConfigOptions, cfg *AppConfig) error { + return SyncInstanceConfig(templateVersion, instanceConfigPath, options, cfg) +} -// if settings.IpAddress == "" { -// settings.IpAddress = "0.0.0.0" -// } -// if settings.MaxClients == 0 { -// settings.MaxClients = 16 -// } - -// data, err := json.MarshalIndent(settings, "", " ") -// if err != nil { -// return fmt.Errorf("failed to marshal server configuration: %w", err) -// } - -// err = os.WriteFile(configPath, data, 0644) -// if err != nil { -// return fmt.Errorf("failed writing serverconfig.json to instance: %w", err) -// } - -// return nil -// } - -func PrepareInstanceConfig(templateVersion string, instanceConfigPath string, config VsServerConfigOptions, cfg *AppConfig) error { +func SyncInstanceConfig(templateVersion string, instanceConfigPath string, options VsServerConfigOptions, cfg *AppConfig) error { templatePath := filepath.Join(cfg.Storage.ConfigTemplatesDir, templateVersion, "serverconfig.json") - if _, err := os.Stat(instanceConfigPath); err == nil { - return nil - } + if _, err := os.Stat(instanceConfigPath); os.IsNotExist(err) { + if err := os.MkdirAll(filepath.Dir(instanceConfigPath), 0755); err != nil { + return fmt.Errorf("failed creating instance directory tree: %w", err) + } - if err := os.MkdirAll(filepath.Dir(instanceConfigPath), 0755); err != nil { - return fmt.Errorf("failed creating instance directory tree: %w", err) - } + source, err := os.Open(templatePath) + if err != nil { + return fmt.Errorf("failed opening baseline template file: %w", err) + } + defer source.Close() - source, err := os.Open(templatePath) - if err != nil { - return fmt.Errorf("failed opening baseline template file: %w", err) - } - defer source.Close() + destination, err := os.Create(instanceConfigPath) + if err != nil { + return fmt.Errorf("failed creating target instance configuration: %w", err) + } - destination, err := os.Create(instanceConfigPath) - if err != nil { - return fmt.Errorf("failed creating target instance configuration: %w", err) + _, err = io.Copy(destination, source) + destination.Close() + if err != nil { + return fmt.Errorf("failed cloning configuration template payload: %w", err) + } } - defer destination.Close() - - if _, err := io.Copy(destination, source); err != nil { - return fmt.Errorf("failed cloning configuration template payload: %w", err) - } - - destination.Close() data, err := os.ReadFile(instanceConfigPath) if err != nil { - return fmt.Errorf("failed reading cloned configuration data: %w", err) + return fmt.Errorf("failed reading configuration data: %w", err) } var rawConfig map[string]interface{} @@ -82,18 +61,17 @@ func PrepareInstanceConfig(templateVersion string, instanceConfigPath string, co return fmt.Errorf("failed parsing configuration JSON payload: %w", err) } - rawConfig["ServerName"] = config.ServerName - rawConfig["Port"] = config.Port - rawConfig["MaxClients"] = config.MaxClients + rawConfig["ServerName"] = options.ServerName + rawConfig["Port"] = options.Port + rawConfig["MaxClients"] = options.MaxClients - if config.Password != "" { - rawConfig["Password"] = config.Password + if options.Password != "" { + rawConfig["Password"] = options.Password } else { rawConfig["Password"] = nil } instanceDir := filepath.Dir(instanceConfigPath) - if worldConfig, ok := rawConfig["WorldConfig"].(map[string]interface{}); ok { worldConfig["SaveFileLocation"] = filepath.Join(instanceDir, "Saves", "default.vcdbs") } @@ -111,9 +89,5 @@ func PrepareInstanceConfig(templateVersion string, instanceConfigPath string, co return fmt.Errorf("failed marshaling updated configuration adjustments: %w", err) } - if err := os.WriteFile(instanceConfigPath, updatedData, 0644); err != nil { - return fmt.Errorf("failed committing updated configuration to disk: %w", err) - } - - return nil + return os.WriteFile(instanceConfigPath, updatedData, 0644) } diff --git a/main.go b/main.go index 92779a4..26de878 100644 --- a/main.go +++ b/main.go @@ -1,65 +1,149 @@ package main import ( + "bytes" + "encoding/json" "fmt" + "io" "log" + "net/http" + "net/url" "os" - "os/signal" "path/filepath" - "syscall" + "text/tabwriter" ) func main() { - fmt.Println("Initializing VS server manager...") - home, err := os.UserHomeDir() if err != nil { - log.Fatalf("Could not locate user home dir") + log.Fatalf("Could not locate user home dir: %v", err) } - configPath := filepath.Join(home, ".config", "vs-manager", "config.json") - + configPath := filepath.Join(home, ".config", "vssm", "config.json") cfg, err := LoadOrCreateConfig(configPath) if err != nil { log.Fatalf("Initialization failed: %v", err) } - instanceName := "sample_server" - targetVersion := "1.22.3" - - options := VsServerConfigOptions{ - ServerName: "Sample Server", - Port: 4000, - MaxClients: 100, - Password: "", + if len(os.Args) < 2 { + printUsage() + return } - err = DownloadAndExtractServer(targetVersion, cfg.Storage.InstallDir) - if err != nil { - log.Fatalf("Downloader module encountered an error: %v", err) + subCommand := os.Args[1] + + switch subCommand { + case "daemon": + fmt.Println("Initializing VS server manager background supervisor...") + if err := StartDaemon(cfg); err != nil { + log.Fatalf("Daemon runtime fatal error: %v", err) + } + + case "create": + if len(os.Args) < 4 { + log.Fatalf("Usage: go run . create ") + } + name := os.Args[2] + version := os.Args[3] + sendIPCRequest(cfg, "POST", fmt.Sprintf("/instances/create?name=%s&version=%s", url.QueryEscape(name), url.QueryEscape(version)), nil) + + case "start": + if len(os.Args) < 3 { + log.Fatalf("Usage: go run . start ") + } + name := os.Args[2] + sendIPCRequest(cfg, "POST", fmt.Sprintf("/instances/start?name=%s", url.QueryEscape(name)), nil) + + case "stop": + if len(os.Args) < 3 { + log.Fatalf("Usage: go run . stop ") + } + name := os.Args[2] + sendIPCRequest(cfg, "POST", fmt.Sprintf("/instances/stop?name=%s", url.QueryEscape(name)), nil) + + case "cmd": + if len(os.Args) < 4 { + log.Fatalf("Usage: go run . cmd \"\"") + } + name := os.Args[2] + serverCmd := os.Args[3] + + payload := CommandPayload{Command: serverCmd} + body, _ := json.Marshal(payload) + sendIPCRequest(cfg, "POST", fmt.Sprintf("/instances/command?name=%s", url.QueryEscape(name)), bytes.NewBuffer(body)) + + case "list", "status": + fetchAndPrintStatus(cfg) + + default: + printUsage() } - - err = CreateNewInstance(instanceName, targetVersion, options, cfg) - if err != nil { - log.Fatalf("Initialization abort: %v", err) - } - - procManager := NewProcessManager() - - fmt.Printf("\n⚡ Launching Instance: %s (v%s)\n", instanceName, targetVersion) - err = procManager.StartInstance(instanceName, targetVersion, options, cfg) - if err != nil { - log.Fatalf("Process startup failed: %v", err) - } - - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) - - fmt.Println("\nServer running. Press Ctrl+C to cleanly shut down...") - <-sigChan - - fmt.Println("\n\nTriggering graceful server termination sequence...") - _ = procManager.SendCommand(instanceName, "stop") - - fmt.Println("Manager exited successfully.") +} + +func sendIPCRequest(cfg *AppConfig, method string, path string, body io.Reader) { + targetUrl := fmt.Sprintf("http://%s%s", cfg.Daemon.ListenAddress, path) + req, err := http.NewRequest(method, targetUrl, body) + if err != nil { + log.Fatalf("Failed to construct IPC frame: %v", err) + } + + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + log.Fatalf("IPC connection failed. Is the vs-manager daemon running? Error: %v", err) + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + log.Fatalf("Error from daemon: %s", string(respBody)) + } + + fmt.Println(string(respBody)) +} + +func fetchAndPrintStatus(cfg *AppConfig) { + targetUrl := fmt.Sprintf("http://%s/instances/list", cfg.Daemon.ListenAddress) + resp, err := http.Get(targetUrl) + if err != nil { + log.Fatalf("IPC connection failed. Is the vs-manager daemon running? Error: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + log.Fatalf("Error from daemon: %s", string(respBody)) + } + + var instances []InstanceStatusResponse + if err := json.NewDecoder(resp.Body).Decode(&instances); err != nil { + log.Fatalf("Failed to decode daemon status matrix: %v", err) + } + + if len(instances) == 0 { + fmt.Println("No instances configured yet. Use 'create' to provision one.") + return + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0) + fmt.Fprintln(w, "INSTANCE NAME\tVERSION\tPORT\tSTATUS") + fmt.Fprintln(w, "-------------\t-------\t----\t------") + + for _, inst := range instances { + fmt.Fprintf(w, "%s\t%s\t%d\t%s\n", inst.Name, inst.Version, inst.Port, inst.Status) + } + w.Flush() +} + +func printUsage() { + fmt.Println("VintageStory Server Manager") + fmt.Println("\nUsage:") + fmt.Println(" vssm daemon Starts the background process supervisor") + fmt.Println(" vssm create Provisions baseline configuration and stores instance profile") + fmt.Println(" vssm start Launches an existing server instance using stored profile") + fmt.Println(" vssm stop Gracefully shuts down a server instance") + fmt.Println(" vssm cmd \"\" Dispatches a terminal console command down the pipe") } diff --git a/process.go b/process.go index 20f5f98..d2924ce 100644 --- a/process.go +++ b/process.go @@ -7,6 +7,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "sync" ) @@ -107,6 +108,10 @@ func (pm *ProcessManager) SendCommand(name string, command string) error { return fmt.Errorf("Cannot send command, instance with name '%s' is not running", name) } + if command != "" && !strings.HasPrefix(command, "/") { + command = "/" + command + } + _, err := io.WriteString(pipe, command+"\n") if err != nil { return fmt.Errorf("failed writing to server process stdin conduit: %w", err)