zlh-agent/internal/mods/scanner.go
2026-03-07 20:59:27 +00:00

267 lines
6.2 KiB
Go

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
}