updates 3-1-26

This commit is contained in:
jester 2026-03-07 20:59:27 +00:00
parent f4f5faf00e
commit b7afe5733a
21 changed files with 3140 additions and 44 deletions

View File

@ -1,2 +1,16 @@
# zlh-agent
## Release Build
Build a versioned Linux AMD64 artifact (with embedded agent version and matching SHA256):
```bash
./scripts/build-release.sh 1.0.6
```
Outputs:
- `dist/1.0.6/zlh-agent-linux-amd64`
- `dist/1.0.6/zlh-agent-linux-amd64.sha256`
The version reported by startup logs and `GET /version` is injected at build time via ldflags.

1021
internal/files/files.go Normal file

File diff suppressed because it is too large Load Diff

254
internal/handlers/files.go Normal file
View File

@ -0,0 +1,254 @@
package handlers
import (
"errors"
"io"
"mime"
"net/http"
"os"
"strconv"
"strings"
agentfiles "zlh-agent/internal/files"
)
func HandleGameFilesList(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeJSONError(w, http.StatusMethodNotAllowed, "GET only")
return
}
_, serverRoot, ok := requireMinecraftGame(w)
if !ok {
return
}
resp, err := agentfiles.List(serverRoot, r.URL.Query().Get("path"))
if err != nil {
writeFilesError(w, err)
return
}
writeJSON(w, http.StatusOK, resp)
}
func HandleGameFilesStat(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeJSONError(w, http.StatusMethodNotAllowed, "GET only")
return
}
_, serverRoot, ok := requireMinecraftGame(w)
if !ok {
return
}
resp, err := agentfiles.Stat(serverRoot, r.URL.Query().Get("path"))
if err != nil {
writeFilesError(w, err)
return
}
writeJSON(w, http.StatusOK, resp)
}
func HandleGameFilesRead(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeJSONError(w, http.StatusMethodNotAllowed, "GET only")
return
}
_, serverRoot, ok := requireMinecraftGame(w)
if !ok {
return
}
resp, err := agentfiles.Read(serverRoot, r.URL.Query().Get("path"))
if err != nil {
writeFilesError(w, err)
return
}
writeJSON(w, http.StatusOK, resp)
}
func HandleGameFilesDownload(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeJSONError(w, http.StatusMethodNotAllowed, "GET only")
return
}
_, serverRoot, ok := requireMinecraftGame(w)
if !ok {
return
}
file, info, name, err := agentfiles.OpenDownload(serverRoot, r.URL.Query().Get("path"))
if err != nil {
writeFilesError(w, err)
return
}
defer file.Close()
w.Header().Set("Content-Length", strconv.FormatInt(info.Size(), 10))
w.Header().Set("Content-Disposition", "attachment; filename=\""+name+"\"")
http.ServeContent(w, r, name, info.ModTime(), file)
}
func HandleGameFilesRoot(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodDelete:
handleGameFilesDelete(w, r)
case http.MethodPut:
handleGameFilesWrite(w, r)
default:
writeJSONError(w, http.StatusMethodNotAllowed, "PUT or DELETE only")
}
}
func HandleGameFilesUpload(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSONError(w, http.StatusMethodNotAllowed, "POST only")
return
}
_, serverRoot, ok := requireMinecraftGame(w)
if !ok {
return
}
mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil || !strings.HasPrefix(mediaType, "multipart/") {
writeJSONError(w, http.StatusBadRequest, "multipart/form-data required")
return
}
normalizedPath, err := agentfiles.NormalizeVisiblePath(r.URL.Query().Get("path"))
if err != nil {
writeFilesError(w, err)
return
}
overwrite := strings.EqualFold(r.URL.Query().Get("overwrite"), "true")
mr, err := r.MultipartReader()
if err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid multipart body")
return
}
for {
part, err := mr.NextPart()
if err == io.EOF {
writeJSONError(w, http.StatusBadRequest, "missing file part")
return
}
if err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid multipart body")
return
}
if part.FormName() != "file" {
part.Close()
continue
}
size, overwritten, err := agentfiles.Upload(serverRoot, normalizedPath, part, 0, overwrite)
part.Close()
if err != nil {
writeFilesError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"uploaded": true,
"path": normalizedPath,
"size": size,
"overwritten": overwritten,
})
return
}
}
func HandleGameFilesRevert(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSONError(w, http.StatusMethodNotAllowed, "POST only")
return
}
_, serverRoot, ok := requireMinecraftGame(w)
if !ok {
return
}
revertedPath, err := agentfiles.Revert(serverRoot, r.URL.Query().Get("path"))
if err != nil {
writeFilesError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"reverted": true,
"path": revertedPath,
})
}
func handleGameFilesDelete(w http.ResponseWriter, r *http.Request) {
_, serverRoot, ok := requireMinecraftGame(w)
if !ok {
return
}
deletedPath, err := agentfiles.Delete(serverRoot, r.URL.Query().Get("path"))
if err != nil {
writeFilesError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"deleted": true,
"path": deletedPath,
})
}
func handleGameFilesWrite(w http.ResponseWriter, r *http.Request) {
_, serverRoot, ok := requireMinecraftGame(w)
if !ok {
return
}
normalizedPath, err := agentfiles.NormalizeVisiblePath(r.URL.Query().Get("path"))
if err != nil {
writeFilesError(w, err)
return
}
limitedBody := io.LimitReader(r.Body, agentfiles.MaxWriteSize+1)
data, err := io.ReadAll(limitedBody)
if err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid request body")
return
}
backupCreated, err := agentfiles.Write(serverRoot, normalizedPath, data)
if err != nil {
writeFilesError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"saved": true,
"path": normalizedPath,
"backupCreated": backupCreated,
})
}
func writeFilesError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, agentfiles.ErrInvalidPath):
writeJSONError(w, http.StatusBadRequest, err.Error())
case errors.Is(err, agentfiles.ErrPathEscape), errors.Is(err, agentfiles.ErrForbiddenPath):
writeJSONError(w, http.StatusForbidden, err.Error())
case errors.Is(err, agentfiles.ErrDeleteDenied), errors.Is(err, agentfiles.ErrWriteDenied), errors.Is(err, agentfiles.ErrUploadDenied):
writeJSONError(w, http.StatusForbidden, err.Error())
case errors.Is(err, agentfiles.ErrAlreadyExists):
writeJSONError(w, http.StatusConflict, err.Error())
case errors.Is(err, agentfiles.ErrNotDir), errors.Is(err, agentfiles.ErrNotFile), errors.Is(err, agentfiles.ErrBinaryFile):
writeJSONError(w, http.StatusBadRequest, err.Error())
case errors.Is(err, agentfiles.ErrTooLarge), errors.Is(err, agentfiles.ErrWriteTooLarge):
writeJSONError(w, http.StatusRequestEntityTooLarge, err.Error())
case errors.Is(err, os.ErrNotExist), errors.Is(err, http.ErrMissingFile):
writeJSONError(w, http.StatusNotFound, "path not found")
default:
writeJSONError(w, http.StatusInternalServerError, err.Error())
}
}

View File

@ -0,0 +1,25 @@
package handlers
import (
"net/http"
"zlh-agent/internal/metrics"
)
func HandleProcessMetrics(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeJSONError(w, http.StatusMethodNotAllowed, "GET only")
return
}
resp, stopped, err := metrics.ProcessMetrics()
if err != nil {
writeJSONError(w, http.StatusInternalServerError, err.Error())
return
}
if stopped {
writeJSON(w, http.StatusNotFound, map[string]any{"status": "stopped", "process": resp.Process})
return
}
writeJSON(w, http.StatusOK, resp)
}

207
internal/handlers/mods.go Normal file
View File

@ -0,0 +1,207 @@
package handlers
import (
"encoding/json"
"errors"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"zlh-agent/internal/mods"
"zlh-agent/internal/state"
)
type jsonError struct {
Error string `json:"error"`
}
func HandleGameMods(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeJSONError(w, http.StatusMethodNotAllowed, "GET only")
return
}
_, serverRoot, ok := requireMinecraftGame(w)
if !ok {
return
}
resp, err := mods.ScanMods(serverRoot)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, resp)
}
func HandleGameModsInstall(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSONError(w, http.StatusMethodNotAllowed, "POST only")
return
}
_, serverRoot, ok := requireMinecraftGame(w)
if !ok {
return
}
var req mods.InstallRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid json")
return
}
filename := strings.TrimSpace(req.Filename)
if strings.TrimSpace(strings.ToLower(req.Source)) != "modrinth" {
filename = ""
if u, err := url.Parse(req.ArtifactURL); err == nil {
base := filepath.Base(u.Path)
safeBase := base != "." && base != "/" &&
!strings.Contains(base, "..") &&
!strings.Contains(base, "/") &&
!strings.Contains(base, "\\") &&
!strings.Contains(base, "~") &&
!strings.ContainsRune(base, 0)
if safeBase {
valid := true
for _, r := range base {
if r <= 31 || r == 127 || r == ' ' || r == '\t' || r == '\n' || r == '\r' {
valid = false
break
}
if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '.' || r == '_' || r == '-' || r == '+') {
valid = false
break
}
}
if valid {
filename = base
}
}
}
if filename == "" {
name := strings.TrimSpace(req.ModID)
if strings.TrimSpace(req.Version) != "" {
name = name + "-" + strings.TrimSpace(req.Version)
}
name = strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '.' || r == '_' || r == '-' {
return r
}
return '_'
}, name)
if len(name) > 64 {
name = name[:64]
}
if name == "" {
name = "unknown"
}
filename = name + ".jar"
}
}
modsDir := filepath.Join(serverRoot, "mods")
enabledPath := filepath.Join(modsDir, filename)
disabledPath := enabledPath + ".disabled"
if _, err := os.Stat(enabledPath); err == nil {
writeJSON(w, http.StatusOK, map[string]any{
"status": "already-installed",
"fileName": filename,
"enabled": true,
})
return
}
if _, err := os.Stat(disabledPath); err == nil {
writeJSON(w, http.StatusOK, map[string]any{
"status": "already-installed",
"fileName": filename,
"enabled": false,
})
return
}
resp, err := mods.InstallCurated(serverRoot, req)
if err != nil {
writeJSONError(w, http.StatusBadRequest, err.Error())
return
}
_ = resp
writeJSON(w, http.StatusOK, map[string]any{
"status": "installed",
"fileName": filename,
})
}
func HandleGameModByID(w http.ResponseWriter, r *http.Request) {
_, serverRoot, ok := requireMinecraftGame(w)
if !ok {
return
}
modID := strings.TrimPrefix(r.URL.Path, "/game/mods/")
if modID == "" || strings.Contains(modID, "/") || !mods.IsValidModID(modID) {
writeJSONError(w, http.StatusBadRequest, "invalid mod_id")
return
}
switch r.Method {
case http.MethodPatch:
var req mods.PatchRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid json")
return
}
resp, err := mods.SetEnabled(serverRoot, modID, req.Enabled)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
writeJSONError(w, http.StatusNotFound, "mod not found")
return
}
writeJSONError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, resp)
case http.MethodDelete:
resp, err := mods.DeleteMod(serverRoot, modID)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
writeJSONError(w, http.StatusNotFound, "mod not found")
return
}
writeJSONError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, resp)
default:
writeJSONError(w, http.StatusMethodNotAllowed, "PATCH or DELETE only")
}
}
func requireMinecraftGame(w http.ResponseWriter) (*state.Config, string, bool) {
cfg, err := state.LoadConfig()
if err != nil {
writeJSONError(w, http.StatusBadRequest, "no config loaded")
return nil, "", false
}
if strings.ToLower(cfg.ContainerType) != "game" {
writeJSONError(w, http.StatusBadRequest, "not a game container")
return nil, "", false
}
if strings.ToLower(cfg.Game) != "minecraft" {
writeJSONError(w, http.StatusNotImplemented, "unsupported game")
return nil, "", false
}
return cfg, mods.ResolveServerRoot(cfg), true
}
func writeJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(payload)
}
func writeJSONError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, jsonError{Error: msg})
}

View File

@ -12,6 +12,7 @@ import (
"strings"
"time"
agenthandlers "zlh-agent/internal/handlers"
mcstatus "zlh-agent/internal/minecraft"
"zlh-agent/internal/provision"
"zlh-agent/internal/provision/devcontainer"
@ -23,6 +24,7 @@ import (
"zlh-agent/internal/state"
"zlh-agent/internal/system"
"zlh-agent/internal/update"
"zlh-agent/internal/util"
"zlh-agent/internal/version"
)
@ -42,6 +44,28 @@ func dirExists(path string) bool {
return err == nil && s.IsDir()
}
func lifecycleLog(cfg *state.Config, phase string, attempt int, started time.Time, format string, args ...any) {
elapsed := time.Since(started).Milliseconds()
msg := fmt.Sprintf(format, args...)
util.LogLifecycle("[lifecycle] vmid=%d phase=%s attempt=%d elapsed_ms=%d %s", cfg.VMID, phase, attempt, elapsed, msg)
}
func waitMinecraftReady(cfg *state.Config, phase string, started time.Time) error {
if strings.ToLower(cfg.Game) != "minecraft" {
return nil
}
lifecycleLog(cfg, phase, 1, started, "probe_begin")
if err := mcstatus.WaitUntilReady(*cfg, 60*time.Second, 3*time.Second); err != nil {
state.SetReadyState(false, "minecraft_ping", err.Error())
lifecycleLog(cfg, phase, 1, started, "probe_timeout err=%v", err)
return err
}
state.SetReadyState(true, "minecraft_ping", "")
lifecycleLog(cfg, phase, 1, started, "probe_ready")
return nil
}
/*
--------------------------------------------------------------------------
Shared provision pipeline (installer + Minecraft verify)
@ -169,6 +193,8 @@ func handleConfig(w http.ResponseWriter, r *http.Request) {
go func(c state.Config) {
log.Println("[agent] async provision+start begin")
started := time.Now()
lifecycleLog(&c, "config_async", 1, started, "begin")
if err := ensureProvisioned(&c); err != nil {
log.Println("[agent] provision error:", err)
@ -176,9 +202,18 @@ func handleConfig(w http.ResponseWriter, r *http.Request) {
}
if c.ContainerType != "dev" {
state.SetState(state.StateStarting)
state.SetReadyState(false, "", "")
lifecycleLog(&c, "start", 1, started, "start_requested")
if err := system.StartServer(&c); err != nil {
log.Println("[agent] start error:", err)
state.SetError(err)
state.SetState(state.StateError)
lifecycleLog(&c, "start", 1, started, "start_failed err=%v", err)
return
}
lifecycleLog(&c, "start", 1, started, "process_started")
if err := waitMinecraftReady(&c, "start_probe", started); err != nil {
state.SetError(err)
state.SetState(state.StateError)
return
@ -191,21 +226,7 @@ func handleConfig(w http.ResponseWriter, r *http.Request) {
variant := strings.ToLower(c.Variant)
if game == "minecraft" && (variant == "forge" || variant == "neoforge") {
deadline := time.Now().Add(5 * time.Minute)
for {
if state.GetState() == state.StateRunning {
break
}
if time.Now().After(deadline) {
err := fmt.Errorf("forge did not reach running state")
log.Println("[agent]", err)
state.SetError(err)
state.SetState(state.StateError)
return
}
time.Sleep(2 * time.Second)
}
lifecycleLog(&c, "forge_post", 1, started, "begin")
// Wait for server.properties to exist before enforcing
propsPath := filepath.Join(provision.ServerDir(c), "server.properties")
@ -225,20 +246,37 @@ func handleConfig(w http.ResponseWriter, r *http.Request) {
}
_ = system.StopServer()
if err := system.WaitForServerExit(20 * time.Second); err != nil {
log.Println("[agent] forge stop wait error:", err)
state.SetError(err)
state.SetState(state.StateError)
lifecycleLog(&c, "forge_post", 1, started, "stop_wait_failed err=%v", err)
return
}
if err := minecraft.EnforceForgeServerProperties(c); err != nil {
log.Println("[agent] forge post-start error:", err)
state.SetError(err)
state.SetState(state.StateError)
lifecycleLog(&c, "forge_post", 1, started, "enforce_failed err=%v", err)
return
}
state.SetState(state.StateStarting)
state.SetReadyState(false, "", "")
if err := system.StartServer(&c); err != nil {
log.Println("[agent] restart error:", err)
state.SetError(err)
state.SetState(state.StateError)
lifecycleLog(&c, "forge_post", 1, started, "restart_failed err=%v", err)
return
}
if err := waitMinecraftReady(&c, "forge_restart_probe", started); err != nil {
state.SetError(err)
state.SetState(state.StateError)
return
}
lifecycleLog(&c, "forge_post", 1, started, "complete")
}
}
@ -268,8 +306,19 @@ func handleStart(w http.ResponseWriter, r *http.Request) {
return
}
started := time.Now()
state.SetState(state.StateStarting)
state.SetReadyState(false, "", "")
lifecycleLog(cfg, "start_manual", 1, started, "start_requested")
if err := system.StartServer(cfg); err != nil {
http.Error(w, "start error: "+err.Error(), http.StatusInternalServerError)
lifecycleLog(cfg, "start_manual", 1, started, "start_failed err=%v", err)
return
}
if err := waitMinecraftReady(cfg, "start_manual_probe", started); err != nil {
state.SetError(err)
state.SetState(state.StateError)
http.Error(w, "start readiness error: "+err.Error(), http.StatusGatewayTimeout)
return
}
@ -310,11 +359,24 @@ func handleRestart(w http.ResponseWriter, r *http.Request) {
}
_ = system.StopServer()
if err := system.WaitForServerExit(20 * time.Second); err != nil {
http.Error(w, "restart error: "+err.Error(), http.StatusInternalServerError)
return
}
started := time.Now()
state.SetState(state.StateStarting)
state.SetReadyState(false, "", "")
if err := system.StartServer(cfg); err != nil {
http.Error(w, "restart error: "+err.Error(), http.StatusInternalServerError)
return
}
if err := waitMinecraftReady(cfg, "restart_manual_probe", started); err != nil {
state.SetError(err)
state.SetState(state.StateError)
http.Error(w, "restart readiness error: "+err.Error(), http.StatusGatewayTimeout)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok": true, "state": "starting"}`))
@ -328,14 +390,24 @@ func handleRestart(w http.ResponseWriter, r *http.Request) {
*/
func handleStatus(w http.ResponseWriter, r *http.Request) {
cfg, _ := state.LoadConfig()
_, processRunning := system.GetServerPID()
readyAt := ""
if t := state.GetLastReadyAt(); !t.IsZero() {
readyAt = t.UTC().Format(time.RFC3339)
}
resp := map[string]any{
"state": state.GetState(),
"installStep": state.GetInstallStep(),
"crashCount": state.GetCrashCount(),
"error": nil,
"config": cfg,
"timestamp": time.Now().Unix(),
"state": state.GetState(),
"processRunning": processRunning,
"ready": state.GetReady(),
"readySource": state.GetReadySource(),
"readyError": state.GetReadyError(),
"lastReadyAt": readyAt,
"installStep": state.GetInstallStep(),
"crashCount": state.GetCrashCount(),
"error": nil,
"config": cfg,
"timestamp": time.Now().Unix(),
}
if err := state.GetError(); err != nil {
@ -533,6 +605,17 @@ func NewMux() *http.ServeMux {
m.HandleFunc("/agent/update/status", handleAgentUpdateStatus)
m.HandleFunc("/version", handleVersion)
m.HandleFunc("/game/players", handleGamePlayers)
m.HandleFunc("/game/mods", agenthandlers.HandleGameMods)
m.HandleFunc("/game/mods/install", agenthandlers.HandleGameModsInstall)
m.HandleFunc("/game/mods/", agenthandlers.HandleGameModByID)
m.HandleFunc("/game/files/list", agenthandlers.HandleGameFilesList)
m.HandleFunc("/game/files", agenthandlers.HandleGameFilesRoot)
m.HandleFunc("/game/files/revert", agenthandlers.HandleGameFilesRevert)
m.HandleFunc("/game/files/upload", agenthandlers.HandleGameFilesUpload)
m.HandleFunc("/game/files/stat", agenthandlers.HandleGameFilesStat)
m.HandleFunc("/game/files/read", agenthandlers.HandleGameFilesRead)
m.HandleFunc("/game/files/download", agenthandlers.HandleGameFilesDownload)
m.HandleFunc("/metrics/process", agenthandlers.HandleProcessMetrics)
registerWebSocket(m)

View File

@ -31,6 +31,7 @@ type consoleSession struct {
mu sync.Mutex
conns map[*websocket.Conn]*consoleConn
readerOnce sync.Once
closeOnce sync.Once
}
var (
@ -48,9 +49,21 @@ func getConsoleSession(cfg *state.Config) (*consoleSession, bool, error) {
sessionMu.Lock()
if sess, ok := sessions[key]; ok {
sessionMu.Unlock()
sess.touch()
log.Printf("[ws] session reuse: vmid=%d type=%s", cfg.VMID, cfg.ContainerType)
return sess, true, nil
currentPTY, err := system.GetConsolePTY(cfg)
if err != nil {
sess.destroy()
return nil, false, err
}
if sess.ptyFile != currentPTY {
log.Printf("[ws] pty changed, destroying stale session: vmid=%d type=%s", cfg.VMID, cfg.ContainerType)
sess.destroy()
} else {
sess.touch()
log.Printf("[ws] session reuse: vmid=%d type=%s", cfg.VMID, cfg.ContainerType)
return sess, true, nil
}
}
sessionMu.Unlock()
@ -104,7 +117,7 @@ func (s *consoleSession) removeConn(conn *websocket.Conn) int {
cc, ok := s.conns[conn]
if ok {
delete(s.conns, conn)
close(cc.send)
safeCloseChan(cc.send)
}
s.lastActive = time.Now()
log.Printf("[ws] conn remove: vmid=%d type=%s conns=%d", s.cfg.VMID, s.cfg.ContainerType, len(s.conns))
@ -129,6 +142,7 @@ func (s *consoleSession) startReader() {
} else {
log.Printf("[ws] pty read loop exit: vmid=%d err=%v", s.cfg.VMID, err)
}
s.destroy()
return
}
if n == 0 && err == nil {
@ -161,6 +175,9 @@ func (s *consoleSession) broadcast(data []byte) {
func (s *consoleSession) writeInput(data []byte) error {
s.touch()
if s.ptyFile == nil {
return fmt.Errorf("pty unavailable")
}
return runtime.Write(s.ptyFile, data)
}
@ -181,9 +198,38 @@ func (s *consoleSession) scheduleCleanupIfIdle() {
if s.cfg.ContainerType == "dev" {
_ = system.StopDevShell()
}
sessionMu.Lock()
delete(sessions, s.key)
sessionMu.Unlock()
s.destroy()
}
}(last)
}
func (s *consoleSession) destroy() {
s.closeOnce.Do(func() {
s.mu.Lock()
for conn, cc := range s.conns {
safeCloseChan(cc.send)
_ = conn.Close()
delete(s.conns, conn)
}
pty := s.ptyFile
s.ptyFile = nil
s.mu.Unlock()
if pty != nil {
_ = pty.Close()
}
sessionMu.Lock()
delete(sessions, s.key)
sessionMu.Unlock()
log.Printf("[ws] session destroyed: vmid=%d type=%s", s.cfg.VMID, s.cfg.ContainerType)
})
}
func safeCloseChan(ch chan []byte) {
defer func() {
_ = recover()
}()
close(ch)
}

103
internal/metrics/process.go Normal file
View File

@ -0,0 +1,103 @@
package metrics
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"zlh-agent/internal/state"
"zlh-agent/internal/system"
)
type ProcessSection struct {
PID int `json:"pid"`
Status string `json:"status"`
UptimeSeconds *int64 `json:"uptime_seconds"`
RestartCount int `json:"restart_count"`
}
type MemorySection struct {
RSSBytes int64 `json:"rss_bytes"`
VMSBytes int64 `json:"vms_bytes"`
}
type ProcessMetricsResponse struct {
Process ProcessSection `json:"process"`
Memory MemorySection `json:"memory"`
}
func ProcessMetrics() (ProcessMetricsResponse, bool, error) {
pid, ok := system.GetServerPID()
if !ok {
return ProcessMetricsResponse{
Process: ProcessSection{
PID: 0,
Status: "stopped",
RestartCount: state.GetCrashCount(),
},
}, true, nil
}
rss, vms, err := readMemoryFromStatus(pid)
if err != nil {
return ProcessMetricsResponse{}, false, err
}
return ProcessMetricsResponse{
Process: ProcessSection{
PID: pid,
Status: "running",
UptimeSeconds: nil,
RestartCount: state.GetCrashCount(),
},
Memory: MemorySection{
RSSBytes: rss,
VMSBytes: vms,
},
}, false, nil
}
func readMemoryFromStatus(pid int) (int64, int64, error) {
statusPath := filepath.Join("/proc", strconv.Itoa(pid), "status")
f, err := os.Open(statusPath)
if err != nil {
return 0, 0, err
}
defer f.Close()
var rssKB int64
var vmsKB int64
s := bufio.NewScanner(f)
for s.Scan() {
line := s.Text()
if strings.HasPrefix(line, "VmRSS:") {
rssKB = parseKBLine(line)
}
if strings.HasPrefix(line, "VmSize:") {
vmsKB = parseKBLine(line)
}
}
if err := s.Err(); err != nil {
return 0, 0, err
}
if rssKB == 0 && vmsKB == 0 {
return 0, 0, fmt.Errorf("missing VmRSS/VmSize in %s", statusPath)
}
return rssKB * 1024, vmsKB * 1024, nil
}
func parseKBLine(line string) int64 {
fields := strings.Fields(line)
if len(fields) < 2 {
return 0
}
n, err := strconv.ParseInt(fields[1], 10, 64)
if err != nil {
return 0
}
return n
}

View File

@ -0,0 +1,85 @@
package minecraft
import (
"fmt"
"log"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"zlh-agent/internal/provision"
"zlh-agent/internal/state"
)
func WaitUntilReady(cfg state.Config, timeout, interval time.Duration) error {
start := time.Now()
ports := candidatePorts(cfg)
protocols := []int{ProtocolForVersion(cfg.Version), 767, 765, 763, 762, 754}
protocols = dedupeInts(protocols)
attempt := 0
deadline := start.Add(timeout)
var lastErr error
for {
attempt++
for _, port := range ports {
for _, protocol := range protocols {
if _, err := QueryStatus("127.0.0.1", port, protocol); err == nil {
elapsed := time.Since(start).Milliseconds()
log.Printf("[lifecycle] vmid=%d phase=probe result=ready attempt=%d elapsed_ms=%d port=%d protocol=%d", cfg.VMID, attempt, elapsed, port, protocol)
return nil
} else {
lastErr = err
}
}
}
elapsed := time.Since(start).Milliseconds()
log.Printf("[lifecycle] vmid=%d phase=probe result=not_ready attempt=%d elapsed_ms=%d err=%v", cfg.VMID, attempt, elapsed, lastErr)
if time.Now().After(deadline) {
if lastErr != nil {
return fmt.Errorf("minecraft readiness probe timeout after %s: %w", timeout, lastErr)
}
return fmt.Errorf("minecraft readiness probe timeout after %s", timeout)
}
time.Sleep(interval)
}
}
func candidatePorts(cfg state.Config) []int {
ports := make([]int, 0, 3)
propsPath := filepath.Join(provision.ServerDir(cfg), "server.properties")
if b, err := os.ReadFile(propsPath); err == nil {
lines := strings.Split(string(b), "\n")
for _, l := range lines {
if strings.HasPrefix(l, "server-port=") {
if p, err := strconv.Atoi(strings.TrimPrefix(l, "server-port=")); err == nil && p > 0 {
ports = append(ports, p)
}
break
}
}
}
if len(cfg.Ports) > 0 && cfg.Ports[0] > 0 {
ports = append(ports, cfg.Ports[0])
}
ports = append(ports, 25565)
return dedupeInts(ports)
}
func dedupeInts(in []int) []int {
seen := make(map[int]struct{}, len(in))
out := make([]int, 0, len(in))
for _, v := range in {
if _, ok := seen[v]; ok {
continue
}
seen[v] = struct{}{}
out = append(out, v)
}
return out
}

435
internal/mods/installer.go Normal file
View File

@ -0,0 +1,435 @@
package mods
import (
"crypto/sha1"
"crypto/sha256"
"crypto/sha512"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
const (
maxDownloadSize = 200 * 1024 * 1024
defaultTimeout = 120 * time.Second
maxRedirects = 3
tempModsDir = "/tmp/zlh-agent/mods"
)
var allowedHosts = []string{
"artifacts.zerolaghub.com",
"cdn.modrinth.com",
}
func InstallCurated(serverRoot string, req InstallRequest) (ActionResponse, error) {
source := strings.TrimSpace(strings.ToLower(req.Source))
if source == "" {
source = "curated"
}
if source != "curated" && source != "modrinth" {
return ActionResponse{}, errors.New("unsupported source")
}
var downloadURL string
var filename string
verifyFunc := func(string) error { return nil }
if source == "modrinth" {
downloadURL = strings.TrimSpace(req.DownloadURL)
filename = strings.TrimSpace(req.Filename)
if downloadURL == "" {
return ActionResponse{}, errors.New("download_url required for modrinth source")
}
if filename == "" || !isSafeFilename(filename) || !strings.HasSuffix(strings.ToLower(filename), ".jar") {
return ActionResponse{}, errors.New("invalid filename")
}
if err := validateArtifactURL(downloadURL); err != nil {
return ActionResponse{}, err
}
if strings.TrimSpace(req.SHA512) != "" {
normalized, err := normalizeExpectedHash(req.SHA512, 128, "sha512")
if err != nil {
return ActionResponse{}, err
}
verifyFunc = func(path string) error { return VerifyHashWithAlgorithm(path, normalized, "sha512") }
} else if strings.TrimSpace(req.SHA1) != "" {
normalized, err := normalizeExpectedHash(req.SHA1, 40, "sha1")
if err != nil {
return ActionResponse{}, err
}
verifyFunc = func(path string) error { return VerifyHashWithAlgorithm(path, normalized, "sha1") }
} else {
return ActionResponse{}, errors.New("sha512 or sha1 required for modrinth source")
}
} else {
if !isValidModID(req.ModID) {
return ActionResponse{}, errors.New("invalid mod_id")
}
if err := validateArtifactURL(req.ArtifactURL); err != nil {
return ActionResponse{}, err
}
if err := validateExpectedHash(req.ArtifactHash); err != nil {
return ActionResponse{}, err
}
downloadURL = req.ArtifactURL
filename = safeInstallFilename(req)
if !isSafeFilename(filename) || !strings.HasSuffix(strings.ToLower(filename), ".jar") {
return ActionResponse{}, errors.New("invalid artifact filename")
}
verifyFunc = func(path string) error { return VerifyHash(path, req.ArtifactHash) }
}
modsDir := filepath.Join(serverRoot, "mods")
if err := os.MkdirAll(modsDir, 0o755); err != nil {
return ActionResponse{}, fmt.Errorf("create mods dir: %w", err)
}
finalPath := filepath.Join(modsDir, filename)
if _, err := os.Stat(finalPath); err == nil {
return ActionResponse{}, fmt.Errorf("mod already exists: %s", filename)
}
if err := os.MkdirAll(tempModsDir, 0o755); err != nil {
return ActionResponse{}, fmt.Errorf("create temp dir: %w", err)
}
tmpFile, err := os.CreateTemp(tempModsDir, "zlh-mod-*.jar")
if err != nil {
return ActionResponse{}, fmt.Errorf("create temp file: %w", err)
}
tmpPath := tmpFile.Name()
defer func() {
_ = os.Remove(tmpPath)
}()
if err := tmpFile.Close(); err != nil {
return ActionResponse{}, fmt.Errorf("close temp file: %w", err)
}
if err := downloadArtifact(downloadURL, tmpPath); err != nil {
return ActionResponse{}, err
}
if err := verifyFunc(tmpPath); err != nil {
return ActionResponse{}, err
}
if err := os.Chmod(tmpPath, 0o644); err != nil {
return ActionResponse{}, fmt.Errorf("chmod temp file: %w", err)
}
if err := os.Rename(tmpPath, finalPath); err != nil {
return ActionResponse{}, fmt.Errorf("install mod: %w", err)
}
if err := os.Chmod(finalPath, 0o644); err != nil {
return ActionResponse{}, fmt.Errorf("set permissions: %w", err)
}
InvalidateCache(serverRoot)
return ActionResponse{Success: true, Action: "installed", RestartRequired: true}, nil
}
func SetEnabled(serverRoot, modID string, enabled bool) (ActionResponse, error) {
modsDir := filepath.Join(serverRoot, "mods")
enabledName, disabledName, err := ResolveByModID(serverRoot, modID)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return ActionResponse{}, os.ErrNotExist
}
return ActionResponse{}, err
}
if enabled {
if enabledName != "" {
return ActionResponse{Success: true, Action: "enabled", RestartRequired: true}, nil
}
src := filepath.Join(modsDir, disabledName)
dstName := strings.TrimSuffix(disabledName, ".disabled")
dst := filepath.Join(modsDir, dstName)
if _, err := os.Stat(dst); err == nil {
return ActionResponse{}, errors.New("cannot enable: target file already exists")
}
if err := os.Rename(src, dst); err != nil {
return ActionResponse{}, fmt.Errorf("enable mod: %w", err)
}
if err := os.Chmod(dst, 0o644); err != nil {
return ActionResponse{}, err
}
InvalidateCache(serverRoot)
return ActionResponse{Success: true, Action: "enabled", RestartRequired: true}, nil
}
if disabledName != "" {
return ActionResponse{Success: true, Action: "disabled", RestartRequired: true}, nil
}
src := filepath.Join(modsDir, enabledName)
dstName := enabledName + ".disabled"
dst := filepath.Join(modsDir, dstName)
if _, err := os.Stat(dst); err == nil {
return ActionResponse{}, errors.New("cannot disable: target file already exists")
}
if err := os.Rename(src, dst); err != nil {
return ActionResponse{}, fmt.Errorf("disable mod: %w", err)
}
if err := os.Chmod(dst, 0o644); err != nil {
return ActionResponse{}, err
}
InvalidateCache(serverRoot)
return ActionResponse{Success: true, Action: "disabled", RestartRequired: true}, nil
}
func DeleteMod(serverRoot, modID string) (ActionResponse, error) {
modsDir := filepath.Join(serverRoot, "mods")
enabledName, disabledName, err := ResolveByModID(serverRoot, modID)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return ActionResponse{}, os.ErrNotExist
}
return ActionResponse{}, err
}
sourceName := enabledName
if sourceName == "" {
sourceName = disabledName
}
src := filepath.Join(modsDir, sourceName)
removedDir := filepath.Join(serverRoot, "mods-removed")
if err := os.MkdirAll(removedDir, 0o755); err != nil {
return ActionResponse{}, fmt.Errorf("create removed dir: %w", err)
}
targetPath := uniqueRemovedPath(removedDir, sourceName)
if err := os.Rename(src, targetPath); err != nil {
return ActionResponse{}, fmt.Errorf("remove mod: %w", err)
}
if err := os.Chmod(targetPath, 0o644); err != nil {
return ActionResponse{}, err
}
InvalidateCache(serverRoot)
return ActionResponse{Success: true, Action: "deleted", RestartRequired: true}, nil
}
func VerifyHash(path string, expected string) error {
if err := validateExpectedHash(expected); err != nil {
return err
}
want := strings.TrimPrefix(expected, "sha256:")
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return err
}
have := hex.EncodeToString(h.Sum(nil))
if !strings.EqualFold(have, want) {
return errors.New("sha256 mismatch")
}
return nil
}
func VerifyHashWithAlgorithm(path, expectedHex, algorithm string) error {
expectedHex = strings.ToLower(strings.TrimSpace(expectedHex))
if expectedHex == "" {
return errors.New("missing expected hash")
}
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
switch algorithm {
case "sha512":
h := sha512.New()
if _, err := io.Copy(h, f); err != nil {
return err
}
if hex.EncodeToString(h.Sum(nil)) != expectedHex {
return errors.New("sha512 mismatch")
}
return nil
case "sha1":
h := sha1.New()
if _, err := io.Copy(h, f); err != nil {
return err
}
if hex.EncodeToString(h.Sum(nil)) != expectedHex {
return errors.New("sha1 mismatch")
}
return nil
default:
return errors.New("unsupported hash algorithm")
}
}
func validateArtifactURL(raw string) error {
u, err := url.Parse(raw)
if err != nil {
return errors.New("invalid artifact_url")
}
if u.Scheme != "https" {
return errors.New("artifact_url must use https")
}
if !isAllowedHost(u.Hostname()) {
return errors.New("artifact_url host not allowed")
}
return nil
}
func validateExpectedHash(v string) error {
if !strings.HasPrefix(v, "sha256:") {
return errors.New("artifact_hash must start with sha256:")
}
hexPart := strings.TrimPrefix(v, "sha256:")
if len(hexPart) != 64 {
return errors.New("artifact_hash must include 64 hex characters")
}
for _, r := range hexPart {
isHex := (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')
if !isHex {
return errors.New("artifact_hash contains non-hex characters")
}
}
return nil
}
func normalizeExpectedHash(raw string, expectedLen int, prefix string) (string, error) {
v := strings.TrimSpace(strings.ToLower(raw))
if strings.HasPrefix(v, prefix+":") {
v = strings.TrimPrefix(v, prefix+":")
}
if len(v) != expectedLen {
return "", fmt.Errorf("%s must include %d hex characters", prefix, expectedLen)
}
for _, r := range v {
isHex := (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f')
if !isHex {
return "", fmt.Errorf("%s contains non-hex characters", prefix)
}
}
return v, nil
}
func safeInstallFilename(req InstallRequest) string {
if u, err := url.Parse(req.ArtifactURL); err == nil {
base := filepath.Base(u.Path)
if base != "." && base != "/" && isSafeFilename(base) {
return base
}
}
name := sanitizeID(req.ModID)
if strings.TrimSpace(req.Version) != "" {
name = sanitizeID(req.ModID + "-" + req.Version)
}
return name + ".jar"
}
func downloadArtifact(rawURL, dest string) error {
timeout := defaultTimeout
if v := strings.TrimSpace(os.Getenv("ZLH_MOD_DOWNLOAD_TIMEOUT")); v != "" {
if d, err := time.ParseDuration(v); err == nil && d > 0 {
timeout = d
}
}
client := &http.Client{
Timeout: timeout,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
// Keep downloads pinned to the curated HTTPS host, even across redirects.
if len(via) >= maxRedirects {
return errors.New("too many redirects")
}
if req.URL.Scheme != "https" {
return errors.New("redirected to non-https url")
}
if !isAllowedHost(req.URL.Hostname()) {
return errors.New("redirected to disallowed host")
}
return nil
},
}
resp, err := client.Get(rawURL)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.Request == nil || resp.Request.URL == nil {
return errors.New("invalid download response")
}
if resp.Request.URL.Scheme != "https" || !isAllowedHost(resp.Request.URL.Hostname()) {
return errors.New("final download url not allowed")
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download failed: status %d", resp.StatusCode)
}
if cl := resp.Header.Get("Content-Length"); cl != "" {
n, err := strconv.ParseInt(cl, 10, 64)
if err == nil && n > maxDownloadSize {
return errors.New("artifact too large")
}
}
out, err := os.Create(dest)
if err != nil {
return err
}
defer out.Close()
limited := io.LimitReader(resp.Body, maxDownloadSize+1)
written, err := io.Copy(out, limited)
if err != nil {
return err
}
if written > maxDownloadSize {
return errors.New("artifact exceeds 200MB limit")
}
return nil
}
func uniqueRemovedPath(dir, filename string) string {
candidate := filepath.Join(dir, filename)
if _, err := os.Stat(candidate); errors.Is(err, os.ErrNotExist) {
return candidate
}
base := filename
ext := ""
if strings.HasSuffix(filename, ".disabled") {
base = strings.TrimSuffix(filename, ".disabled")
ext = ".disabled"
}
if strings.HasSuffix(base, ".jar") {
base = strings.TrimSuffix(base, ".jar")
ext = ".jar" + ext
}
ts := time.Now().UTC().Format("20060102T150405")
for i := 1; ; i++ {
name := fmt.Sprintf("%s-%s-%d%s", base, ts, i, ext)
candidate = filepath.Join(dir, name)
if _, err := os.Stat(candidate); errors.Is(err, os.ErrNotExist) {
return candidate
}
}
}
func isAllowedHost(host string) bool {
for _, allowed := range allowedHosts {
if strings.EqualFold(host, allowed) {
return true
}
}
return false
}

236
internal/mods/metadata.go Normal file
View File

@ -0,0 +1,236 @@
package mods
import (
"archive/zip"
"encoding/json"
"errors"
"fmt"
"io"
"path/filepath"
"strings"
)
const (
maxMetadataEntrySize = 2 * 1024 * 1024
maxCompressionRatio = 200
)
type jarMetadata struct {
ID string
Name string
Version string
HasFabricMeta bool
HasForgeMeta bool
HasPluginMeta bool
MinecraftVersion string
}
func parseJarMetadata(path string) (jarMetadata, error) {
r, err := zip.OpenReader(path)
if err != nil {
return jarMetadata{}, err
}
defer r.Close()
var out jarMetadata
for _, f := range r.File {
name := strings.ToLower(f.Name)
switch name {
case "fabric.mod.json":
out.HasFabricMeta = true
b, err := readZipEntryLimited(f, maxMetadataEntrySize)
if err == nil {
mergeFabricMetadata(&out, b)
}
case "meta-inf/mods.toml":
out.HasForgeMeta = true
b, err := readZipEntryLimited(f, maxMetadataEntrySize)
if err == nil {
mergeModsTOMLMetadata(&out, b)
}
case "mcmod.info":
b, err := readZipEntryLimited(f, maxMetadataEntrySize)
if err == nil {
mergeMCModInfoMetadata(&out, b)
}
case "plugin.yml":
out.HasPluginMeta = true
b, err := readZipEntryLimited(f, maxMetadataEntrySize)
if err == nil {
mergePluginYAMLMetadata(&out, b)
}
}
}
if out.ID == "" && out.Name == "" && out.Version == "" && !out.HasFabricMeta && !out.HasForgeMeta && !out.HasPluginMeta {
return out, errors.New("no metadata found")
}
return out, nil
}
func readZipEntryLimited(f *zip.File, limit int64) ([]byte, error) {
if f.UncompressedSize64 > uint64(limit) {
return nil, fmt.Errorf("zip entry too large: %s", f.Name)
}
if f.CompressedSize64 > 0 && f.UncompressedSize64/f.CompressedSize64 > maxCompressionRatio {
return nil, fmt.Errorf("zip entry compression ratio too high: %s", f.Name)
}
rc, err := f.Open()
if err != nil {
return nil, err
}
defer rc.Close()
lr := io.LimitReader(rc, limit+1)
b, err := io.ReadAll(lr)
if err != nil {
return nil, err
}
if int64(len(b)) > limit {
return nil, fmt.Errorf("zip entry too large after read: %s", f.Name)
}
return b, nil
}
func mergeFabricMetadata(out *jarMetadata, b []byte) {
var payload struct {
ID string `json:"id"`
Name string `json:"name"`
Version string `json:"version"`
Depends interface{} `json:"depends"`
}
if err := json.Unmarshal(b, &payload); err != nil {
return
}
if out.ID == "" {
out.ID = strings.TrimSpace(payload.ID)
}
if out.Name == "" {
out.Name = strings.TrimSpace(payload.Name)
}
if out.Version == "" {
out.Version = strings.TrimSpace(payload.Version)
}
switch d := payload.Depends.(type) {
case map[string]any:
if v, ok := d["minecraft"]; ok {
out.MinecraftVersion = strings.TrimSpace(fmt.Sprint(v))
}
}
}
func mergeModsTOMLMetadata(out *jarMetadata, b []byte) {
lines := strings.Split(string(b), "\n")
for _, raw := range lines {
line := strings.TrimSpace(raw)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, val, ok := splitKV(line)
if !ok {
continue
}
switch key {
case "modId":
if out.ID == "" {
out.ID = val
}
case "displayName":
if out.Name == "" {
out.Name = val
}
case "version":
if out.Version == "" {
out.Version = val
}
case "loaderVersion":
if out.MinecraftVersion == "" {
out.MinecraftVersion = val
}
}
}
}
func mergeMCModInfoMetadata(out *jarMetadata, b []byte) {
var arr []map[string]any
if err := json.Unmarshal(b, &arr); err == nil && len(arr) > 0 {
mergeModInfoMap(out, arr[0])
return
}
var obj map[string]any
if err := json.Unmarshal(b, &obj); err == nil {
mergeModInfoMap(out, obj)
}
}
func mergeModInfoMap(out *jarMetadata, obj map[string]any) {
if out.ID == "" {
out.ID = strings.TrimSpace(fmt.Sprint(obj["modid"]))
}
if out.Name == "" {
out.Name = strings.TrimSpace(fmt.Sprint(obj["name"]))
}
if out.Version == "" {
out.Version = strings.TrimSpace(fmt.Sprint(obj["version"]))
}
if out.MinecraftVersion == "" {
out.MinecraftVersion = strings.TrimSpace(fmt.Sprint(obj["mcversion"]))
}
}
func mergePluginYAMLMetadata(out *jarMetadata, b []byte) {
lines := strings.Split(string(b), "\n")
for _, raw := range lines {
line := strings.TrimSpace(raw)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, val, ok := splitKV(line)
if !ok {
continue
}
switch strings.ToLower(key) {
case "name":
if out.Name == "" {
out.Name = val
}
case "version":
if out.Version == "" {
out.Version = val
}
}
}
}
func splitKV(line string) (string, string, bool) {
sep := "="
idx := strings.Index(line, sep)
if idx == -1 {
sep = ":"
idx = strings.Index(line, sep)
if idx == -1 {
return "", "", false
}
}
k := strings.TrimSpace(line[:idx])
v := strings.TrimSpace(line[idx+1:])
v = strings.Trim(v, `"'`)
if k == "" || v == "" {
return "", "", false
}
return k, v, true
}
func fallbackNameFromFilename(filename string) (id string, name string) {
base := strings.TrimSuffix(filename, filepath.Ext(filename))
base = strings.TrimSuffix(base, ".jar")
base = strings.TrimSuffix(base, ".disabled")
base = strings.TrimSpace(base)
if base == "" {
base = "unknown"
}
return sanitizeID(base), base
}

266
internal/mods/scanner.go Normal file
View File

@ -0,0 +1,266 @@
package mods
import (
"errors"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"time"
"zlh-agent/internal/provision"
"zlh-agent/internal/state"
)
var (
modIDPattern = regexp.MustCompile(`^[a-zA-Z0-9._-]{1,64}$`)
filenamePattern = regexp.MustCompile(`^[a-zA-Z0-9._+-]{1,128}$`)
cacheMu sync.Mutex
scanCache = map[string]cacheEntry{}
)
const (
cacheTTL = 5 * time.Minute
defaultServerRoot = "/opt/zlh/minecraft/vanilla/world"
)
func ResolveServerRoot(cfg *state.Config) string {
if v := strings.TrimSpace(os.Getenv("ZLH_SERVER_ROOT")); v != "" {
return filepath.Clean(v)
}
if cfg != nil {
return filepath.Clean(provision.ServerDir(*cfg))
}
return defaultServerRoot
}
func ScanMods(serverRoot string) (ScanResponse, error) {
serverRoot = filepath.Clean(serverRoot)
cacheMu.Lock()
if c, ok := scanCache[serverRoot]; ok && time.Now().Before(c.ExpiresAt) {
resp := c.Resp
cacheMu.Unlock()
return resp, nil
}
cacheMu.Unlock()
resp, err := scanModsUncached(serverRoot)
if err != nil {
return ScanResponse{}, err
}
cacheMu.Lock()
scanCache[serverRoot] = cacheEntry{ExpiresAt: time.Now().Add(cacheTTL), Resp: resp}
cacheMu.Unlock()
return resp, nil
}
func InvalidateCache(serverRoot string) {
cacheMu.Lock()
defer cacheMu.Unlock()
if serverRoot == "" {
scanCache = map[string]cacheEntry{}
return
}
delete(scanCache, filepath.Clean(serverRoot))
}
func scanModsUncached(serverRoot string) (ScanResponse, error) {
modsDir := filepath.Join(serverRoot, "mods")
entries, err := os.ReadDir(modsDir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return ScanResponse{
Variant: detectVariant(serverRoot, nil),
Mods: []ModInfo{},
TotalCount: 0,
ScanTimestamp: time.Now().UTC().Format(time.RFC3339),
}, nil
}
return ScanResponse{}, err
}
mods := make([]ModInfo, 0, len(entries))
var evidence []jarMetadata
for _, entry := range entries {
if entry.IsDir() {
continue
}
filename := entry.Name()
enabled, ok := modEnabledState(filename)
if !ok || !isSafeFilename(filename) {
continue
}
fullPath := filepath.Join(modsDir, filename)
meta, err := parseJarMetadata(fullPath)
if err == nil {
evidence = append(evidence, meta)
}
id, name := fallbackNameFromFilename(filename)
version := "unknown"
if err == nil {
if v := strings.TrimSpace(meta.ID); v != "" {
id = sanitizeID(v)
}
if v := strings.TrimSpace(meta.Name); v != "" {
name = v
}
if v := strings.TrimSpace(meta.Version); v != "" {
version = v
}
}
if !isValidModID(id) {
id = sanitizeID(strings.TrimSuffix(strings.TrimSuffix(filename, ".jar"), ".disabled"))
}
mods = append(mods, ModInfo{
ID: id,
Name: name,
Version: version,
Filename: filename,
Enabled: enabled,
Source: "manual",
})
}
sort.Slice(mods, func(i, j int) bool { return mods[i].Filename < mods[j].Filename })
variant, mcVersion, variantVersion := detectVariantWithVersion(serverRoot, evidence)
return ScanResponse{
Variant: variant,
MinecraftVersion: mcVersion,
VariantVersion: variantVersion,
Mods: mods,
TotalCount: len(mods),
ScanTimestamp: time.Now().UTC().Format(time.RFC3339),
}, nil
}
func detectVariant(serverRoot string, evidence []jarMetadata) string {
v, _, _ := detectVariantWithVersion(serverRoot, evidence)
return v
}
func detectVariantWithVersion(serverRoot string, evidence []jarMetadata) (variant string, minecraftVersion string, variantVersion string) {
for _, m := range evidence {
if m.HasFabricMeta {
return "fabric", m.MinecraftVersion, m.Version
}
}
for _, m := range evidence {
if m.HasForgeMeta {
return "forge", m.MinecraftVersion, m.Version
}
}
for _, m := range evidence {
if m.HasPluginMeta {
return "paper", m.MinecraftVersion, m.Version
}
}
pluginsDir := filepath.Join(serverRoot, "plugins")
if entries, err := os.ReadDir(pluginsDir); err == nil {
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(strings.ToLower(e.Name()), ".jar") {
return "paper", "", ""
}
}
}
forgeChecks := []string{
filepath.Join(serverRoot, "forge-server.toml"),
filepath.Join(serverRoot, "forge-client.toml"),
filepath.Join(serverRoot, "config", "forge-common.toml"),
}
for _, p := range forgeChecks {
if _, err := os.Stat(p); err == nil {
return "forge", "", ""
}
}
return "unknown", "", ""
}
func modEnabledState(filename string) (enabled bool, ok bool) {
lower := strings.ToLower(filename)
if strings.HasSuffix(lower, ".jar") {
return true, true
}
if strings.HasSuffix(lower, ".jar.disabled") {
return false, true
}
return false, false
}
func isSafeFilename(name string) bool {
if strings.Contains(name, "..") || strings.Contains(name, "/") || strings.Contains(name, "\\") || strings.ContainsRune(name, 0) || strings.Contains(name, "~") {
return false
}
for _, r := range name {
if r <= 31 || r == 127 || r == ' ' || r == '\t' || r == '\n' || r == '\r' {
return false
}
}
return filenamePattern.MatchString(name)
}
func sanitizeID(id string) string {
id = strings.TrimSpace(id)
id = strings.ReplaceAll(id, " ", "_")
id = strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '.' || r == '_' || r == '-' {
return r
}
return '_'
}, id)
if len(id) > 64 {
id = id[:64]
}
if id == "" {
id = "unknown"
}
return id
}
func isValidModID(id string) bool {
return modIDPattern.MatchString(id)
}
func IsValidModID(id string) bool {
return isValidModID(id)
}
func ResolveByModID(serverRoot, modID string) (enabledName string, disabledName string, err error) {
if !isValidModID(modID) {
return "", "", errors.New("invalid mod_id")
}
resp, err := ScanMods(serverRoot)
if err != nil {
return "", "", err
}
for _, mod := range resp.Mods {
if mod.ID != modID {
continue
}
if mod.Enabled {
enabledName = mod.Filename
} else {
disabledName = mod.Filename
}
}
if enabledName != "" && disabledName != "" {
return "", "", errors.New("mod has both enabled and disabled files")
}
if enabledName == "" && disabledName == "" {
return "", "", os.ErrNotExist
}
return enabledName, disabledName, nil
}

48
internal/mods/types.go Normal file
View File

@ -0,0 +1,48 @@
package mods
import "time"
type ModInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Version string `json:"version"`
Filename string `json:"filename"`
Enabled bool `json:"enabled"`
Source string `json:"source"`
}
type ScanResponse struct {
Variant string `json:"variant"`
MinecraftVersion string `json:"minecraft_version,omitempty"`
VariantVersion string `json:"variant_version,omitempty"`
Mods []ModInfo `json:"mods"`
TotalCount int `json:"total_count"`
ScanTimestamp string `json:"scan_timestamp"`
}
type InstallRequest struct {
Source string `json:"source"`
ModID string `json:"mod_id"`
Version string `json:"version"`
ArtifactURL string `json:"artifact_url"`
ArtifactHash string `json:"artifact_hash"`
DownloadURL string `json:"download_url"`
Filename string `json:"filename"`
SHA512 string `json:"sha512"`
SHA1 string `json:"sha1"`
}
type ActionResponse struct {
Success bool `json:"success"`
Action string `json:"action"`
RestartRequired bool `json:"restart_required"`
}
type PatchRequest struct {
Enabled bool `json:"enabled"`
}
type cacheEntry struct {
ExpiresAt time.Time
Resp ScanResponse
}

View File

@ -2,6 +2,7 @@ package state
import (
"encoding/json"
"fmt"
"log"
"os"
"strconv"
@ -71,6 +72,10 @@ type agentStatus struct {
lastError error
crashCount int
lastCrash time.Time
ready bool
readySource string
readyError string
lastReadyAt time.Time
}
var global = &agentStatus{
@ -112,6 +117,30 @@ func GetLastChange() time.Time {
return global.lastChange
}
func GetReady() bool {
global.mu.Lock()
defer global.mu.Unlock()
return global.ready
}
func GetReadySource() string {
global.mu.Lock()
defer global.mu.Unlock()
return global.readySource
}
func GetReadyError() string {
global.mu.Lock()
defer global.mu.Unlock()
return global.readyError
}
func GetLastReadyAt() time.Time {
global.mu.Lock()
defer global.mu.Unlock()
return global.lastReadyAt
}
/* --------------------------------------------------------------------------
STATE SETTERS unified with logging
----------------------------------------------------------------------------*/
@ -149,6 +178,24 @@ func RecordCrash(err error) {
global.lastError = err
global.crashCount++
global.lastCrash = time.Now()
global.ready = false
global.readySource = ""
global.readyError = fmt.Sprintf("%v", err)
global.lastReadyAt = time.Time{}
}
func SetReadyState(ready bool, source, errText string) {
global.mu.Lock()
defer global.mu.Unlock()
global.ready = ready
global.readySource = source
global.readyError = errText
if ready {
global.lastReadyAt = time.Now()
} else {
global.lastReadyAt = time.Time{}
}
}
/* --------------------------------------------------------------------------

View File

@ -27,6 +27,16 @@ var (
devPTY *os.File
)
func GetServerPID() (int, bool) {
mu.Lock()
defer mu.Unlock()
if serverCmd == nil || serverCmd.Process == nil {
return 0, false
}
return serverCmd.Process.Pid, true
}
/* --------------------------------------------------------------------------
StartServer (fixed)
----------------------------------------------------------------------------*/
@ -56,6 +66,7 @@ func StartServer(cfg *state.Config) error {
state.SetState(state.StateRunning)
state.SetError(nil)
state.SetReadyState(false, "", "")
go func() {
err := cmd.Wait()
@ -71,6 +82,7 @@ func StartServer(cfg *state.Config) error {
state.RecordCrash(err)
} else {
state.SetState(state.StateIdle)
state.SetReadyState(false, "", "")
}
serverCmd = nil
@ -93,6 +105,7 @@ func StopServer() error {
}
state.SetState(state.StateStopping)
state.SetReadyState(false, "", "")
// Try graceful stop
if serverPTY != nil {
@ -112,6 +125,23 @@ func StopServer() error {
return nil
}
func WaitForServerExit(timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for {
mu.Lock()
running := serverCmd != nil
mu.Unlock()
if !running {
return nil
}
if time.Now().After(deadline) {
return fmt.Errorf("timeout waiting for server process to exit")
}
time.Sleep(200 * time.Millisecond)
}
}
/* --------------------------------------------------------------------------
RestartServer
----------------------------------------------------------------------------*/

View File

@ -13,20 +13,23 @@ import (
"os/exec"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"time"
)
const (
artifactBaseURL = "http://10.60.0.251:8080/agents/"
releasesDir = "/opt/zlh-agent/releases"
currentLink = "/opt/zlh-agent/current"
previousLink = "/opt/zlh-agent/previous"
binaryPath = "/opt/zlh-agent/zlh-agent"
stateDir = "/opt/zlh-agent/state"
statusFile = "/opt/zlh-agent/state/update.json"
defaultUnit = "zlh-agent"
defaultMode = "notify"
artifactBaseURL = "http://10.60.0.251:8080/agents/"
releasesDir = "/opt/zlh-agent/releases"
currentLink = "/opt/zlh-agent/current"
previousLink = "/opt/zlh-agent/previous"
binaryPath = "/opt/zlh-agent/zlh-agent"
stateDir = "/opt/zlh-agent/state"
statusFile = "/opt/zlh-agent/state/update.json"
defaultUnit = "zlh-agent"
defaultMode = "notify"
defaultKeepReleases = 3 // current + 2 previous
)
type Manifest struct {
@ -296,9 +299,24 @@ func CheckAndUpdate(currentVersion string) Result {
return result
}
keep := defaultKeepReleases
if v := strings.TrimSpace(os.Getenv("ZLH_AGENT_KEEP_RELEASES")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 2 {
keep = n
}
}
if err := pruneOldReleases(keep); err != nil {
log.Printf("[update] prune warning: %v", err)
}
result.Status = "updated"
result.Error = ""
writeStatus(result)
if err := scheduleRollbackGuard(target); err != nil {
log.Printf("[update] rollback guard warning: %v", err)
}
go func() {
if err := restartService(); err != nil {
log.Printf("[update] restart failed: %v", err)
@ -387,10 +405,48 @@ func restartService() error {
if unit == "" {
unit = defaultUnit
}
cmd := exec.Command("systemctl", "restart", unit)
cmd := exec.Command("systemctl", "restart", "--no-block", unit)
return cmd.Run()
}
func scheduleRollbackGuard(target string) error {
unit := os.Getenv("ZLH_AGENT_UNIT")
if unit == "" {
unit = defaultUnit
}
port := strings.TrimSpace(os.Getenv("ZLH_AGENT_PORT"))
if port == "" {
port = "18888"
}
target = normalizeVersion(target)
if target == "" {
return nil
}
script := fmt.Sprintf(
"sleep 25; "+
"if ! curl -fsS http://127.0.0.1:%s/health >/dev/null 2>&1 || "+
"! curl -fsS http://127.0.0.1:%s/version 2>/dev/null | grep -q '\"version\":\"v%s\"'; then "+
"prev=$(readlink -f %s || true); "+
"if [ -n \"$prev\" ] && [ -d \"$prev\" ]; then "+
"b=$(basename \"$prev\"); "+
"ln -sfn \"releases/$b\" %s; "+
"ln -sfn %s/zlh-agent %s; "+
"systemctl restart --no-block %s; "+
"fi; "+
"fi",
port, port, target, previousLink, currentLink, currentLink, binaryPath, unit,
)
transientUnit := fmt.Sprintf("zlh-agent-update-verify-%d", time.Now().UnixNano())
cmd := exec.Command("systemd-run", "--unit", transientUnit, "--collect", "/bin/sh", "-c", script)
if err := cmd.Run(); err != nil {
return fmt.Errorf("schedule rollback guard: %w", err)
}
return nil
}
func normalizeVersion(v string) string {
return strings.TrimPrefix(strings.TrimSpace(v), "v")
}
@ -528,3 +584,74 @@ func copyFile(src, dst string) error {
}
return out.Close()
}
func pruneOldReleases(keep int) error {
entries, err := os.ReadDir(releasesDir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
currentResolved, _ := filepath.EvalSymlinks(currentLink)
previousResolved, _ := filepath.EvalSymlinks(previousLink)
protected := map[string]struct{}{}
if currentResolved != "" {
protected[currentResolved] = struct{}{}
}
if previousResolved != "" {
protected[previousResolved] = struct{}{}
}
type rel struct {
name string
path string
}
rels := make([]rel, 0, len(entries))
for _, e := range entries {
if !e.IsDir() {
continue
}
name := e.Name()
if !isSemverLike(name) {
continue
}
rels = append(rels, rel{name: name, path: filepath.Join(releasesDir, name)})
}
sort.Slice(rels, func(i, j int) bool {
return compareVersions(rels[i].name, rels[j].name) > 0
})
for idx, r := range rels {
if idx < keep {
continue
}
if _, ok := protected[r.path]; ok {
continue
}
if err := os.RemoveAll(r.path); err != nil {
log.Printf("[update] prune failed for %s: %v", r.path, err)
}
}
return nil
}
func isSemverLike(v string) bool {
parts := strings.Split(v, ".")
if len(parts) != 3 {
return false
}
for _, p := range parts {
if p == "" {
return false
}
for _, r := range p {
if r < '0' || r > '9' {
return false
}
}
}
return true
}

View File

@ -9,8 +9,9 @@ import (
)
var (
logFile *os.File
logReady bool
logFile *os.File
lifecycleFile *os.File
logReady bool
)
// InitLogFile sets up a log file inside the agent directory.
@ -28,7 +29,10 @@ func InitLogFile(path string) error {
func CloseLog() {
if logReady && logFile != nil {
logFile.Close()
_ = logFile.Close()
}
if lifecycleFile != nil {
_ = lifecycleFile.Close()
}
}
@ -44,6 +48,26 @@ func Log(format string, v ...any) {
// Optionally also write to file
if logReady && logFile != nil {
logFile.WriteString(line + "\n")
_, _ = logFile.WriteString(line + "\n")
}
}
func InitLifecycleLog(path string) error {
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
return err
}
lifecycleFile = f
return nil
}
func LogLifecycle(format string, v ...any) {
line := fmt.Sprintf("[%s] %s",
time.Now().Format("2006-01-02 15:04:05"),
fmt.Sprintf(format, v...),
)
log.Println(line)
if lifecycleFile != nil {
_, _ = lifecycleFile.WriteString(line + "\n")
}
}

View File

@ -1,3 +1,6 @@
package version
const AgentVersion = "v1.0.0"
// AgentVersion is set at build-time via:
// -ldflags "-X zlh-agent/internal/version.AgentVersion=vX.Y.Z"
// Falls back to "v0.0.0-dev" for local/dev builds.
var AgentVersion = "v0.0.0-dev"

View File

@ -9,6 +9,7 @@ import (
"syscall"
"time"
agentfiles "zlh-agent/internal/files"
agenthttp "zlh-agent/internal/http"
"zlh-agent/internal/system"
"zlh-agent/internal/update"
@ -28,6 +29,11 @@ func main() {
} else {
log.Printf("[agent] file logging enabled")
}
if err := util.InitLifecycleLog("/opt/zlh-agent/logs/lifecycle.log"); err != nil {
log.Printf("[agent] warning: lifecycle log init failed: %v", err)
} else {
log.Printf("[agent] lifecycle logging enabled")
}
defer util.CloseLog()
// ------------------------------------------------------------
@ -47,6 +53,7 @@ func main() {
// (does nothing unless AutoStartEnabled=true)
// ------------------------------------------------------------
system.InitAutoStart()
agentfiles.StartShadowCleanup()
update.StartPeriodic(version.AgentVersion)
server := &http.Server{

35
scripts/build-release.sh Executable file
View File

@ -0,0 +1,35 @@
#!/usr/bin/env bash
set -euo pipefail
VERSION="${1:-}"
if [[ -z "$VERSION" ]]; then
echo "Usage: $0 <version>"
echo "Example: $0 1.0.6"
exit 1
fi
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Version must be semver without 'v' prefix (e.g. 1.0.6)"
exit 1
fi
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
OUT_DIR="${ROOT_DIR}/dist/${VERSION}"
BIN_NAME="zlh-agent-linux-amd64"
BIN_PATH="${OUT_DIR}/${BIN_NAME}"
SHA_PATH="${BIN_PATH}.sha256"
mkdir -p "${OUT_DIR}"
echo "[build] version=v${VERSION}"
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build \
-trimpath \
-ldflags "-s -w -X zlh-agent/internal/version.AgentVersion=v${VERSION}" \
-o "${BIN_PATH}" \
"${ROOT_DIR}"
sha256sum "${BIN_PATH}" > "${SHA_PATH}"
echo "[ok] binary: ${BIN_PATH}"
echo "[ok] sha256: ${SHA_PATH}"

BIN
zlh-agent

Binary file not shown.