Initial Go agent implementation

This commit is contained in:
root 2025-12-13 20:27:44 +00:00 committed by jester
parent 5647a8ca6c
commit 052fd8f874
31 changed files with 2621 additions and 0 deletions

19
.gitignore vendored Normal file
View File

@ -0,0 +1,19 @@
# Go
/bin/
/pkg/
*.exe
*.exe~
*.dll
*.so
*.dylib
# Build
/build/
/dist/
# IDE
.vscode/
.idea/
# OS
.DS_Store

3
go.mod Executable file
View File

@ -0,0 +1,3 @@
module zlh-agent
go 1.21.6

259
internal/http/agent.go Executable file
View File

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

168
internal/http/websocket.go Normal file
View File

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

View File

@ -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/<game>/<variant>/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/<game>/<variant>/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, "/")
}

60
internal/provision/artifacts.go Executable file
View File

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

50
internal/provision/common.go Executable file
View File

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

153
internal/provision/files.go Executable file
View File

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

133
internal/provision/java.go Executable file
View File

@ -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 → <version>/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 → <version>/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
}

View File

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

View File

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

View File

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

View File

@ -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/<game>/<variant>/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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

173
internal/state/state.go Executable file
View File

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

View File

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

View File

@ -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
*/

217
internal/system/process.go Executable file
View File

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

57
internal/util/disk.go Normal file
View File

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

47
internal/util/log.go Normal file
View File

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

87
main.go Executable file
View File

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

67
scripts/install-agent.sh Executable file
View File

@ -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 <<EOF > $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 "==============================="

View File

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

19
scripts/zlh-agent.service Executable file
View File

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

BIN
testinstall Executable file

Binary file not shown.

BIN
zlh-agent Executable file

Binary file not shown.