zlh-agent/internal/provision/minecraft/fabric_proxy.go
2026-04-07 12:31:09 +00:00

418 lines
10 KiB
Go

package minecraft
import (
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"zlh-agent/internal/provcommon"
"zlh-agent/internal/state"
)
var (
fabricProxyArtifactRoot = "minecraft/fabric/fabric-proxy-lite"
fabricAPIArtifactPath = "minecraft/fabric/fabric-api/fabric-api.jar"
fabricProxyConfigPath = "minecraft/fabric/FabricProxy-Lite.toml"
)
var (
fabricProxyMapOnce sync.Once
fabricProxyMap map[string]string
fabricProxyMapErr error
)
func resolveProxyVersion(mcVersion string) (string, error) {
mappings, err := loadFabricProxyMap()
if err != nil {
return "", err
}
mcVersion = strings.TrimSpace(mcVersion)
if mcVersion == "" {
return "", fmt.Errorf("empty minecraft version")
}
compareVersion, err := parseVersion(mcVersion)
if err != nil {
return "", fmt.Errorf("invalid minecraft version %q: %w", mcVersion, err)
}
if version, ok := mappings[mcVersion]; ok {
return version, nil
}
for spec, version := range mappings {
start, end, ok := parseVersionRange(spec)
if !ok {
continue
}
if compareVersionSlices(compareVersion, start) >= 0 && compareVersionSlices(compareVersion, end) <= 0 {
return version, nil
}
}
return "", fmt.Errorf("no fabricproxy-lite mapping for minecraft version %q", mcVersion)
}
func InjectFabricProxyLite(cfg state.Config) error {
if !shouldInjectFabricProxyLite(cfg) {
return nil
}
modsDir, targetPath, err := ensureSystemModsDir(cfg)
if err != nil {
log.Printf("[mods] vmid=%d type=system name=fabricproxy-lite error=%v", cfg.VMID, err)
return err
}
version, err := resolveProxyVersion(cfg.Version)
if err != nil {
log.Printf("[mods] vmid=%d type=system name=fabricproxy-lite error=%v", cfg.VMID, err)
return err
}
source := proxyArtifactURL(version)
log.Printf("[mods] vmid=%d type=system name=fabricproxy-lite source=artifact version=%s", cfg.VMID, version)
if err := installSystemMod(source, targetPath, modsDir); err != nil {
err = fmt.Errorf("install fabricproxy-lite: %w", err)
log.Printf("[mods] vmid=%d type=system name=fabricproxy-lite error=%v", cfg.VMID, err)
return err
}
log.Printf("[mods] vmid=%d type=system name=fabricproxy-lite installed=true", cfg.VMID)
fabricAPIPath := filepath.Join(modsDir, "fabric-api.jar")
log.Printf("[mods] vmid=%d type=system name=fabric-api source=artifact", cfg.VMID)
if err := installSystemMod(provcommon.BuildArtifactURL(fabricAPIArtifactPath), fabricAPIPath, modsDir); err != nil {
err = fmt.Errorf("install fabric-api: %w", err)
log.Printf("[mods] vmid=%d type=system name=fabric-api error=%v", cfg.VMID, err)
return err
}
log.Printf("[mods] vmid=%d type=system name=fabric-api installed=true", cfg.VMID)
if err := installFabricProxyConfig(cfg); err != nil {
log.Printf("[mods] vmid=%d type=system name=fabricproxy-lite-config error=%v", cfg.VMID, err)
return err
}
return nil
}
func shouldInjectFabricProxyLite(cfg state.Config) bool {
if !strings.EqualFold(cfg.ContainerType, "game") {
return false
}
if !strings.EqualFold(cfg.Game, "minecraft") {
return false
}
return strings.EqualFold(cfg.InternalProfile, "vanilla-fabric")
}
func loadFabricProxyMap() (map[string]string, error) {
fabricProxyMapOnce.Do(func() {
raw, err := fetchArtifact(proxyMapURL())
if err != nil {
fabricProxyMapErr = fmt.Errorf("read fabricproxy-lite map: %w", err)
return
}
var mappings map[string]string
if err := json.Unmarshal(raw, &mappings); err != nil {
fabricProxyMapErr = fmt.Errorf("parse fabricproxy-lite map: %w", err)
return
}
if len(mappings) == 0 {
fabricProxyMapErr = fmt.Errorf("fabricproxy-lite map is empty")
return
}
for spec, version := range mappings {
spec = strings.TrimSpace(spec)
version = strings.TrimSpace(version)
if spec == "" || version == "" {
fabricProxyMapErr = fmt.Errorf("fabricproxy-lite map contains empty key or value")
return
}
if start, end, ok := parseVersionRange(spec); ok {
if compareVersionSlices(start, end) > 0 {
fabricProxyMapErr = fmt.Errorf("invalid fabricproxy-lite range %q", spec)
return
}
continue
}
if _, err := parseVersion(spec); err != nil {
fabricProxyMapErr = fmt.Errorf("invalid fabricproxy-lite version key %q: %w", spec, err)
return
}
}
fabricProxyMap = mappings
})
if fabricProxyMapErr != nil {
return nil, fabricProxyMapErr
}
return fabricProxyMap, nil
}
func proxyMapURL() string {
return provcommon.BuildArtifactURL(filepath.ToSlash(filepath.Join(fabricProxyArtifactRoot, "map.json")))
}
func proxyArtifactURL(version string) string {
rel := filepath.ToSlash(filepath.Join(
fabricProxyArtifactRoot,
version,
fmt.Sprintf("FabricProxy-Lite-%s.jar", version),
))
return provcommon.BuildArtifactURL(rel)
}
func ensureSystemModsDir(cfg state.Config) (string, string, error) {
serverDir := provcommon.ServerDir(cfg)
info, err := os.Stat(serverDir)
if err != nil {
return "", "", fmt.Errorf("stat server dir: %w", err)
}
modsDir := filepath.Join(serverDir, "mods")
created := false
if _, err := os.Stat(modsDir); err != nil {
if !os.IsNotExist(err) {
return "", "", fmt.Errorf("stat mods dir: %w", err)
}
created = true
}
if err := os.MkdirAll(modsDir, info.Mode().Perm()); err != nil {
return "", "", fmt.Errorf("create mods dir: %w", err)
}
if err := syncOwnershipAndPerms(modsDir, info); err != nil {
return "", "", fmt.Errorf("sync mods dir ownership: %w", err)
}
log.Printf("[provision] vmid=%d profile=vanilla-fabric step=mods_dir created=%t", cfg.VMID, created)
return modsDir, filepath.Join(modsDir, "fabricproxy-lite.jar"), nil
}
func installFabricProxyConfig(cfg state.Config) error {
serverDir := provcommon.ServerDir(cfg)
info, err := os.Stat(serverDir)
if err != nil {
return fmt.Errorf("stat server dir: %w", err)
}
configDir := filepath.Join(serverDir, "config")
created := false
if _, err := os.Stat(configDir); err != nil {
if !os.IsNotExist(err) {
return fmt.Errorf("stat config dir: %w", err)
}
created = true
}
if err := os.MkdirAll(configDir, info.Mode().Perm()); err != nil {
return fmt.Errorf("create config dir: %w", err)
}
if err := syncOwnershipAndPerms(configDir, info); err != nil {
return fmt.Errorf("sync config dir ownership: %w", err)
}
targetPath := filepath.Join(configDir, "FabricProxy-Lite.toml")
if err := installSystemFile(provcommon.BuildArtifactURL(fabricProxyConfigPath), targetPath, configDir, 0o644); err != nil {
return fmt.Errorf("install FabricProxy-Lite.toml: %w", err)
}
log.Printf("[provision] vmid=%d profile=vanilla-fabric step=config_dir created=%t", cfg.VMID, created)
log.Printf("[mods] vmid=%d type=system name=FabricProxy-Lite.toml installed=true", cfg.VMID)
return nil
}
func parseVersionRange(spec string) ([]int, []int, bool) {
parts := strings.Split(spec, "-")
if len(parts) != 2 {
return nil, nil, false
}
start, err := parseVersion(parts[0])
if err != nil {
return nil, nil, false
}
end, err := parseVersion(parts[1])
if err != nil {
return nil, nil, false
}
return start, end, true
}
func parseVersion(raw string) ([]int, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, fmt.Errorf("empty version")
}
parts := strings.Split(raw, ".")
out := make([]int, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
return nil, fmt.Errorf("invalid version %q", raw)
}
n, err := strconv.Atoi(part)
if err != nil {
return nil, fmt.Errorf("invalid version segment %q", part)
}
out = append(out, n)
}
return out, nil
}
func compareVersionSlices(a, b []int) int {
maxLen := len(a)
if len(b) > maxLen {
maxLen = len(b)
}
for i := 0; i < maxLen; i++ {
ai := 0
if i < len(a) {
ai = a[i]
}
bi := 0
if i < len(b) {
bi = b[i]
}
switch {
case ai < bi:
return -1
case ai > bi:
return 1
}
}
return 0
}
func fetchArtifact(url string) ([]byte, error) {
client := &http.Client{}
resp, err := client.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("status %d", resp.StatusCode)
}
return io.ReadAll(resp.Body)
}
func installSystemMod(url, dst, modsDir string) error {
payload, err := fetchArtifact(url)
if err != nil {
return err
}
if same, err := sameFileChecksum(dst, payload); err == nil && same {
return syncFileOwnership(dst, modsDir)
}
tmp, err := os.CreateTemp(filepath.Dir(dst), "fabricproxy-lite-*.jar")
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.Close(); err != nil {
return err
}
if err := os.Rename(tmpPath, dst); err != nil {
return err
}
return syncFileOwnership(dst, modsDir)
}
func installSystemFile(url, dst, referenceDir string, mode os.FileMode) error {
payload, err := fetchArtifact(url)
if err != nil {
return err
}
if same, err := sameFileChecksum(dst, payload); err == nil && same {
return syncFileOwnershipWithMode(dst, referenceDir, mode)
}
tmp, err := os.CreateTemp(filepath.Dir(dst), "zlh-system-file-*")
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.Close(); err != nil {
return err
}
if err := os.Rename(tmpPath, dst); err != nil {
return err
}
return syncFileOwnershipWithMode(dst, referenceDir, mode)
}
func sameFileChecksum(path string, payload []byte) (bool, error) {
current, err := os.ReadFile(path)
if err != nil {
return false, err
}
currentSum := sha256.Sum256(current)
newSum := sha256.Sum256(payload)
return currentSum == newSum, nil
}
func syncOwnershipAndPerms(path string, info os.FileInfo) error {
if err := os.Chmod(path, info.Mode().Perm()); err != nil {
return err
}
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return nil
}
return os.Chown(path, int(stat.Uid), int(stat.Gid))
}
func syncFileOwnership(path, referenceDir string) error {
return syncFileOwnershipWithMode(path, referenceDir, 0o644)
}
func syncFileOwnershipWithMode(path, referenceDir string, mode os.FileMode) error {
info, err := os.Stat(referenceDir)
if err != nil {
return err
}
if err := os.Chmod(path, mode); err != nil {
return err
}
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return nil
}
return os.Chown(path, int(stat.Uid), int(stat.Gid))
}