updates 3-1-26
This commit is contained in:
parent
f4f5faf00e
commit
b7afe5733a
14
README.md
14
README.md
@ -1,2 +1,16 @@
|
|||||||
# zlh-agent
|
# 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
1021
internal/files/files.go
Normal file
File diff suppressed because it is too large
Load Diff
254
internal/handlers/files.go
Normal file
254
internal/handlers/files.go
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
25
internal/handlers/metrics.go
Normal file
25
internal/handlers/metrics.go
Normal 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
207
internal/handlers/mods.go
Normal 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})
|
||||||
|
}
|
||||||
@ -12,6 +12,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
agenthandlers "zlh-agent/internal/handlers"
|
||||||
mcstatus "zlh-agent/internal/minecraft"
|
mcstatus "zlh-agent/internal/minecraft"
|
||||||
"zlh-agent/internal/provision"
|
"zlh-agent/internal/provision"
|
||||||
"zlh-agent/internal/provision/devcontainer"
|
"zlh-agent/internal/provision/devcontainer"
|
||||||
@ -23,6 +24,7 @@ import (
|
|||||||
"zlh-agent/internal/state"
|
"zlh-agent/internal/state"
|
||||||
"zlh-agent/internal/system"
|
"zlh-agent/internal/system"
|
||||||
"zlh-agent/internal/update"
|
"zlh-agent/internal/update"
|
||||||
|
"zlh-agent/internal/util"
|
||||||
"zlh-agent/internal/version"
|
"zlh-agent/internal/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -42,6 +44,28 @@ func dirExists(path string) bool {
|
|||||||
return err == nil && s.IsDir()
|
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)
|
Shared provision pipeline (installer + Minecraft verify)
|
||||||
@ -169,6 +193,8 @@ func handleConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
go func(c state.Config) {
|
go func(c state.Config) {
|
||||||
log.Println("[agent] async provision+start begin")
|
log.Println("[agent] async provision+start begin")
|
||||||
|
started := time.Now()
|
||||||
|
lifecycleLog(&c, "config_async", 1, started, "begin")
|
||||||
|
|
||||||
if err := ensureProvisioned(&c); err != nil {
|
if err := ensureProvisioned(&c); err != nil {
|
||||||
log.Println("[agent] provision error:", err)
|
log.Println("[agent] provision error:", err)
|
||||||
@ -176,9 +202,18 @@ func handleConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if c.ContainerType != "dev" {
|
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 {
|
if err := system.StartServer(&c); err != nil {
|
||||||
log.Println("[agent] start error:", err)
|
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.SetError(err)
|
||||||
state.SetState(state.StateError)
|
state.SetState(state.StateError)
|
||||||
return
|
return
|
||||||
@ -191,21 +226,7 @@ func handleConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
variant := strings.ToLower(c.Variant)
|
variant := strings.ToLower(c.Variant)
|
||||||
|
|
||||||
if game == "minecraft" && (variant == "forge" || variant == "neoforge") {
|
if game == "minecraft" && (variant == "forge" || variant == "neoforge") {
|
||||||
|
lifecycleLog(&c, "forge_post", 1, started, "begin")
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for server.properties to exist before enforcing
|
// Wait for server.properties to exist before enforcing
|
||||||
propsPath := filepath.Join(provision.ServerDir(c), "server.properties")
|
propsPath := filepath.Join(provision.ServerDir(c), "server.properties")
|
||||||
@ -225,20 +246,37 @@ func handleConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_ = system.StopServer()
|
_ = 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 {
|
if err := minecraft.EnforceForgeServerProperties(c); err != nil {
|
||||||
log.Println("[agent] forge post-start error:", err)
|
log.Println("[agent] forge post-start error:", err)
|
||||||
state.SetError(err)
|
state.SetError(err)
|
||||||
state.SetState(state.StateError)
|
state.SetState(state.StateError)
|
||||||
|
lifecycleLog(&c, "forge_post", 1, started, "enforce_failed err=%v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
state.SetState(state.StateStarting)
|
||||||
|
state.SetReadyState(false, "", "")
|
||||||
if err := system.StartServer(&c); err != nil {
|
if err := system.StartServer(&c); err != nil {
|
||||||
log.Println("[agent] restart error:", err)
|
log.Println("[agent] restart error:", err)
|
||||||
state.SetError(err)
|
state.SetError(err)
|
||||||
state.SetState(state.StateError)
|
state.SetState(state.StateError)
|
||||||
|
lifecycleLog(&c, "forge_post", 1, started, "restart_failed err=%v", err)
|
||||||
return
|
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
|
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 {
|
if err := system.StartServer(cfg); err != nil {
|
||||||
http.Error(w, "start error: "+err.Error(), http.StatusInternalServerError)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -310,11 +359,24 @@ func handleRestart(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_ = system.StopServer()
|
_ = 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 {
|
if err := system.StartServer(cfg); err != nil {
|
||||||
http.Error(w, "restart error: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "restart error: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
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.Header().Set("Content-Type", "application/json")
|
||||||
_, _ = w.Write([]byte(`{"ok": true, "state": "starting"}`))
|
_, _ = 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) {
|
func handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
cfg, _ := state.LoadConfig()
|
cfg, _ := state.LoadConfig()
|
||||||
|
_, processRunning := system.GetServerPID()
|
||||||
|
readyAt := ""
|
||||||
|
if t := state.GetLastReadyAt(); !t.IsZero() {
|
||||||
|
readyAt = t.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
resp := map[string]any{
|
resp := map[string]any{
|
||||||
"state": state.GetState(),
|
"state": state.GetState(),
|
||||||
"installStep": state.GetInstallStep(),
|
"processRunning": processRunning,
|
||||||
"crashCount": state.GetCrashCount(),
|
"ready": state.GetReady(),
|
||||||
"error": nil,
|
"readySource": state.GetReadySource(),
|
||||||
"config": cfg,
|
"readyError": state.GetReadyError(),
|
||||||
"timestamp": time.Now().Unix(),
|
"lastReadyAt": readyAt,
|
||||||
|
"installStep": state.GetInstallStep(),
|
||||||
|
"crashCount": state.GetCrashCount(),
|
||||||
|
"error": nil,
|
||||||
|
"config": cfg,
|
||||||
|
"timestamp": time.Now().Unix(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := state.GetError(); err != nil {
|
if err := state.GetError(); err != nil {
|
||||||
@ -533,6 +605,17 @@ func NewMux() *http.ServeMux {
|
|||||||
m.HandleFunc("/agent/update/status", handleAgentUpdateStatus)
|
m.HandleFunc("/agent/update/status", handleAgentUpdateStatus)
|
||||||
m.HandleFunc("/version", handleVersion)
|
m.HandleFunc("/version", handleVersion)
|
||||||
m.HandleFunc("/game/players", handleGamePlayers)
|
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)
|
registerWebSocket(m)
|
||||||
|
|
||||||
|
|||||||
@ -31,6 +31,7 @@ type consoleSession struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
conns map[*websocket.Conn]*consoleConn
|
conns map[*websocket.Conn]*consoleConn
|
||||||
readerOnce sync.Once
|
readerOnce sync.Once
|
||||||
|
closeOnce sync.Once
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@ -48,9 +49,21 @@ func getConsoleSession(cfg *state.Config) (*consoleSession, bool, error) {
|
|||||||
sessionMu.Lock()
|
sessionMu.Lock()
|
||||||
if sess, ok := sessions[key]; ok {
|
if sess, ok := sessions[key]; ok {
|
||||||
sessionMu.Unlock()
|
sessionMu.Unlock()
|
||||||
sess.touch()
|
|
||||||
log.Printf("[ws] session reuse: vmid=%d type=%s", cfg.VMID, cfg.ContainerType)
|
currentPTY, err := system.GetConsolePTY(cfg)
|
||||||
return sess, true, nil
|
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()
|
sessionMu.Unlock()
|
||||||
|
|
||||||
@ -104,7 +117,7 @@ func (s *consoleSession) removeConn(conn *websocket.Conn) int {
|
|||||||
cc, ok := s.conns[conn]
|
cc, ok := s.conns[conn]
|
||||||
if ok {
|
if ok {
|
||||||
delete(s.conns, conn)
|
delete(s.conns, conn)
|
||||||
close(cc.send)
|
safeCloseChan(cc.send)
|
||||||
}
|
}
|
||||||
s.lastActive = time.Now()
|
s.lastActive = time.Now()
|
||||||
log.Printf("[ws] conn remove: vmid=%d type=%s conns=%d", s.cfg.VMID, s.cfg.ContainerType, len(s.conns))
|
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 {
|
} else {
|
||||||
log.Printf("[ws] pty read loop exit: vmid=%d err=%v", s.cfg.VMID, err)
|
log.Printf("[ws] pty read loop exit: vmid=%d err=%v", s.cfg.VMID, err)
|
||||||
}
|
}
|
||||||
|
s.destroy()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if n == 0 && err == nil {
|
if n == 0 && err == nil {
|
||||||
@ -161,6 +175,9 @@ func (s *consoleSession) broadcast(data []byte) {
|
|||||||
|
|
||||||
func (s *consoleSession) writeInput(data []byte) error {
|
func (s *consoleSession) writeInput(data []byte) error {
|
||||||
s.touch()
|
s.touch()
|
||||||
|
if s.ptyFile == nil {
|
||||||
|
return fmt.Errorf("pty unavailable")
|
||||||
|
}
|
||||||
return runtime.Write(s.ptyFile, data)
|
return runtime.Write(s.ptyFile, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -181,9 +198,38 @@ func (s *consoleSession) scheduleCleanupIfIdle() {
|
|||||||
if s.cfg.ContainerType == "dev" {
|
if s.cfg.ContainerType == "dev" {
|
||||||
_ = system.StopDevShell()
|
_ = system.StopDevShell()
|
||||||
}
|
}
|
||||||
sessionMu.Lock()
|
s.destroy()
|
||||||
delete(sessions, s.key)
|
|
||||||
sessionMu.Unlock()
|
|
||||||
}
|
}
|
||||||
}(last)
|
}(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
103
internal/metrics/process.go
Normal 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
|
||||||
|
}
|
||||||
85
internal/minecraft/readiness.go
Normal file
85
internal/minecraft/readiness.go
Normal 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
435
internal/mods/installer.go
Normal 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
236
internal/mods/metadata.go
Normal 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
266
internal/mods/scanner.go
Normal 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
48
internal/mods/types.go
Normal 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
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@ package state
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
@ -71,6 +72,10 @@ type agentStatus struct {
|
|||||||
lastError error
|
lastError error
|
||||||
crashCount int
|
crashCount int
|
||||||
lastCrash time.Time
|
lastCrash time.Time
|
||||||
|
ready bool
|
||||||
|
readySource string
|
||||||
|
readyError string
|
||||||
|
lastReadyAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
var global = &agentStatus{
|
var global = &agentStatus{
|
||||||
@ -112,6 +117,30 @@ func GetLastChange() time.Time {
|
|||||||
return global.lastChange
|
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
|
STATE SETTERS — unified with logging
|
||||||
----------------------------------------------------------------------------*/
|
----------------------------------------------------------------------------*/
|
||||||
@ -149,6 +178,24 @@ func RecordCrash(err error) {
|
|||||||
global.lastError = err
|
global.lastError = err
|
||||||
global.crashCount++
|
global.crashCount++
|
||||||
global.lastCrash = time.Now()
|
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{}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --------------------------------------------------------------------------
|
/* --------------------------------------------------------------------------
|
||||||
|
|||||||
@ -27,6 +27,16 @@ var (
|
|||||||
devPTY *os.File
|
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)
|
StartServer (fixed)
|
||||||
----------------------------------------------------------------------------*/
|
----------------------------------------------------------------------------*/
|
||||||
@ -56,6 +66,7 @@ func StartServer(cfg *state.Config) error {
|
|||||||
|
|
||||||
state.SetState(state.StateRunning)
|
state.SetState(state.StateRunning)
|
||||||
state.SetError(nil)
|
state.SetError(nil)
|
||||||
|
state.SetReadyState(false, "", "")
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
err := cmd.Wait()
|
err := cmd.Wait()
|
||||||
@ -71,6 +82,7 @@ func StartServer(cfg *state.Config) error {
|
|||||||
state.RecordCrash(err)
|
state.RecordCrash(err)
|
||||||
} else {
|
} else {
|
||||||
state.SetState(state.StateIdle)
|
state.SetState(state.StateIdle)
|
||||||
|
state.SetReadyState(false, "", "")
|
||||||
}
|
}
|
||||||
|
|
||||||
serverCmd = nil
|
serverCmd = nil
|
||||||
@ -93,6 +105,7 @@ func StopServer() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
state.SetState(state.StateStopping)
|
state.SetState(state.StateStopping)
|
||||||
|
state.SetReadyState(false, "", "")
|
||||||
|
|
||||||
// Try graceful stop
|
// Try graceful stop
|
||||||
if serverPTY != nil {
|
if serverPTY != nil {
|
||||||
@ -112,6 +125,23 @@ func StopServer() error {
|
|||||||
return nil
|
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
|
RestartServer
|
||||||
----------------------------------------------------------------------------*/
|
----------------------------------------------------------------------------*/
|
||||||
|
|||||||
@ -13,20 +13,23 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
artifactBaseURL = "http://10.60.0.251:8080/agents/"
|
artifactBaseURL = "http://10.60.0.251:8080/agents/"
|
||||||
releasesDir = "/opt/zlh-agent/releases"
|
releasesDir = "/opt/zlh-agent/releases"
|
||||||
currentLink = "/opt/zlh-agent/current"
|
currentLink = "/opt/zlh-agent/current"
|
||||||
previousLink = "/opt/zlh-agent/previous"
|
previousLink = "/opt/zlh-agent/previous"
|
||||||
binaryPath = "/opt/zlh-agent/zlh-agent"
|
binaryPath = "/opt/zlh-agent/zlh-agent"
|
||||||
stateDir = "/opt/zlh-agent/state"
|
stateDir = "/opt/zlh-agent/state"
|
||||||
statusFile = "/opt/zlh-agent/state/update.json"
|
statusFile = "/opt/zlh-agent/state/update.json"
|
||||||
defaultUnit = "zlh-agent"
|
defaultUnit = "zlh-agent"
|
||||||
defaultMode = "notify"
|
defaultMode = "notify"
|
||||||
|
defaultKeepReleases = 3 // current + 2 previous
|
||||||
)
|
)
|
||||||
|
|
||||||
type Manifest struct {
|
type Manifest struct {
|
||||||
@ -296,9 +299,24 @@ func CheckAndUpdate(currentVersion string) Result {
|
|||||||
return 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.Status = "updated"
|
||||||
result.Error = ""
|
result.Error = ""
|
||||||
writeStatus(result)
|
writeStatus(result)
|
||||||
|
|
||||||
|
if err := scheduleRollbackGuard(target); err != nil {
|
||||||
|
log.Printf("[update] rollback guard warning: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
if err := restartService(); err != nil {
|
if err := restartService(); err != nil {
|
||||||
log.Printf("[update] restart failed: %v", err)
|
log.Printf("[update] restart failed: %v", err)
|
||||||
@ -387,10 +405,48 @@ func restartService() error {
|
|||||||
if unit == "" {
|
if unit == "" {
|
||||||
unit = defaultUnit
|
unit = defaultUnit
|
||||||
}
|
}
|
||||||
cmd := exec.Command("systemctl", "restart", unit)
|
cmd := exec.Command("systemctl", "restart", "--no-block", unit)
|
||||||
return cmd.Run()
|
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 {
|
func normalizeVersion(v string) string {
|
||||||
return strings.TrimPrefix(strings.TrimSpace(v), "v")
|
return strings.TrimPrefix(strings.TrimSpace(v), "v")
|
||||||
}
|
}
|
||||||
@ -528,3 +584,74 @@ func copyFile(src, dst string) error {
|
|||||||
}
|
}
|
||||||
return out.Close()
|
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
|
||||||
|
}
|
||||||
|
|||||||
@ -9,8 +9,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
logFile *os.File
|
logFile *os.File
|
||||||
logReady bool
|
lifecycleFile *os.File
|
||||||
|
logReady bool
|
||||||
)
|
)
|
||||||
|
|
||||||
// InitLogFile sets up a log file inside the agent directory.
|
// InitLogFile sets up a log file inside the agent directory.
|
||||||
@ -28,7 +29,10 @@ func InitLogFile(path string) error {
|
|||||||
|
|
||||||
func CloseLog() {
|
func CloseLog() {
|
||||||
if logReady && logFile != nil {
|
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
|
// Optionally also write to file
|
||||||
if logReady && logFile != nil {
|
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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,6 @@
|
|||||||
package version
|
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"
|
||||||
|
|||||||
7
main.go
7
main.go
@ -9,6 +9,7 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
agentfiles "zlh-agent/internal/files"
|
||||||
agenthttp "zlh-agent/internal/http"
|
agenthttp "zlh-agent/internal/http"
|
||||||
"zlh-agent/internal/system"
|
"zlh-agent/internal/system"
|
||||||
"zlh-agent/internal/update"
|
"zlh-agent/internal/update"
|
||||||
@ -28,6 +29,11 @@ func main() {
|
|||||||
} else {
|
} else {
|
||||||
log.Printf("[agent] file logging enabled")
|
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()
|
defer util.CloseLog()
|
||||||
|
|
||||||
// ------------------------------------------------------------
|
// ------------------------------------------------------------
|
||||||
@ -47,6 +53,7 @@ func main() {
|
|||||||
// (does nothing unless AutoStartEnabled=true)
|
// (does nothing unless AutoStartEnabled=true)
|
||||||
// ------------------------------------------------------------
|
// ------------------------------------------------------------
|
||||||
system.InitAutoStart()
|
system.InitAutoStart()
|
||||||
|
agentfiles.StartShadowCleanup()
|
||||||
update.StartPeriodic(version.AgentVersion)
|
update.StartPeriodic(version.AgentVersion)
|
||||||
|
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
|
|||||||
35
scripts/build-release.sh
Executable file
35
scripts/build-release.sh
Executable 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}"
|
||||||
Loading…
Reference in New Issue
Block a user