package agenthttp import ( "encoding/json" "fmt" "io" "log" "net/http" "os" "path/filepath" "strings" "time" "zlh-agent/internal/provision" "zlh-agent/internal/provision/minecraft" "zlh-agent/internal/state" "zlh-agent/internal/system" ) /* -------------------------------------------------------------------------- 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() } /* -------------------------------------------------------------------------- Shared provision pipeline (installer + Minecraft verify) ----------------------------------------------------------------------------*/ func runProvisionPipeline(cfg *state.Config) error { state.SetState(state.StateInstalling) state.SetInstallStep("provision_all") // Installer (downloads files, patches, configs, etc.) if err := provision.ProvisionAll(*cfg); err != nil { state.SetError(err) state.SetState(state.StateError) return err } // Extra Minecraft verification 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, no goto ----------------------------------------------------------------------------*/ func ensureProvisioned(cfg *state.Config) error { 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" // ---------- STEAM GAMES ALWAYS REQUIRE PROVISION ---------- if isSteam { return runProvisionPipeline(cfg) } // ---------- FORGE / NEOFORGE ---------- if isForgeLike { runSh := filepath.Join(dir, "run.sh") libraries := filepath.Join(dir, "libraries") if fileExists(runSh) && dirExists(libraries) { return nil // already provisioned } return runProvisionPipeline(cfg) } // ---------- VANILLA / PAPER / PURPUR / FABRIC ---------- jar := filepath.Join(dir, "server.jar") if fileExists(jar) { return nil // already provisioned } 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 } // Run provision + server start asynchronously go func(c state.Config) { log.Println("[agent] async provision+start begin") if err := ensureProvisioned(&c); err != nil { log.Println("[agent] provision error:", err) return } if err := system.StartServer(&c); err != nil { log.Println("[agent] start error:", err) state.SetError(err) state.SetState(state.StateError) return } log.Println("[agent] async provision+start complete") }(*&cfg) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusAccepted) _, _ = w.Write([]byte(`{"ok": true, "state": "installing"}`)) } /* -------------------------------------------------------------------------- /start — manual UI start (does NOT re-provision) ----------------------------------------------------------------------------*/ 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 err := system.StartServer(cfg); err != nil { http.Error(w, "start error: "+err.Error(), http.StatusInternalServerError) 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 } _ = system.StopServer() if err := system.StartServer(cfg); err != nil { http.Error(w, "restart error: "+err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"ok": true, "state": "starting"}`)) } /* -------------------------------------------------------------------------- /status — API polls this ----------------------------------------------------------------------------*/ func handleStatus(w http.ResponseWriter, r *http.Request) { cfg, _ := state.LoadConfig() resp := map[string]any{ "state": state.GetState(), "installStep": state.GetInstallStep(), "crashCount": state.GetCrashCount(), "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) } /* -------------------------------------------------------------------------- Router + WebSocket ----------------------------------------------------------------------------*/ 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) registerWebSocket(m) m.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok")) }) log.Println("[agent] routes registered") return m }