955 lines
28 KiB
Go
Executable File
955 lines
28 KiB
Go
Executable File
package agenthttp
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"zlh-agent/internal/alloy"
|
|
"zlh-agent/internal/auth"
|
|
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
|
|
MaxConfigBytes = 1 << 20
|
|
)
|
|
|
|
/*
|
|
--------------------------------------------------------------------------
|
|
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()
|
|
}
|
|
|
|
var (
|
|
provisionAll = provision.ProvisionAll
|
|
devIsProvisioned = devcontainer.IsProvisioned
|
|
devRuntimeInstalled = devcontainer.RuntimeInstalled
|
|
codeServerInstall = codeserver.Install
|
|
codeServerStart = codeserver.Start
|
|
codeServerVerify = codeserver.Verify
|
|
codeServerInstalled = codeserver.Installed
|
|
codeServerRunning = codeserver.Running
|
|
)
|
|
|
|
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 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
|
|
}
|
|
|
|
func beginHTTPOperation(w http.ResponseWriter, opType string, maintenance bool, message string) (func(), bool) {
|
|
end, ok, current := state.TryStartOperation(opType, maintenance, message)
|
|
if ok {
|
|
return end, true
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusConflict)
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"error": "operation already in progress",
|
|
"operationType": current.Type,
|
|
"maintenance": current.Maintenance,
|
|
"operationSince": current.StartedAt.UTC().Format(time.RFC3339),
|
|
"message": current.Message,
|
|
})
|
|
return nil, false
|
|
}
|
|
|
|
/*
|
|
--------------------------------------------------------------------------
|
|
Shared provision pipeline (installer + Minecraft verify)
|
|
|
|
----------------------------------------------------------------------------
|
|
*/
|
|
func runProvisionPipeline(cfg *state.Config) error {
|
|
state.SetState(state.StateInstalling)
|
|
state.SetInstallStep("provision_all")
|
|
|
|
if err := 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
|
|
}
|
|
|
|
func startProvisionedGame(cfg *state.Config, started time.Time) error {
|
|
state.SetState(state.StateStarting)
|
|
state.SetReadyState(false, "", "")
|
|
lifecycleLog(cfg, "start", 1, started, "start_requested")
|
|
|
|
if isForgeLikeMinecraft(*cfg) {
|
|
return startForgeFirstRun(cfg, started)
|
|
}
|
|
|
|
if err := system.StartServerReady(cfg); err != nil {
|
|
log.Printf("[http] vmid=%d start error: %v", cfg.VMID, err)
|
|
state.SetError(err)
|
|
state.SetState(state.StateError)
|
|
lifecycleLog(cfg, "start", 1, started, "start_failed err=%v", err)
|
|
return err
|
|
}
|
|
lifecycleLog(cfg, "start", 1, started, "process_started")
|
|
return nil
|
|
}
|
|
|
|
func startForgeFirstRun(cfg *state.Config, started time.Time) error {
|
|
if err := system.StartServer(cfg); err != nil {
|
|
log.Printf("[http] vmid=%d start error: %v", cfg.VMID, err)
|
|
state.SetError(err)
|
|
state.SetState(state.StateError)
|
|
lifecycleLog(cfg, "start", 1, started, "start_failed err=%v", err)
|
|
return err
|
|
}
|
|
lifecycleLog(cfg, "start", 1, started, "process_started")
|
|
lifecycleLog(cfg, "forge_post", 1, started, "begin")
|
|
|
|
if err := waitForForgeServerProperties(*cfg, 2*time.Minute); err != nil {
|
|
log.Printf("[http] vmid=%d forge post-start error: %v", cfg.VMID, err)
|
|
state.SetError(err)
|
|
state.SetState(state.StateError)
|
|
return err
|
|
}
|
|
|
|
_ = system.StopServer()
|
|
if err := system.WaitForServerExit(20 * time.Second); err != nil {
|
|
log.Printf("[http] vmid=%d forge stop wait error: %v", cfg.VMID, err)
|
|
state.SetError(err)
|
|
state.SetState(state.StateError)
|
|
lifecycleLog(cfg, "forge_post", 1, started, "stop_wait_failed err=%v", err)
|
|
return err
|
|
}
|
|
|
|
if err := minecraft.EnforceForgeServerProperties(*cfg); err != nil {
|
|
log.Printf("[http] vmid=%d forge post-start error: %v", cfg.VMID, err)
|
|
state.SetError(err)
|
|
state.SetState(state.StateError)
|
|
lifecycleLog(cfg, "forge_post", 1, started, "enforce_failed err=%v", err)
|
|
return err
|
|
}
|
|
|
|
state.SetState(state.StateStarting)
|
|
state.SetReadyState(false, "", "")
|
|
if err := system.StartServerReady(cfg); err != nil {
|
|
log.Printf("[http] vmid=%d restart error: %v", cfg.VMID, err)
|
|
state.SetError(err)
|
|
state.SetState(state.StateError)
|
|
lifecycleLog(cfg, "forge_post", 1, started, "restart_failed err=%v", err)
|
|
return err
|
|
}
|
|
lifecycleLog(cfg, "forge_post", 1, started, "complete")
|
|
return nil
|
|
}
|
|
|
|
func waitForForgeServerProperties(cfg state.Config, timeout time.Duration) error {
|
|
propsPath := filepath.Join(provision.ServerDir(cfg), "server.properties")
|
|
deadline := time.Now().Add(timeout)
|
|
for {
|
|
if _, err := os.Stat(propsPath); err == nil {
|
|
return nil
|
|
}
|
|
if time.Now().After(deadline) {
|
|
return fmt.Errorf("forge server.properties not found before timeout")
|
|
}
|
|
time.Sleep(2 * time.Second)
|
|
}
|
|
}
|
|
|
|
func isForgeLikeMinecraft(cfg state.Config) bool {
|
|
game := strings.ToLower(cfg.Game)
|
|
variant := strings.ToLower(cfg.Variant)
|
|
return game == "minecraft" && (variant == "forge" || variant == "neoforge")
|
|
}
|
|
|
|
/*
|
|
--------------------------------------------------------------------------
|
|
ensureProvisioned() — idempotent, unified
|
|
|
|
----------------------------------------------------------------------------
|
|
*/
|
|
func ensureProvisioned(cfg *state.Config) error {
|
|
|
|
if cfg.ContainerType == "dev" {
|
|
|
|
if !devIsProvisioned() || !devRuntimeInstalled(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
|
|
}
|
|
|
|
if err := ensureDevCodeServer(cfg); 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, provision.ExpectedMinecraftJar(*cfg))
|
|
if fileExists(jar) {
|
|
return nil
|
|
}
|
|
|
|
return runProvisionPipeline(cfg)
|
|
}
|
|
|
|
func ensureDevCodeServer(cfg *state.Config) error {
|
|
if cfg == nil || !provision.CodeServerRequested(*cfg) {
|
|
return nil
|
|
}
|
|
|
|
if !codeServerInstalled() {
|
|
if err := codeServerInstall(*cfg); err != nil {
|
|
return fmt.Errorf("code-server install failed: %w", err)
|
|
}
|
|
}
|
|
if !codeServerRunning() {
|
|
if err := codeServerStart(*cfg); err != nil {
|
|
return fmt.Errorf("code-server start failed: %w", err)
|
|
}
|
|
}
|
|
if err := codeServerVerify(); err != nil {
|
|
return fmt.Errorf("code-server verification failed: %w", err)
|
|
}
|
|
if !codeServerRunning() {
|
|
return fmt.Errorf("code-server did not stay running")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
/*
|
|
--------------------------------------------------------------------------
|
|
/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
|
|
}
|
|
endOp, ok := beginHTTPOperation(w, "provision", true, "provisioning container")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var cfg state.Config
|
|
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, MaxConfigBytes))
|
|
dec.DisallowUnknownFields()
|
|
if err := dec.Decode(&cfg); err != nil {
|
|
endOp()
|
|
http.Error(w, "bad json", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if err := state.ValidateConfig(&cfg); err != nil {
|
|
endOp()
|
|
http.Error(w, "invalid config: "+err.Error(), 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 {
|
|
endOp()
|
|
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
|
|
}
|
|
if _, err := alloy.EnsureConfig(cfg); err != nil {
|
|
log.Printf("[http] vmid=%d action=config status=alloy_failed non_fatal=true err=%v", cfg.VMID, err)
|
|
}
|
|
|
|
go func(c state.Config) {
|
|
defer endOp()
|
|
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" {
|
|
if err := startProvisionedGame(&c, started); err != nil {
|
|
return
|
|
}
|
|
}
|
|
|
|
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) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
endOp, ok := beginHTTPOperation(w, "start", false, "starting server")
|
|
if !ok {
|
|
return
|
|
}
|
|
defer endOp()
|
|
|
|
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.StartServerReady(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
|
|
}
|
|
log.Printf("[http] vmid=%d action=start status=ok", cfg.VMID)
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"ok": true, "state": "running"}`))
|
|
}
|
|
|
|
/*
|
|
--------------------------------------------------------------------------
|
|
/stop
|
|
|
|
----------------------------------------------------------------------------
|
|
*/
|
|
func handleStop(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
endOp, ok := beginHTTPOperation(w, "stop", false, "stopping server")
|
|
if !ok {
|
|
return
|
|
}
|
|
defer endOp()
|
|
|
|
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) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
endOp, ok := beginHTTPOperation(w, "restart", false, "restarting server")
|
|
if !ok {
|
|
return
|
|
}
|
|
defer endOp()
|
|
|
|
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.StartServerReady(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
|
|
}
|
|
lifecycleLog(cfg, "restart_manual", 1, started, "ready")
|
|
log.Printf("[http] vmid=%d action=restart status=ok", cfg.VMID)
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"ok": true, "state": "running"}`))
|
|
}
|
|
|
|
/*
|
|
--------------------------------------------------------------------------
|
|
/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)
|
|
}
|
|
}
|
|
op := state.GetOperation()
|
|
|
|
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,
|
|
"operationInProgress": op.InProgress,
|
|
"operationType": op.Type,
|
|
"maintenance": op.Maintenance,
|
|
"operationStartedAt": "",
|
|
"operationMessage": op.Message,
|
|
"timestamp": time.Now().Unix(),
|
|
}
|
|
if op.InProgress && !op.StartedAt.IsZero() {
|
|
resp["operationStartedAt"] = op.StartedAt.UTC().Format(time.RFC3339)
|
|
}
|
|
|
|
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) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
cmd := ""
|
|
contentType := r.Header.Get("Content-Type")
|
|
if strings.Contains(contentType, "application/json") {
|
|
var req struct {
|
|
Command string `json:"command"`
|
|
Cmd string `json:"cmd"`
|
|
}
|
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&req); err != nil {
|
|
http.Error(w, "bad json", http.StatusBadRequest)
|
|
return
|
|
}
|
|
cmd = req.Command
|
|
if cmd == "" {
|
|
cmd = req.Cmd
|
|
}
|
|
} else {
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "bad form", http.StatusBadRequest)
|
|
return
|
|
}
|
|
cmd = r.Form.Get("command")
|
|
if cmd == "" {
|
|
cmd = r.Form.Get("cmd")
|
|
}
|
|
}
|
|
if cmd == "" {
|
|
cmd = r.URL.Query().Get("cmd")
|
|
}
|
|
if cmd == "" {
|
|
http.Error(w, "cmd required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if err := validateConsoleCommand(cmd); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if err := system.SendConsoleCommand(cmd); err != nil {
|
|
http.Error(w, "command error: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func validateConsoleCommand(cmd string) error {
|
|
if len(cmd) > 512 {
|
|
return fmt.Errorf("command exceeds 512 byte limit")
|
|
}
|
|
for _, r := range cmd {
|
|
if r < 32 || r == 127 {
|
|
return fmt.Errorf("command contains control characters")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func handleReady(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 || cfg == nil {
|
|
http.Error(w, "not configured", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
op := state.GetOperation()
|
|
if op.InProgress {
|
|
http.Error(w, "operation in progress: "+op.Type, http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
switch state.GetState() {
|
|
case state.StateInstalling, state.StateStarting, state.StateStopping, state.StateError, state.StateCrashed:
|
|
http.Error(w, "not ready: "+string(state.GetState()), http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
if strings.EqualFold(cfg.ContainerType, "game") {
|
|
_, running := system.GetServerPID()
|
|
if !running {
|
|
http.Error(w, "server process not running", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
if strings.EqualFold(cfg.Game, "minecraft") && !state.GetReady() {
|
|
http.Error(w, "minecraft not ready", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"ready": true})
|
|
}
|
|
|
|
/* --------------------------------------------------------------------------
|
|
/agent/update
|
|
----------------------------------------------------------------------------*/
|
|
|
|
func handleAgentUpdate(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
endOp, ok := beginHTTPOperation(w, "agent_update", true, "updating agent")
|
|
if !ok {
|
|
return
|
|
}
|
|
defer endOp()
|
|
|
|
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 := mcstatus.CandidatePorts(*cfg)
|
|
protocols := mcstatus.CandidateProtocols(cfg.Version)
|
|
|
|
var status mcstatus.StatusResponse
|
|
var lastErr error
|
|
for _, port := range ports {
|
|
for _, protocol := range protocols {
|
|
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.Handler {
|
|
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("/ready", handleReady)
|
|
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/backups", agenthandlers.HandleGameBackups)
|
|
m.HandleFunc("/game/backups/restore", agenthandlers.HandleGameBackupRestore)
|
|
m.HandleFunc("/game/backups/", agenthandlers.HandleGameBackupByID)
|
|
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 auth.Wrap(m, auth.Policy{
|
|
Public: map[string]map[string]struct{}{
|
|
"/health": auth.Public(http.MethodGet),
|
|
"/version": auth.Public(http.MethodGet),
|
|
},
|
|
})
|
|
}
|