package agenthttp import ( "encoding/json" "fmt" "io" "log" "net/http" "os" "path/filepath" "strconv" "strings" "time" agenthandlers "zlh-agent/internal/handlers" mcstatus "zlh-agent/internal/minecraft" "zlh-agent/internal/provision" "zlh-agent/internal/provision/devcontainer" "zlh-agent/internal/provision/devcontainer/dotnet" "zlh-agent/internal/provision/devcontainer/go" "zlh-agent/internal/provision/devcontainer/java" "zlh-agent/internal/provision/devcontainer/node" "zlh-agent/internal/provision/devcontainer/python" "zlh-agent/internal/provision/minecraft" "zlh-agent/internal/state" "zlh-agent/internal/system" "zlh-agent/internal/update" "zlh-agent/internal/util" "zlh-agent/internal/version" ) const ReadinessTimeout = 60 * time.Second /* -------------------------------------------------------------------------- Helpers ---------------------------------------------------------------------------- */ func fileExists(path string) bool { _, err := os.Stat(path) return err == nil } func dirExists(path string) bool { s, err := os.Stat(path) 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, ReadinessTimeout, 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) ---------------------------------------------------------------------------- */ func runProvisionPipeline(cfg *state.Config) error { state.SetState(state.StateInstalling) state.SetInstallStep("provision_all") if err := provision.ProvisionAll(*cfg); err != nil { state.SetError(err) state.SetState(state.StateError) return err } if strings.ToLower(cfg.Game) == "minecraft" { if err := minecraft.VerifyMinecraftInstallWithRepair(*cfg); err != nil { state.SetError(err) state.SetState(state.StateError) return fmt.Errorf("minecraft verification failed: %w", err) } } state.SetInstallStep("") state.SetState(state.StateIdle) return nil } /* -------------------------------------------------------------------------- ensureProvisioned() — idempotent, unified ---------------------------------------------------------------------------- */ func ensureProvisioned(cfg *state.Config) error { if cfg.ContainerType == "dev" { if !devcontainer.IsProvisioned() || !devcontainer.RuntimeInstalled(cfg.Runtime, cfg.Version) { if err := runProvisionPipeline(cfg); err != nil { return err } } var err error switch strings.ToLower(cfg.Runtime) { case "node": err = node.Verify(*cfg) case "python": err = python.Verify(*cfg) case "go": err = goenv.Verify(*cfg) case "java": err = java.Verify(*cfg) case "dotnet": err = dotnet.Verify(*cfg) default: return fmt.Errorf("unsupported devcontainer runtime: %s", cfg.Runtime) } if err != nil { return err } state.SetState(state.StateIdle) state.SetError(nil) return nil } dir := provision.ServerDir(*cfg) game := strings.ToLower(cfg.Game) variant := strings.ToLower(cfg.Variant) _ = os.MkdirAll(dir, 0o755) isSteam := provision.IsSteamGame(game) isForgeLike := variant == "forge" || variant == "neoforge" if isSteam { return runProvisionPipeline(cfg) } if isForgeLike { runSh := filepath.Join(dir, "run.sh") libraries := filepath.Join(dir, "libraries") if fileExists(runSh) && dirExists(libraries) { return nil } return runProvisionPipeline(cfg) } jar := filepath.Join(dir, "server.jar") if fileExists(jar) { return nil } return runProvisionPipeline(cfg) } /* -------------------------------------------------------------------------- /config — the REAL provisioning trigger (async) ---------------------------------------------------------------------------- */ func handleConfig(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "POST only", http.StatusMethodNotAllowed) return } body, _ := io.ReadAll(r.Body) var cfg state.Config if err := json.Unmarshal(body, &cfg); err != nil { http.Error(w, "bad json", http.StatusBadRequest) return } if err := state.SaveConfig(&cfg); err != nil { http.Error(w, "save config failed: "+err.Error(), http.StatusInternalServerError) return } go func(c state.Config) { log.Printf("[http] vmid=%d async provision+start begin", c.VMID) started := time.Now() lifecycleLog(&c, "config_async", 1, started, "begin") if err := ensureProvisioned(&c); err != nil { log.Printf("[http] vmid=%d provision error: %v", c.VMID, err) return } 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.Printf("[http] vmid=%d start error: %v", c.VMID, 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 } // ------------------------------------------------- // FORGE / NEOFORGE: wait → stop → patch → restart // ------------------------------------------------- game := strings.ToLower(c.Game) variant := strings.ToLower(c.Variant) if game == "minecraft" && (variant == "forge" || variant == "neoforge") { lifecycleLog(&c, "forge_post", 1, started, "begin") // Wait for server.properties to exist before enforcing propsPath := filepath.Join(provision.ServerDir(c), "server.properties") propsDeadline := time.Now().Add(2 * time.Minute) for { if _, err := os.Stat(propsPath); err == nil { break } if time.Now().After(propsDeadline) { err := fmt.Errorf("forge server.properties not found before timeout") log.Printf("[http] vmid=%d forge post-start error: %v", c.VMID, err) state.SetError(err) state.SetState(state.StateError) return } time.Sleep(2 * time.Second) } _ = system.StopServer() if err := system.WaitForServerExit(20 * time.Second); err != nil { log.Printf("[http] vmid=%d forge stop wait error: %v", c.VMID, 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.Printf("[http] vmid=%d forge post-start error: %v", c.VMID, 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.Printf("[http] vmid=%d restart error: %v", c.VMID, 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") } } log.Printf("[http] vmid=%d async provision+start complete", c.VMID) }(cfg) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusAccepted) _, _ = w.Write([]byte(`{"ok": true, "state": "installing"}`)) } /* -------------------------------------------------------------------------- /start ---------------------------------------------------------------------------- */ func handleStart(w http.ResponseWriter, r *http.Request) { cfg, err := state.LoadConfig() if err != nil { http.Error(w, "no config: "+err.Error(), http.StatusBadRequest) return } if cfg.ContainerType == "dev" { http.Error(w, "dev containers do not support manual start", http.StatusBadRequest) 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 } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"ok": true, "state": "starting"}`)) } /* -------------------------------------------------------------------------- /stop ---------------------------------------------------------------------------- */ func handleStop(w http.ResponseWriter, r *http.Request) { if err := system.StopServer(); err != nil { http.Error(w, "stop error: "+err.Error(), http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } /* -------------------------------------------------------------------------- /restart ---------------------------------------------------------------------------- */ func handleRestart(w http.ResponseWriter, r *http.Request) { cfg, err := state.LoadConfig() if err != nil { http.Error(w, "no config", http.StatusBadRequest) return } if cfg.ContainerType == "dev" { http.Error(w, "dev containers do not support restart", http.StatusBadRequest) return } _ = 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"}`)) } /* -------------------------------------------------------------------------- /status ---------------------------------------------------------------------------- */ 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) } lastCrashTime := "" lastCrashExitCode := 0 lastCrashSignal := 0 lastCrashUptimeSeconds := int64(0) var lastCrashLogTail []string if crash := state.GetLastCrash(); crash != nil { if !crash.Time.IsZero() { lastCrashTime = crash.Time.UTC().Format(time.RFC3339) } lastCrashExitCode = crash.ExitCode lastCrashSignal = crash.Signal lastCrashUptimeSeconds = crash.UptimeSeconds lastCrashLogTail = crash.LogTail } resp := map[string]any{ "state": state.GetState(), "processRunning": processRunning, "ready": state.GetReady(), "readySource": state.GetReadySource(), "readyError": state.GetReadyError(), "lastReadyAt": readyAt, "installStep": state.GetInstallStep(), "crashCount": state.GetCrashCount(), "lastCrashTime": lastCrashTime, "lastCrashExitCode": lastCrashExitCode, "lastCrashSignal": lastCrashSignal, "lastCrashUptimeSeconds": lastCrashUptimeSeconds, "lastCrashLogTail": lastCrashLogTail, "error": nil, "config": cfg, "timestamp": time.Now().Unix(), } if err := state.GetError(); err != nil { resp["error"] = err.Error() } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(resp) } /* -------------------------------------------------------------------------- /console/command ---------------------------------------------------------------------------- */ func handleSendCommand(w http.ResponseWriter, r *http.Request) { cmd := r.URL.Query().Get("cmd") if cmd == "" { http.Error(w, "cmd required", http.StatusBadRequest) return } if err := system.SendConsoleCommand(cmd); err != nil { http.Error(w, "command error: "+err.Error(), http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } /* -------------------------------------------------------------------------- /agent/update ----------------------------------------------------------------------------*/ func handleAgentUpdate(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "POST only", http.StatusMethodNotAllowed) return } res := update.CheckAndUpdate(version.AgentVersion) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(res) } /* -------------------------------------------------------------------------- /agent/update/status ----------------------------------------------------------------------------*/ func handleAgentUpdateStatus(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "GET only", http.StatusMethodNotAllowed) return } res := update.ReadStatus() w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(res) } /* -------------------------------------------------------------------------- /version ----------------------------------------------------------------------------*/ func handleVersion(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "GET only", http.StatusMethodNotAllowed) return } resp := map[string]any{ "version": version.AgentVersion, } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(resp) } /* -------------------------------------------------------------------------- /game/players ----------------------------------------------------------------------------*/ func handleGamePlayers(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "GET only", http.StatusMethodNotAllowed) return } cfg, err := state.LoadConfig() if err != nil { http.Error(w, "no config loaded", http.StatusBadRequest) return } if strings.ToLower(cfg.ContainerType) != "game" { http.Error(w, "not a game container", http.StatusBadRequest) return } if strings.ToLower(cfg.Game) != "minecraft" { http.Error(w, "unsupported game", http.StatusNotImplemented) return } 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) seenPorts := make(map[int]struct{}, len(ports)) uniqPorts := make([]int, 0, len(ports)) for _, p := range ports { if _, ok := seenPorts[p]; ok { continue } seenPorts[p] = struct{}{} uniqPorts = append(uniqPorts, p) } protocols := []int{mcstatus.ProtocolForVersion(cfg.Version), 767, 765, 763, 762, 754} seenProtocols := make(map[int]struct{}, len(protocols)) uniqProtocols := make([]int, 0, len(protocols)) for _, pr := range protocols { if _, ok := seenProtocols[pr]; ok { continue } seenProtocols[pr] = struct{}{} uniqProtocols = append(uniqProtocols, pr) } var status mcstatus.StatusResponse var lastErr error for _, port := range uniqPorts { for _, protocol := range uniqProtocols { s, err := mcstatus.QueryStatus("127.0.0.1", port, protocol) if err != nil { lastErr = err continue } status = s lastErr = nil break } if lastErr == nil { break } } if lastErr != nil { http.Error(w, "status query failed: "+lastErr.Error(), http.StatusBadGateway) return } players := make([]string, 0, len(status.Players.Sample)) for _, p := range status.Players.Sample { if p.Name != "" { players = append(players, p.Name) } } resp := map[string]any{ "online": status.Players.Online, "max": status.Players.Max, "players": players, } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(resp) } /* -------------------------------------------------------------------------- Router ---------------------------------------------------------------------------- */ func NewMux() *http.ServeMux { m := http.NewServeMux() m.HandleFunc("/config", handleConfig) m.HandleFunc("/start", handleStart) m.HandleFunc("/stop", handleStop) m.HandleFunc("/restart", handleRestart) m.HandleFunc("/status", handleStatus) m.HandleFunc("/console/command", handleSendCommand) m.HandleFunc("/agent/update", handleAgentUpdate) 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) m.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok")) }) log.Println("[agent] routes registered") return m }