From 70483037c90710e382a143a3ef0f8c6e536cbe08 Mon Sep 17 00:00:00 2001 From: chris bell Date: Fri, 5 Jun 2026 17:12:24 -0500 Subject: [PATCH 1/4] Made the service into a daemon that you can route IPC commands to --- config.go | 9 ++- daemon.go | 178 +++++++++++++++++++++++++++++++++++++++++++++ instance_config.go | 24 +----- main.go | 129 +++++++++++++++++++++----------- process.go | 5 ++ 5 files changed, 280 insertions(+), 65 deletions(-) create mode 100644 daemon.go diff --git a/config.go b/config.go index 9a927b6..9b54e6d 100644 --- a/config.go +++ b/config.go @@ -16,8 +16,11 @@ 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 { @@ -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..421191f --- /dev/null +++ b/daemon.go @@ -0,0 +1,178 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "os/signal" + "path/filepath" + "syscall" +) + +type CommandPayload struct { + Command string `json:"command"` +} + +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) + + server := &http.Server{ + Addr: cfg.Daemon.ListenAddress, + Handler: mux, + } + + 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.Errorf("Daemon runtime failure: %v", err) + } + }() + + <-sigChan + fmt.Println("\nShutting down supervisor daemon threads...") + return nil +} + +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, // Pull from custom parameters later if desired + 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 + } + + 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) +} diff --git a/instance_config.go b/instance_config.go index faa9b61..fb6dbef 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,29 +21,6 @@ type VsServerConfigOptions struct { PreApprovedRole string `json:"PreApprovedRole"` } -// func WriteInstanceConfig(instanceDir string, settings VsServerConfig) error { -// configPath := filepath.Join(instanceDir, "serverconfig.json") - -// 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 { templatePath := filepath.Join(cfg.Storage.ConfigTemplatesDir, templateVersion, "serverconfig.json") diff --git a/main.go b/main.go index 92779a4..68f72b5 100644 --- a/main.go +++ b/main.go @@ -1,65 +1,112 @@ package main import ( + "bytes" + "encoding/json" "fmt" + "io" "log" + "net/http" + "net/url" "os" - "os/signal" "path/filepath" - "syscall" ) 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") - 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)) + + 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 printUsage() { + fmt.Println("Vintage Story Server Manager") + fmt.Println("\nUsage:") + fmt.Println(" go run . daemon Starts the background process supervisor") + fmt.Println(" go run . create Provisions baseline configuration and stores instance profile") + fmt.Println(" go run . start Launches an existing server instance using stored profile") + fmt.Println(" go run . stop Gracefully shuts down a server instance") + fmt.Println(" go run . 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) From d29d51cede5f7e7e33cb556d23491302614e45da Mon Sep 17 00:00:00 2001 From: chris bell Date: Fri, 5 Jun 2026 17:21:40 -0500 Subject: [PATCH 2/4] Added a status command --- daemon.go | 40 +++++++++++++++++++++++++++++++++++++++- main.go | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/daemon.go b/daemon.go index 421191f..f1d651b 100644 --- a/daemon.go +++ b/daemon.go @@ -19,6 +19,13 @@ type DaemonServer struct { procManager *ProcessManager } +type InstanceStatusResponse struct { + Name string `json:"name"` + Version string `json:"version"` + Port int `json:"port"` + Status string `json:"status"` +} + func StartDaemon(cfg *AppConfig) error { ds := &DaemonServer{ cfg: cfg, @@ -30,6 +37,7 @@ func StartDaemon(cfg *AppConfig) error { mux.HandleFunc("/instances/start", ds.handleStart) mux.HandleFunc("/instances/stop", ds.handleStop) mux.HandleFunc("/instances/command", ds.handleCommand) + mux.HandleFunc("/instances/list", ds.handleList) server := &http.Server{ Addr: cfg.Daemon.ListenAddress, @@ -72,7 +80,7 @@ func (ds *DaemonServer) handleCreate(w http.ResponseWriter, r *http.Request) { options := VsServerConfigOptions{ Version: version, ServerName: name, - Port: 42424, // Pull from custom parameters later if desired + Port: 42424, MaxClients: 10, } @@ -176,3 +184,33 @@ func (ds *DaemonServer) handleCommand(w http.ResponseWriter, r *http.Request) { 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/main.go b/main.go index 68f72b5..715f8df 100644 --- a/main.go +++ b/main.go @@ -10,6 +10,7 @@ import ( "net/url" "os" "path/filepath" + "text/tabwriter" ) func main() { @@ -71,6 +72,9 @@ func main() { 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() } @@ -101,6 +105,39 @@ func sendIPCRequest(cfg *AppConfig, method string, path string, body io.Reader) 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("Vintage Story Server Manager") fmt.Println("\nUsage:") From 8e575cab6ed3dcc3401c2db1ae8b69217a15b78f Mon Sep 17 00:00:00 2001 From: chris bell Date: Fri, 5 Jun 2026 17:27:05 -0500 Subject: [PATCH 3/4] Sanity check to make sure servers stop gracefully if daemon closes --- daemon.go | 74 +++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 67 insertions(+), 7 deletions(-) diff --git a/daemon.go b/daemon.go index f1d651b..a59fab6 100644 --- a/daemon.go +++ b/daemon.go @@ -7,18 +7,15 @@ import ( "os" "os/signal" "path/filepath" + "sync" "syscall" + "time" ) type CommandPayload struct { Command string `json:"command"` } -type DaemonServer struct { - cfg *AppConfig - procManager *ProcessManager -} - type InstanceStatusResponse struct { Name string `json:"name"` Version string `json:"version"` @@ -26,6 +23,11 @@ type InstanceStatusResponse struct { Status string `json:"status"` } +type DaemonServer struct { + cfg *AppConfig + procManager *ProcessManager +} + func StartDaemon(cfg *AppConfig) error { ds := &DaemonServer{ cfg: cfg, @@ -50,15 +52,73 @@ func StartDaemon(cfg *AppConfig) error { go func() { fmt.Printf("Engine daemon actively listening on http://%s\n", cfg.Daemon.ListenAddress) if err := server.ListenAndServe(); err != http.ErrServerClosed { - fmt.Errorf("Daemon runtime failure: %v", err) + fmt.Printf("Daemon runtime failure: %v\n", err) } }() <-sigChan - fmt.Println("\nShutting down supervisor daemon threads...") + 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) From ba5dd4f1ca70d5f7838a92d972fc42670d2a36a4 Mon Sep 17 00:00:00 2001 From: chris bell Date: Fri, 5 Jun 2026 19:39:32 -0500 Subject: [PATCH 4/4] Renaming to vssm and adding a readme --- README.md | 49 +++++++++++++++++++++++++++++++++++ config.go | 2 +- daemon.go | 26 +++++++++++++++++-- go.mod | 2 +- instance_config.go | 64 ++++++++++++++++++++++------------------------ main.go | 14 +++++----- 6 files changed, 112 insertions(+), 45 deletions(-) create mode 100644 README.md 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 9b54e6d..5e15c98 100644 --- a/config.go +++ b/config.go @@ -25,7 +25,7 @@ type AppConfig struct { 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") diff --git a/daemon.go b/daemon.go index a59fab6..292cb27 100644 --- a/daemon.go +++ b/daemon.go @@ -41,9 +41,22 @@ func StartDaemon(cfg *AppConfig) error { 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: mux, + Handler: corsWrappedHandler, } sigChan := make(chan os.Signal, 1) @@ -58,8 +71,10 @@ func StartDaemon(cfg *AppConfig) error { <-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 } @@ -185,7 +200,14 @@ func (ds *DaemonServer) handleStart(w http.ResponseWriter, r *http.Request) { return } - err := ds.procManager.StartInstance(name, options.Version, options, ds.cfg) + 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 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 fb6dbef..1494ea5 100644 --- a/instance_config.go +++ b/instance_config.go @@ -21,38 +21,39 @@ type VsServerConfigOptions struct { PreApprovedRole string `json:"PreApprovedRole"` } -func PrepareInstanceConfig(templateVersion string, instanceConfigPath string, config VsServerConfigOptions, cfg *AppConfig) error { +func PrepareInstanceConfig(templateVersion string, instanceConfigPath string, options VsServerConfigOptions, cfg *AppConfig) error { + return SyncInstanceConfig(templateVersion, instanceConfigPath, options, cfg) +} + +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{} @@ -60,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") } @@ -89,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 715f8df..26de878 100644 --- a/main.go +++ b/main.go @@ -19,7 +19,7 @@ func main() { 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) @@ -139,11 +139,11 @@ func fetchAndPrintStatus(cfg *AppConfig) { } func printUsage() { - fmt.Println("Vintage Story Server Manager") + fmt.Println("VintageStory Server Manager") fmt.Println("\nUsage:") - fmt.Println(" go run . daemon Starts the background process supervisor") - fmt.Println(" go run . create Provisions baseline configuration and stores instance profile") - fmt.Println(" go run . start Launches an existing server instance using stored profile") - fmt.Println(" go run . stop Gracefully shuts down a server instance") - fmt.Println(" go run . cmd \"\" Dispatches a terminal console command down the pipe") + 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") }