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
|
||||
|
||||
## 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"
|
||||
"time"
|
||||
|
||||
agenthandlers "zlh-agent/internal/handlers"
|
||||
mcstatus "zlh-agent/internal/minecraft"
|
||||
"zlh-agent/internal/provision"
|
||||
"zlh-agent/internal/provision/devcontainer"
|
||||
@ -23,6 +24,7 @@ import (
|
||||
"zlh-agent/internal/state"
|
||||
"zlh-agent/internal/system"
|
||||
"zlh-agent/internal/update"
|
||||
"zlh-agent/internal/util"
|
||||
"zlh-agent/internal/version"
|
||||
)
|
||||
|
||||
@ -42,6 +44,28 @@ func dirExists(path string) bool {
|
||||
return err == nil && s.IsDir()
|
||||
}
|
||||
|
||||
func lifecycleLog(cfg *state.Config, phase string, attempt int, started time.Time, format string, args ...any) {
|
||||
elapsed := time.Since(started).Milliseconds()
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
util.LogLifecycle("[lifecycle] vmid=%d phase=%s attempt=%d elapsed_ms=%d %s", cfg.VMID, phase, attempt, elapsed, msg)
|
||||
}
|
||||
|
||||
func waitMinecraftReady(cfg *state.Config, phase string, started time.Time) error {
|
||||
if strings.ToLower(cfg.Game) != "minecraft" {
|
||||
return nil
|
||||
}
|
||||
|
||||
lifecycleLog(cfg, phase, 1, started, "probe_begin")
|
||||
if err := mcstatus.WaitUntilReady(*cfg, 60*time.Second, 3*time.Second); err != nil {
|
||||
state.SetReadyState(false, "minecraft_ping", err.Error())
|
||||
lifecycleLog(cfg, phase, 1, started, "probe_timeout err=%v", err)
|
||||
return err
|
||||
}
|
||||
state.SetReadyState(true, "minecraft_ping", "")
|
||||
lifecycleLog(cfg, phase, 1, started, "probe_ready")
|
||||
return nil
|
||||
}
|
||||
|
||||
/*
|
||||
--------------------------------------------------------------------------
|
||||
Shared provision pipeline (installer + Minecraft verify)
|
||||
@ -169,6 +193,8 @@ func handleConfig(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
go func(c state.Config) {
|
||||
log.Println("[agent] async provision+start begin")
|
||||
started := time.Now()
|
||||
lifecycleLog(&c, "config_async", 1, started, "begin")
|
||||
|
||||
if err := ensureProvisioned(&c); err != nil {
|
||||
log.Println("[agent] provision error:", err)
|
||||
@ -176,9 +202,18 @@ func handleConfig(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if c.ContainerType != "dev" {
|
||||
|
||||
state.SetState(state.StateStarting)
|
||||
state.SetReadyState(false, "", "")
|
||||
lifecycleLog(&c, "start", 1, started, "start_requested")
|
||||
if err := system.StartServer(&c); err != nil {
|
||||
log.Println("[agent] start error:", err)
|
||||
state.SetError(err)
|
||||
state.SetState(state.StateError)
|
||||
lifecycleLog(&c, "start", 1, started, "start_failed err=%v", err)
|
||||
return
|
||||
}
|
||||
lifecycleLog(&c, "start", 1, started, "process_started")
|
||||
if err := waitMinecraftReady(&c, "start_probe", started); err != nil {
|
||||
state.SetError(err)
|
||||
state.SetState(state.StateError)
|
||||
return
|
||||
@ -191,21 +226,7 @@ func handleConfig(w http.ResponseWriter, r *http.Request) {
|
||||
variant := strings.ToLower(c.Variant)
|
||||
|
||||
if game == "minecraft" && (variant == "forge" || variant == "neoforge") {
|
||||
|
||||
deadline := time.Now().Add(5 * time.Minute)
|
||||
for {
|
||||
if state.GetState() == state.StateRunning {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
err := fmt.Errorf("forge did not reach running state")
|
||||
log.Println("[agent]", err)
|
||||
state.SetError(err)
|
||||
state.SetState(state.StateError)
|
||||
return
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
lifecycleLog(&c, "forge_post", 1, started, "begin")
|
||||
|
||||
// Wait for server.properties to exist before enforcing
|
||||
propsPath := filepath.Join(provision.ServerDir(c), "server.properties")
|
||||
@ -225,20 +246,37 @@ func handleConfig(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
_ = system.StopServer()
|
||||
if err := system.WaitForServerExit(20 * time.Second); err != nil {
|
||||
log.Println("[agent] forge stop wait error:", err)
|
||||
state.SetError(err)
|
||||
state.SetState(state.StateError)
|
||||
lifecycleLog(&c, "forge_post", 1, started, "stop_wait_failed err=%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := minecraft.EnforceForgeServerProperties(c); err != nil {
|
||||
log.Println("[agent] forge post-start error:", err)
|
||||
state.SetError(err)
|
||||
state.SetState(state.StateError)
|
||||
lifecycleLog(&c, "forge_post", 1, started, "enforce_failed err=%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
state.SetState(state.StateStarting)
|
||||
state.SetReadyState(false, "", "")
|
||||
if err := system.StartServer(&c); err != nil {
|
||||
log.Println("[agent] restart error:", err)
|
||||
state.SetError(err)
|
||||
state.SetState(state.StateError)
|
||||
lifecycleLog(&c, "forge_post", 1, started, "restart_failed err=%v", err)
|
||||
return
|
||||
}
|
||||
if err := waitMinecraftReady(&c, "forge_restart_probe", started); err != nil {
|
||||
state.SetError(err)
|
||||
state.SetState(state.StateError)
|
||||
return
|
||||
}
|
||||
lifecycleLog(&c, "forge_post", 1, started, "complete")
|
||||
}
|
||||
}
|
||||
|
||||
@ -268,8 +306,19 @@ func handleStart(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
started := time.Now()
|
||||
state.SetState(state.StateStarting)
|
||||
state.SetReadyState(false, "", "")
|
||||
lifecycleLog(cfg, "start_manual", 1, started, "start_requested")
|
||||
if err := system.StartServer(cfg); err != nil {
|
||||
http.Error(w, "start error: "+err.Error(), http.StatusInternalServerError)
|
||||
lifecycleLog(cfg, "start_manual", 1, started, "start_failed err=%v", err)
|
||||
return
|
||||
}
|
||||
if err := waitMinecraftReady(cfg, "start_manual_probe", started); err != nil {
|
||||
state.SetError(err)
|
||||
state.SetState(state.StateError)
|
||||
http.Error(w, "start readiness error: "+err.Error(), http.StatusGatewayTimeout)
|
||||
return
|
||||
}
|
||||
|
||||
@ -310,11 +359,24 @@ func handleRestart(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
_ = system.StopServer()
|
||||
if err := system.WaitForServerExit(20 * time.Second); err != nil {
|
||||
http.Error(w, "restart error: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
started := time.Now()
|
||||
state.SetState(state.StateStarting)
|
||||
state.SetReadyState(false, "", "")
|
||||
if err := system.StartServer(cfg); err != nil {
|
||||
http.Error(w, "restart error: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := waitMinecraftReady(cfg, "restart_manual_probe", started); err != nil {
|
||||
state.SetError(err)
|
||||
state.SetState(state.StateError)
|
||||
http.Error(w, "restart readiness error: "+err.Error(), http.StatusGatewayTimeout)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"ok": true, "state": "starting"}`))
|
||||
@ -328,14 +390,24 @@ func handleRestart(w http.ResponseWriter, r *http.Request) {
|
||||
*/
|
||||
func handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
cfg, _ := state.LoadConfig()
|
||||
_, processRunning := system.GetServerPID()
|
||||
readyAt := ""
|
||||
if t := state.GetLastReadyAt(); !t.IsZero() {
|
||||
readyAt = t.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
resp := map[string]any{
|
||||
"state": state.GetState(),
|
||||
"installStep": state.GetInstallStep(),
|
||||
"crashCount": state.GetCrashCount(),
|
||||
"error": nil,
|
||||
"config": cfg,
|
||||
"timestamp": time.Now().Unix(),
|
||||
"state": state.GetState(),
|
||||
"processRunning": processRunning,
|
||||
"ready": state.GetReady(),
|
||||
"readySource": state.GetReadySource(),
|
||||
"readyError": state.GetReadyError(),
|
||||
"lastReadyAt": readyAt,
|
||||
"installStep": state.GetInstallStep(),
|
||||
"crashCount": state.GetCrashCount(),
|
||||
"error": nil,
|
||||
"config": cfg,
|
||||
"timestamp": time.Now().Unix(),
|
||||
}
|
||||
|
||||
if err := state.GetError(); err != nil {
|
||||
@ -533,6 +605,17 @@ func NewMux() *http.ServeMux {
|
||||
m.HandleFunc("/agent/update/status", handleAgentUpdateStatus)
|
||||
m.HandleFunc("/version", handleVersion)
|
||||
m.HandleFunc("/game/players", handleGamePlayers)
|
||||
m.HandleFunc("/game/mods", agenthandlers.HandleGameMods)
|
||||
m.HandleFunc("/game/mods/install", agenthandlers.HandleGameModsInstall)
|
||||
m.HandleFunc("/game/mods/", agenthandlers.HandleGameModByID)
|
||||
m.HandleFunc("/game/files/list", agenthandlers.HandleGameFilesList)
|
||||
m.HandleFunc("/game/files", agenthandlers.HandleGameFilesRoot)
|
||||
m.HandleFunc("/game/files/revert", agenthandlers.HandleGameFilesRevert)
|
||||
m.HandleFunc("/game/files/upload", agenthandlers.HandleGameFilesUpload)
|
||||
m.HandleFunc("/game/files/stat", agenthandlers.HandleGameFilesStat)
|
||||
m.HandleFunc("/game/files/read", agenthandlers.HandleGameFilesRead)
|
||||
m.HandleFunc("/game/files/download", agenthandlers.HandleGameFilesDownload)
|
||||
m.HandleFunc("/metrics/process", agenthandlers.HandleProcessMetrics)
|
||||
|
||||
registerWebSocket(m)
|
||||
|
||||
|
||||
@ -31,6 +31,7 @@ type consoleSession struct {
|
||||
mu sync.Mutex
|
||||
conns map[*websocket.Conn]*consoleConn
|
||||
readerOnce sync.Once
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
var (
|
||||
@ -48,9 +49,21 @@ func getConsoleSession(cfg *state.Config) (*consoleSession, bool, error) {
|
||||
sessionMu.Lock()
|
||||
if sess, ok := sessions[key]; ok {
|
||||
sessionMu.Unlock()
|
||||
sess.touch()
|
||||
log.Printf("[ws] session reuse: vmid=%d type=%s", cfg.VMID, cfg.ContainerType)
|
||||
return sess, true, nil
|
||||
|
||||
currentPTY, err := system.GetConsolePTY(cfg)
|
||||
if err != nil {
|
||||
sess.destroy()
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
if sess.ptyFile != currentPTY {
|
||||
log.Printf("[ws] pty changed, destroying stale session: vmid=%d type=%s", cfg.VMID, cfg.ContainerType)
|
||||
sess.destroy()
|
||||
} else {
|
||||
sess.touch()
|
||||
log.Printf("[ws] session reuse: vmid=%d type=%s", cfg.VMID, cfg.ContainerType)
|
||||
return sess, true, nil
|
||||
}
|
||||
}
|
||||
sessionMu.Unlock()
|
||||
|
||||
@ -104,7 +117,7 @@ func (s *consoleSession) removeConn(conn *websocket.Conn) int {
|
||||
cc, ok := s.conns[conn]
|
||||
if ok {
|
||||
delete(s.conns, conn)
|
||||
close(cc.send)
|
||||
safeCloseChan(cc.send)
|
||||
}
|
||||
s.lastActive = time.Now()
|
||||
log.Printf("[ws] conn remove: vmid=%d type=%s conns=%d", s.cfg.VMID, s.cfg.ContainerType, len(s.conns))
|
||||
@ -129,6 +142,7 @@ func (s *consoleSession) startReader() {
|
||||
} else {
|
||||
log.Printf("[ws] pty read loop exit: vmid=%d err=%v", s.cfg.VMID, err)
|
||||
}
|
||||
s.destroy()
|
||||
return
|
||||
}
|
||||
if n == 0 && err == nil {
|
||||
@ -161,6 +175,9 @@ func (s *consoleSession) broadcast(data []byte) {
|
||||
|
||||
func (s *consoleSession) writeInput(data []byte) error {
|
||||
s.touch()
|
||||
if s.ptyFile == nil {
|
||||
return fmt.Errorf("pty unavailable")
|
||||
}
|
||||
return runtime.Write(s.ptyFile, data)
|
||||
}
|
||||
|
||||
@ -181,9 +198,38 @@ func (s *consoleSession) scheduleCleanupIfIdle() {
|
||||
if s.cfg.ContainerType == "dev" {
|
||||
_ = system.StopDevShell()
|
||||
}
|
||||
sessionMu.Lock()
|
||||
delete(sessions, s.key)
|
||||
sessionMu.Unlock()
|
||||
s.destroy()
|
||||
}
|
||||
}(last)
|
||||
}
|
||||
|
||||
func (s *consoleSession) destroy() {
|
||||
s.closeOnce.Do(func() {
|
||||
s.mu.Lock()
|
||||
for conn, cc := range s.conns {
|
||||
safeCloseChan(cc.send)
|
||||
_ = conn.Close()
|
||||
delete(s.conns, conn)
|
||||
}
|
||||
pty := s.ptyFile
|
||||
s.ptyFile = nil
|
||||
s.mu.Unlock()
|
||||
|
||||
if pty != nil {
|
||||
_ = pty.Close()
|
||||
}
|
||||
|
||||
sessionMu.Lock()
|
||||
delete(sessions, s.key)
|
||||
sessionMu.Unlock()
|
||||
|
||||
log.Printf("[ws] session destroyed: vmid=%d type=%s", s.cfg.VMID, s.cfg.ContainerType)
|
||||
})
|
||||
}
|
||||
|
||||
func safeCloseChan(ch chan []byte) {
|
||||
defer func() {
|
||||
_ = recover()
|
||||
}()
|
||||
close(ch)
|
||||
}
|
||||
|
||||
103
internal/metrics/process.go
Normal file
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 (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
@ -71,6 +72,10 @@ type agentStatus struct {
|
||||
lastError error
|
||||
crashCount int
|
||||
lastCrash time.Time
|
||||
ready bool
|
||||
readySource string
|
||||
readyError string
|
||||
lastReadyAt time.Time
|
||||
}
|
||||
|
||||
var global = &agentStatus{
|
||||
@ -112,6 +117,30 @@ func GetLastChange() time.Time {
|
||||
return global.lastChange
|
||||
}
|
||||
|
||||
func GetReady() bool {
|
||||
global.mu.Lock()
|
||||
defer global.mu.Unlock()
|
||||
return global.ready
|
||||
}
|
||||
|
||||
func GetReadySource() string {
|
||||
global.mu.Lock()
|
||||
defer global.mu.Unlock()
|
||||
return global.readySource
|
||||
}
|
||||
|
||||
func GetReadyError() string {
|
||||
global.mu.Lock()
|
||||
defer global.mu.Unlock()
|
||||
return global.readyError
|
||||
}
|
||||
|
||||
func GetLastReadyAt() time.Time {
|
||||
global.mu.Lock()
|
||||
defer global.mu.Unlock()
|
||||
return global.lastReadyAt
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
STATE SETTERS — unified with logging
|
||||
----------------------------------------------------------------------------*/
|
||||
@ -149,6 +178,24 @@ func RecordCrash(err error) {
|
||||
global.lastError = err
|
||||
global.crashCount++
|
||||
global.lastCrash = time.Now()
|
||||
global.ready = false
|
||||
global.readySource = ""
|
||||
global.readyError = fmt.Sprintf("%v", err)
|
||||
global.lastReadyAt = time.Time{}
|
||||
}
|
||||
|
||||
func SetReadyState(ready bool, source, errText string) {
|
||||
global.mu.Lock()
|
||||
defer global.mu.Unlock()
|
||||
|
||||
global.ready = ready
|
||||
global.readySource = source
|
||||
global.readyError = errText
|
||||
if ready {
|
||||
global.lastReadyAt = time.Now()
|
||||
} else {
|
||||
global.lastReadyAt = time.Time{}
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
|
||||
@ -27,6 +27,16 @@ var (
|
||||
devPTY *os.File
|
||||
)
|
||||
|
||||
func GetServerPID() (int, bool) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
if serverCmd == nil || serverCmd.Process == nil {
|
||||
return 0, false
|
||||
}
|
||||
return serverCmd.Process.Pid, true
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
StartServer (fixed)
|
||||
----------------------------------------------------------------------------*/
|
||||
@ -56,6 +66,7 @@ func StartServer(cfg *state.Config) error {
|
||||
|
||||
state.SetState(state.StateRunning)
|
||||
state.SetError(nil)
|
||||
state.SetReadyState(false, "", "")
|
||||
|
||||
go func() {
|
||||
err := cmd.Wait()
|
||||
@ -71,6 +82,7 @@ func StartServer(cfg *state.Config) error {
|
||||
state.RecordCrash(err)
|
||||
} else {
|
||||
state.SetState(state.StateIdle)
|
||||
state.SetReadyState(false, "", "")
|
||||
}
|
||||
|
||||
serverCmd = nil
|
||||
@ -93,6 +105,7 @@ func StopServer() error {
|
||||
}
|
||||
|
||||
state.SetState(state.StateStopping)
|
||||
state.SetReadyState(false, "", "")
|
||||
|
||||
// Try graceful stop
|
||||
if serverPTY != nil {
|
||||
@ -112,6 +125,23 @@ func StopServer() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func WaitForServerExit(timeout time.Duration) error {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
mu.Lock()
|
||||
running := serverCmd != nil
|
||||
mu.Unlock()
|
||||
|
||||
if !running {
|
||||
return nil
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return fmt.Errorf("timeout waiting for server process to exit")
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
RestartServer
|
||||
----------------------------------------------------------------------------*/
|
||||
|
||||
@ -13,20 +13,23 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
artifactBaseURL = "http://10.60.0.251:8080/agents/"
|
||||
releasesDir = "/opt/zlh-agent/releases"
|
||||
currentLink = "/opt/zlh-agent/current"
|
||||
previousLink = "/opt/zlh-agent/previous"
|
||||
binaryPath = "/opt/zlh-agent/zlh-agent"
|
||||
stateDir = "/opt/zlh-agent/state"
|
||||
statusFile = "/opt/zlh-agent/state/update.json"
|
||||
defaultUnit = "zlh-agent"
|
||||
defaultMode = "notify"
|
||||
artifactBaseURL = "http://10.60.0.251:8080/agents/"
|
||||
releasesDir = "/opt/zlh-agent/releases"
|
||||
currentLink = "/opt/zlh-agent/current"
|
||||
previousLink = "/opt/zlh-agent/previous"
|
||||
binaryPath = "/opt/zlh-agent/zlh-agent"
|
||||
stateDir = "/opt/zlh-agent/state"
|
||||
statusFile = "/opt/zlh-agent/state/update.json"
|
||||
defaultUnit = "zlh-agent"
|
||||
defaultMode = "notify"
|
||||
defaultKeepReleases = 3 // current + 2 previous
|
||||
)
|
||||
|
||||
type Manifest struct {
|
||||
@ -296,9 +299,24 @@ func CheckAndUpdate(currentVersion string) Result {
|
||||
return result
|
||||
}
|
||||
|
||||
keep := defaultKeepReleases
|
||||
if v := strings.TrimSpace(os.Getenv("ZLH_AGENT_KEEP_RELEASES")); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n >= 2 {
|
||||
keep = n
|
||||
}
|
||||
}
|
||||
if err := pruneOldReleases(keep); err != nil {
|
||||
log.Printf("[update] prune warning: %v", err)
|
||||
}
|
||||
|
||||
result.Status = "updated"
|
||||
result.Error = ""
|
||||
writeStatus(result)
|
||||
|
||||
if err := scheduleRollbackGuard(target); err != nil {
|
||||
log.Printf("[update] rollback guard warning: %v", err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
if err := restartService(); err != nil {
|
||||
log.Printf("[update] restart failed: %v", err)
|
||||
@ -387,10 +405,48 @@ func restartService() error {
|
||||
if unit == "" {
|
||||
unit = defaultUnit
|
||||
}
|
||||
cmd := exec.Command("systemctl", "restart", unit)
|
||||
cmd := exec.Command("systemctl", "restart", "--no-block", unit)
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func scheduleRollbackGuard(target string) error {
|
||||
unit := os.Getenv("ZLH_AGENT_UNIT")
|
||||
if unit == "" {
|
||||
unit = defaultUnit
|
||||
}
|
||||
|
||||
port := strings.TrimSpace(os.Getenv("ZLH_AGENT_PORT"))
|
||||
if port == "" {
|
||||
port = "18888"
|
||||
}
|
||||
target = normalizeVersion(target)
|
||||
if target == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
script := fmt.Sprintf(
|
||||
"sleep 25; "+
|
||||
"if ! curl -fsS http://127.0.0.1:%s/health >/dev/null 2>&1 || "+
|
||||
"! curl -fsS http://127.0.0.1:%s/version 2>/dev/null | grep -q '\"version\":\"v%s\"'; then "+
|
||||
"prev=$(readlink -f %s || true); "+
|
||||
"if [ -n \"$prev\" ] && [ -d \"$prev\" ]; then "+
|
||||
"b=$(basename \"$prev\"); "+
|
||||
"ln -sfn \"releases/$b\" %s; "+
|
||||
"ln -sfn %s/zlh-agent %s; "+
|
||||
"systemctl restart --no-block %s; "+
|
||||
"fi; "+
|
||||
"fi",
|
||||
port, port, target, previousLink, currentLink, currentLink, binaryPath, unit,
|
||||
)
|
||||
|
||||
transientUnit := fmt.Sprintf("zlh-agent-update-verify-%d", time.Now().UnixNano())
|
||||
cmd := exec.Command("systemd-run", "--unit", transientUnit, "--collect", "/bin/sh", "-c", script)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("schedule rollback guard: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeVersion(v string) string {
|
||||
return strings.TrimPrefix(strings.TrimSpace(v), "v")
|
||||
}
|
||||
@ -528,3 +584,74 @@ func copyFile(src, dst string) error {
|
||||
}
|
||||
return out.Close()
|
||||
}
|
||||
|
||||
func pruneOldReleases(keep int) error {
|
||||
entries, err := os.ReadDir(releasesDir)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
currentResolved, _ := filepath.EvalSymlinks(currentLink)
|
||||
previousResolved, _ := filepath.EvalSymlinks(previousLink)
|
||||
protected := map[string]struct{}{}
|
||||
if currentResolved != "" {
|
||||
protected[currentResolved] = struct{}{}
|
||||
}
|
||||
if previousResolved != "" {
|
||||
protected[previousResolved] = struct{}{}
|
||||
}
|
||||
|
||||
type rel struct {
|
||||
name string
|
||||
path string
|
||||
}
|
||||
rels := make([]rel, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if !isSemverLike(name) {
|
||||
continue
|
||||
}
|
||||
rels = append(rels, rel{name: name, path: filepath.Join(releasesDir, name)})
|
||||
}
|
||||
|
||||
sort.Slice(rels, func(i, j int) bool {
|
||||
return compareVersions(rels[i].name, rels[j].name) > 0
|
||||
})
|
||||
|
||||
for idx, r := range rels {
|
||||
if idx < keep {
|
||||
continue
|
||||
}
|
||||
if _, ok := protected[r.path]; ok {
|
||||
continue
|
||||
}
|
||||
if err := os.RemoveAll(r.path); err != nil {
|
||||
log.Printf("[update] prune failed for %s: %v", r.path, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isSemverLike(v string) bool {
|
||||
parts := strings.Split(v, ".")
|
||||
if len(parts) != 3 {
|
||||
return false
|
||||
}
|
||||
for _, p := range parts {
|
||||
if p == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range p {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@ -9,8 +9,9 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
logFile *os.File
|
||||
logReady bool
|
||||
logFile *os.File
|
||||
lifecycleFile *os.File
|
||||
logReady bool
|
||||
)
|
||||
|
||||
// InitLogFile sets up a log file inside the agent directory.
|
||||
@ -28,7 +29,10 @@ func InitLogFile(path string) error {
|
||||
|
||||
func CloseLog() {
|
||||
if logReady && logFile != nil {
|
||||
logFile.Close()
|
||||
_ = logFile.Close()
|
||||
}
|
||||
if lifecycleFile != nil {
|
||||
_ = lifecycleFile.Close()
|
||||
}
|
||||
}
|
||||
|
||||
@ -44,6 +48,26 @@ func Log(format string, v ...any) {
|
||||
|
||||
// Optionally also write to file
|
||||
if logReady && logFile != nil {
|
||||
logFile.WriteString(line + "\n")
|
||||
_, _ = logFile.WriteString(line + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
func InitLifecycleLog(path string) error {
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
lifecycleFile = f
|
||||
return nil
|
||||
}
|
||||
|
||||
func LogLifecycle(format string, v ...any) {
|
||||
line := fmt.Sprintf("[%s] %s",
|
||||
time.Now().Format("2006-01-02 15:04:05"),
|
||||
fmt.Sprintf(format, v...),
|
||||
)
|
||||
log.Println(line)
|
||||
if lifecycleFile != nil {
|
||||
_, _ = lifecycleFile.WriteString(line + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
package version
|
||||
|
||||
const AgentVersion = "v1.0.0"
|
||||
// AgentVersion is set at build-time via:
|
||||
// -ldflags "-X zlh-agent/internal/version.AgentVersion=vX.Y.Z"
|
||||
// Falls back to "v0.0.0-dev" for local/dev builds.
|
||||
var AgentVersion = "v0.0.0-dev"
|
||||
|
||||
7
main.go
7
main.go
@ -9,6 +9,7 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
agentfiles "zlh-agent/internal/files"
|
||||
agenthttp "zlh-agent/internal/http"
|
||||
"zlh-agent/internal/system"
|
||||
"zlh-agent/internal/update"
|
||||
@ -28,6 +29,11 @@ func main() {
|
||||
} else {
|
||||
log.Printf("[agent] file logging enabled")
|
||||
}
|
||||
if err := util.InitLifecycleLog("/opt/zlh-agent/logs/lifecycle.log"); err != nil {
|
||||
log.Printf("[agent] warning: lifecycle log init failed: %v", err)
|
||||
} else {
|
||||
log.Printf("[agent] lifecycle logging enabled")
|
||||
}
|
||||
defer util.CloseLog()
|
||||
|
||||
// ------------------------------------------------------------
|
||||
@ -47,6 +53,7 @@ func main() {
|
||||
// (does nothing unless AutoStartEnabled=true)
|
||||
// ------------------------------------------------------------
|
||||
system.InitAutoStart()
|
||||
agentfiles.StartShadowCleanup()
|
||||
update.StartPeriodic(version.AgentVersion)
|
||||
|
||||
server := &http.Server{
|
||||
|
||||
35
scripts/build-release.sh
Executable file
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