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. /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 panelctl admin [flags] set-config KEY=VALUE [KEY=VALUE...] panelctl admin [flags] del-config KEY [KEY...] panelctl admin [flags] recreate [--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 /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 ") } adminArkRebind(*httpURL, token, rest[1]) case "set-config": if len(rest) < 3 { die("usage: panelctl admin set-config KEY=VALUE [KEY=VALUE...]") } adminSetConfig(*httpURL, token, rest[1], rest[2:]) case "del-config": if len(rest) < 3 { die("usage: panelctl admin del-config KEY [KEY...]") } adminDelConfig(*httpURL, token, rest[1], rest[2:]) case "recreate": if len(rest) < 2 { die("usage: panelctl admin recreate [--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)) }