582b5a6b08
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
507 lines
18 KiB
Go
507 lines
18 KiB
Go
package main
|
|
|
|
// Admin HTTP surface for out-of-band operator repair.
|
|
//
|
|
// These endpoints duplicate (and intentionally bypass) the regular
|
|
// session-protected /api/* surface so an operator with shell access to
|
|
// the prod box can drive recreate-dance fixes without first having a
|
|
// dashboard session — which historically required deploying a temporary
|
|
// "localhost-bypass" build on every incident (see changelog 2026-04-28).
|
|
//
|
|
// Auth model: a request is admitted if EITHER
|
|
// 1. RemoteAddr resolves to loopback (127.0.0.1 / ::1), OR
|
|
// 2. the X-Panel-Admin-Token header (constant-time compared) matches
|
|
// the on-disk token at $data_dir/admin-token.
|
|
//
|
|
// The token is auto-generated on first start. It lives at mode 0600 so
|
|
// only the panel user (refuge in prod) can read it. There is no
|
|
// rotation API yet — to rotate, delete the file and restart the
|
|
// controller. The new token is logged once at boot.
|
|
//
|
|
// Endpoints (all under /admin/v1/, not /api/, so they sit OUTSIDE the
|
|
// session middleware):
|
|
// GET /admin/v1/health
|
|
// GET /admin/v1/instances
|
|
// POST /admin/v1/instances/{id}/ark-cluster-rebind
|
|
// POST /admin/v1/instances/{id}/config body: {"set":{...},"delete":[...]}
|
|
// POST /admin/v1/instances/{id}/recreate body: {"mount_overrides":{...}}
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/subtle"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/dbledeez/panel/controller/internal/db"
|
|
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
|
|
"google.golang.org/protobuf/types/known/timestamppb"
|
|
)
|
|
|
|
const (
|
|
adminTokenFile = "admin-token"
|
|
adminTokenHeader = "X-Panel-Admin-Token"
|
|
adminRecreateBudget = 3 * time.Minute
|
|
)
|
|
|
|
// ensureAdminToken loads the existing token from $dataDir/admin-token, or
|
|
// generates and persists a new one. Returns the token bytes (raw hex).
|
|
// Logs a "first run" message when generating; subsequent reads are silent.
|
|
func ensureAdminToken(dataDir string) (string, bool, error) {
|
|
path := filepath.Join(dataDir, adminTokenFile)
|
|
if b, err := os.ReadFile(path); err == nil {
|
|
tok := strings.TrimSpace(string(b))
|
|
if len(tok) >= 32 {
|
|
return tok, false, nil
|
|
}
|
|
// File exists but is empty/short — treat as missing and regenerate.
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
return "", false, fmt.Errorf("read admin-token: %w", err)
|
|
}
|
|
if err := os.MkdirAll(dataDir, 0o755); err != nil {
|
|
return "", false, fmt.Errorf("mkdir data: %w", err)
|
|
}
|
|
buf := make([]byte, 32)
|
|
if _, err := rand.Read(buf); err != nil {
|
|
return "", false, fmt.Errorf("rand: %w", err)
|
|
}
|
|
tok := hex.EncodeToString(buf)
|
|
if err := os.WriteFile(path, []byte(tok+"\n"), 0o600); err != nil {
|
|
return "", false, fmt.Errorf("write admin-token: %w", err)
|
|
}
|
|
return tok, true, nil
|
|
}
|
|
|
|
// adminAuth gates the admin mux. Loopback (127.0.0.1 / ::1) is always
|
|
// allowed without a token — this matches the on-host operator pattern.
|
|
// Otherwise the request must carry X-Panel-Admin-Token = the on-disk
|
|
// token. Constant-time compare so a timing oracle can't probe the token.
|
|
func (h *httpServer) adminAuth(token string, next http.Handler) http.Handler {
|
|
want := []byte(token)
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if isLoopback(r.RemoteAddr) {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
got := r.Header.Get(adminTokenHeader)
|
|
if got == "" || subtle.ConstantTimeCompare([]byte(got), want) != 1 {
|
|
h.log.Warn("admin: token rejected", "remote", r.RemoteAddr, "path", r.URL.Path)
|
|
writeError(w, http.StatusUnauthorized, "unauthorized", "valid "+adminTokenHeader+" required for non-loopback requests")
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func isLoopback(remoteAddr string) bool {
|
|
host, _, err := net.SplitHostPort(remoteAddr)
|
|
if err != nil {
|
|
host = remoteAddr
|
|
}
|
|
ip := net.ParseIP(host)
|
|
return ip != nil && ip.IsLoopback()
|
|
}
|
|
|
|
// requireSessionOrAdminToken is the gate for /api/*. A request is admitted if:
|
|
//
|
|
// 1. caller is loopback (on-host operator drives), OR
|
|
// 2. caller carries a valid X-Panel-Admin-Token header (service callers
|
|
// from a different LAN host — AMP-Monitor on toulouse, etc.), OR
|
|
// 3. caller has a valid panel_session cookie (browser session).
|
|
//
|
|
// Falls back through the cases in that order. Routes that need an
|
|
// authenticated user (handleMe, handleChangePassword) read sessionUserKey
|
|
// from the request context and 401 themselves if absent — service callers
|
|
// don't have a user identity, just admit-or-deny.
|
|
//
|
|
// adminToken empty (shouldn't happen at runtime; only in tests) → behaves
|
|
// exactly like requireSession.
|
|
func (h *httpServer) requireSessionOrAdminToken(isAPI bool, next http.Handler) http.Handler {
|
|
sessionGate := h.auth.requireSession(isAPI, next)
|
|
if h.adminToken == "" {
|
|
return sessionGate
|
|
}
|
|
want := []byte(h.adminToken)
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if isLoopback(r.RemoteAddr) {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
if got := r.Header.Get(adminTokenHeader); got != "" &&
|
|
subtle.ConstantTimeCompare([]byte(got), want) == 1 {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
sessionGate.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// registerAdminRoutes wires the admin mux onto the outer mux. Called
|
|
// from httpServer.handler() during construction.
|
|
func (h *httpServer) registerAdminRoutes(mux *http.ServeMux, token string) {
|
|
if token == "" {
|
|
// Defensive: refuse to register if no token is available. This
|
|
// would only fire if ensureAdminToken returned empty, which
|
|
// shouldn't happen on a healthy boot.
|
|
h.log.Error("admin routes NOT registered (empty token)")
|
|
return
|
|
}
|
|
adm := http.NewServeMux()
|
|
adm.HandleFunc("GET /admin/v1/health", h.adminHealth)
|
|
adm.HandleFunc("GET /admin/v1/instances", h.adminListInstances)
|
|
adm.HandleFunc("POST /admin/v1/instances/{id}/ark-cluster-rebind", h.adminArkClusterRebind)
|
|
adm.HandleFunc("POST /admin/v1/instances/{id}/config", h.adminSetConfig)
|
|
adm.HandleFunc("POST /admin/v1/instances/{id}/recreate", h.adminRecreate)
|
|
adm.HandleFunc("POST /admin/v1/instances/{id}/install-base-mods", h.adminInstallBaseMods)
|
|
adm.HandleFunc("GET /admin/v1/empyrion/discoveries", h.adminEmpyrionDiscoveries)
|
|
adm.HandleFunc("GET /admin/v1/empyrion/player-summary", h.adminEmpyrionPlayerSummary)
|
|
mux.Handle("/admin/v1/", h.adminAuth(token, adm))
|
|
}
|
|
|
|
// ---- handlers ----
|
|
|
|
func (h *httpServer) adminHealth(w http.ResponseWriter, r *http.Request) {
|
|
host, _ := os.Hostname()
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"ok": true,
|
|
"version": controllerVersion,
|
|
"hostname": host,
|
|
"loopback": isLoopback(r.RemoteAddr),
|
|
"time": time.Now().UTC().Format(time.RFC3339),
|
|
})
|
|
}
|
|
|
|
type adminInstanceDTO struct {
|
|
InstanceID string `json:"instance_id"`
|
|
AgentID string `json:"agent_id"`
|
|
ModuleID string `json:"module_id"`
|
|
Status string `json:"status"`
|
|
ConfigValues map[string]string `json:"config_values"`
|
|
AssignedPorts map[string]uint32 `json:"assigned_ports"`
|
|
ARKClusterID string `json:"ark_cluster_id,omitempty"`
|
|
}
|
|
|
|
func (h *httpServer) adminListInstances(w http.ResponseWriter, r *http.Request) {
|
|
rows, err := h.db.ListInstances(r.Context(), "")
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "db", err.Error())
|
|
return
|
|
}
|
|
clusterMembers := map[string]string{}
|
|
if h.arkClusters != nil {
|
|
h.arkClusters.mu.Lock()
|
|
reg, lerr := h.arkClusters.load()
|
|
h.arkClusters.mu.Unlock()
|
|
if lerr == nil {
|
|
clusterMembers = reg.Members
|
|
}
|
|
}
|
|
out := make([]adminInstanceDTO, 0, len(rows))
|
|
for _, row := range rows {
|
|
out = append(out, adminInstanceDTO{
|
|
InstanceID: row.InstanceID,
|
|
AgentID: row.AgentID,
|
|
ModuleID: row.ModuleID,
|
|
Status: row.Status,
|
|
ConfigValues: row.ConfigValues,
|
|
AssignedPorts: row.AssignedPorts,
|
|
ARKClusterID: clusterMembers[row.InstanceID],
|
|
})
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].InstanceID < out[j].InstanceID })
|
|
writeJSON(w, http.StatusOK, map[string]any{"instances": out})
|
|
}
|
|
|
|
// adminArkClusterRebind re-runs recreateArkWithClusterMount against the
|
|
// instance's currently-registered cluster. Use case: container drifted
|
|
// off the bind mount (recreate path missed MountOverrides), and we want
|
|
// to re-attach without changing any other state.
|
|
func (h *httpServer) adminArkClusterRebind(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
row, err := h.loadInstanceForAdmin(r.Context(), id)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "not_found", err.Error())
|
|
return
|
|
}
|
|
if row.ModuleID != "ark-sa" {
|
|
writeError(w, http.StatusBadRequest, "wrong_module", fmt.Sprintf("instance %q is module %q, not ark-sa", id, row.ModuleID))
|
|
return
|
|
}
|
|
h.arkClusters.mu.Lock()
|
|
reg, lerr := h.arkClusters.load()
|
|
h.arkClusters.mu.Unlock()
|
|
if lerr != nil {
|
|
writeError(w, http.StatusInternalServerError, "cluster_load", lerr.Error())
|
|
return
|
|
}
|
|
cid := reg.Members[id]
|
|
if cid == "" {
|
|
writeError(w, http.StatusBadRequest, "not_clustered", "instance is not registered as a cluster member; use the dashboard to set up/join a cluster first")
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), adminRecreateBudget)
|
|
defer cancel()
|
|
if err := h.recreateArkWithClusterMount(ctx, row, row.AgentID, cid); err != nil {
|
|
writeError(w, http.StatusBadGateway, "recreate", err.Error())
|
|
return
|
|
}
|
|
h.log.Info("admin: ark-cluster-rebind", "instance", id, "cluster", cid, "remote", r.RemoteAddr)
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"ok": true,
|
|
"instance_id": id,
|
|
"cluster_id": cid,
|
|
})
|
|
}
|
|
|
|
type adminSetConfigReq struct {
|
|
Set map[string]string `json:"set"`
|
|
Delete []string `json:"delete"`
|
|
}
|
|
|
|
// adminSetConfig merges Set into config_values and removes Delete keys,
|
|
// then runs the env-config recreate dance preserving ports + cluster
|
|
// mount overrides.
|
|
func (h *httpServer) adminSetConfig(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
var req adminSetConfigReq
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
|
|
writeError(w, http.StatusBadRequest, "bad_json", err.Error())
|
|
return
|
|
}
|
|
if len(req.Set) == 0 && len(req.Delete) == 0 {
|
|
writeError(w, http.StatusBadRequest, "no_changes", "set and delete are both empty")
|
|
return
|
|
}
|
|
row, err := h.loadInstanceForAdmin(r.Context(), id)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "not_found", err.Error())
|
|
return
|
|
}
|
|
merged := map[string]string{}
|
|
for k, v := range row.ConfigValues {
|
|
merged[k] = v
|
|
}
|
|
for k, v := range req.Set {
|
|
merged[k] = v
|
|
}
|
|
for _, k := range req.Delete {
|
|
delete(merged, k)
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), adminRecreateBudget)
|
|
defer cancel()
|
|
if err := h.adminRecreateInstance(ctx, row, merged, h.mountOverridesFor(id)); err != nil {
|
|
writeError(w, http.StatusBadGateway, "recreate", err.Error())
|
|
return
|
|
}
|
|
h.log.Info("admin: set-config", "instance", id, "set_keys", mapKeys(req.Set), "delete_keys", req.Delete, "remote", r.RemoteAddr)
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"ok": true,
|
|
"instance_id": id,
|
|
"config_values": merged,
|
|
})
|
|
}
|
|
|
|
type adminRecreateReq struct {
|
|
MountOverrides map[string]string `json:"mount_overrides"`
|
|
}
|
|
|
|
// adminRecreate re-runs the stop→delete-preserve→create→start dance with
|
|
// no config changes, optionally with custom MountOverrides. If the body
|
|
// is empty or omits mount_overrides, the cluster-derived overrides
|
|
// (mountOverridesFor) are used — same as every regular recreate path.
|
|
func (h *httpServer) adminRecreate(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
var req adminRecreateReq
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
|
|
writeError(w, http.StatusBadRequest, "bad_json", err.Error())
|
|
return
|
|
}
|
|
row, err := h.loadInstanceForAdmin(r.Context(), id)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "not_found", err.Error())
|
|
return
|
|
}
|
|
mounts := req.MountOverrides
|
|
if mounts == nil {
|
|
mounts = h.mountOverridesFor(id)
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), adminRecreateBudget)
|
|
defer cancel()
|
|
if err := h.adminRecreateInstance(ctx, row, row.ConfigValues, mounts); err != nil {
|
|
writeError(w, http.StatusBadGateway, "recreate", err.Error())
|
|
return
|
|
}
|
|
h.log.Info("admin: recreate", "instance", id, "mount_overrides", mounts, "remote", r.RemoteAddr)
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"ok": true,
|
|
"instance_id": id,
|
|
"mount_overrides": mounts,
|
|
})
|
|
}
|
|
|
|
// adminInstallBaseMods triggers the agent's base-mod installer for the
|
|
// given instance — Allocs Server Fixes + PrismaCore, downloaded fresh
|
|
// from authoritative sources (illy.bz + GitHub). TFP_* and xMarkers are
|
|
// shipped by the SteamCMD install and don't need re-download. Custom
|
|
// mods (RefugeBot, AGF-VP, etc.) are not touched. Useful when an
|
|
// operator wants to ensure the standard base-mod set is present on a
|
|
// freshly-installed instance that didn't get warm-seeded from a sibling.
|
|
//
|
|
// Implementation note: routes through the existing Update RPC machinery
|
|
// using the special provider id "_basemods" — the agent dispatcher
|
|
// short-circuits that to installBaseMods() instead of running the
|
|
// module's normal updater. Saves us a new proto round-trip.
|
|
func (h *httpServer) adminInstallBaseMods(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
row, err := h.loadInstanceForAdmin(r.Context(), id)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "not_found", err.Error())
|
|
return
|
|
}
|
|
if row.ModuleID != "7dtd" {
|
|
writeError(w, http.StatusBadRequest, "wrong_module", fmt.Sprintf("base-mods installer is 7DTD-only; instance %q is module %q", id, row.ModuleID))
|
|
return
|
|
}
|
|
conn := h.registry.get(row.AgentID)
|
|
if conn == nil {
|
|
writeError(w, http.StatusBadGateway, "agent_offline", fmt.Sprintf("agent %q is not connected", row.AgentID))
|
|
return
|
|
}
|
|
// Fire-and-forget: Update RPC produces an UpdateResult routed via
|
|
// AgentEvent (not CommandResult via pending registry), so awaitAgentCall
|
|
// would block forever. The agent's _basemods short-circuit runs async
|
|
// and emits the same UPDATING / install-finished pulses as a real
|
|
// update — operators see progress on the instance card or via
|
|
// `journalctl -u panel-agent.service | grep install-base-mods`.
|
|
corrID := newCorrelationID("update-basemods")
|
|
env := &panelv1.ControllerEnvelope{
|
|
CorrelationId: corrID,
|
|
SentAt: timestamppb.Now(),
|
|
Payload: &panelv1.ControllerEnvelope_Update{
|
|
Update: &panelv1.UpdateRequest{InstanceId: id, ProviderId: "_basemods"},
|
|
},
|
|
}
|
|
if err := conn.Send(env); err != nil {
|
|
writeError(w, http.StatusBadGateway, "agent_call", err.Error())
|
|
return
|
|
}
|
|
h.log.Info("admin: install-base-mods kicked", "instance", id, "remote", r.RemoteAddr, "corr_id", corrID)
|
|
writeJSON(w, http.StatusAccepted, map[string]any{
|
|
"ok": true,
|
|
"instance_id": id,
|
|
"correlation_id": corrID,
|
|
"note": "base-mods install kicked off async; watch agent log for 'install-base-mods' lines (~30-60s)",
|
|
})
|
|
}
|
|
|
|
// ---- shared recreate helper ----
|
|
|
|
// adminRecreateInstance is the admin-side equivalent of
|
|
// recreateWithEnvConfig + changePorts: stop (if running), delete-preserve,
|
|
// upsert with the new config, recreate with the given mount overrides
|
|
// and original ports, restart if it was running before.
|
|
func (h *httpServer) adminRecreateInstance(ctx context.Context, row *db.InstanceRow, mergedCV map[string]string, mountOverrides map[string]string) error {
|
|
conn := h.registry.get(row.AgentID)
|
|
if conn == nil {
|
|
return fmt.Errorf("agent %q is not connected", row.AgentID)
|
|
}
|
|
wasRunning := row.Status == "running"
|
|
|
|
if wasRunning {
|
|
if err := h.awaitAgentCall(ctx, conn, "stop", &panelv1.ControllerEnvelope_InstanceStop{
|
|
InstanceStop: &panelv1.InstanceStop{InstanceId: row.InstanceID, GraceSeconds: 30},
|
|
}); err != nil {
|
|
return fmt.Errorf("stop: %w", err)
|
|
}
|
|
}
|
|
if err := h.awaitAgentCall(ctx, conn, "delete", &panelv1.ControllerEnvelope_InstanceDelete{
|
|
InstanceDelete: &panelv1.InstanceDelete{InstanceId: row.InstanceID, PurgeVolumes: false},
|
|
}); err != nil {
|
|
return fmt.Errorf("delete: %w", err)
|
|
}
|
|
if err := h.db.UpsertInstance(ctx, db.InstanceRow{
|
|
InstanceID: row.InstanceID,
|
|
AgentID: row.AgentID,
|
|
ModuleID: row.ModuleID,
|
|
ModuleVersion: row.ModuleVersion,
|
|
RunMode: row.RunMode,
|
|
Status: "creating",
|
|
ConfigValues: mergedCV,
|
|
AssignedPorts: row.AssignedPorts,
|
|
}); err != nil {
|
|
return fmt.Errorf("db upsert: %w", err)
|
|
}
|
|
var ports []*panelv1.PortMap
|
|
for name, hp := range row.AssignedPorts {
|
|
ports = append(ports, &panelv1.PortMap{Name: name, HostPort: hp})
|
|
}
|
|
// Copy so we can inject control sentinels without mutating mergedCV.
|
|
// This recreate PRESERVES the install volume — tell the agent to skip the
|
|
// create-time warm-seed + first-update, which would otherwise wipe /game
|
|
// and re-seed from a sibling, silently dropping a 7DTD server's custom Mods.
|
|
createCV := make(map[string]string, len(mergedCV)+2)
|
|
for k, v := range mergedCV {
|
|
createCV[k] = v
|
|
}
|
|
createCV["_PANEL_SKIP_REINSTALL"] = "true"
|
|
if !wasRunning {
|
|
createCV["_PANEL_SUPPRESS_AUTOSTART"] = "true"
|
|
}
|
|
if err := h.awaitAgentCall(ctx, conn, "create", &panelv1.ControllerEnvelope_InstanceCreate{
|
|
InstanceCreate: &panelv1.InstanceCreate{
|
|
InstanceId: row.InstanceID,
|
|
ModuleId: row.ModuleID,
|
|
Version: row.ModuleVersion,
|
|
RunMode: panelv1.RunMode_RUN_MODE_DOCKER,
|
|
Ports: ports,
|
|
ConfigValues: createCV,
|
|
MountOverrides: mountOverrides,
|
|
},
|
|
}); err != nil {
|
|
return fmt.Errorf("create: %w", err)
|
|
}
|
|
if wasRunning {
|
|
if err := h.awaitAgentCall(ctx, conn, "start", &panelv1.ControllerEnvelope_InstanceStart{
|
|
InstanceStart: &panelv1.InstanceStart{InstanceId: row.InstanceID},
|
|
}); err != nil {
|
|
return fmt.Errorf("start: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (h *httpServer) loadInstanceForAdmin(ctx context.Context, id string) (*db.InstanceRow, error) {
|
|
if id == "" {
|
|
return nil, fmt.Errorf("instance id required")
|
|
}
|
|
rows, err := h.db.ListInstances(ctx, "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for i := range rows {
|
|
if rows[i].InstanceID == id {
|
|
return &rows[i], nil
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("instance %q not found", id)
|
|
}
|
|
|
|
func mapKeys(m map[string]string) []string {
|
|
out := make([]string, 0, len(m))
|
|
for k := range m {
|
|
out = append(out, k)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|