441 lines
11 KiB
Go
441 lines
11 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"
|
|
fabricAPIArtifactRoot = "minecraft/fabric/fabric-api"
|
|
fabricProxyConfigPath = "minecraft/fabric/FabricProxy-Lite.toml"
|
|
)
|
|
|
|
var (
|
|
fabricProxyMapOnce sync.Once
|
|
fabricProxyMap map[string]string
|
|
fabricProxyMapErr error
|
|
)
|
|
|
|
func resolveProxyVersion(mcVersion string) (string, error) {
|
|
return resolveMappedArtifactPath(mcVersion, loadFabricProxyMap, "fabricproxy-lite", func(value string) string {
|
|
return value
|
|
})
|
|
}
|
|
|
|
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")
|
|
fabricAPISource, err := resolveFabricAPIArtifactURL(cfg.Version)
|
|
if err != nil {
|
|
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 source=artifact", cfg.VMID)
|
|
if err := installSystemMod(fabricAPISource, 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
|
|
}
|
|
if !strings.EqualFold(cfg.Variant, "vanilla") {
|
|
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
|
|
}
|
|
|
|
fabricProxyMap, fabricProxyMapErr = parseArtifactMap(raw, "fabricproxy-lite")
|
|
})
|
|
|
|
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 resolveFabricAPIArtifactURL(mcVersion string) (string, error) {
|
|
mcVersion = strings.TrimSpace(mcVersion)
|
|
if mcVersion == "" {
|
|
return "", fmt.Errorf("empty minecraft version")
|
|
}
|
|
if _, err := parseVersion(mcVersion); err != nil {
|
|
return "", fmt.Errorf("invalid minecraft version %q: %w", mcVersion, err)
|
|
}
|
|
|
|
rel := filepath.ToSlash(filepath.Join(fabricAPIArtifactRoot, mcVersion, "fabric-api.jar"))
|
|
return provcommon.BuildArtifactURL(rel), nil
|
|
}
|
|
|
|
func resolveMappedArtifactPath(mcVersion string, loadMap func() (map[string]string, error), name string, valueToPath func(string) string) (string, error) {
|
|
mappings, err := loadMap()
|
|
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 value, ok := mappings[mcVersion]; ok {
|
|
return valueToPath(value), nil
|
|
}
|
|
|
|
for spec, value := range mappings {
|
|
start, end, ok := parseVersionRange(spec)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if compareVersionSlices(compareVersion, start) >= 0 && compareVersionSlices(compareVersion, end) <= 0 {
|
|
return valueToPath(value), nil
|
|
}
|
|
}
|
|
|
|
return "", fmt.Errorf("no %s mapping for minecraft version %q", name, mcVersion)
|
|
}
|
|
|
|
func parseArtifactMap(raw []byte, name string) (map[string]string, error) {
|
|
var mappings map[string]string
|
|
if err := json.Unmarshal(raw, &mappings); err != nil {
|
|
return nil, fmt.Errorf("parse %s map: %w", name, err)
|
|
}
|
|
if len(mappings) == 0 {
|
|
return nil, fmt.Errorf("%s map is empty", name)
|
|
}
|
|
|
|
for spec, version := range mappings {
|
|
spec = strings.TrimSpace(spec)
|
|
version = strings.TrimSpace(version)
|
|
if spec == "" || version == "" {
|
|
return nil, fmt.Errorf("%s map contains empty key or value", name)
|
|
}
|
|
if start, end, ok := parseVersionRange(spec); ok {
|
|
if compareVersionSlices(start, end) > 0 {
|
|
return nil, fmt.Errorf("invalid %s range %q", name, spec)
|
|
}
|
|
continue
|
|
}
|
|
if _, err := parseVersion(spec); err != nil {
|
|
return nil, fmt.Errorf("invalid %s version key %q: %w", name, spec, err)
|
|
}
|
|
}
|
|
|
|
return mappings, nil
|
|
}
|
|
|
|
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 := max(len(b), len(a))
|
|
|
|
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))
|
|
}
|