Adding current project state

This commit is contained in:
2026-06-05 16:44:41 -05:00
commit 073db62fb1
10 changed files with 663 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
.envrc
.direnv
.cache
.vscode

77
config.go Normal file
View File

@@ -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
}

125
downloader.go Normal file
View File

@@ -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
}

61
flake.lock generated Normal file
View File

@@ -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
}

44
flake.nix Normal file
View File

@@ -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 == "
'';
};
}
);
}

3
go.mod Normal file
View File

@@ -0,0 +1,3 @@
module vs-manager
go 1.26.3

49
instance.go Normal file
View File

@@ -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
}

119
instance_config.go Normal file
View File

@@ -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
}

65
main.go Normal file
View File

@@ -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.")
}

116
process.go Normal file
View File

@@ -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
}