diff --git a/README.md b/README.md index 17033fd..a4f5eba 100644 --- a/README.md +++ b/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. diff --git a/internal/files/files.go b/internal/files/files.go new file mode 100644 index 0000000..16581b6 --- /dev/null +++ b/internal/files/files.go @@ -0,0 +1,1021 @@ +package files + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" + "unicode/utf8" + + "zlh-agent/internal/mods" + "zlh-agent/internal/state" +) + +const ( + MaxListEntries = 200 + MaxReadSize = 2 * 1024 * 1024 + MaxWriteSize = 1 * 1024 * 1024 + binaryProbeLen = 8192 + shadowDirName = ".zlh-shadow" + metadataFileName = ".zlh_metadata.json" + shadowCleanupEvery = 6 * time.Hour + shadowRetention = 7 * 24 * time.Hour + shadowOriginalName = "original" + shadowMetadataName = "metadata.json" + MaxModUploadSize = 250 * 1024 * 1024 + MaxDataPackSize = 100 * 1024 * 1024 +) + +var ( + ErrInvalidPath = errors.New("invalid path") + ErrPathEscape = errors.New("path outside runtime root") + ErrForbiddenPath = errors.New("path not allowed") + ErrNotFile = errors.New("path is not a file") + ErrNotDir = errors.New("path is not a directory") + ErrTooLarge = errors.New("file exceeds 2MB limit") + ErrWriteTooLarge = errors.New("file exceeds 1MB limit") + ErrBinaryFile = errors.New("binary file") + ErrDeleteDenied = errors.New("delete not allowed for this path") + ErrWriteDenied = errors.New("write not allowed for this path") + ErrUploadDenied = errors.New("upload denied") + ErrAlreadyExists = errors.New("file already exists") + + shadowCleanupOnce sync.Once +) + +type Entry struct { + Name string `json:"name"` + Path string `json:"path"` + Type string `json:"type"` + Size int64 `json:"size"` + Modified string `json:"modified"` +} + +type StatResponse struct { + Name string `json:"name"` + Path string `json:"path"` + Type string `json:"type"` + Size int64 `json:"size"` + Modified string `json:"modified"` + IsWritable bool `json:"isWritable"` + HasBackup bool `json:"hasBackup"` + Source *string `json:"source"` +} + +type ListResponse struct { + Path string `json:"path"` + Entries []Entry `json:"entries"` + Limit int `json:"limit"` + Truncated bool `json:"truncated"` +} + +type ReadResponse struct { + Path string `json:"path"` + Size int64 `json:"size"` + Content string `json:"content"` + Truncated bool `json:"truncated"` +} + +type shadowMetadata struct { + Path string `json:"path"` + CreatedAt string `json:"created_at"` + LastModifiedAt string `json:"last_modified_at"` +} + +type Meta struct { + Source string `json:"source"` + UploadedAt time.Time `json:"uploaded_at"` +} + +func RuntimeRoot(cfg *state.Config) string { + return mods.ResolveServerRoot(cfg) +} + +func NormalizeVisiblePath(rel string) (string, error) { + return normalizeAndCheckVisibleRel(rel) +} + +func StartShadowCleanup() { + shadowCleanupOnce.Do(func() { + go func() { + ticker := time.NewTicker(shadowCleanupEvery) + defer ticker.Stop() + cleanupShadowRootFromConfig() + for range ticker.C { + cleanupShadowRootFromConfig() + } + }() + }) +} + +func ResolveWorldPath(root, rel string) (string, string, error) { + resolvedRoot, err := filepath.EvalSymlinks(filepath.Clean(root)) + if err != nil { + return "", "", err + } + + normalizedRel, err := normalizeRelativePath(rel) + if err != nil { + return "", "", err + } + if isHiddenInternalPath(normalizedRel) { + return "", "", ErrForbiddenPath + } + + joined := filepath.Join(resolvedRoot, filepath.FromSlash(normalizedRel)) + resolvedPath, err := filepath.EvalSymlinks(joined) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return "", normalizedRel, os.ErrNotExist + } + return "", "", err + } + resolvedPath = filepath.Clean(resolvedPath) + if !withinRoot(resolvedRoot, resolvedPath) { + return "", "", ErrPathEscape + } + + finalRel, err := filepath.Rel(resolvedRoot, resolvedPath) + if err != nil { + return "", "", err + } + if finalRel == "." { + finalRel = "" + } + finalRel = filepath.ToSlash(finalRel) + if isHiddenInternalPath(finalRel) { + return "", "", ErrForbiddenPath + } + return resolvedPath, finalRel, nil +} + +func List(root, rel string) (ListResponse, error) { + resolvedPath, responsePath, err := ResolveWorldPath(root, rel) + if err != nil { + return ListResponse{}, err + } + + info, err := os.Stat(resolvedPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return ListResponse{}, os.ErrNotExist + } + return ListResponse{}, err + } + if !info.IsDir() { + return ListResponse{}, ErrNotDir + } + + entries, err := os.ReadDir(resolvedPath) + if err != nil { + return ListResponse{}, err + } + sort.Slice(entries, func(i, j int) bool { + leftIsDir := dirEntryIsDir(entries[i]) + rightIsDir := dirEntryIsDir(entries[j]) + if leftIsDir != rightIsDir { + return leftIsDir + } + return strings.ToLower(entries[i].Name()) < strings.ToLower(entries[j].Name()) + }) + + respEntries := make([]Entry, 0, min(len(entries), MaxListEntries)) + truncated := len(entries) > MaxListEntries + for _, entry := range entries { + if len(respEntries) >= MaxListEntries { + break + } + if isHiddenInternalName(entry.Name()) { + continue + } + childRel := entry.Name() + if responsePath != "" { + childRel = filepath.ToSlash(filepath.Join(responsePath, entry.Name())) + } + childPath, childRespPath, err := ResolveWorldPath(root, childRel) + if err != nil { + continue + } + childInfo, err := os.Stat(childPath) + if err != nil { + continue + } + entryType := "file" + if childInfo.IsDir() { + entryType = "dir" + } + respEntries = append(respEntries, Entry{ + Name: entry.Name(), + Path: childRespPath, + Type: entryType, + Size: childInfo.Size(), + Modified: childInfo.ModTime().UTC().Format(time.RFC3339), + }) + } + + return ListResponse{ + Path: responsePath, + Entries: respEntries, + Limit: MaxListEntries, + Truncated: truncated, + }, nil +} + +func Stat(root, rel string) (StatResponse, error) { + resolvedPath, responsePath, err := ResolveWorldPath(root, rel) + if err != nil { + return StatResponse{}, err + } + info, err := os.Stat(resolvedPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return StatResponse{}, os.ErrNotExist + } + return StatResponse{}, err + } + entryType := "file" + if info.IsDir() { + entryType = "dir" + } + name := filepath.Base(resolvedPath) + if responsePath == "" { + name = filepath.Base(filepath.Clean(root)) + } + return StatResponse{ + Name: name, + Path: responsePath, + Type: entryType, + Size: info.Size(), + Modified: info.ModTime().UTC().Format(time.RFC3339), + IsWritable: isWritablePath(root, responsePath), + HasBackup: hasBackup(root, responsePath), + Source: sourceForPath(root, responsePath), + }, nil +} + +func Read(root, rel string) (ReadResponse, error) { + resolvedPath, responsePath, err := ResolveWorldPath(root, rel) + if err != nil { + return ReadResponse{}, err + } + info, err := os.Stat(resolvedPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return ReadResponse{}, os.ErrNotExist + } + return ReadResponse{}, err + } + if info.IsDir() { + return ReadResponse{}, ErrNotFile + } + if info.Size() > MaxReadSize { + return ReadResponse{}, ErrTooLarge + } + + data, err := os.ReadFile(resolvedPath) + if err != nil { + return ReadResponse{}, err + } + if isBinary(data) { + return ReadResponse{}, ErrBinaryFile + } + return ReadResponse{ + Path: responsePath, + Size: int64(len(data)), + Content: string(data), + Truncated: false, + }, nil +} + +func OpenDownload(root, rel string) (*os.File, os.FileInfo, string, error) { + resolvedPath, _, err := ResolveWorldPath(root, rel) + if err != nil { + return nil, nil, "", err + } + info, err := os.Stat(resolvedPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil, "", os.ErrNotExist + } + return nil, nil, "", err + } + if info.IsDir() { + return nil, nil, "", ErrNotFile + } + file, err := os.Open(resolvedPath) + if err != nil { + return nil, nil, "", err + } + return file, info, filepath.Base(resolvedPath), nil +} + +func Delete(root, rel string) (string, error) { + resolvedRoot, err := filepath.EvalSymlinks(filepath.Clean(root)) + if err != nil { + return "", err + } + + normalizedRel, err := normalizeAndCheckVisibleRel(rel) + if err != nil { + return "", err + } + if !isAllowedDelete(normalizedRel) { + return "", ErrDeleteDenied + } + + resolvedPath, err := resolveExistingNoFollowFinal(resolvedRoot, normalizedRel) + if err != nil { + return "", err + } + info, err := os.Lstat(resolvedPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return "", os.ErrNotExist + } + return "", err + } + if info.IsDir() { + return "", ErrDeleteDenied + } + if info.Mode()&os.ModeSymlink != 0 { + targetPath, err := filepath.EvalSymlinks(resolvedPath) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return "", err + } + if err == nil && !withinRoot(resolvedRoot, filepath.Clean(targetPath)) { + return "", ErrPathEscape + } + } + + if err := os.Remove(resolvedPath); err != nil { + if errors.Is(err, os.ErrNotExist) { + return "", os.ErrNotExist + } + return "", err + } + return normalizedRel, nil +} + +func Write(root, rel string, data []byte) (bool, error) { + resolvedRoot, err := filepath.EvalSymlinks(filepath.Clean(root)) + if err != nil { + return false, err + } + + normalizedRel, err := normalizeAndCheckVisibleRel(rel) + if err != nil { + return false, err + } + if !isAllowedWrite(normalizedRel) { + return false, ErrWriteDenied + } + if len(data) > MaxWriteSize { + return false, ErrWriteTooLarge + } + if isBinary(data) { + return false, ErrBinaryFile + } + + resolvedPath, err := resolveExistingNoFollowFinal(resolvedRoot, normalizedRel) + if err != nil { + return false, err + } + info, err := os.Lstat(resolvedPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return false, os.ErrNotExist + } + return false, err + } + if info.IsDir() { + return false, ErrWriteDenied + } + if info.Mode()&os.ModeSymlink != 0 { + return false, ErrWriteDenied + } + + backupCreated, err := ensureShadow(resolvedRoot, normalizedRel, resolvedPath) + if err != nil { + return false, err + } + if err := writeAtomic(resolvedPath, data, info.Mode().Perm()); err != nil { + return false, err + } + if err := updateShadowMetadata(resolvedRoot, normalizedRel, backupCreated); err != nil { + return false, err + } + return backupCreated, nil +} + +func Revert(root, rel string) (string, error) { + resolvedRoot, err := filepath.EvalSymlinks(filepath.Clean(root)) + if err != nil { + return "", err + } + + normalizedRel, err := normalizeAndCheckVisibleRel(rel) + if err != nil { + return "", err + } + if !isAllowedWrite(normalizedRel) { + return "", ErrWriteDenied + } + + resolvedPath, err := resolveForRestore(resolvedRoot, normalizedRel) + if err != nil { + return "", err + } + if info, err := os.Lstat(resolvedPath); err == nil { + if info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return "", ErrWriteDenied + } + } else if !errors.Is(err, os.ErrNotExist) { + return "", err + } + + shadowDir := shadowPath(resolvedRoot, normalizedRel) + originalPath := filepath.Join(shadowDir, shadowOriginalName) + originalInfo, err := os.Stat(originalPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return "", os.ErrNotExist + } + return "", err + } + if originalInfo.IsDir() { + return "", os.ErrNotExist + } + data, err := os.ReadFile(originalPath) + if err != nil { + return "", err + } + if err := writeAtomic(resolvedPath, data, originalInfo.Mode().Perm()); err != nil { + return "", err + } + if err := os.RemoveAll(shadowDir); err != nil { + return "", err + } + return normalizedRel, nil +} + +func Upload(root, rel string, r io.Reader, sizeLimit int64, overwrite bool) (int64, bool, error) { + resolvedRoot, err := filepath.EvalSymlinks(filepath.Clean(root)) + if err != nil { + return 0, false, err + } + + normalizedRel, err := normalizeAndCheckVisibleRel(rel) + if err != nil { + return 0, false, err + } + allowedLimit, ok := uploadLimitForPath(normalizedRel) + if !ok { + return 0, false, ErrUploadDenied + } + if sizeLimit <= 0 || sizeLimit > allowedLimit { + sizeLimit = allowedLimit + } + + resolvedPath, err := resolveUploadTarget(resolvedRoot, normalizedRel) + if err != nil { + return 0, false, err + } + + overwritten := false + if info, err := os.Lstat(resolvedPath); err == nil { + if info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return 0, false, ErrUploadDenied + } + if !overwrite { + return 0, false, ErrAlreadyExists + } + overwritten = true + } else if !errors.Is(err, os.ErrNotExist) { + return 0, false, err + } + + dir := filepath.Dir(resolvedPath) + tmp, err := os.CreateTemp(dir, ".zlh-upload-*") + if err != nil { + return 0, false, err + } + tmpPath := tmp.Name() + defer func() { + _ = os.Remove(tmpPath) + }() + + limited := io.LimitReader(r, sizeLimit+1) + written, err := io.Copy(tmp, limited) + if closeErr := tmp.Close(); err == nil && closeErr != nil { + err = closeErr + } + if err != nil { + return 0, false, err + } + if written > sizeLimit { + return 0, false, ErrWriteTooLarge + } + if err := os.Chmod(tmpPath, 0o644); err != nil { + return 0, false, err + } + if err := os.Rename(tmpPath, resolvedPath); err != nil { + return 0, false, err + } + if err := updateMetadata(resolvedRoot, normalizedRel); err != nil { + return 0, false, err + } + return written, overwritten, nil +} + +func shadowPath(root, rel string) string { + sum := sha256.Sum256([]byte(rel)) + return filepath.Join(root, shadowDirName, hex.EncodeToString(sum[:])) +} + +func normalizeRelativePath(rel string) (string, error) { + rel = strings.TrimSpace(rel) + if rel == "" || rel == "." { + return "", nil + } + if filepath.IsAbs(rel) || strings.HasPrefix(rel, "/") { + return "", ErrInvalidPath + } + for _, r := range rel { + if r == 0 || r < 32 || r == 127 { + return "", ErrInvalidPath + } + } + cleaned := filepath.Clean(rel) + if cleaned == "." { + return "", nil + } + if cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) { + return "", ErrInvalidPath + } + return filepath.ToSlash(cleaned), nil +} + +func withinRoot(root, target string) bool { + rel, err := filepath.Rel(root, target) + if err != nil { + return false + } + return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))) +} + +func dirEntryIsDir(entry os.DirEntry) bool { + if entry.IsDir() { + return true + } + if entry.Type()&os.ModeSymlink != 0 { + if info, err := entry.Info(); err == nil { + return info.IsDir() + } + } + return false +} + +func isBinary(data []byte) bool { + if len(data) == 0 { + return false + } + probe := data + if len(probe) > binaryProbeLen { + probe = probe[:binaryProbeLen] + } + if bytes.IndexByte(probe, 0) >= 0 { + return true + } + if !utf8.Valid(probe) { + return true + } + return false +} + +func isAllowedDelete(rel string) bool { + parts := strings.Split(rel, "/") + if len(parts) != 2 { + return false + } + switch parts[0] { + case "mods-removed", "mods-uploaded": + return parts[1] != "" + case "logs": + return strings.HasSuffix(parts[1], ".log") || strings.HasSuffix(parts[1], ".log.gz") + default: + return false + } +} + +func isAllowedWrite(rel string) bool { + parts := strings.Split(rel, "/") + if len(parts) == 1 { + return parts[0] == "server.properties" + } + if len(parts) != 2 || parts[0] != "config" { + return false + } + ext := strings.ToLower(filepath.Ext(parts[1])) + switch ext { + case ".toml", ".json", ".properties": + return true + default: + return false + } +} + +func uploadLimitForPath(rel string) (int64, bool) { + parts := strings.Split(rel, "/") + switch { + case len(parts) == 2 && parts[0] == "mods" && strings.HasSuffix(strings.ToLower(parts[1]), ".jar"): + return MaxModUploadSize, true + case len(parts) == 3 && parts[0] == "world" && parts[1] == "datapacks" && strings.HasSuffix(strings.ToLower(parts[2]), ".zip"): + return MaxDataPackSize, true + default: + return 0, false + } +} + +func isWritablePath(root, rel string) bool { + if !isAllowedWrite(rel) { + return false + } + + resolvedRoot, err := filepath.EvalSymlinks(filepath.Clean(root)) + if err != nil { + return false + } + resolvedPath, err := resolveExistingNoFollowFinal(resolvedRoot, rel) + if err != nil { + return false + } + info, err := os.Lstat(resolvedPath) + if err != nil { + return false + } + if info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return false + } + return true +} + +func hasBackup(root, rel string) bool { + if rel == "" { + return false + } + resolvedRoot, err := filepath.EvalSymlinks(filepath.Clean(root)) + if err != nil { + return false + } + originalPath := filepath.Join(shadowPath(resolvedRoot, rel), shadowOriginalName) + info, err := os.Stat(originalPath) + if err != nil { + return false + } + return !info.IsDir() +} + +func sourceForPath(root, rel string) *string { + if rel == "" { + return nil + } + resolvedRoot, err := filepath.EvalSymlinks(filepath.Clean(root)) + if err != nil { + return nil + } + meta, err := loadMetadata(resolvedRoot) + if err != nil { + return nil + } + entry, ok := meta[rel] + if !ok || strings.TrimSpace(entry.Source) == "" { + return nil + } + source := entry.Source + return &source +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +func normalizeAndCheckVisibleRel(rel string) (string, error) { + normalizedRel, err := normalizeRelativePath(rel) + if err != nil { + return "", err + } + if isHiddenInternalPath(normalizedRel) { + return "", ErrForbiddenPath + } + return normalizedRel, nil +} + +func isShadowPath(rel string) bool { + if rel == "" { + return false + } + return rel == shadowDirName || strings.HasPrefix(rel, shadowDirName+"/") +} + +func isHiddenInternalPath(rel string) bool { + if rel == "" { + return false + } + return isShadowPath(rel) || rel == metadataFileName +} + +func isHiddenInternalName(name string) bool { + return name == shadowDirName || name == metadataFileName +} + +func resolveExistingNoFollowFinal(resolvedRoot, rel string) (string, error) { + candidate := filepath.Join(resolvedRoot, filepath.FromSlash(rel)) + parentPath := filepath.Dir(candidate) + resolvedParent, err := filepath.EvalSymlinks(parentPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return "", os.ErrNotExist + } + return "", err + } + resolvedParent = filepath.Clean(resolvedParent) + if !withinRoot(resolvedRoot, resolvedParent) { + return "", ErrPathEscape + } + resolvedPath := filepath.Clean(filepath.Join(resolvedParent, filepath.Base(candidate))) + if !withinRoot(resolvedRoot, resolvedPath) { + return "", ErrPathEscape + } + return resolvedPath, nil +} + +func resolveForRestore(resolvedRoot, rel string) (string, error) { + candidate := filepath.Join(resolvedRoot, filepath.FromSlash(rel)) + parentPath := filepath.Dir(candidate) + resolvedParent, err := filepath.EvalSymlinks(parentPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return "", os.ErrNotExist + } + return "", err + } + resolvedParent = filepath.Clean(resolvedParent) + if !withinRoot(resolvedRoot, resolvedParent) { + return "", ErrPathEscape + } + resolvedPath := filepath.Clean(filepath.Join(resolvedParent, filepath.Base(candidate))) + if !withinRoot(resolvedRoot, resolvedPath) { + return "", ErrPathEscape + } + return resolvedPath, nil +} + +func resolveUploadTarget(resolvedRoot, rel string) (string, error) { + candidate := filepath.Join(resolvedRoot, filepath.FromSlash(rel)) + parentPath := filepath.Dir(candidate) + resolvedParent, err := filepath.EvalSymlinks(parentPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return "", os.ErrNotExist + } + return "", err + } + resolvedParent = filepath.Clean(resolvedParent) + if !withinRoot(resolvedRoot, resolvedParent) { + return "", ErrPathEscape + } + parentInfo, err := os.Stat(resolvedParent) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return "", os.ErrNotExist + } + return "", err + } + if !parentInfo.IsDir() { + return "", ErrUploadDenied + } + resolvedPath := filepath.Clean(filepath.Join(resolvedParent, filepath.Base(candidate))) + if !withinRoot(resolvedRoot, resolvedPath) { + return "", ErrPathEscape + } + return resolvedPath, nil +} + +func ensureShadow(resolvedRoot, rel, resolvedPath string) (bool, error) { + shadowDir := shadowPath(resolvedRoot, rel) + originalPath := filepath.Join(shadowDir, shadowOriginalName) + if _, err := os.Stat(originalPath); err == nil { + return false, nil + } else if !errors.Is(err, os.ErrNotExist) { + return false, err + } + + if err := os.MkdirAll(shadowDir, 0o755); err != nil { + return false, err + } + data, err := os.ReadFile(resolvedPath) + if err != nil { + return false, err + } + info, err := os.Stat(resolvedPath) + if err != nil { + return false, err + } + if err := os.WriteFile(originalPath, data, info.Mode().Perm()); err != nil { + return false, err + } + now := time.Now().UTC() + meta := shadowMetadata{ + Path: rel, + CreatedAt: now.Format(time.RFC3339), + LastModifiedAt: now.Format(time.RFC3339), + } + if err := writeShadowMetadataFile(filepath.Join(shadowDir, shadowMetadataName), meta); err != nil { + return false, err + } + return true, nil +} + +func updateShadowMetadata(resolvedRoot, rel string, backupCreated bool) error { + shadowDir := shadowPath(resolvedRoot, rel) + metaPath := filepath.Join(shadowDir, shadowMetadataName) + now := time.Now().UTC() + meta := shadowMetadata{ + Path: rel, + CreatedAt: now.Format(time.RFC3339), + LastModifiedAt: now.Format(time.RFC3339), + } + if !backupCreated { + if existing, err := readShadowMetadata(metaPath); err == nil { + meta = existing + if strings.TrimSpace(meta.Path) == "" { + meta.Path = rel + } + if strings.TrimSpace(meta.CreatedAt) == "" { + meta.CreatedAt = now.Format(time.RFC3339) + } + meta.LastModifiedAt = now.Format(time.RFC3339) + } + } + return writeShadowMetadataFile(metaPath, meta) +} + +func writeShadowMetadataFile(path string, meta shadowMetadata) error { + data, err := json.Marshal(meta) + if err != nil { + return err + } + data = append(data, '\n') + return os.WriteFile(path, data, 0o644) +} + +func readShadowMetadata(path string) (shadowMetadata, error) { + data, err := os.ReadFile(path) + if err != nil { + return shadowMetadata{}, err + } + var meta shadowMetadata + if err := json.Unmarshal(data, &meta); err != nil { + return shadowMetadata{}, err + } + return meta, nil +} + +func writeAtomic(dest string, data []byte, mode os.FileMode) error { + dir := filepath.Dir(dest) + tmp, err := os.CreateTemp(dir, ".zlh-write-*") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer func() { + _ = os.Remove(tmpPath) + }() + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Chmod(tmpPath, mode); err != nil { + return err + } + return os.Rename(tmpPath, dest) +} + +func cleanupShadowRootFromConfig() { + cfg, err := state.LoadConfig() + if err != nil { + return + } + cleanupShadowRoot(RuntimeRoot(cfg)) +} + +func cleanupShadowRoot(root string) { + resolvedRoot, err := filepath.EvalSymlinks(filepath.Clean(root)) + if err != nil { + return + } + base := filepath.Join(resolvedRoot, shadowDirName) + entries, err := os.ReadDir(base) + if err != nil { + return + } + now := time.Now().UTC() + for _, entry := range entries { + if !entry.IsDir() { + continue + } + shadowDir := filepath.Join(base, entry.Name()) + meta, err := readShadowMetadata(filepath.Join(shadowDir, shadowMetadataName)) + if err != nil { + continue + } + ts, err := time.Parse(time.RFC3339, strings.TrimSpace(meta.LastModifiedAt)) + if err != nil { + continue + } + if now.Sub(ts) > shadowRetention { + _ = os.RemoveAll(shadowDir) + } + } +} + +func loadMetadata(root string) (map[string]Meta, error) { + path := filepath.Join(root, metadataFileName) + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return map[string]Meta{}, nil + } + return nil, err + } + var meta map[string]Meta + if err := json.Unmarshal(data, &meta); err != nil { + return nil, err + } + if meta == nil { + meta = map[string]Meta{} + } + return meta, nil +} + +func writeMetadata(root string, data map[string]Meta) error { + payload, err := json.Marshal(data) + if err != nil { + return err + } + payload = append(payload, '\n') + target := filepath.Join(root, metadataFileName) + tmp, err := os.CreateTemp(root, ".zlh-metadata-*") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer func() { + _ = os.Remove(tmpPath) + }() + if _, err := tmp.Write(payload); err != nil { + tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Chmod(tmpPath, 0o644); err != nil { + return err + } + return os.Rename(tmpPath, target) +} + +func updateMetadata(root, rel string) error { + meta, err := loadMetadata(root) + if err != nil { + return err + } + meta[rel] = Meta{ + Source: "user", + UploadedAt: time.Now().UTC(), + } + return writeMetadata(root, meta) +} diff --git a/internal/handlers/files.go b/internal/handlers/files.go new file mode 100644 index 0000000..2178de1 --- /dev/null +++ b/internal/handlers/files.go @@ -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()) + } +} diff --git a/internal/handlers/metrics.go b/internal/handlers/metrics.go new file mode 100644 index 0000000..dcfa380 --- /dev/null +++ b/internal/handlers/metrics.go @@ -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) +} diff --git a/internal/handlers/mods.go b/internal/handlers/mods.go new file mode 100644 index 0000000..8f3f7d4 --- /dev/null +++ b/internal/handlers/mods.go @@ -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}) +} diff --git a/internal/http/agent.go b/internal/http/agent.go index 17ededa..826208d 100755 --- a/internal/http/agent.go +++ b/internal/http/agent.go @@ -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) diff --git a/internal/http/console_sessions.go b/internal/http/console_sessions.go index adf8ced..3566d7b 100644 --- a/internal/http/console_sessions.go +++ b/internal/http/console_sessions.go @@ -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) +} diff --git a/internal/metrics/process.go b/internal/metrics/process.go new file mode 100644 index 0000000..61b3cf5 --- /dev/null +++ b/internal/metrics/process.go @@ -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 +} diff --git a/internal/minecraft/readiness.go b/internal/minecraft/readiness.go new file mode 100644 index 0000000..189b902 --- /dev/null +++ b/internal/minecraft/readiness.go @@ -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 +} diff --git a/internal/mods/installer.go b/internal/mods/installer.go new file mode 100644 index 0000000..2bcc74e --- /dev/null +++ b/internal/mods/installer.go @@ -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 +} diff --git a/internal/mods/metadata.go b/internal/mods/metadata.go new file mode 100644 index 0000000..8b60433 --- /dev/null +++ b/internal/mods/metadata.go @@ -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 +} diff --git a/internal/mods/scanner.go b/internal/mods/scanner.go new file mode 100644 index 0000000..111d121 --- /dev/null +++ b/internal/mods/scanner.go @@ -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 +} diff --git a/internal/mods/types.go b/internal/mods/types.go new file mode 100644 index 0000000..1f29f5d --- /dev/null +++ b/internal/mods/types.go @@ -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 +} diff --git a/internal/state/state.go b/internal/state/state.go index 2fb6be5..ead1913 100755 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -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{} + } } /* -------------------------------------------------------------------------- diff --git a/internal/system/process.go b/internal/system/process.go index 2c8ab2d..f431745 100755 --- a/internal/system/process.go +++ b/internal/system/process.go @@ -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 ----------------------------------------------------------------------------*/ diff --git a/internal/update/update.go b/internal/update/update.go index bf8f0d4..69dfc94 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -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 +} diff --git a/internal/util/log.go b/internal/util/log.go index f690e94..c32caa5 100644 --- a/internal/util/log.go +++ b/internal/util/log.go @@ -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") } } diff --git a/internal/version/version.go b/internal/version/version.go index 5c8fd1a..0a64966 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -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" diff --git a/main.go b/main.go index 3fa4066..c2d4851 100755 --- a/main.go +++ b/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{ diff --git a/scripts/build-release.sh b/scripts/build-release.sh new file mode 100755 index 0000000..8697c98 --- /dev/null +++ b/scripts/build-release.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +VERSION="${1:-}" +if [[ -z "$VERSION" ]]; then + echo "Usage: $0 " + 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}" diff --git a/zlh-agent b/zlh-agent index 9dea1f3..02bdb42 100755 Binary files a/zlh-agent and b/zlh-agent differ