diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1a4c697 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Go +/bin/ +/pkg/ +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Build +/build/ +/dist/ + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store diff --git a/go.mod b/go.mod new file mode 100755 index 0000000..7788eef --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module zlh-agent + +go 1.21.6 diff --git a/internal/http/agent.go b/internal/http/agent.go new file mode 100755 index 0000000..36c397c --- /dev/null +++ b/internal/http/agent.go @@ -0,0 +1,259 @@ +package agenthttp + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "zlh-agent/internal/provision" + "zlh-agent/internal/provision/minecraft" + "zlh-agent/internal/state" + "zlh-agent/internal/system" +) + +/* -------------------------------------------------------------------------- + Helpers +----------------------------------------------------------------------------*/ +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func dirExists(path string) bool { + s, err := os.Stat(path) + return err == nil && s.IsDir() +} + +/* -------------------------------------------------------------------------- + Shared provision pipeline (installer + Minecraft verify) +----------------------------------------------------------------------------*/ +func runProvisionPipeline(cfg *state.Config) error { + state.SetState(state.StateInstalling) + state.SetInstallStep("provision_all") + + // Installer (downloads files, patches, configs, etc.) + if err := provision.ProvisionAll(*cfg); err != nil { + state.SetError(err) + state.SetState(state.StateError) + return err + } + + // Extra Minecraft verification + if strings.ToLower(cfg.Game) == "minecraft" { + if err := minecraft.VerifyMinecraftInstallWithRepair(*cfg); err != nil { + state.SetError(err) + state.SetState(state.StateError) + return fmt.Errorf("minecraft verification failed: %w", err) + } + } + + state.SetInstallStep("") + state.SetState(state.StateIdle) + return nil +} + +/* -------------------------------------------------------------------------- + ensureProvisioned() — idempotent, no goto +----------------------------------------------------------------------------*/ +func ensureProvisioned(cfg *state.Config) error { + dir := provision.ServerDir(*cfg) + game := strings.ToLower(cfg.Game) + variant := strings.ToLower(cfg.Variant) + + _ = os.MkdirAll(dir, 0o755) + + isSteam := provision.IsSteamGame(game) + isForgeLike := variant == "forge" || variant == "neoforge" + + // ---------- STEAM GAMES ALWAYS REQUIRE PROVISION ---------- + if isSteam { + return runProvisionPipeline(cfg) + } + + // ---------- FORGE / NEOFORGE ---------- + if isForgeLike { + runSh := filepath.Join(dir, "run.sh") + libraries := filepath.Join(dir, "libraries") + + if fileExists(runSh) && dirExists(libraries) { + return nil // already provisioned + } + + return runProvisionPipeline(cfg) + } + + // ---------- VANILLA / PAPER / PURPUR / FABRIC ---------- + jar := filepath.Join(dir, "server.jar") + if fileExists(jar) { + return nil // already provisioned + } + + return runProvisionPipeline(cfg) +} + +/* -------------------------------------------------------------------------- + /config — the REAL provisioning trigger (async) +----------------------------------------------------------------------------*/ +func handleConfig(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "POST only", http.StatusMethodNotAllowed) + return + } + + body, _ := io.ReadAll(r.Body) + + var cfg state.Config + if err := json.Unmarshal(body, &cfg); err != nil { + http.Error(w, "bad json", http.StatusBadRequest) + return + } + + if err := state.SaveConfig(&cfg); err != nil { + http.Error(w, "save config failed: "+err.Error(), http.StatusInternalServerError) + return + } + + // Run provision + server start asynchronously + go func(c state.Config) { + log.Println("[agent] async provision+start begin") + + if err := ensureProvisioned(&c); err != nil { + log.Println("[agent] provision error:", err) + return + } + + if err := system.StartServer(&c); err != nil { + log.Println("[agent] start error:", err) + state.SetError(err) + state.SetState(state.StateError) + return + } + + log.Println("[agent] async provision+start complete") + }(*&cfg) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write([]byte(`{"ok": true, "state": "installing"}`)) +} + +/* -------------------------------------------------------------------------- + /start — manual UI start (does NOT re-provision) +----------------------------------------------------------------------------*/ +func handleStart(w http.ResponseWriter, r *http.Request) { + cfg, err := state.LoadConfig() + if err != nil { + http.Error(w, "no config: "+err.Error(), http.StatusBadRequest) + return + } + + if err := system.StartServer(cfg); err != nil { + http.Error(w, "start error: "+err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok": true, "state": "starting"}`)) +} + +/* -------------------------------------------------------------------------- + /stop +----------------------------------------------------------------------------*/ +func handleStop(w http.ResponseWriter, r *http.Request) { + if err := system.StopServer(); err != nil { + http.Error(w, "stop error: "+err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} + +/* -------------------------------------------------------------------------- + /restart +----------------------------------------------------------------------------*/ +func handleRestart(w http.ResponseWriter, r *http.Request) { + cfg, err := state.LoadConfig() + if err != nil { + http.Error(w, "no config", http.StatusBadRequest) + return + } + + _ = system.StopServer() + + if err := system.StartServer(cfg); err != nil { + http.Error(w, "restart error: "+err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok": true, "state": "starting"}`)) +} + +/* -------------------------------------------------------------------------- + /status — API polls this +----------------------------------------------------------------------------*/ +func handleStatus(w http.ResponseWriter, r *http.Request) { + cfg, _ := state.LoadConfig() + + resp := map[string]any{ + "state": state.GetState(), + "installStep": state.GetInstallStep(), + "crashCount": state.GetCrashCount(), + "error": nil, + "config": cfg, + "timestamp": time.Now().Unix(), + } + + if err := state.GetError(); err != nil { + resp["error"] = err.Error() + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) +} + +/* -------------------------------------------------------------------------- + /console/command +----------------------------------------------------------------------------*/ +func handleSendCommand(w http.ResponseWriter, r *http.Request) { + cmd := r.URL.Query().Get("cmd") + if cmd == "" { + http.Error(w, "cmd required", http.StatusBadRequest) + return + } + + if err := system.SendConsoleCommand(cmd); err != nil { + http.Error(w, "command error: "+err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +/* -------------------------------------------------------------------------- + Router + WebSocket +----------------------------------------------------------------------------*/ +func NewMux() *http.ServeMux { + m := http.NewServeMux() + + m.HandleFunc("/config", handleConfig) + m.HandleFunc("/start", handleStart) + m.HandleFunc("/stop", handleStop) + m.HandleFunc("/restart", handleRestart) + m.HandleFunc("/status", handleStatus) + m.HandleFunc("/console/command", handleSendCommand) + + registerWebSocket(m) + + m.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("ok")) + }) + + log.Println("[agent] routes registered") + return m +} diff --git a/internal/http/websocket.go b/internal/http/websocket.go new file mode 100644 index 0000000..7442f62 --- /dev/null +++ b/internal/http/websocket.go @@ -0,0 +1,168 @@ +package agenthttp + +import ( + "bufio" + "crypto/sha1" + "encoding/base64" + "fmt" + "log" + "net" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "zlh-agent/internal/provision" + "zlh-agent/internal/state" +) + +/* + websocket.go + ZeroLagHub Agent — Real-time Log WebSocket + + Endpoint: + GET /console/stream +*/ + +const maxInitialTail = 4096 // 4 KB + +/* -------------------------------------------------------------------------- + Minimal WebSocket Upgrader (stdlib only) +----------------------------------------------------------------------------*/ + +type WebSocketConn struct { + Conn net.Conn + Rw *bufio.ReadWriter +} + +func upgradeToWebSocket(w http.ResponseWriter, r *http.Request) (*WebSocketConn, error) { + if !strings.Contains(strings.ToLower(r.Header.Get("Connection")), "upgrade") || + strings.ToLower(r.Header.Get("Upgrade")) != "websocket" { + return nil, fmt.Errorf("invalid websocket upgrade request") + } + + key := r.Header.Get("Sec-WebSocket-Key") + if key == "" { + return nil, fmt.Errorf("missing Sec-WebSocket-Key") + } + + accept := computeAcceptKey(key) + + h := w.Header() + h.Set("Upgrade", "websocket") + h.Set("Connection", "Upgrade") + h.Set("Sec-WebSocket-Accept", accept) + h.Set("Sec-WebSocket-Version", "13") + + w.WriteHeader(http.StatusSwitchingProtocols) + + hj, ok := w.(http.Hijacker) + if !ok { + return nil, fmt.Errorf("websocket: hijacking not supported") + } + + conn, rw, err := hj.Hijack() + if err != nil { + return nil, fmt.Errorf("websocket hijack: %w", err) + } + + return &WebSocketConn{ + Conn: conn, + Rw: rw, + }, nil +} + +func (c *WebSocketConn) WriteText(msg string) error { + payload := []byte(msg) + + // FIN + opcode(1 = text) + header := []byte{0x81} + + // Length encoding + if len(payload) < 126 { + header = append(header, byte(len(payload))) + } else { + header = append(header, 126, byte(len(payload)>>8), byte(len(payload))) + } + + if _, err := c.Conn.Write(header); err != nil { + return err + } + _, err := c.Conn.Write(payload) + return err +} + +func (c *WebSocketConn) Close() error { + return c.Conn.Close() +} + +/* -------------------------------------------------------------------------- + SHA-1 + Base64 for Sec-WebSocket-Accept +----------------------------------------------------------------------------*/ + +func computeAcceptKey(key string) string { + const magic = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" + h := sha1.Sum([]byte(key + magic)) + return base64.StdEncoding.EncodeToString(h[:]) +} + +/* -------------------------------------------------------------------------- + MAIN HANDLER — /console/stream +----------------------------------------------------------------------------*/ + +func handleConsoleStream(w http.ResponseWriter, r *http.Request) { + cfg, err := state.LoadConfig() + if err != nil { + http.Error(w, "no config loaded", http.StatusBadRequest) + return + } + + ws, err := upgradeToWebSocket(w, r) + if err != nil { + log.Println("[ws] upgrade failed:", err) + http.Error(w, "websocket upgrade failed", http.StatusBadRequest) + return + } + defer ws.Close() + + dir := provision.ServerDir(*cfg) + logfile := filepath.Join(dir, "logs", "latest.log") + + f, err := os.Open(logfile) + if err != nil { + _ = ws.WriteText(fmt.Sprintf("[error] cannot open log: %v", err)) + return + } + defer f.Close() + + // 1) Send last 4 KB (initial tail) + stat, _ := f.Stat() + sz := stat.Size() + if sz > maxInitialTail { + _, _ = f.Seek(sz-maxInitialTail, 0) + } + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + _ = ws.WriteText(scanner.Text()) + } + + // 2) Follow the file — stream new log lines live + for { + time.Sleep(500 * time.Millisecond) + for scanner.Scan() { + line := scanner.Text() + _ = ws.WriteText(line) + } + // on EOF, loop continues and scanner will pick up new lines + } +} + +/* -------------------------------------------------------------------------- + Register handler in NewMux() +----------------------------------------------------------------------------*/ + +func registerWebSocket(m *http.ServeMux) { + m.HandleFunc("/console/stream", handleConsoleStream) +} diff --git a/internal/provcommon/common.go b/internal/provcommon/common.go new file mode 100644 index 0000000..f5d856e --- /dev/null +++ b/internal/provcommon/common.go @@ -0,0 +1,65 @@ +// internal/provcommon/common.go +package provcommon + +import ( + "os" + "path/filepath" + "strings" + + "zlh-agent/internal/state" +) + +const ( + // Root for all ZLH-managed game data inside the container. + // New layout: /opt/zlh///world + ServersRoot = "/opt/zlh" + JavaRoot = "/opt/zlh/runtime" + SteamCMDPath = "/opt/zlh/steamcmd" +) + +// ServerDir determines where a game's server files live. +// +// Final layout: +// /opt/zlh///world +// +// Examples: +// minecraft + forge -> /opt/zlh/minecraft/forge/world +// minecraft + vanilla -> /opt/zlh/minecraft/vanilla/world +// valheim + vanilla -> /opt/zlh/valheim/vanilla/world +func ServerDir(cfg state.Config) string { + game := strings.ToLower(cfg.Game) + if game == "" { + game = "unknown" + } + + variant := strings.ToLower(cfg.Variant) + if variant == "" { + variant = "vanilla" + } + + world := strings.ToLower(cfg.World) + if world == "" { + world = "world" + } + + return filepath.Join(ServersRoot, game, variant, world) +} + +// JavaDir returns the root of the extracted JDK runtime. +func JavaDir(cfg state.Config) string { + return JavaRoot +} + +// BuildArtifactURL resolves relative artifact paths into full URLs. +func BuildArtifactURL(path string) string { + if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") { + return path + } + + base := os.Getenv("ZLH_ARTIFACT_BASE_URL") + if base == "" { + base = "http://10.60.0.251:8080/" + } + + return strings.TrimRight(base, "/") + "/" + strings.TrimLeft(path, "/") +} diff --git a/internal/provision/artifacts.go b/internal/provision/artifacts.go new file mode 100755 index 0000000..1cfb2a6 --- /dev/null +++ b/internal/provision/artifacts.go @@ -0,0 +1,60 @@ +package provision + +import ( + "fmt" + "io" + "net/http" + "os" + "path/filepath" + + "zlh-agent/internal/state" +) + +/* + ensureDir makes sure the destination directory exists. +*/ +func ensureDir(path string) error { + return os.MkdirAll(path, 0755) +} + +/* + downloadFile downloads a file from URL → destination path. +*/ +func downloadFile(url, dest string) error { + if err := ensureDir(filepath.Dir(dest)); err != nil { + return fmt.Errorf("mkdir: %w", err) + } + + resp, err := http.Get(url) + if err != nil { + return fmt.Errorf("download %s: %w", url, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("download %s: http %s", url, resp.Status) + } + + out, err := os.Create(dest) + if err != nil { + return fmt.Errorf("create %s: %w", dest, err) + } + defer out.Close() + + if _, err := io.Copy(out, resp.Body); err != nil { + return fmt.Errorf("write %s: %w", dest, err) + } + + return nil +} + +/* + DownloadArtifact is a small wrapper that: + + - resolves relative paths with buildArtifactURL() + - downloads to "dest" +*/ +func DownloadArtifact(cfg state.Config, relativeOrFullURL, dest string) error { + url := buildArtifactURL(relativeOrFullURL) + return downloadFile(url, dest) +} diff --git a/internal/provision/common.go b/internal/provision/common.go new file mode 100755 index 0000000..55be35a --- /dev/null +++ b/internal/provision/common.go @@ -0,0 +1,50 @@ +package provision + +import ( + "os" + "strings" + + "zlh-agent/internal/provcommon" + "zlh-agent/internal/state" +) + +const ( + ServersRoot = provcommon.ServersRoot + JavaRoot = provcommon.JavaRoot + SteamCMDPath = provcommon.SteamCMDPath +) + +func ServerDir(cfg state.Config) string { + return provcommon.ServerDir(cfg) +} + +func JavaDir(cfg state.Config) string { + return provcommon.JavaDir(cfg) +} + +func BuildArtifactURL(path string) string { + return provcommon.BuildArtifactURL(path) +} + +// Local fallback used ONLY inside this package — NOT exported. +func buildArtifactURL(path string) string { + if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") { + return path + } + + base := os.Getenv("ZLH_ARTIFACT_BASE_URL") + if base == "" { + base = "http://10.60.0.251:8080/" // fallback + } + + return strings.TrimRight(base, "/") + "/" + strings.TrimLeft(path, "/") +} + +func IsSteamGame(game string) bool { + g := strings.ToLower(game) + switch g { + case "valheim", "rust", "terraria", "projectzomboid": + return true + } + return false +} diff --git a/internal/provision/files.go b/internal/provision/files.go new file mode 100755 index 0000000..49e3382 --- /dev/null +++ b/internal/provision/files.go @@ -0,0 +1,153 @@ +package provision + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "zlh-agent/internal/state" +) + +/* + WriteEula writes eula.txt for all Minecraft variants. +*/ +func WriteEula(cfg state.Config) error { + dir := ServerDir(cfg) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("mkdir for eula: %w", err) + } + + content := []byte("eula=true\n") + return os.WriteFile(filepath.Join(dir, "eula.txt"), content, 0644) +} + +/* + WriteServerProperties writes server.properties for jar-based Minecraft servers. + + Forge/NeoForge generate their own, so they are skipped. +*/ +func WriteServerProperties(cfg state.Config) error { + variant := strings.ToLower(cfg.Variant) + + // Forge/NeoForge auto-generate properties + if variant == "forge" || variant == "neoforge" { + return nil + } + + dir := ServerDir(cfg) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("mkdir for server.properties: %w", err) + } + + var gamePort int + if len(cfg.Ports) > 0 { + gamePort = cfg.Ports[0] + } else { + gamePort = 25565 + } + + motd := cfg.World + if motd == "" { + motd = "ZeroLagHub Server" + } + + content := fmt.Sprintf( + `motd=%s +server-port=%d +query.port=%d +allow-flight=true +online-mode=false +difficulty=normal +max-players=20 +`, + motd, gamePort, gamePort+1, + ) + + return os.WriteFile(filepath.Join(dir, "server.properties"), []byte(content), 0644) +} + +/* + WriteStartScript creates start.sh for all supported games. + - Vanilla/Paper/Purpur/Fabric: exec java -jar server.jar + - Forge/NeoForge: use JAVA_TOOL_OPTIONS and run.sh +*/ +func WriteStartScript(cfg state.Config) error { + dir := ServerDir(cfg) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("mkdir for start.sh: %w", err) + } + + variant := strings.ToLower(cfg.Variant) + javaBin := filepath.Join(JavaRoot, "java") + + // ------------------------------------------------------------ + // Memory calculation + // ------------------------------------------------------------ + mem := cfg.MemoryMB + if mem <= 0 { + switch variant { + case "forge", "neoforge": + mem = 4096 // default Forge memory + default: + mem = 2048 + } + } + + xmx := mem + xms := mem / 2 + if xms < 512 { + xms = 512 + } + if xms > xmx { + xms = xmx + } + + // ------------------------------------------------------------ + // Build script based on variant + // ------------------------------------------------------------ + var script string + + switch variant { + + // ------------------------------------------------------------ + // Forge-based loaders + // ------------------------------------------------------------ + case "forge", "neoforge": + script = fmt.Sprintf( + `#!/bin/bash +cd %s + +export JAVA_TOOL_OPTIONS="-Xms%dM -Xmx%dM" + +if [ -f "./run.sh" ]; then + exec /bin/bash ./run.sh +elif [ -f "./run" ]; then + exec /bin/bash ./run +else + echo "ERROR: Forge run script not found!" + exit 1 +fi +`, dir, xms, xmx) + + // ------------------------------------------------------------ + // All jar-based servers (vanilla, paper, purpur, fabric, quilt) + // ------------------------------------------------------------ + default: + script = fmt.Sprintf( + `#!/bin/bash +cd %s +exec %s -Xms%dM -Xmx%dM -jar server.jar nogui +`, dir, javaBin, xms, xmx) + } + + // ------------------------------------------------------------ + // Write start.sh + // ------------------------------------------------------------ + path := filepath.Join(dir, "start.sh") + if err := os.WriteFile(path, []byte(script), 0755); err != nil { + return fmt.Errorf("write start.sh: %w", err) + } + + return nil +} diff --git a/internal/provision/java.go b/internal/provision/java.go new file mode 100755 index 0000000..cfbd205 --- /dev/null +++ b/internal/provision/java.go @@ -0,0 +1,133 @@ +package provision + +import ( + "archive/tar" + "compress/gzip" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + + "zlh-agent/internal/state" +) + +// InstallJava downloads and extracts the Java runtime, then creates a stable +// symlink at /opt/zlh/runtime/java → /bin/java +func InstallJava(cfg state.Config) error { + if cfg.JavaPath == "" { + return fmt.Errorf("java_path missing in config") + } + + url := BuildArtifactURL(cfg.JavaPath) + resp, err := http.Get(url) + if err != nil { + return fmt.Errorf("download java from %s: %w", url, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("download java: status %s", resp.Status) + } + + runtimeRoot := JavaDir(cfg) + if err := os.MkdirAll(runtimeRoot, 0755); err != nil { + return fmt.Errorf("create java root dir: %w", err) + } + + // Extract into temp directory + tmpDir := filepath.Join(runtimeRoot, "tmp-extract") + _ = os.RemoveAll(tmpDir) + if err := os.MkdirAll(tmpDir, 0755); err != nil { + return fmt.Errorf("create tmp java dir: %w", err) + } + + if err := extractTarGz(resp.Body, tmpDir); err != nil { + return fmt.Errorf("extract java: %w", err) + } + + // Detect root folder (JDK name) + entries, err := os.ReadDir(tmpDir) + if err != nil { + return fmt.Errorf("read tmp java dir: %w", err) + } + if len(entries) != 1 || !entries[0].IsDir() { + return fmt.Errorf("java extract: unexpected folder layout in %s", tmpDir) + } + + rootFolder := entries[0].Name() + versionPath := filepath.Join(runtimeRoot, rootFolder) + + // Move extracted Java dir into runtime/ + _ = os.RemoveAll(versionPath) + if err := os.Rename(filepath.Join(tmpDir, rootFolder), versionPath); err != nil { + return fmt.Errorf("move java folder: %w", err) + } + + // Cleanup temp + _ = os.RemoveAll(tmpDir) + + // Make java binary executable + javaBin := filepath.Join(versionPath, "bin", "java") + if err := os.Chmod(javaBin, 0755); err != nil { + return fmt.Errorf("chmod java binary: %w", err) + } + + // Symlink: java → /bin/java + linkPath := filepath.Join(JavaRoot, "java") + _ = os.Remove(linkPath) + if err := os.Symlink(javaBin, linkPath); err != nil { + return fmt.Errorf("create java symlink: %w", err) + } + + fmt.Println("[java] Installed runtime:", versionPath, "->", linkPath) + return nil +} + +// extractTarGz restores the original signature returning only "error" +func extractTarGz(r io.Reader, dest string) error { + gz, err := gzip.NewReader(r) + if err != nil { + return err + } + defer gz.Close() + + tr := tar.NewReader(gz) + + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return err + } + + target := filepath.Join(dest, hdr.Name) + + switch hdr.Typeflag { + + case tar.TypeDir: + if err := os.MkdirAll(target, 0755); err != nil { + return err + } + + case tar.TypeReg: + if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { + return err + } + + f, err := os.Create(target) + if err != nil { + return err + } + if _, err := io.Copy(f, tr); err != nil { + f.Close() + return err + } + f.Close() + } + } + + return nil +} diff --git a/internal/provision/minecraft/forge.go b/internal/provision/minecraft/forge.go new file mode 100644 index 0000000..f77a57e --- /dev/null +++ b/internal/provision/minecraft/forge.go @@ -0,0 +1,111 @@ +package minecraft + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "zlh-agent/internal/provcommon" + "zlh-agent/internal/state" +) + +/* -------------------------------------------------------------------------- + Helper: patch any script/arg files that call plain "java " +----------------------------------------------------------------------------*/ +func patchForgeJavaCalls(dir string) error { + entries, err := os.ReadDir(dir) + if err != nil { + return err + } + + for _, e := range entries { + if e.IsDir() { + continue + } + + name := e.Name() + if !strings.HasSuffix(name, ".sh") && + !strings.HasSuffix(name, ".txt") && + !strings.HasSuffix(name, ".args") { + continue + } + + path := filepath.Join(dir, name) + data, err := os.ReadFile(path) + if err != nil { + continue // best-effort + } + + content := string(data) + patched := strings.ReplaceAll(content, "java ", "/opt/zlh/runtime/java ") + if patched != content { + fmt.Println("[forge] patching java call in", name) + if err := os.WriteFile(path, []byte(patched), 0o755); err != nil { + return fmt.Errorf("write patched %s: %w", name, err) + } + } + } + + return nil +} + +/* -------------------------------------------------------------------------- + InstallMinecraftForge + - Uses cfg.ArtifactPath as the Forge installer path (relative or absolute URL) + - Installs into /opt/zlh/minecraft/forge/world +----------------------------------------------------------------------------*/ +func InstallMinecraftForge(cfg state.Config) error { + dir := provcommon.ServerDir(cfg) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("mkdir server dir: %w", err) + } + + if cfg.ArtifactPath == "" { + return fmt.Errorf("artifact_path missing for forge install") + } + + url := provcommon.BuildArtifactURL(cfg.ArtifactPath) + + installerPath := filepath.Join(dir, "forge-installer.jar") + + // Download Forge installer into server dir + cmdDl := exec.Command("bash", "-c", + fmt.Sprintf("cd %s && curl -fLo %s %q", dir, "forge-installer.jar", url), + ) + cmdDl.Stdout = os.Stdout + cmdDl.Stderr = os.Stderr + + fmt.Println("[forge] downloading installer from:", url) + if err := cmdDl.Run(); err != nil { + return fmt.Errorf("forge installer download failed: %w", err) + } + + javaBin := filepath.Join(provcommon.JavaRoot, "java") + + // Run Forge installer + cmd := exec.Command("/bin/bash", "-c", + fmt.Sprintf("cd %s && %s -jar %s --installServer", dir, javaBin, installerPath), + ) + + // Ensure PATH contains our java for any nested calls + cmd.Env = append(os.Environ(), + "PATH=/opt/zlh/runtime:/opt/zlh/runtime/java:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + ) + + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + fmt.Println("[forge] running installer in", dir) + if err := cmd.Run(); err != nil { + return fmt.Errorf("forge installer failed: %w", err) + } + + // Patch scripts/args to use /opt/zlh/runtime/java instead of plain "java" + if err := patchForgeJavaCalls(dir); err != nil { + return fmt.Errorf("patch java calls: %w", err) + } + + return nil +} diff --git a/internal/provision/minecraft/neoforge.go b/internal/provision/minecraft/neoforge.go new file mode 100644 index 0000000..2c237ab --- /dev/null +++ b/internal/provision/minecraft/neoforge.go @@ -0,0 +1,69 @@ +package minecraft + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + + "zlh-agent/internal/provcommon" + "zlh-agent/internal/state" +) + +/* -------------------------------------------------------------------------- + InstallMinecraftNeoForge + - Uses cfg.ArtifactPath as the NeoForge installer path + - Installs into /opt/zlh/minecraft/neoforge/world +----------------------------------------------------------------------------*/ +func InstallMinecraftNeoForge(cfg state.Config) error { + dir := provcommon.ServerDir(cfg) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("mkdir server dir: %w", err) + } + + if cfg.ArtifactPath == "" { + return fmt.Errorf("artifact_path missing for neoforge install") + } + + url := provcommon.BuildArtifactURL(cfg.ArtifactPath) + + installerPath := filepath.Join(dir, "neoforge-installer.jar") + + // Download NeoForge installer into server dir + cmdDl := exec.Command("bash", "-c", + fmt.Sprintf("cd %s && curl -fLo %s %q", dir, "neoforge-installer.jar", url), + ) + cmdDl.Stdout = os.Stdout + cmdDl.Stderr = os.Stderr + + fmt.Println("[neoforge] downloading installer from:", url) + if err := cmdDl.Run(); err != nil { + return fmt.Errorf("neoforge installer download failed: %w", err) + } + + javaBin := filepath.Join(provcommon.JavaRoot, "java") + + // Run NeoForge installer + cmd := exec.Command("/bin/bash", "-c", + fmt.Sprintf("cd %s && %s -jar %s --installServer", dir, javaBin, installerPath), + ) + + cmd.Env = append(os.Environ(), + "PATH=/opt/zlh/runtime:/opt/zlh/runtime/java:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + ) + + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + fmt.Println("[neoforge] running installer in", dir) + if err := cmd.Run(); err != nil { + return fmt.Errorf("neoforge installer failed: %w", err) + } + + // Reuse same script patcher as Forge + if err := patchForgeJavaCalls(dir); err != nil { + return fmt.Errorf("patch java calls: %w", err) + } + + return nil +} diff --git a/internal/provision/minecraft/vanilla.go b/internal/provision/minecraft/vanilla.go new file mode 100644 index 0000000..9299b4d --- /dev/null +++ b/internal/provision/minecraft/vanilla.go @@ -0,0 +1,50 @@ +package minecraft + +import ( + "fmt" + "io" + "net/http" + "os" + "path/filepath" + + "zlh-agent/internal/state" + "zlh-agent/internal/provcommon" +) + +func InstallMinecraftVanilla(cfg state.Config) error { + dir := provcommon.ServerDir(cfg) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("mkdir server dir: %w", err) + } + + url := provcommon.BuildArtifactURL(cfg.ArtifactPath) + dest := filepath.Join(dir, "server.jar") + + resp, err := http.Get(url) + if err != nil { + return fmt.Errorf("download vanilla: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("bad status %s", resp.Status) + } + + out, err := os.Create(dest) + if err != nil { + return fmt.Errorf("create server.jar: %w", err) + } + defer out.Close() + + if _, err := io.Copy(out, resp.Body); err != nil { + return fmt.Errorf("write server.jar: %w", err) + } + + // 🔹 NEW: verify + self-repair (java, server.jar, start.sh, etc.) + if err := VerifyMinecraftInstallWithRepair(cfg); err != nil { + return fmt.Errorf("vanilla install verification failed: %w", err) + } + + return nil +} + diff --git a/internal/provision/minecraft/verify.go b/internal/provision/minecraft/verify.go new file mode 100644 index 0000000..d5ebb95 --- /dev/null +++ b/internal/provision/minecraft/verify.go @@ -0,0 +1,263 @@ +package minecraft + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "zlh-agent/internal/provcommon" + "zlh-agent/internal/state" +) + +/* + Smart verification + self-repair for Minecraft installs. + + Layout: /opt/zlh///world + + - Ensures Java symlink exists and is executable + - For Vanilla-like variants: verifies ONLY server.jar (NO start.sh) + - For Forge/NeoForge: verifies run.sh, user_jvm_args.txt, libraries/, patched scripts + - Automatic correction on failure (2 attempts) +*/ + +func VerifyMinecraftInstallWithRepair(cfg state.Config) error { + const maxAttempts = 2 + + var lastErr error + + for attempt := 1; attempt <= maxAttempts; attempt++ { + if attempt > 1 { + fmt.Println("[verify] Attempt", attempt, "after correction") + } + + if err := verifyMinecraftInstallOnce(cfg); err != nil { + lastErr = err + fmt.Println("[verify] Install verification failed:", err) + + // Try to correct issues before retrying + if attempt < maxAttempts { + if corrErr := correctMinecraftInstall(cfg); corrErr != nil { + fmt.Println("[verify] Correction step failed:", corrErr) + break + } + continue + } + + break + } + + fmt.Println("[verify] Minecraft installation verified OK") + return nil + } + + if lastErr == nil { + lastErr = fmt.Errorf("minecraft installation invalid (unknown reason)") + } + return lastErr +} + +// ----------------------------------------------------------------------------- +// Single verification attempt (no repair) +// ----------------------------------------------------------------------------- + +func verifyMinecraftInstallOnce(cfg state.Config) error { + dir := provcommon.ServerDir(cfg) + variant := strings.ToLower(cfg.Variant) + + // Java symlink must exist + if err := verifyJavaSymlink(); err != nil { + return err + } + + // --------------------------------------------------------------------- + // VANILLA / PAPER / PURPUR / FABRIC / QUILT → *ONLY REQUIRE server.jar* + // --------------------------------------------------------------------- + if variant == "vanilla" || + variant == "paper" || + variant == "purpur" || + variant == "fabric" || + variant == "quilt" { + + if err := verifyServerJar(dir); err != nil { + return err + } + + // ❌ Removed verifyStartScript(dir) + // Vanilla-like variants DO NOT use start.sh + + return nil + } + + // --------------------------------------------------------------------- + // FORGE / NEOFORGE → require run.sh + libs + JVM args + // --------------------------------------------------------------------- + if variant == "forge" || variant == "neoforge" { + if err := verifyForgeLayout(dir); err != nil { + return err + } + return nil + } + + return fmt.Errorf("unknown minecraft variant: %s", variant) +} + +/* -------------------------------------------------------------------------- + Java symlink check +----------------------------------------------------------------------------*/ + +func verifyJavaSymlink() error { + linkPath := filepath.Join(provcommon.JavaRoot, "java") + + info, err := os.Lstat(linkPath) + if err != nil { + return fmt.Errorf("java symlink missing at %s: %w", linkPath, err) + } + if info.Mode()&os.ModeSymlink == 0 { + return fmt.Errorf("expected java symlink at %s, found non-symlink", linkPath) + } + + target, err := os.Readlink(linkPath) + if err != nil { + return fmt.Errorf("readlink java symlink: %w", err) + } + + if !filepath.IsAbs(target) { + target = filepath.Join(filepath.Dir(linkPath), target) + } + + if tInfo, err := os.Stat(target); err != nil || tInfo.IsDir() { + return fmt.Errorf("java symlink invalid: %s", target) + } + + return nil +} + +// ----------------------------------------------------------------------------- +// Vanilla-like variants: require ONLY server.jar +// ----------------------------------------------------------------------------- + +func verifyServerJar(dir string) error { + jar := filepath.Join(dir, "server.jar") + if _, err := os.Stat(jar); err != nil { + return fmt.Errorf("server.jar missing in %s: %w", dir, err) + } + return nil +} + +// ----------------------------------------------------------------------------- +// Forge / NeoForge layout validation +// ----------------------------------------------------------------------------- + +func verifyForgeLayout(dir string) error { + runSh := filepath.Join(dir, "run.sh") + info, err := os.Stat(runSh) + if err != nil { + return fmt.Errorf("forge requires run.sh but it is missing: %w", err) + } + if info.Mode()&0o111 == 0 { + return fmt.Errorf("run.sh is not executable (%s)", runSh) + } + + userArgs := filepath.Join(dir, "user_jvm_args.txt") + if _, err := os.Stat(userArgs); err != nil { + return fmt.Errorf("forge user_jvm_args.txt missing: %w", err) + } + + libs := filepath.Join(dir, "libraries") + if s, err := os.Stat(libs); err != nil || !s.IsDir() { + return fmt.Errorf("forge libraries folder missing or not a directory: %w", err) + } + + if err := verifyJavaPatchedInScripts(dir); err != nil { + return err + } + + return nil +} + +func verifyJavaPatchedInScripts(dir string) error { + runSh := filepath.Join(dir, "run.sh") + data, err := os.ReadFile(runSh) + if err != nil { + return fmt.Errorf("read run.sh failed: %w", err) + } + + if !strings.Contains(string(data), "/opt/zlh/runtime/java") { + return fmt.Errorf("run.sh does not reference /opt/zlh/runtime/java") + } + + return nil +} + +/* -------------------------------------------------------------------------- + Automatic correction logic +----------------------------------------------------------------------------*/ + +func correctMinecraftInstall(cfg state.Config) error { + dir := provcommon.ServerDir(cfg) + + fmt.Println("[verify] Attempting automatic correction of Minecraft install") + + if err := ensureJavaSymlink(cfg); err != nil { + fmt.Println("[verify] ensureJavaSymlink failed:", err) + } + + if err := ensureScriptsPatched(dir); err != nil { + fmt.Println("[verify] ensureScriptsPatched failed:", err) + } + + // If start.sh exists (forge), ensure executable + script := filepath.Join(dir, "start.sh") + if info, err := os.Stat(script); err == nil { + if info.Mode()&0o111 == 0 { + _ = os.Chmod(script, info.Mode()|0o755) + fmt.Println("[verify] made start.sh executable") + } + } + + return nil +} + +func ensureJavaSymlink(cfg state.Config) error { + if err := verifyJavaSymlink(); err == nil { + return nil + } + return fmt.Errorf("java runtime invalid or missing") +} + +func ensureScriptsPatched(dir string) error { + entries, err := os.ReadDir(dir) + if err != nil { + return err + } + + for _, e := range entries { + if e.IsDir() { + continue + } + + name := e.Name() + if !strings.HasSuffix(name, ".sh") && + !strings.HasSuffix(name, ".txt") && + !strings.HasSuffix(name, ".args") { + continue + } + + path := filepath.Join(dir, name) + data, err := os.ReadFile(path) + if err != nil { + continue + } + + content := string(data) + patched := strings.ReplaceAll(content, "java ", "/opt/zlh/runtime/java ") + + if patched != content { + fmt.Println("[verify] patching java call in", name) + _ = os.WriteFile(path, []byte(patched), 0o755) + } + } + + return nil +} diff --git a/internal/provision/provision.go b/internal/provision/provision.go new file mode 100644 index 0000000..70b1a03 --- /dev/null +++ b/internal/provision/provision.go @@ -0,0 +1,121 @@ +package provision + +import ( + "fmt" + "strings" + + "zlh-agent/internal/state" + "zlh-agent/internal/provision/minecraft" + "zlh-agent/internal/provision/steam" +) + +/* + ProvisionAll — unified entrypoint for MC + Steam. + IMPORTANT: + - This function ONLY performs installation. + - Validation/verification happens in ensureProvisioned(). +*/ +func ProvisionAll(cfg state.Config) error { + + game := strings.ToLower(cfg.Game) + variant := strings.ToLower(cfg.Variant) + + /* --------------------------------------------------------- + MINECRAFT + --------------------------------------------------------- */ + if game == "minecraft" { + + // 1. Install Java (runtime) + if err := InstallJava(cfg); err != nil { + return fmt.Errorf("java install failed: %w", err) + } + + // 2. Game variant install + switch variant { + + case "vanilla", "paper", "purpur", "fabric", "quilt": + if err := minecraft.InstallMinecraftVanilla(cfg); err != nil { + return fmt.Errorf("minecraft vanilla install failed: %w", err) + } + + case "forge": + if err := minecraft.InstallMinecraftForge(cfg); err != nil { + return fmt.Errorf("forge install failed: %w", err) + } + + case "neoforge": + if err := minecraft.InstallMinecraftNeoForge(cfg); err != nil { + return fmt.Errorf("neoforge install failed: %w", err) + } + + default: + return fmt.Errorf("unsupported minecraft variant: %s", variant) + } + + // 3. Config files generated AFTER variant installer + if err := WriteEula(cfg); err != nil { + return err + } + if err := WriteServerProperties(cfg); err != nil { + return err + } + if err := WriteStartScript(cfg); err != nil { + return err + } + + // DO NOT VERIFY HERE. + // Verification happens in ensureProvisioned(), AFTER install. + return nil + } + + /* --------------------------------------------------------- + STEAM GAMES + --------------------------------------------------------- */ + if IsSteamGame(game) { + + // 1. SteamCMD install + if err := steam.EnsureSteamCMD(); err != nil { + return fmt.Errorf("steamcmd install failed: %w", err) + } + + // 2. Install game-specific content + switch game { + + case "valheim": + if err := steam.InstallValheim(cfg); err != nil { + return err + } + + case "rust": + if err := steam.InstallRust(cfg); err != nil { + return err + } + + case "terraria": + if err := steam.InstallTerraria(cfg); err != nil { + return err + } + + case "projectzomboid": + if err := steam.InstallProjectZomboid(cfg); err != nil { + return err + } + + default: + return fmt.Errorf("unsupported steam game: %s", game) + } + + // 3. Start script + if err := WriteStartScript(cfg); err != nil { + return err + } + + // DO NOT VERIFY HERE (Steam verification TBD later) + return nil + } + + /* --------------------------------------------------------- + UNKNOWN GAME TYPE + --------------------------------------------------------- */ + return fmt.Errorf("unsupported game type: %s", game) +} diff --git a/internal/provision/steam/common_steam.go b/internal/provision/steam/common_steam.go new file mode 100644 index 0000000..5f31c8d --- /dev/null +++ b/internal/provision/steam/common_steam.go @@ -0,0 +1,80 @@ +package steam + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "zlh-agent/internal/provcommon" + "zlh-agent/internal/state" +) + +func GameDir(cfg state.Config) string { + return provcommon.ServerDir(cfg) +} + +func EnsureSteamCMD() error { + path := filepath.Join(provcommon.SteamCMDPath, "steamcmd.sh") + if _, err := os.Stat(path); err != nil { + return fmt.Errorf("steamcmd not found at %s", path) + } + return nil +} + +func RunSteamCMD(args ...string) error { + if err := EnsureSteamCMD(); err != nil { + return err + } + + steamcmd := filepath.Join(provcommon.SteamCMDPath, "steamcmd.sh") + + flatArgs := []string{} + for _, a := range args { + parts := strings.Fields(a) + flatArgs = append(flatArgs, parts...) + } + + cmd := exec.Command(steamcmd, flatArgs...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + return cmd.Run() +} + +func EnsureGameDir(cfg state.Config) (string, error) { + dir := GameDir(cfg) + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", fmt.Errorf("mkdir game dir: %w", err) + } + return dir, nil +} + +func ValidateApp(appID string) error { + if strings.TrimSpace(appID) == "" { + return fmt.Errorf("invalid Steam App ID") + } + return nil +} + +func SteamLoginArgs(cfg state.Config) string { + user := strings.TrimSpace(cfg.SteamUser) + pass := strings.TrimSpace(cfg.SteamPass) + auth := strings.TrimSpace(cfg.SteamAuth) + + // Default: anonymous login if nothing set + if user == "" { + return "anonymous" + } + + parts := []string{user} + if pass != "" { + parts = append(parts, pass) + } + if auth != "" { + parts = append(parts, auth) + } + + return strings.Join(parts, " ") +} diff --git a/internal/provision/steam/projectzomboid.go b/internal/provision/steam/projectzomboid.go new file mode 100644 index 0000000..98e7d8f --- /dev/null +++ b/internal/provision/steam/projectzomboid.go @@ -0,0 +1,30 @@ +package steam + +import ( + "fmt" + + "zlh-agent/internal/state" +) + +func InstallProjectZomboid(cfg state.Config) error { + dir, err := EnsureGameDir(cfg) + if err != nil { + return err + } + + login := SteamLoginArgs(cfg) + appID := "380870" // Project Zomboid Dedicated Server + + args := []string{ + "+login", login, + "+force_install_dir", dir, + "+app_update", appID, "validate", + "+quit", + } + + if err := RunSteamCMD(args...); err != nil { + return fmt.Errorf("project zomboid install failed: %w", err) + } + + return nil +} diff --git a/internal/provision/steam/rust.go b/internal/provision/steam/rust.go new file mode 100644 index 0000000..6711a89 --- /dev/null +++ b/internal/provision/steam/rust.go @@ -0,0 +1,30 @@ +package steam + +import ( + "fmt" + + "zlh-agent/internal/state" +) + +func InstallRust(cfg state.Config) error { + dir, err := EnsureGameDir(cfg) + if err != nil { + return err + } + + login := SteamLoginArgs(cfg) + appID := "258550" // Rust Dedicated Server + + args := []string{ + "+login", login, + "+force_install_dir", dir, + "+app_update", appID, "validate", + "+quit", + } + + if err := RunSteamCMD(args...); err != nil { + return fmt.Errorf("rust install failed: %w", err) + } + + return nil +} diff --git a/internal/provision/steam/terraria.go b/internal/provision/steam/terraria.go new file mode 100644 index 0000000..3c68cc3 --- /dev/null +++ b/internal/provision/steam/terraria.go @@ -0,0 +1,30 @@ +package steam + +import ( + "fmt" + + "zlh-agent/internal/state" +) + +func InstallTerraria(cfg state.Config) error { + dir, err := EnsureGameDir(cfg) + if err != nil { + return err + } + + login := SteamLoginArgs(cfg) + appID := "105600" // Terraria Dedicated + + args := []string{ + "+login", login, + "+force_install_dir", dir, + "+app_update", appID, "validate", + "+quit", + } + + if err := RunSteamCMD(args...); err != nil { + return fmt.Errorf("terraria install failed: %w", err) + } + + return nil +} diff --git a/internal/provision/steam/valheim.go b/internal/provision/steam/valheim.go new file mode 100644 index 0000000..f10305c --- /dev/null +++ b/internal/provision/steam/valheim.go @@ -0,0 +1,30 @@ +package steam + +import ( + "fmt" + + "zlh-agent/internal/state" +) + +func InstallValheim(cfg state.Config) error { + dir, err := EnsureGameDir(cfg) + if err != nil { + return err + } + + login := SteamLoginArgs(cfg) + appID := "896660" // Valheim Dedicated Server + + args := []string{ + "+login", login, + "+force_install_dir", dir, + "+app_update", appID, "validate", + "+quit", + } + + if err := RunSteamCMD(args...); err != nil { + return fmt.Errorf("valheim install failed: %w", err) + } + + return nil +} diff --git a/internal/state/state.go b/internal/state/state.go new file mode 100755 index 0000000..c339957 --- /dev/null +++ b/internal/state/state.go @@ -0,0 +1,173 @@ +package state + +import ( + "encoding/json" + "log" + "os" + "sync" + "time" +) + +/* -------------------------------------------------------------------------- + CONFIG STRUCT +----------------------------------------------------------------------------*/ + +type Config struct { + VMID int `json:"vmid"` + Game string `json:"game"` + Variant string `json:"variant"` + Version string `json:"version"` + World string `json:"world"` + Ports []int `json:"ports"` + ArtifactPath string `json:"artifact_path"` + JavaPath string `json:"java_path"` + MemoryMB int `json:"memory_mb"` + + // Steam + admin credentials + SteamUser string `json:"steam_user,omitempty"` + SteamPass string `json:"steam_pass,omitempty"` + SteamAuth string `json:"steam_auth,omitempty"` + + AdminUser string `json:"admin_user,omitempty"` + AdminPass string `json:"admin_pass,omitempty"` +} + +/* -------------------------------------------------------------------------- + AGENT STATE ENUM +----------------------------------------------------------------------------*/ + +type AgentState string + +const ( + StateIdle AgentState = "idle" + StateInstalling AgentState = "installing" + StateStarting AgentState = "starting" + StateRunning AgentState = "running" + StateStopping AgentState = "stopping" + StateCrashed AgentState = "crashed" + StateError AgentState = "error" +) + +/* -------------------------------------------------------------------------- + GLOBAL STATE STRUCT +----------------------------------------------------------------------------*/ + +type agentStatus struct { + mu sync.Mutex + state AgentState + lastChange time.Time + installStep string + lastError error + crashCount int + lastCrash time.Time +} + +var global = &agentStatus{ + state: StateIdle, + lastChange: time.Now(), +} + +/* -------------------------------------------------------------------------- + STATE GETTERS +----------------------------------------------------------------------------*/ + +func GetState() AgentState { + global.mu.Lock() + defer global.mu.Unlock() + return global.state +} + +func GetInstallStep() string { + global.mu.Lock() + defer global.mu.Unlock() + return global.installStep +} + +func GetError() error { + global.mu.Lock() + defer global.mu.Unlock() + return global.lastError +} + +func GetCrashCount() int { + global.mu.Lock() + defer global.mu.Unlock() + return global.crashCount +} + +func GetLastChange() time.Time { + global.mu.Lock() + defer global.mu.Unlock() + return global.lastChange +} + +/* -------------------------------------------------------------------------- + STATE SETTERS — unified with logging +----------------------------------------------------------------------------*/ + +func SetState(s AgentState) { + global.mu.Lock() + defer global.mu.Unlock() + + if global.state != s { + log.Printf("[state] %s → %s\n", global.state, s) + global.state = s + global.lastChange = time.Now() + } +} + +func SetInstallStep(step string) { + global.mu.Lock() + defer global.mu.Unlock() + global.installStep = step +} + +func SetError(err error) { + global.mu.Lock() + defer global.mu.Unlock() + global.lastError = err +} + +func RecordCrash(err error) { + global.mu.Lock() + defer global.mu.Unlock() + + log.Printf("[state] crash detected: %v", err) + + global.state = StateCrashed + global.lastError = err + global.crashCount++ + global.lastCrash = time.Now() +} + +/* -------------------------------------------------------------------------- + CONFIG SAVE / LOAD +----------------------------------------------------------------------------*/ + +const configPath = "/opt/zlh-agent/config/payload.json" + +func SaveConfig(cfg *Config) error { + if err := os.MkdirAll("/opt/zlh-agent/config", 0o755); err != nil { + return err + } + + b, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + + return os.WriteFile(configPath, b, 0o644) +} + +func LoadConfig() (*Config, error) { + b, err := os.ReadFile(configPath) + if err != nil { + return nil, err + } + + var cfg Config + if err := json.Unmarshal(b, &cfg); err != nil { + return nil, err + } + return &cfg, nil +} diff --git a/internal/system/autostart.go b/internal/system/autostart.go new file mode 100644 index 0000000..3490f43 --- /dev/null +++ b/internal/system/autostart.go @@ -0,0 +1,98 @@ +package system + +import ( + "log" + "time" + + "zlh-agent/internal/state" +) + +/* + autostart.go + Handles: + - Optional auto-start on LXC boot + - Optional crash backoff / retry + - Global control for enabling/disabling auto-start + + NOTE: + - This file does NOT call StartServer automatically unless + AutoStartEnabled == true. + + - The template can choose to enable auto-start by writing a small + JSON flag into the payload config, or the API can set it. +*/ + +var AutoStartEnabled = false // controlled by config or template +var AutoRestartOnCrash = true // can be disabled for debugging + +// optional exponential backoff (3 attempts max) +var backoffDelays = []time.Duration{ + 5 * time.Second, + 10 * time.Second, + 20 * time.Second, +} + +/* -------------------------------------------------------------------------- + InitAutoStart — called from main.go +----------------------------------------------------------------------------*/ + +func InitAutoStart() { + if !AutoStartEnabled { + log.Println("[autostart] disabled (ok)") + return + } + + log.Println("[autostart] enabled: waiting for config...") + + go func() { + // Wait until config is loaded + for { + cfg, err := state.LoadConfig() + if err == nil && cfg != nil { + log.Println("[autostart] config detected: boot-starting server") + _ = StartServer(cfg) + go monitorCrashes(cfg) + return + } + time.Sleep(3 * time.Second) + } + }() +} + +/* -------------------------------------------------------------------------- + monitorCrashes — restarts server if AutoRestartOnCrash=true +----------------------------------------------------------------------------*/ + +func monitorCrashes(cfg *state.Config) { + if !AutoRestartOnCrash { + log.Println("[autostart] crash monitoring disabled") + return + } + + attempt := 0 + + for { + time.Sleep(3 * time.Second) + + if state.GetState() != state.StateCrashed { + continue + } + + log.Println("[autostart] SERVER CRASH DETECTED") + + if attempt >= len(backoffDelays) { + log.Println("[autostart] max crash retries reached, not restarting") + return + } + + wait := backoffDelays[attempt] + log.Printf("[autostart] waiting %s before restart", wait) + + time.Sleep(wait) + attempt++ + + if err := StartServer(cfg); err != nil { + log.Println("[autostart]", err) + } + } +} diff --git a/internal/system/console.go b/internal/system/console.go new file mode 100644 index 0000000..b55335e --- /dev/null +++ b/internal/system/console.go @@ -0,0 +1,95 @@ +package system + +import ( + "fmt" + "os" + "path/filepath" + "sync" + + "zlh-agent/internal/provision" + "zlh-agent/internal/state" +) + +/* + console.go + Handles: + - Stdin command sending (bridge to process.go) + - Log tailing + - (future) WebSocket log streaming +*/ + +var ( + consoleMu sync.Mutex +) + +/* -------------------------------------------------------------------------- + SendCommand (public wrapper) + Used by HTTP endpoint: /console/command?cmd=... +----------------------------------------------------------------------------*/ +func SendCommand(cmd string) error { + return SendConsoleCommand(cmd) +} + +/* -------------------------------------------------------------------------- + TailLatestLog (simple tail) + Returns last N bytes from logs/latest.log + + Used now for API "tail logs" + Will be used by WebSocket console streaming later. +----------------------------------------------------------------------------*/ +func TailLatestLog(cfg *state.Config, maxBytes int) ([]byte, error) { + dir := provision.ServerDir(*cfg) + logFile := filepath.Join(dir, "logs", "latest.log") + + f, err := os.Open(logFile) + if err != nil { + return nil, fmt.Errorf("open log: %w", err) + } + defer f.Close() + + stat, err := f.Stat() + if err != nil { + return nil, fmt.Errorf("stat log: %w", err) + } + + size := stat.Size() + if size <= int64(maxBytes) { + // Read whole file + b, err := os.ReadFile(logFile) + if err != nil { + return nil, err + } + return b, nil + } + + // Tail last N bytes + offset := size - int64(maxBytes) + buf := make([]byte, maxBytes) + + _, err = f.ReadAt(buf, offset) + if err != nil { + return nil, err + } + + return buf, nil +} + +/* -------------------------------------------------------------------------- + Future: WebSocket Console Support + (stub, safe to leave here) +----------------------------------------------------------------------------*/ + +// PrepareWebSocketSession creates a placeholder session object. +// This will be expanded when we add WebSocket streaming. +func PrepareWebSocketSession() { + // Stub for future WebSocket log streaming +} + +/* + Notes for WebSocket console: + + - Use TailLatestLog() to send first chunk + - Then subscribe to real-time stdout/stderr pump via channels + - console.go will own log streaming + - process.go will expose a channel of new log lines +*/ diff --git a/internal/system/process.go b/internal/system/process.go new file mode 100755 index 0000000..c0b229e --- /dev/null +++ b/internal/system/process.go @@ -0,0 +1,217 @@ +package system + +import ( + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" // <-- ADD THIS + "sync" + "time" + + "zlh-agent/internal/provision" + "zlh-agent/internal/state" +) + + +/* -------------------------------------------------------------------------- + GLOBAL PROCESS STATE +----------------------------------------------------------------------------*/ + +var ( + mu sync.Mutex + serverCmd *exec.Cmd + serverStdin io.WriteCloser +) + +/* -------------------------------------------------------------------------- + StartServer (fixed) +----------------------------------------------------------------------------*/ + +func StartServer(cfg *state.Config) error { + mu.Lock() + defer mu.Unlock() + + // Already running? + if serverCmd != nil { + return fmt.Errorf("server already running") + } + + dir := provision.ServerDir(*cfg) + startScript := filepath.Join(dir, "start.sh") + + cmd := exec.Command("/bin/bash", startScript) + cmd.Dir = dir + + stdout, _ := cmd.StdoutPipe() + stderr, _ := cmd.StderrPipe() + stdin, _ := cmd.StdinPipe() + + serverStdin = stdin + serverCmd = cmd + + // Mark STARTING (not running) + state.SetState(state.StateStarting) + + if err := cmd.Start(); err != nil { + serverCmd = nil + return fmt.Errorf("start server: %w", err) + } + + /* ------------------------- + Log pumps + --------------------------*/ + go pumpOutput(stdout, os.Stdout) + go pumpOutput(stderr, os.Stderr) + + /* ------------------------- + Detect "Done" → running + --------------------------*/ + go detectMinecraftReady(cfg) + + /* ------------------------- + Crash watcher + --------------------------*/ + go func() { + err := cmd.Wait() + + mu.Lock() + defer mu.Unlock() + + if err != nil { + state.RecordCrash(err) + serverCmd = nil + serverStdin = nil + return + } + + // Normal stop + state.SetState(state.StateIdle) + serverCmd = nil + serverStdin = nil + }() + + return nil +} + +/* helper to pump logs */ +func pumpOutput(r io.Reader, w *os.File) { + buf := make([]byte, 4096) + for { + n, err := r.Read(buf) + if n > 0 { + w.Write(buf[:n]) + } + if err != nil { + return + } + } +} + +/* Detects Minecraft "Done" and updates state */ +func detectMinecraftReady(cfg *state.Config) { + dir := provision.ServerDir(*cfg) + logPath := filepath.Join(dir, "logs", "latest.log") + + deadline := time.Now().Add(5 * time.Minute) // FORGE NEEDS MORE TIME + + lastSize := int64(0) + + for time.Now().Before(deadline) { + + // Wait for log file to appear + st, err := os.Stat(logPath) + if err == nil { + // ensure file is growing + if st.Size() != lastSize { + lastSize = st.Size() + + b, _ := os.ReadFile(logPath) + s := string(b) + + // UNIVERSAL READY MATCHES + if strings.Contains(s, "Done (") || + strings.Contains(s, "For help, type \"help\"") || + strings.Contains(s, "Successfully loaded forge") || + strings.Contains(s, "Preparing spawn area: 100%") { + + state.SetState(state.StateRunning) + state.SetError(nil) + return + } + } + } + + time.Sleep(2 * time.Second) + } + + state.SetState(state.StateError) + state.SetError(fmt.Errorf("server failed to reach running state before timeout")) +} + + + + +/* -------------------------------------------------------------------------- + StopServer +----------------------------------------------------------------------------*/ + +func StopServer() error { + mu.Lock() + defer mu.Unlock() + + if serverCmd == nil { + return fmt.Errorf("server not running") + } + + state.SetState(state.StateStopping) + + // Try graceful stop + if serverStdin != nil { + _, _ = serverStdin.Write([]byte("save-all\n")) + time.Sleep(2 * time.Second) + _, _ = serverStdin.Write([]byte("stop\n")) + } + + // Wait a moment + time.Sleep(4 * time.Second) + + // If still running, force kill + if serverCmd.Process != nil { + _ = serverCmd.Process.Kill() + } + + state.SetState(state.StateIdle) + serverCmd = nil + serverStdin = nil + return nil +} + +/* -------------------------------------------------------------------------- + RestartServer +----------------------------------------------------------------------------*/ + +func RestartServer(cfg *state.Config) error { + if err := StopServer(); err != nil { + // ignore if not running + } + + return StartServer(cfg) +} + +/* -------------------------------------------------------------------------- + SendConsoleCommand +----------------------------------------------------------------------------*/ + +func SendConsoleCommand(cmd string) error { + mu.Lock() + defer mu.Unlock() + + if serverStdin == nil { + return fmt.Errorf("server console not available") + } + + _, err := serverStdin.Write([]byte(cmd + "\n")) + return err +} diff --git a/internal/util/disk.go b/internal/util/disk.go new file mode 100644 index 0000000..d310682 --- /dev/null +++ b/internal/util/disk.go @@ -0,0 +1,57 @@ +package util + +import ( + "fmt" + "syscall" +) + +// DiskUsage represents available/total disk space in bytes. +type DiskUsage struct { + FreeBytes uint64 + TotalBytes uint64 +} + +// CheckDisk returns free and total disk space for the given path. +func CheckDisk(path string) (*DiskUsage, error) { + var stat syscall.Statfs_t + + err := syscall.Statfs(path, &stat) + if err != nil { + return nil, fmt.Errorf("statfs: %w", err) + } + + free := stat.Bavail * uint64(stat.Bsize) + total := stat.Blocks * uint64(stat.Bsize) + + return &DiskUsage{ + FreeBytes: free, + TotalBytes: total, + }, nil +} + +// HasEnoughSpace returns true if "path" has at least requiredMB available. +func HasEnoughSpace(path string, requiredMB uint64) (bool, *DiskUsage, error) { + usage, err := CheckDisk(path) + if err != nil { + return false, nil, err + } + + requiredBytes := requiredMB * 1024 * 1024 + return usage.FreeBytes >= requiredBytes, usage, nil +} + +// RequireSpace errors if insufficient disk is available. +func RequireSpace(path string, minMB uint64) error { + ok, usage, err := HasEnoughSpace(path, minMB) + if err != nil { + return err + } + if !ok { + return fmt.Errorf( + "insufficient disk: free=%dMB required=%dMB", + usage.FreeBytes/1024/1024, + minMB, + ) + } + return nil +} diff --git a/internal/util/log.go b/internal/util/log.go new file mode 100644 index 0000000..582ed08 --- /dev/null +++ b/internal/util/log.go @@ -0,0 +1,47 @@ +package util + +import ( + "fmt" + "log" + "os" + "time" +) + +var ( + logFile *os.File + logReady bool +) + +// InitLogFile sets up a log file inside the agent directory. +// Called from main.go (optional). +func InitLogFile(path string) error { + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + if err != nil { + return err + } + logFile = f + logReady = true + return nil +} + +func CloseLog() { + if logReady && logFile != nil { + logFile.Close() + } +} + +// Log writes a timestamped line to stdout AND to log file (if enabled). +func Log(format string, v ...any) { + line := fmt.Sprintf("[%s] %s", + time.Now().Format("2006-01-02 15:04:05"), + fmt.Sprintf(format, v...), + ) + + // Always print to stdout + log.Println(line) + + // Optionally also write to file + if logReady && logFile != nil { + logFile.WriteString(line + "\n") + } +} diff --git a/main.go b/main.go new file mode 100755 index 0000000..ccc920a --- /dev/null +++ b/main.go @@ -0,0 +1,87 @@ +package main + +import ( + "context" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + agenthttp "zlh-agent/internal/http" + "zlh-agent/internal/system" // <-- ADD THIS + "zlh-agent/internal/util" +) + +const AgentVersion = "v1.0.0" // Consolidated agent version tag + +func main() { + log.Printf("[agent] starting ZeroLagHub Agent %s", AgentVersion) + + // ------------------------------------------------------------ + // Optional: enable log file (safe if path doesn't exist yet) + // ------------------------------------------------------------ + _ = os.MkdirAll("/opt/zlh-agent/logs", 0755) + if err := util.InitLogFile("/opt/zlh-agent/logs/agent.log"); err != nil { + log.Printf("[agent] warning: log file init failed: %v", err) + } else { + log.Printf("[agent] file logging enabled") + } + defer util.CloseLog() + + // ------------------------------------------------------------ + // HTTP listen address + // ------------------------------------------------------------ + addr := ":18888" + if v := os.Getenv("ZLH_AGENT_LISTEN"); v != "" { + addr = v + } else if v := os.Getenv("ZLH_AGENT_PORT"); v != "" { + addr = ":" + v + } + + mux := agenthttp.NewMux() + + // ------------------------------------------------------------ + // Enable autostart subsystem + // (does nothing unless AutoStartEnabled=true) + // ------------------------------------------------------------ + system.InitAutoStart() // <-- ADD THIS + + server := &http.Server{ + Addr: addr, + Handler: mux, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + } + + // ------------------------------------------------------------ + // Graceful shutdown handling + // ------------------------------------------------------------ + go func() { + log.Printf("[agent] listening on %s", addr) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("[agent] http server error: %v", err) + } + }() + + // Catch SIGINT / SIGTERM + stop := make(chan os.Signal, 1) + signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) + + <-stop + log.Println("[agent] shutdown signal received") + + // Allow up to 10 seconds for graceful shutdown + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if err := server.Shutdown(ctx); err != nil { + log.Printf("[agent] http shutdown error: %v", err) + } else { + log.Println("[agent] http server stopped gracefully") + } + + log.Println("[agent] exiting") +} diff --git a/scripts/install-agent.sh b/scripts/install-agent.sh new file mode 100755 index 0000000..80e2ef4 --- /dev/null +++ b/scripts/install-agent.sh @@ -0,0 +1,67 @@ +#!/bin/bash +set -e + +echo "===============================" +echo " Installing ZeroLagHub Agent" +echo "===============================" + +AGENT_DIR="/opt/zlh-agent" +BIN_PATH="$AGENT_DIR/zlh-agent" +SERVICE_PATH="/etc/systemd/system/zlh-agent.service" + +echo "[1/6] Creating folders..." +mkdir -p $AGENT_DIR +mkdir -p /opt/zlh/server +mkdir -p /opt/zlh/java + +echo "[2/6] Copying agent binary..." +if [ ! -f ./zlh-agent ]; then + echo "ERROR: No zlh-agent binary found in current directory!" + exit 1 +fi + +cp ./zlh-agent $BIN_PATH +chmod +x $BIN_PATH + +echo "[3/6] Installing systemd service..." +cat < $SERVICE_PATH +[Unit] +Description=ZeroLagHub Game Provisioning Agent +After=network.target +Wants=network-online.target + +[Service] +Type=simple +User=root +WorkingDirectory=$AGENT_DIR +ExecStart=$BIN_PATH +Restart=always +RestartSec=2 +Environment=ZLH_AGENT_PORT=18888 +Environment=ZLH_AGENT_ENV=production +LimitNPROC=infinity +LimitNOFILE=65535 + +[Install] +WantedBy=multi-user.target +EOF + +echo "[4/6] Reloading systemd..." +systemctl daemon-reload + +echo "[5/6] Enabling & starting service..." +systemctl enable zlh-agent +systemctl restart zlh-agent + +echo "[6/6] Verifying agent..." +sleep 1 +if systemctl is-active --quiet zlh-agent; then + echo "✔ Agent is running" +else + echo "✖ Agent failed to start" + journalctl -u zlh-agent --no-pager + exit 1 +fi + +echo "Agent installation complete!" +echo "===============================" diff --git a/scripts/steamcmd_install.sh b/scripts/steamcmd_install.sh new file mode 100644 index 0000000..e2930fa --- /dev/null +++ b/scripts/steamcmd_install.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -e + +STEAMCMD_DIR="/opt/zlh/steamcmd" + +echo "[steamcmd] Installing SteamCMD..." + +apt-get update -y +apt-get install -y --no-install-recommends \ + ca-certificates \ + lib32gcc-s1 \ + lib32stdc++6 \ + curl \ + wget \ + tar \ + python3-minimal + +mkdir -p "$STEAMCMD_DIR" +cd "$STEAMCMD_DIR" + +if [ ! -f "$STEAMCMD_DIR/steamcmd.sh" ]; then + echo "[steamcmd] Downloading SteamCMD..." + wget -q https://steamcdn-a.akamaihd.net/client/installer/steamcmd_linux.tar.gz \ + -O steamcmd_linux.tar.gz + echo "[steamcmd] Extracting..." + tar -xzf steamcmd_linux.tar.gz + rm -f steamcmd_linux.tar.gz +fi + +if [ ! -e /usr/local/bin/steamcmd ]; then + ln -s "$STEAMCMD_DIR/steamcmd.sh" /usr/local/bin/steamcmd +fi + +echo "[steamcmd] Running initial update..." +/usr/local/bin/steamcmd +quit || true + +echo "[steamcmd] SteamCMD install complete." diff --git a/scripts/zlh-agent.service b/scripts/zlh-agent.service new file mode 100755 index 0000000..5d92df7 --- /dev/null +++ b/scripts/zlh-agent.service @@ -0,0 +1,19 @@ +[Unit] +Description=ZeroLagHub Game Provisioning Agent +After=network.target +Wants=network-online.target + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/zlh-agent +ExecStart=/opt/zlh-agent/zlh-agent +Restart=always +RestartSec=2 +Environment=ZLH_AGENT_PORT=18888 +Environment=ZLH_AGENT_ENV=production +LimitNPROC=infinity +LimitNOFILE=65535 + +[Install] +WantedBy=multi-user.target diff --git a/testinstall b/testinstall new file mode 100755 index 0000000..38652f4 Binary files /dev/null and b/testinstall differ diff --git a/zlh-agent b/zlh-agent new file mode 100755 index 0000000..adb914f Binary files /dev/null and b/zlh-agent differ