Files
panel/controller/cmd/controller/admin.go
T
2026-07-14 23:37:25 -07:00

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
}