Initial Go agent implementation
This commit is contained in:
parent
5647a8ca6c
commit
052fd8f874
19
.gitignore
vendored
Normal file
19
.gitignore
vendored
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
# Go
|
||||||
|
/bin/
|
||||||
|
/pkg/
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
|
||||||
|
# Build
|
||||||
|
/build/
|
||||||
|
/dist/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
259
internal/http/agent.go
Executable file
259
internal/http/agent.go
Executable 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
168
internal/http/websocket.go
Normal 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)
|
||||||
|
}
|
||||||
65
internal/provcommon/common.go
Normal file
65
internal/provcommon/common.go
Normal 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
60
internal/provision/artifacts.go
Executable 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
50
internal/provision/common.go
Executable 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
153
internal/provision/files.go
Executable 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
133
internal/provision/java.go
Executable 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
|
||||||
|
}
|
||||||
111
internal/provision/minecraft/forge.go
Normal file
111
internal/provision/minecraft/forge.go
Normal 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
|
||||||
|
}
|
||||||
69
internal/provision/minecraft/neoforge.go
Normal file
69
internal/provision/minecraft/neoforge.go
Normal 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
|
||||||
|
}
|
||||||
50
internal/provision/minecraft/vanilla.go
Normal file
50
internal/provision/minecraft/vanilla.go
Normal 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
|
||||||
|
}
|
||||||
|
|
||||||
263
internal/provision/minecraft/verify.go
Normal file
263
internal/provision/minecraft/verify.go
Normal 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
|
||||||
|
}
|
||||||
121
internal/provision/provision.go
Normal file
121
internal/provision/provision.go
Normal 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)
|
||||||
|
}
|
||||||
80
internal/provision/steam/common_steam.go
Normal file
80
internal/provision/steam/common_steam.go
Normal 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, " ")
|
||||||
|
}
|
||||||
30
internal/provision/steam/projectzomboid.go
Normal file
30
internal/provision/steam/projectzomboid.go
Normal 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
|
||||||
|
}
|
||||||
30
internal/provision/steam/rust.go
Normal file
30
internal/provision/steam/rust.go
Normal 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
|
||||||
|
}
|
||||||
30
internal/provision/steam/terraria.go
Normal file
30
internal/provision/steam/terraria.go
Normal 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
|
||||||
|
}
|
||||||
30
internal/provision/steam/valheim.go
Normal file
30
internal/provision/steam/valheim.go
Normal 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
173
internal/state/state.go
Executable 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
|
||||||
|
}
|
||||||
98
internal/system/autostart.go
Normal file
98
internal/system/autostart.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
95
internal/system/console.go
Normal file
95
internal/system/console.go
Normal 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
217
internal/system/process.go
Executable 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
57
internal/util/disk.go
Normal 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
47
internal/util/log.go
Normal 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
87
main.go
Executable 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
67
scripts/install-agent.sh
Executable 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 "==============================="
|
||||||
37
scripts/steamcmd_install.sh
Normal file
37
scripts/steamcmd_install.sh
Normal 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
19
scripts/zlh-agent.service
Executable 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
BIN
testinstall
Executable file
Binary file not shown.
Loading…
Reference in New Issue
Block a user