546 lines
14 KiB
Go
Executable File
546 lines
14 KiB
Go
Executable File
package agenthttp
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
mcstatus "zlh-agent/internal/minecraft"
|
|
"zlh-agent/internal/provision"
|
|
"zlh-agent/internal/provision/devcontainer"
|
|
"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/version"
|
|
)
|
|
|
|
/*
|
|
--------------------------------------------------------------------------
|
|
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")
|
|
|
|
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() {
|
|
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)
|
|
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.Println("[agent] async provision+start begin")
|
|
|
|
if err := ensureProvisioned(&c); err != nil {
|
|
log.Println("[agent] provision error:", err)
|
|
return
|
|
}
|
|
|
|
if c.ContainerType != "dev" {
|
|
|
|
if err := system.StartServer(&c); err != nil {
|
|
log.Println("[agent] start error:", err)
|
|
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") {
|
|
|
|
deadline := time.Now().Add(5 * time.Minute)
|
|
for {
|
|
if state.GetState() == state.StateRunning {
|
|
break
|
|
}
|
|
if time.Now().After(deadline) {
|
|
err := fmt.Errorf("forge did not reach running state")
|
|
log.Println("[agent]", err)
|
|
state.SetError(err)
|
|
state.SetState(state.StateError)
|
|
return
|
|
}
|
|
time.Sleep(2 * time.Second)
|
|
}
|
|
|
|
// 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.Println("[agent] forge post-start error:", err)
|
|
state.SetError(err)
|
|
state.SetState(state.StateError)
|
|
return
|
|
}
|
|
time.Sleep(2 * time.Second)
|
|
}
|
|
|
|
_ = system.StopServer()
|
|
|
|
if err := minecraft.EnforceForgeServerProperties(c); err != nil {
|
|
log.Println("[agent] forge post-start error:", err)
|
|
state.SetError(err)
|
|
state.SetState(state.StateError)
|
|
return
|
|
}
|
|
|
|
if err := system.StartServer(&c); err != nil {
|
|
log.Println("[agent] restart 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
|
|
|
|
----------------------------------------------------------------------------
|
|
*/
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
if cfg.ContainerType == "dev" {
|
|
http.Error(w, "dev containers do not support restart", 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
|
|
|
|
----------------------------------------------------------------------------
|
|
*/
|
|
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)
|
|
}
|
|
|
|
/* --------------------------------------------------------------------------
|
|
/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)
|
|
|
|
registerWebSocket(m)
|
|
|
|
m.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Write([]byte("ok"))
|
|
})
|
|
|
|
log.Println("[agent] routes registered")
|
|
return m
|
|
}
|