From 073db62fb141f28571a5c680e7a7bef16fe23e81 Mon Sep 17 00:00:00 2001 From: chris bell Date: Fri, 5 Jun 2026 16:44:41 -0500 Subject: [PATCH] Adding current project state --- .gitignore | 4 ++ config.go | 77 ++++++++++++++++++++++++++++ downloader.go | 125 +++++++++++++++++++++++++++++++++++++++++++++ flake.lock | 61 ++++++++++++++++++++++ flake.nix | 44 ++++++++++++++++ go.mod | 3 ++ instance.go | 49 ++++++++++++++++++ instance_config.go | 119 ++++++++++++++++++++++++++++++++++++++++++ main.go | 65 +++++++++++++++++++++++ process.go | 116 +++++++++++++++++++++++++++++++++++++++++ 10 files changed, 663 insertions(+) create mode 100644 .gitignore create mode 100644 config.go create mode 100644 downloader.go create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 go.mod create mode 100644 instance.go create mode 100644 instance_config.go create mode 100644 main.go create mode 100644 process.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c9d31a5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.envrc +.direnv +.cache +.vscode diff --git a/config.go b/config.go new file mode 100644 index 0000000..9a927b6 --- /dev/null +++ b/config.go @@ -0,0 +1,77 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +type AppConfig struct { + Storage struct { + InstallDir string `json:"install_dir"` + InstancesDir string `json:"instances_dir"` + BackupDir string `json:"backup_dir"` + ConfigTemplatesDir string `json:"config_templates_dir"` + } `json:"storage"` + + Daemon struct { + UseNixOs bool `json:"use_nixos"` + } +} + +func DefaultConfig() *AppConfig { + home, _ := os.UserHomeDir() + basePath := filepath.Join(home, ".local", "share", "vs-manager") + + cfg := &AppConfig{} + cfg.Storage.InstallDir = filepath.Join(basePath, "installs") + cfg.Storage.InstancesDir = filepath.Join(basePath, "instances") + cfg.Storage.BackupDir = filepath.Join(basePath, "backups") + cfg.Storage.ConfigTemplatesDir = filepath.Join(basePath, "config_templates") + + return cfg +} + +func LoadOrCreateConfig(configPath string) (*AppConfig, error) { + if _, err := os.Stat(configPath); os.IsNotExist(err) { + cfg := DefaultConfig() + + if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil { + return nil, fmt.Errorf("Failed to create config directory: %w", err) + } + + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return nil, err + } + + if err := os.WriteFile(configPath, data, 0644); err != nil { + return nil, fmt.Errorf("Failed to write config file at %s\n", configPath) + } + } + + data, err := os.ReadFile(configPath) + if err != nil { + return nil, err + } + + var cfg AppConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("Invalid JSON layout in config file: %w", err) + } + + dirs := []string{ + cfg.Storage.InstallDir, + cfg.Storage.InstancesDir, + cfg.Storage.BackupDir, + cfg.Storage.ConfigTemplatesDir, + } + for _, dir := range dirs { + if err := os.MkdirAll(dir, 0755); err != nil { + return nil, fmt.Errorf("Failed creating workspace directory %s: %w", dir, err) + } + } + + return &cfg, nil +} diff --git a/downloader.go b/downloader.go new file mode 100644 index 0000000..967e69c --- /dev/null +++ b/downloader.go @@ -0,0 +1,125 @@ +package main + +import ( + "archive/tar" + "compress/gzip" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" +) + +func DownloadAndExtractServer(version string, installBaseDir string) error { + targetDir := filepath.Join(installBaseDir, version) + + if _, err := os.Stat(filepath.Join(targetDir, "VintagestoryServer")); err == nil { + fmt.Printf("[Downloader] Server version %s is already installed\n", version) + return nil + } + + url := fmt.Sprintf("https://cdn.vintagestory.at/gamefiles/stable/vs_server_linux-x64_%s.tar.gz", version) + fmt.Printf("Establishing connection to '%s'\n", url) + + resp, err := http.Get(url) + if err != nil { + return fmt.Errorf("Error connecting to '%s', %w", url, err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return fmt.Errorf("Version %s not found on the stable CDN branch (404)", version) + } else if resp.StatusCode != http.StatusOK { + return fmt.Errorf("Unexpected CDN response status: %s", resp.Status) + } + + fmt.Printf("Downloading and extracting archive to '%s'\n", targetDir) + + if err := os.MkdirAll(targetDir, 0755); err != nil { + return fmt.Errorf("Failed to create version installation directory: %w", err) + } + + gzipReader, err := gzip.NewReader(resp.Body) + if err != nil { + return fmt.Errorf("Failed to initialize gzip decompressor: %w", err) + } + defer gzipReader.Close() + + tarReader := tar.NewReader(gzipReader) + + for { + header, err := tarReader.Next() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("Error reading TAR stream: %w", err) + } + + targetFilePath := filepath.Join(targetDir, header.Name) + + switch header.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(targetFilePath, 0755); err != nil { + return fmt.Errorf("failed creating archive directory entry: %w", err) + } + + case tar.TypeReg: + if err := os.MkdirAll(filepath.Dir(targetFilePath), 0755); err != nil { + return fmt.Errorf("failed creating parent path for file: %w", err) + } + + outFile, err := os.OpenFile(targetFilePath, os.O_CREATE|os.O_RDWR|os.O_TRUNC, header.FileInfo().Mode()) + if err != nil { + return fmt.Errorf("failed creating target system file: %w", err) + } + + if _, err := io.Copy(outFile, tarReader); err != nil { + outFile.Close() + return fmt.Errorf("failed streaming file payload extraction: %w", err) + } + outFile.Close() + } + } + + binaryExecutor := filepath.Join(targetDir, "VintagestoryServer") + if err := patchBinaryForNixos(binaryExecutor); err != nil { + return fmt.Errorf("failed fixing platform compatibility gates: %w", err) + } + + fmt.Printf("Server version %s successfully installed\n", version) + return nil +} + +func patchBinaryForNixos(binaryPath string) error { + if _, err := os.Stat("/etc/NIXOS"); os.IsNotExist(err) { + return nil + } + + fmt.Println("[NixOS Detected] Patching server interpreter pathway...") + + dotnetRoot := os.Getenv("DOTNET_ROOT") + var interpreter string + + if dotnetRoot != "" { + // Use the glibc version that matches the active .NET runtime exactly + // Finds the underlying ld-linux-x86-64.so.2 link within the .NET store closure + out, err := exec.Command("patchelf", "--print-interpreter", filepath.Join(dotnetRoot, "dotnet")).Output() + if err == nil && len(out) > 0 { + interpreter = strings.TrimSpace(string(out)) + } + } + + if interpreter == "" { + interpreter = "/lib/ld-linux-x86-64.so.2" + } + + cmd := exec.Command("patchelf", "--set-interpreter", interpreter, binaryPath) + if err := cmd.Run(); err != nil { + return fmt.Errorf("patchelf failed to link binary: %w", err) + } + + return nil +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..0d3e526 --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1780243769, + "narHash": "sha256-x5UQuRsH3MqI0U9afaXSNqzTPSeZlRLvFAav2Ux1pNw=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "331800de5053fcebacf6813adb5db9c9dca22a0c", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs", + "utils": "utils" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..8296849 --- /dev/null +++ b/flake.nix @@ -0,0 +1,44 @@ +{ + description = "Dev shell"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, utils }: + utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { + inherit system; + config = { + allowUnfree = true; + }; + }; + + dotnetRuntime = pkgs.dotnet-runtime_10.passthru.unwrapped; + in + { + devShells.default = pkgs.mkShell { + buildInputs = with pkgs; [ + go + gopls + gotools + delve + golangci-lint + + pkgs.dotnet-runtime_10 + patchelf + ]; + + shellHook = '' + export DOTNET_ROOT="${dotnetRuntime}/share/dotnet" + export DOTNET_ROOT_X64="${dotnetRuntime}/share/dotnet" + export LD_LIBRARY_PATH="${pkgs.stdenv.cc.cc.lib}/lib:${pkgs.zlib}/lib:/run/opengl-driver/lib:$LD_LIBRARY_PATH" + + echo " == Dev shell loaded == " + ''; + }; + } + ); +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..047dd0b --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module vs-manager + +go 1.26.3 diff --git a/instance.go b/instance.go new file mode 100644 index 0000000..6746000 --- /dev/null +++ b/instance.go @@ -0,0 +1,49 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" +) + +type InstanceState string + +const ( + StateStopped InstanceState = "STOPPED" + StateRunning InstanceState = "RUNNING" +) + +type ManagedInstance struct { + Name string `json:"name"` + Version string `json:"version"` + Port int `json:"port"` + Status InstanceState `json:"status"` + + Cmd *exec.Cmd `json:"-"` + Stdin interface{} `json:"-"` +} + +func CreateNewInstance(name string, version string, options VsServerConfigOptions, cfg *AppConfig) error { + instanceDir := filepath.Join(cfg.Storage.InstancesDir, name) + + dirs := []string{ + instanceDir, + filepath.Join(instanceDir, "Saves"), + filepath.Join(instanceDir, "Mods"), + } + + for _, dir := range dirs { + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("Failed creating core instance directory layout: %w", err) + } + } + + instanceConfigPath := filepath.Join(instanceDir, "serverconfig.json") + if err := PrepareInstanceConfig(version, instanceConfigPath, options, cfg); err != nil { + return fmt.Errorf("Failed provisioning server baseline configuration: %w", err) + } + + fmt.Printf("Instance '%s' (v%s) successfully initialized\n", name, version) + return nil +} diff --git a/instance_config.go b/instance_config.go new file mode 100644 index 0000000..faa9b61 --- /dev/null +++ b/instance_config.go @@ -0,0 +1,119 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +type VsServerConfigOptions struct { + ServerName string `json:"ServerName"` + Port int `json:"Port"` + IpAddress string `json:"IpAddress"` + MaxClients int `json:"MaxClients"` + Password string `json:"Password"` + DefaultRole string `json:"DefaultRole"` + GuestRole string `json:"GuestRole"` + 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") + + if _, err := os.Stat(instanceConfigPath); err == nil { + return nil + } + + 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() + + destination, err := os.Create(instanceConfigPath) + if err != nil { + return fmt.Errorf("failed creating target instance configuration: %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) + } + + var rawConfig map[string]interface{} + if err := json.Unmarshal(data, &rawConfig); err != nil { + return fmt.Errorf("failed parsing configuration JSON payload: %w", err) + } + + rawConfig["ServerName"] = config.ServerName + rawConfig["Port"] = config.Port + rawConfig["MaxClients"] = config.MaxClients + + if config.Password != "" { + rawConfig["Password"] = config.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") + } + + if modPaths, ok := rawConfig["ModPaths"].([]interface{}); ok { + for i, pathVal := range modPaths { + if strPath, ok := pathVal.(string); ok && (strings.Contains(strPath, "/vs-manager/instances/") || filepath.IsAbs(strPath)) && strPath != "Mods" { + modPaths[i] = filepath.Join(instanceDir, "Mods") + } + } + } + + updatedData, err := json.MarshalIndent(rawConfig, "", " ") + if err != nil { + 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 +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..92779a4 --- /dev/null +++ b/main.go @@ -0,0 +1,65 @@ +package main + +import ( + "fmt" + "log" + "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") + } + + 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: "", + } + + err = DownloadAndExtractServer(targetVersion, cfg.Storage.InstallDir) + if err != nil { + log.Fatalf("Downloader module encountered an error: %v", err) + } + + 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.") +} diff --git a/process.go b/process.go new file mode 100644 index 0000000..20f5f98 --- /dev/null +++ b/process.go @@ -0,0 +1,116 @@ +package main + +import ( + "bufio" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "sync" +) + +type ProcessManager struct { + sync.RWMutex + ActiveInstances map[string]*exec.Cmd + StdinPipes map[string]io.WriteCloser +} + +func NewProcessManager() *ProcessManager { + return &ProcessManager{ + ActiveInstances: make(map[string]*exec.Cmd), + StdinPipes: make(map[string]io.WriteCloser), + } +} + +func (pm *ProcessManager) StartInstance(name string, version string, options VsServerConfigOptions, config *AppConfig) error { + pm.Lock() + defer pm.Unlock() + + if _, running := pm.ActiveInstances[name]; running { + return fmt.Errorf("Instance '%s' is already running", name) + } + + binaryPath := filepath.Join(config.Storage.InstallDir, version, "VintagestoryServer") + instanceDataPath := filepath.Join(config.Storage.InstancesDir, name) + + if _, err := os.Stat(binaryPath); os.IsNotExist(err) { + fmt.Printf("Server version '%s' not installed, attempting to install\n", version) + install_err := DownloadAndExtractServer(version, config.Storage.InstallDir) + if install_err != nil { + return fmt.Errorf("Could not locate or download a server binary for version '%s'", version) + } + } + + cmd := exec.Command(binaryPath, "--dataPath", instanceDataPath) + cmd.Env = os.Environ() + cmd.Stderr = os.Stderr + + stdinPipe, err := cmd.StdinPipe() + if err != nil { + return fmt.Errorf("Failed to open stdin pipe: %w", err) + } + + stdoutPipe, err := cmd.StdoutPipe() + if err != nil { + stdinPipe.Close() + return fmt.Errorf("failed to open stdout conduit: %w", err) + } + + if err := cmd.Start(); err != nil { + stdinPipe.Close() + stdoutPipe.Close() + return fmt.Errorf("Failed to start process thread: %w", err) + } + + pm.ActiveInstances[name] = cmd + pm.StdinPipes[name] = stdinPipe + + go pm.streamLogs(name, stdoutPipe) + go pm.watchProcessExit(name, cmd) + + fmt.Printf("Server instance '%s' successfully spawned under PID %d\n", name, cmd.Process.Pid) + return nil +} + +func (pm *ProcessManager) streamLogs(name string, stdout io.ReadCloser) { + defer stdout.Close() + scanner := bufio.NewScanner(stdout) + + for scanner.Scan() { + //stream straight to the manager console terminal, + // but later dispatch payloads to our web UI or a log file + fmt.Printf("[%s]: %s\n", name, scanner.Text()) + } +} + +func (pm *ProcessManager) watchProcessExit(name string, cmd *exec.Cmd) { + _ = cmd.Wait() + + pm.Lock() + defer pm.Unlock() + + delete(pm.ActiveInstances, name) + if pipe, exists := pm.StdinPipes[name]; exists { + pipe.Close() + delete(pm.StdinPipes, name) + } + fmt.Printf("Server instance '%s' has shut down or terminated.\n", name) +} + +func (pm *ProcessManager) SendCommand(name string, command string) error { + pm.RLock() + pipe, exists := pm.StdinPipes[name] + pm.RUnlock() + + if !exists { + return fmt.Errorf("Cannot send command, instance with name '%s' is not running", name) + } + + _, err := io.WriteString(pipe, command+"\n") + if err != nil { + return fmt.Errorf("failed writing to server process stdin conduit: %w", err) + } + + return nil +}