0a941f3ba6
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
330 lines
9.7 KiB
Go
330 lines
9.7 KiB
Go
package main
|
|
|
|
// panelctl admin: out-of-band repair commands that drive the controller's
|
|
// /admin/v1/ HTTP surface (NOT the gRPC Panel service used by every other
|
|
// command in this CLI). Auth is loopback-bypass on the controller host or
|
|
// X-Panel-Admin-Token over the wire — see admin.go in the controller for
|
|
// the server-side pair.
|
|
//
|
|
// Token resolution order, used by every admin subcommand:
|
|
// 1. --admin-token flag value (if non-empty)
|
|
// 2. $PANEL_ADMIN_TOKEN environment variable
|
|
// 3. <data-dir>/admin-token file (default data dir: ./data)
|
|
//
|
|
// The empty-token case is allowed: when running on the controller host
|
|
// itself, the request reaches the admin mux over loopback and the
|
|
// server bypasses the token check.
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
defaultAdminHTTP = "http://localhost:8080"
|
|
adminTokenFilename = "admin-token"
|
|
adminAuthHeaderName = "X-Panel-Admin-Token"
|
|
)
|
|
|
|
func adminUsage() {
|
|
fmt.Fprintln(os.Stderr, `panelctl admin — out-of-band repair commands.
|
|
|
|
Usage:
|
|
panelctl admin [flags] token-show
|
|
panelctl admin [flags] status
|
|
panelctl admin [flags] instances
|
|
panelctl admin [flags] ark-rebind <instance-id>
|
|
panelctl admin [flags] set-config <instance-id> KEY=VALUE [KEY=VALUE...]
|
|
panelctl admin [flags] del-config <instance-id> KEY [KEY...]
|
|
panelctl admin [flags] recreate <instance-id> [--mount CONTAINER_PATH=HOST_PATH ...]
|
|
|
|
Flags:
|
|
--http URL Controller HTTP base URL (default `+defaultAdminHTTP+`, prod: http://localhost:8180)
|
|
Or set PANEL_HTTP env var.
|
|
--admin-token TOK Admin token. Defaults to $PANEL_ADMIN_TOKEN or <data-dir>/admin-token.
|
|
Not required when calling from the controller host (loopback bypass).
|
|
--data-dir DIR Where to look for admin-token file (default ./data; prod: /home/refuge/panel/data).
|
|
Or set PANEL_DATA_DIR env var.
|
|
|
|
Notes:
|
|
set-config / del-config / recreate trigger a stop→delete-preserve→
|
|
recreate→start dance. ARK cluster mount overrides are preserved
|
|
automatically. Pass --mount to recreate to override the mount map.`)
|
|
}
|
|
|
|
// cmdAdmin is the entrypoint dispatched from main.go's switch.
|
|
func cmdAdmin(args []string) {
|
|
fs := flag.NewFlagSet("panelctl admin", flag.ExitOnError)
|
|
httpURL := fs.String("http", envOrDefault("PANEL_HTTP", defaultAdminHTTP), "controller HTTP base URL")
|
|
tokenFlag := fs.String("admin-token", os.Getenv("PANEL_ADMIN_TOKEN"), "admin token (or use PANEL_ADMIN_TOKEN, or read from data-dir)")
|
|
dataDir := fs.String("data-dir", envOrDefault("PANEL_DATA_DIR", "./data"), "data directory containing admin-token")
|
|
fs.Usage = adminUsage
|
|
if err := fs.Parse(args); err != nil {
|
|
os.Exit(2)
|
|
}
|
|
rest := fs.Args()
|
|
if len(rest) < 1 {
|
|
adminUsage()
|
|
os.Exit(2)
|
|
}
|
|
|
|
// Resolve the token unless the user explicitly passed --admin-token=.
|
|
// The empty result is fine — server-side loopback bypass covers it.
|
|
token := *tokenFlag
|
|
if token == "" {
|
|
token = readTokenFile(*dataDir)
|
|
}
|
|
|
|
switch rest[0] {
|
|
case "token-show":
|
|
adminTokenShow(*dataDir)
|
|
case "status", "ping", "health":
|
|
adminStatus(*httpURL, token)
|
|
case "instances", "ls":
|
|
adminInstances(*httpURL, token)
|
|
case "ark-rebind":
|
|
if len(rest) < 2 {
|
|
die("usage: panelctl admin ark-rebind <instance-id>")
|
|
}
|
|
adminArkRebind(*httpURL, token, rest[1])
|
|
case "set-config":
|
|
if len(rest) < 3 {
|
|
die("usage: panelctl admin set-config <instance-id> KEY=VALUE [KEY=VALUE...]")
|
|
}
|
|
adminSetConfig(*httpURL, token, rest[1], rest[2:])
|
|
case "del-config":
|
|
if len(rest) < 3 {
|
|
die("usage: panelctl admin del-config <instance-id> KEY [KEY...]")
|
|
}
|
|
adminDelConfig(*httpURL, token, rest[1], rest[2:])
|
|
case "recreate":
|
|
if len(rest) < 2 {
|
|
die("usage: panelctl admin recreate <instance-id> [--mount CONTAINER=HOST ...]")
|
|
}
|
|
adminRecreate(*httpURL, token, rest[1], rest[2:])
|
|
default:
|
|
adminUsage()
|
|
os.Exit(2)
|
|
}
|
|
}
|
|
|
|
// ---- handlers ----
|
|
|
|
func adminTokenShow(dataDir string) {
|
|
tok := readTokenFile(dataDir)
|
|
if tok == "" {
|
|
fmt.Fprintf(os.Stderr, "panelctl: no admin-token file found at %s\n", filepath.Join(dataDir, adminTokenFilename))
|
|
fmt.Fprintln(os.Stderr, " (controller writes this on first start; check $PANEL_DATA_DIR)")
|
|
os.Exit(1)
|
|
}
|
|
fmt.Println(tok)
|
|
}
|
|
|
|
func adminStatus(httpURL, token string) {
|
|
body, status := adminGET(httpURL, "/admin/v1/health", token)
|
|
if status != http.StatusOK {
|
|
fmt.Fprintf(os.Stderr, "health: HTTP %d\n%s\n", status, string(body))
|
|
os.Exit(1)
|
|
}
|
|
prettyPrintJSON(body)
|
|
}
|
|
|
|
func adminInstances(httpURL, token string) {
|
|
body, status := adminGET(httpURL, "/admin/v1/instances", token)
|
|
if status != http.StatusOK {
|
|
fmt.Fprintf(os.Stderr, "instances: HTTP %d\n%s\n", status, string(body))
|
|
os.Exit(1)
|
|
}
|
|
var resp struct {
|
|
Instances []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"`
|
|
} `json:"instances"`
|
|
}
|
|
if err := json.Unmarshal(body, &resp); err != nil {
|
|
fmt.Fprintf(os.Stderr, "decode: %v\n%s\n", err, string(body))
|
|
os.Exit(1)
|
|
}
|
|
if len(resp.Instances) == 0 {
|
|
fmt.Println("(no instances)")
|
|
return
|
|
}
|
|
fmt.Printf("%-20s %-18s %-14s %-10s %-12s %s\n",
|
|
"INSTANCE_ID", "AGENT_ID", "MODULE", "STATUS", "CFG_KEYS", "CLUSTER")
|
|
for _, i := range resp.Instances {
|
|
fmt.Printf("%-20s %-18s %-14s %-10s %-12d %s\n",
|
|
i.InstanceID, i.AgentID, i.ModuleID, i.Status, len(i.ConfigValues), i.ARKClusterID)
|
|
}
|
|
}
|
|
|
|
func adminArkRebind(httpURL, token, instanceID string) {
|
|
body, status := adminPOST(httpURL, "/admin/v1/instances/"+instanceID+"/ark-cluster-rebind", token, nil)
|
|
if status != http.StatusOK {
|
|
fmt.Fprintf(os.Stderr, "ark-rebind: HTTP %d\n%s\n", status, string(body))
|
|
os.Exit(1)
|
|
}
|
|
prettyPrintJSON(body)
|
|
}
|
|
|
|
func adminSetConfig(httpURL, token, instanceID string, kvs []string) {
|
|
set := map[string]string{}
|
|
for _, kv := range kvs {
|
|
eq := strings.IndexByte(kv, '=')
|
|
if eq <= 0 {
|
|
die("malformed KEY=VALUE: %q (must have a non-empty key before '=')", kv)
|
|
}
|
|
set[kv[:eq]] = kv[eq+1:]
|
|
}
|
|
payload := map[string]any{"set": set}
|
|
body, status := adminPOST(httpURL, "/admin/v1/instances/"+instanceID+"/config", token, payload)
|
|
if status != http.StatusOK {
|
|
fmt.Fprintf(os.Stderr, "set-config: HTTP %d\n%s\n", status, string(body))
|
|
os.Exit(1)
|
|
}
|
|
prettyPrintJSON(body)
|
|
}
|
|
|
|
func adminDelConfig(httpURL, token, instanceID string, keys []string) {
|
|
payload := map[string]any{"delete": keys}
|
|
body, status := adminPOST(httpURL, "/admin/v1/instances/"+instanceID+"/config", token, payload)
|
|
if status != http.StatusOK {
|
|
fmt.Fprintf(os.Stderr, "del-config: HTTP %d\n%s\n", status, string(body))
|
|
os.Exit(1)
|
|
}
|
|
prettyPrintJSON(body)
|
|
}
|
|
|
|
func adminRecreate(httpURL, token, instanceID string, args []string) {
|
|
mounts := map[string]string{}
|
|
hasMount := false
|
|
for i := 0; i < len(args); i++ {
|
|
a := args[i]
|
|
switch {
|
|
case a == "--mount":
|
|
if i+1 >= len(args) {
|
|
die("--mount requires CONTAINER_PATH=HOST_PATH")
|
|
}
|
|
i++
|
|
parseMountInto(args[i], mounts)
|
|
hasMount = true
|
|
case strings.HasPrefix(a, "--mount="):
|
|
parseMountInto(a[len("--mount="):], mounts)
|
|
hasMount = true
|
|
default:
|
|
die("unknown arg %q to recreate", a)
|
|
}
|
|
}
|
|
payload := map[string]any{}
|
|
if hasMount {
|
|
payload["mount_overrides"] = mounts
|
|
}
|
|
body, status := adminPOST(httpURL, "/admin/v1/instances/"+instanceID+"/recreate", token, payload)
|
|
if status != http.StatusOK {
|
|
fmt.Fprintf(os.Stderr, "recreate: HTTP %d\n%s\n", status, string(body))
|
|
os.Exit(1)
|
|
}
|
|
prettyPrintJSON(body)
|
|
}
|
|
|
|
func parseMountInto(spec string, out map[string]string) {
|
|
eq := strings.IndexByte(spec, '=')
|
|
if eq <= 0 {
|
|
die("malformed --mount %q (expected CONTAINER_PATH=HOST_PATH)", spec)
|
|
}
|
|
out[spec[:eq]] = spec[eq+1:]
|
|
}
|
|
|
|
// ---- HTTP helpers ----
|
|
|
|
func adminGET(httpURL, path, token string) ([]byte, int) {
|
|
req, err := http.NewRequest(http.MethodGet, strings.TrimRight(httpURL, "/")+path, nil)
|
|
if err != nil {
|
|
die("request: %v", err)
|
|
}
|
|
return doAdminRequest(req, token)
|
|
}
|
|
|
|
func adminPOST(httpURL, path, token string, payload any) ([]byte, int) {
|
|
var body io.Reader
|
|
if payload != nil {
|
|
b, err := json.Marshal(payload)
|
|
if err != nil {
|
|
die("marshal: %v", err)
|
|
}
|
|
body = bytes.NewReader(b)
|
|
}
|
|
req, err := http.NewRequest(http.MethodPost, strings.TrimRight(httpURL, "/")+path, body)
|
|
if err != nil {
|
|
die("request: %v", err)
|
|
}
|
|
if payload != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
return doAdminRequest(req, token)
|
|
}
|
|
|
|
func doAdminRequest(req *http.Request, token string) ([]byte, int) {
|
|
if token != "" {
|
|
req.Header.Set(adminAuthHeaderName, token)
|
|
}
|
|
client := &http.Client{Timeout: 4 * time.Minute}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
die("http: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
die("read body: %v", err)
|
|
}
|
|
return body, resp.StatusCode
|
|
}
|
|
|
|
// ---- misc ----
|
|
|
|
func readTokenFile(dataDir string) string {
|
|
b, err := os.ReadFile(filepath.Join(dataDir, adminTokenFilename))
|
|
if err != nil {
|
|
if !errors.Is(err, os.ErrNotExist) {
|
|
fmt.Fprintf(os.Stderr, "panelctl: warn: read admin-token: %v\n", err)
|
|
}
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(string(b))
|
|
}
|
|
|
|
func envOrDefault(key, def string) string {
|
|
if v := os.Getenv(key); v != "" {
|
|
return v
|
|
}
|
|
return def
|
|
}
|
|
|
|
func prettyPrintJSON(body []byte) {
|
|
var v any
|
|
if err := json.Unmarshal(body, &v); err != nil {
|
|
fmt.Println(string(body))
|
|
return
|
|
}
|
|
out, err := json.MarshalIndent(v, "", " ")
|
|
if err != nil {
|
|
fmt.Println(string(body))
|
|
return
|
|
}
|
|
fmt.Println(string(out))
|
|
}
|