commit 073db62fb141f28571a5c680e7a7bef16fe23e81 Author: chris bell Date: Fri Jun 5 16:44:41 2026 -0500 Adding current project state 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 +}