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