zlh-agent/internal/http/agent.go
2026-03-20 23:17:19 +00:00

795 lines
24 KiB
Go
Executable File

package agenthttp
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
agentfiles "zlh-agent/internal/files"
agenthandlers "zlh-agent/internal/handlers"
mcstatus "zlh-agent/internal/minecraft"
"zlh-agent/internal/provision"
"zlh-agent/internal/provision/addons/codeserver"
"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
}
func requireDevContainer() (*state.Config, error) {
cfg, err := state.LoadConfig()
if err != nil {
return nil, fmt.Errorf("load config: %w", err)
}
if cfg.ContainerType != "dev" {
return nil, fmt.Errorf("code-server controls are only available for dev containers")
}
if !cfg.EnableCodeServer {
return nil, fmt.Errorf("code-server is not enabled for this container")
}
return cfg, 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
}
log.Printf("[http] vmid=%d action=config status=received type=%s runtime=%s game=%s variant=%s version=%s", cfg.VMID, cfg.ContainerType, cfg.Runtime, cfg.Game, cfg.Variant, cfg.Version)
if err := state.SaveConfig(&cfg); err != nil {
log.Printf("[http] vmid=%d action=config status=save_failed err=%v", cfg.VMID, err)
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
}
log.Printf("[http] vmid=%d action=start status=requested", cfg.VMID)
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 {
log.Printf("[http] vmid=%d action=start status=failed err=%v", cfg.VMID, err)
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 {
log.Printf("[http] vmid=%d action=start status=readiness_failed err=%v", cfg.VMID, err)
state.SetError(err)
state.SetState(state.StateError)
http.Error(w, "start readiness error: "+err.Error(), http.StatusGatewayTimeout)
return
}
log.Printf("[http] vmid=%d action=start status=ok", cfg.VMID)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok": true, "state": "starting"}`))
}
/*
--------------------------------------------------------------------------
/stop
----------------------------------------------------------------------------
*/
func handleStop(w http.ResponseWriter, r *http.Request) {
if cfg, err := state.LoadConfig(); err == nil && cfg != nil {
log.Printf("[http] vmid=%d action=stop status=requested", cfg.VMID)
}
if err := system.StopServer(); err != nil {
http.Error(w, "stop error: "+err.Error(), http.StatusInternalServerError)
return
}
if cfg, err := state.LoadConfig(); err == nil && cfg != nil {
log.Printf("[http] vmid=%d action=stop status=ok", cfg.VMID)
}
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
}
log.Printf("[http] vmid=%d action=restart status=requested", cfg.VMID)
_ = system.StopServer()
if err := system.WaitForServerExit(20 * time.Second); err != nil {
log.Printf("[http] vmid=%d action=restart status=stop_wait_failed err=%v", cfg.VMID, err)
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 {
log.Printf("[http] vmid=%d action=restart status=start_failed err=%v", cfg.VMID, err)
http.Error(w, "restart error: "+err.Error(), http.StatusInternalServerError)
return
}
if err := waitMinecraftReady(cfg, "restart_manual_probe", started); err != nil {
log.Printf("[http] vmid=%d action=restart status=readiness_failed err=%v", cfg.VMID, err)
state.SetError(err)
state.SetState(state.StateError)
http.Error(w, "restart readiness error: "+err.Error(), http.StatusGatewayTimeout)
return
}
log.Printf("[http] vmid=%d action=restart status=ok", cfg.VMID)
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)
lastCrashClassification := ""
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
lastCrashClassification = crash.Classification
lastCrashLogTail = crash.LogTail
}
workspaceRoot := ""
serverRoot := ""
runtimeInstallPath := ""
runtimeInstalled := false
devProvisioned := false
devReadyAt := ""
codeServerInstalled := false
codeServerRunning := false
if cfg != nil {
if cfg.ContainerType == "dev" {
workspaceRoot = agentfiles.RuntimeRoot(cfg)
runtimeInstallPath = devcontainer.RuntimeInstallDir(cfg.Runtime, cfg.Version)
runtimeInstalled = devcontainer.RuntimeInstalled(cfg.Runtime, cfg.Version)
devProvisioned = devcontainer.IsProvisioned()
if readyInfo, err := devcontainer.ReadReadyMarker(); err == nil && readyInfo != nil {
devReadyAt = readyInfo.ReadyAt
}
if cfg.EnableCodeServer {
codeServerInstalled = codeserver.Installed()
codeServerRunning = codeserver.Running()
}
} else {
serverRoot = provision.ServerDir(*cfg)
}
}
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,
"lastCrashClassification": lastCrashClassification,
"lastCrashLogTail": lastCrashLogTail,
"error": nil,
"config": cfg,
"workspaceRoot": workspaceRoot,
"serverRoot": serverRoot,
"runtimeInstallPath": runtimeInstallPath,
"runtimeInstalled": runtimeInstalled,
"devProvisioned": devProvisioned,
"devReadyAt": devReadyAt,
"codeServerInstalled": codeServerInstalled,
"codeServerRunning": codeServerRunning,
"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)
}
func handleCodeServerStart(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "POST only", http.StatusMethodNotAllowed)
return
}
cfg, err := requireDevContainer()
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
log.Printf("[http] vmid=%d action=codeserver_start status=requested", cfg.VMID)
if err := codeserver.Start(*cfg); err != nil {
log.Printf("[http] vmid=%d action=codeserver_start status=failed err=%v", cfg.VMID, err)
http.Error(w, "code-server start failed: "+err.Error(), http.StatusInternalServerError)
return
}
log.Printf("[http] vmid=%d action=codeserver_start status=ok", cfg.VMID)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"status": "ok", "running": codeserver.Running()})
}
func handleCodeServerStop(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "POST only", http.StatusMethodNotAllowed)
return
}
cfg, err := requireDevContainer()
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
log.Printf("[http] vmid=%d action=codeserver_stop status=requested", cfg.VMID)
if err := codeserver.Stop(); err != nil {
log.Printf("[http] vmid=%d action=codeserver_stop status=failed err=%v", cfg.VMID, err)
http.Error(w, "code-server stop failed: "+err.Error(), http.StatusInternalServerError)
return
}
log.Printf("[http] vmid=%d action=codeserver_stop status=ok", cfg.VMID)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"status": "ok", "running": codeserver.Running()})
}
func handleCodeServerRestart(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "POST only", http.StatusMethodNotAllowed)
return
}
cfg, err := requireDevContainer()
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
log.Printf("[http] vmid=%d action=codeserver_restart status=requested", cfg.VMID)
if err := codeserver.Restart(*cfg); err != nil {
log.Printf("[http] vmid=%d action=codeserver_restart status=failed err=%v", cfg.VMID, err)
http.Error(w, "code-server restart failed: "+err.Error(), http.StatusInternalServerError)
return
}
log.Printf("[http] vmid=%d action=codeserver_restart status=ok", cfg.VMID)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"status": "ok", "running": codeserver.Running()})
}
/*
--------------------------------------------------------------------------
/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("/dev/codeserver/start", handleCodeServerStart)
m.HandleFunc("/dev/codeserver/stop", handleCodeServerStop)
m.HandleFunc("/dev/codeserver/restart", handleCodeServerRestart)
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
}