panel — open-source game server manager (public release)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,329 @@
|
||||
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))
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
// panelctl is the operator-facing CLI. It talks to the Controller's Panel
|
||||
// gRPC service to inspect connected agents and drive instance lifecycle.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// panelctl [flags] agents
|
||||
// panelctl [flags] create <agent-id> <instance-id> <module-id>
|
||||
// panelctl [flags] start <agent-id> <instance-id>
|
||||
// panelctl [flags] stop <agent-id> <instance-id> [grace-seconds]
|
||||
//
|
||||
// Flags:
|
||||
//
|
||||
// --controller host:port Controller gRPC address (default localhost:8443)
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
|
||||
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
|
||||
)
|
||||
|
||||
func usage() {
|
||||
fmt.Fprintln(os.Stderr, `panelctl — operator CLI for panel.
|
||||
|
||||
Usage:
|
||||
panelctl [flags] agents
|
||||
panelctl [flags] instances [agent-id]
|
||||
panelctl [flags] create <agent-id> <instance-id> <module-id>
|
||||
panelctl [flags] start <agent-id> <instance-id>
|
||||
panelctl [flags] stop <agent-id> <instance-id> [grace-seconds]
|
||||
panelctl [flags] watch [instance-id]
|
||||
panelctl [flags] rcon <agent-id> <instance-id> <command...>
|
||||
panelctl [flags] delete <agent-id> <instance-id> [--purge]
|
||||
panelctl [flags] update <agent-id> <instance-id> [provider-id]
|
||||
panelctl [flags] modules
|
||||
panelctl [flags] backup list <instance-id>
|
||||
panelctl [flags] backup create <agent-id> <instance-id> [description]
|
||||
panelctl [flags] backup restore <instance-id> <backup-id>
|
||||
panelctl admin <subcommand> Out-of-band repair (HTTP). See: panelctl admin
|
||||
|
||||
Flags:
|
||||
--controller host:port Controller gRPC address (default localhost:8443)
|
||||
--cert-dir DIR Use mTLS with agent certs from DIR (ca.crt + agent.crt + agent.key).
|
||||
Defaults to /home/refuge/panel/data/certs if it exists.
|
||||
Without certs, dials plaintext (only works against --insecure controllers).`)
|
||||
}
|
||||
|
||||
func main() {
|
||||
// "admin" is a parallel subcommand group that uses HTTP, not gRPC, and
|
||||
// has its own --http / --admin-token / --data-dir flags. Dispatch it
|
||||
// before flag.Parse so its FlagSet handles its own args without colliding
|
||||
// with the gRPC --controller flag below.
|
||||
if len(os.Args) >= 2 && os.Args[1] == "admin" {
|
||||
cmdAdmin(os.Args[2:])
|
||||
return
|
||||
}
|
||||
|
||||
controllerAddr := flag.String("controller", "localhost:8443", "controller gRPC address")
|
||||
certDir := flag.String("cert-dir", defaultCertDir(), "directory holding ca.crt + agent.crt + agent.key for mTLS (empty disables)")
|
||||
flag.Usage = usage
|
||||
flag.Parse()
|
||||
|
||||
args := flag.Args()
|
||||
if len(args) < 1 {
|
||||
usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
// Most subcommands need a short deadline; watch overrides.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
if len(args) > 0 && (args[0] == "watch" || args[0] == "w" || args[0] == "tail") {
|
||||
cancel()
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
dialCreds, err := buildCreds(*certDir, *controllerAddr)
|
||||
if err != nil {
|
||||
die("tls: %v", err)
|
||||
}
|
||||
conn, err := grpc.NewClient(*controllerAddr, grpc.WithTransportCredentials(dialCreds))
|
||||
if err != nil {
|
||||
die("grpc client: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
client := panelv1.NewPanelClient(conn)
|
||||
|
||||
switch args[0] {
|
||||
case "agents", "ls", "list":
|
||||
cmdAgents(ctx, client)
|
||||
case "instances", "insts", "i":
|
||||
agentFilter := ""
|
||||
if len(args) >= 2 {
|
||||
agentFilter = args[1]
|
||||
}
|
||||
cmdInstances(ctx, client, agentFilter)
|
||||
case "watch", "w", "tail":
|
||||
// watch has its own long-lived ctx (not timeout-bound)
|
||||
watchCtx, watchCancel := context.WithCancel(context.Background())
|
||||
defer watchCancel()
|
||||
instanceFilter := ""
|
||||
if len(args) >= 2 {
|
||||
instanceFilter = args[1]
|
||||
}
|
||||
cmdWatch(watchCtx, client, instanceFilter)
|
||||
case "create":
|
||||
if len(args) < 4 {
|
||||
die("usage: panelctl create <agent-id> <instance-id> <module-id>")
|
||||
}
|
||||
cmdCreate(ctx, client, args[1], args[2], args[3])
|
||||
case "start":
|
||||
if len(args) < 3 {
|
||||
die("usage: panelctl start <agent-id> <instance-id>")
|
||||
}
|
||||
cmdStart(ctx, client, args[1], args[2])
|
||||
case "stop":
|
||||
if len(args) < 3 {
|
||||
die("usage: panelctl stop <agent-id> <instance-id> [grace-seconds]")
|
||||
}
|
||||
grace := uint32(30)
|
||||
if len(args) >= 4 {
|
||||
g, err := strconv.ParseUint(args[3], 10, 32)
|
||||
if err != nil {
|
||||
die("invalid grace-seconds %q: %v", args[3], err)
|
||||
}
|
||||
grace = uint32(g)
|
||||
}
|
||||
cmdStop(ctx, client, args[1], args[2], grace)
|
||||
case "rcon", "r":
|
||||
if len(args) < 4 {
|
||||
die("usage: panelctl rcon <agent-id> <instance-id> <command...>")
|
||||
}
|
||||
cmd := args[3]
|
||||
for _, s := range args[4:] {
|
||||
cmd += " " + s
|
||||
}
|
||||
cmdRCON(ctx, client, args[1], args[2], cmd)
|
||||
case "delete", "rm", "del":
|
||||
if len(args) < 3 {
|
||||
die("usage: panelctl delete <agent-id> <instance-id> [--purge]")
|
||||
}
|
||||
purge := false
|
||||
for _, a := range args[3:] {
|
||||
if a == "--purge" {
|
||||
purge = true
|
||||
}
|
||||
}
|
||||
cmdDelete(ctx, client, args[1], args[2], purge)
|
||||
case "update", "upd":
|
||||
if len(args) < 3 {
|
||||
die("usage: panelctl update <agent-id> <instance-id> [provider-id]")
|
||||
}
|
||||
providerID := ""
|
||||
if len(args) >= 4 {
|
||||
providerID = args[3]
|
||||
}
|
||||
cmdUpdate(ctx, client, args[1], args[2], providerID)
|
||||
case "modules", "mods":
|
||||
cmdModules(ctx, *controllerAddr)
|
||||
case "backup", "bkp":
|
||||
if len(args) < 3 {
|
||||
die("usage: panelctl backup <list|create|restore> ...")
|
||||
}
|
||||
cmdBackup(ctx, client, *controllerAddr, args[2:])
|
||||
default:
|
||||
usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
func cmdAgents(ctx context.Context, c panelv1.PanelClient) {
|
||||
resp, err := c.ListAgents(ctx, &panelv1.ListAgentsRequest{})
|
||||
if err != nil {
|
||||
die("list agents: %v", err)
|
||||
}
|
||||
if len(resp.Agents) == 0 {
|
||||
fmt.Println("(no agents connected)")
|
||||
return
|
||||
}
|
||||
fmt.Printf("%-24s %-10s %-12s %-10s %s\n", "AGENT_ID", "VERSION", "OS/ARCH", "HOSTNAME", "CONNECTED")
|
||||
for _, a := range resp.Agents {
|
||||
conn := a.ConnectedAt.AsTime().Format(time.RFC3339)
|
||||
fmt.Printf("%-24s %-10s %-12s %-10s %s\n",
|
||||
a.AgentId, a.Version, a.HostOs+"/"+a.HostArch, a.Hostname, conn)
|
||||
}
|
||||
}
|
||||
|
||||
func cmdInstances(ctx context.Context, c panelv1.PanelClient, agentFilter string) {
|
||||
resp, err := c.ListInstances(ctx, &panelv1.ListInstancesRequest{AgentId: agentFilter})
|
||||
if err != nil {
|
||||
die("list instances: %v", err)
|
||||
}
|
||||
if len(resp.Instances) == 0 {
|
||||
fmt.Println("(no instances)")
|
||||
return
|
||||
}
|
||||
fmt.Printf("%-18s %-18s %-14s %-10s %-18s %s\n",
|
||||
"INSTANCE_ID", "AGENT_ID", "MODULE", "STATUS", "UPDATED", "DETAIL")
|
||||
for _, i := range resp.Instances {
|
||||
module := i.ModuleId
|
||||
if i.ModuleVersion != "" {
|
||||
module = module + "@" + i.ModuleVersion
|
||||
}
|
||||
updated := i.UpdatedAt.AsTime().Format("01-02 15:04:05")
|
||||
fmt.Printf("%-18s %-18s %-14s %-10s %-18s %s\n",
|
||||
i.InstanceId, i.AgentId, module, shortStatus(i.Status), updated, i.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func shortStatus(s panelv1.InstanceStatus) string {
|
||||
switch s {
|
||||
case panelv1.InstanceStatus_INSTANCE_STATUS_STOPPED:
|
||||
return "stopped"
|
||||
case panelv1.InstanceStatus_INSTANCE_STATUS_STARTING:
|
||||
return "starting"
|
||||
case panelv1.InstanceStatus_INSTANCE_STATUS_RUNNING:
|
||||
return "running"
|
||||
case panelv1.InstanceStatus_INSTANCE_STATUS_STOPPING:
|
||||
return "stopping"
|
||||
case panelv1.InstanceStatus_INSTANCE_STATUS_CRASHED:
|
||||
return "crashed"
|
||||
case panelv1.InstanceStatus_INSTANCE_STATUS_UPDATING:
|
||||
return "updating"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func cmdWatch(ctx context.Context, c panelv1.PanelClient, instanceFilter string) {
|
||||
stream, err := c.StreamEvents(ctx, &panelv1.StreamEventsRequest{InstanceId: instanceFilter})
|
||||
if err != nil {
|
||||
die("stream events: %v", err)
|
||||
}
|
||||
if instanceFilter == "" {
|
||||
fmt.Fprintln(os.Stderr, "watching all events; Ctrl-C to stop")
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "watching instance %s; Ctrl-C to stop\n", instanceFilter)
|
||||
}
|
||||
for {
|
||||
ev, err := stream.Recv()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "stream ended: %v\n", err)
|
||||
return
|
||||
}
|
||||
renderEvent(ev)
|
||||
}
|
||||
}
|
||||
|
||||
func renderEvent(ev *panelv1.Event) {
|
||||
at := ev.At.AsTime().Format("15:04:05")
|
||||
switch p := ev.Payload.(type) {
|
||||
case *panelv1.Event_InstanceState:
|
||||
fmt.Printf("%s STATE %-14s %-10s %s\n",
|
||||
at, p.InstanceState.InstanceId, shortStatus(p.InstanceState.Status), p.InstanceState.Detail)
|
||||
case *panelv1.Event_AppState:
|
||||
fmt.Printf("%s APP %-14s players=%d/%d uptime=%ds\n",
|
||||
at, p.AppState.InstanceId, p.AppState.PlayersOnline, p.AppState.PlayersMax, p.AppState.UptimeSeconds)
|
||||
case *panelv1.Event_Player:
|
||||
fmt.Printf("%s %-6s %-14s %s %s\n",
|
||||
at, shortKind(p.Player.Kind), p.Player.InstanceId, p.Player.PlayerName, p.Player.Detail)
|
||||
case *panelv1.Event_Log:
|
||||
fmt.Printf("%s LOG %-14s %s\n",
|
||||
at, p.Log.InstanceId, p.Log.Line)
|
||||
default:
|
||||
fmt.Printf("%s ? %T\n", at, ev.Payload)
|
||||
}
|
||||
}
|
||||
|
||||
func shortKind(k panelv1.PlayerEvent_Kind) string {
|
||||
switch k {
|
||||
case panelv1.PlayerEvent_KIND_JOIN:
|
||||
return "JOIN"
|
||||
case panelv1.PlayerEvent_KIND_LEAVE:
|
||||
return "LEAVE"
|
||||
case panelv1.PlayerEvent_KIND_CHAT:
|
||||
return "CHAT"
|
||||
case panelv1.PlayerEvent_KIND_DEATH:
|
||||
return "DEATH"
|
||||
case panelv1.PlayerEvent_KIND_CUSTOM:
|
||||
return "CUST"
|
||||
default:
|
||||
return "?"
|
||||
}
|
||||
}
|
||||
|
||||
func cmdCreate(ctx context.Context, c panelv1.PanelClient, agentID, instanceID, moduleID string) {
|
||||
resp, err := c.CreateInstance(ctx, &panelv1.CreateInstanceRequest{
|
||||
AgentId: agentID,
|
||||
InstanceId: instanceID,
|
||||
ModuleId: moduleID,
|
||||
RunMode: panelv1.RunMode_RUN_MODE_DOCKER,
|
||||
})
|
||||
if err != nil {
|
||||
die("create: %v", err)
|
||||
}
|
||||
fmt.Printf("create OK instance=%s correlation=%s\n", resp.InstanceId, resp.CorrelationId)
|
||||
}
|
||||
|
||||
func cmdStart(ctx context.Context, c panelv1.PanelClient, agentID, instanceID string) {
|
||||
resp, err := c.StartInstance(ctx, &panelv1.StartInstanceRequest{
|
||||
AgentId: agentID,
|
||||
InstanceId: instanceID,
|
||||
})
|
||||
if err != nil {
|
||||
die("start: %v", err)
|
||||
}
|
||||
fmt.Printf("start OK instance=%s correlation=%s\n", instanceID, resp.CorrelationId)
|
||||
}
|
||||
|
||||
func cmdRCON(ctx context.Context, c panelv1.PanelClient, agentID, instanceID, command string) {
|
||||
resp, err := c.ExecRCON(ctx, &panelv1.ExecRCONRequest{
|
||||
AgentId: agentID,
|
||||
InstanceId: instanceID,
|
||||
Command: command,
|
||||
TimeoutMs: 10000,
|
||||
})
|
||||
if err != nil {
|
||||
die("rcon: %v", err)
|
||||
}
|
||||
if resp.Error != "" {
|
||||
fmt.Fprintf(os.Stderr, "error: %s\n", resp.Error)
|
||||
}
|
||||
if resp.Output != "" {
|
||||
fmt.Print(resp.Output)
|
||||
if !strings.HasSuffix(resp.Output, "\n") {
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cmdStop(ctx context.Context, c panelv1.PanelClient, agentID, instanceID string, grace uint32) {
|
||||
resp, err := c.StopInstance(ctx, &panelv1.StopInstanceRequest{
|
||||
AgentId: agentID,
|
||||
InstanceId: instanceID,
|
||||
GraceSeconds: grace,
|
||||
})
|
||||
if err != nil {
|
||||
die("stop: %v", err)
|
||||
}
|
||||
fmt.Printf("stop OK instance=%s correlation=%s\n", instanceID, resp.CorrelationId)
|
||||
}
|
||||
|
||||
func cmdDelete(ctx context.Context, c panelv1.PanelClient, agentID, instanceID string, purge bool) {
|
||||
resp, err := c.DeleteInstance(ctx, &panelv1.DeleteInstanceRequest{
|
||||
AgentId: agentID,
|
||||
InstanceId: instanceID,
|
||||
PurgeVolumes: purge,
|
||||
})
|
||||
if err != nil {
|
||||
die("delete: %v", err)
|
||||
}
|
||||
fmt.Printf("delete queued instance=%s purge=%t correlation=%s\n", instanceID, purge, resp.CorrelationId)
|
||||
}
|
||||
|
||||
func cmdUpdate(ctx context.Context, c panelv1.PanelClient, agentID, instanceID, providerID string) {
|
||||
resp, err := c.UpdateInstance(ctx, &panelv1.UpdateInstanceRequest{
|
||||
AgentId: agentID,
|
||||
InstanceId: instanceID,
|
||||
ProviderId: providerID,
|
||||
})
|
||||
if err != nil {
|
||||
die("update: %v", err)
|
||||
}
|
||||
fmt.Printf("update accepted=%t provider=%s kind=%s correlation=%s error=%s\n",
|
||||
resp.Accepted, resp.ProviderId, resp.ProviderKind, resp.CorrelationId, resp.Error)
|
||||
fmt.Fprintln(os.Stderr, "watch progress with: panelctl watch "+instanceID)
|
||||
}
|
||||
|
||||
// cmdModules is REST because the modules endpoint isn't a Panel RPC.
|
||||
func cmdModules(ctx context.Context, controllerAddr string) {
|
||||
base := "http://" + strings.TrimPrefix(strings.Split(controllerAddr, ":")[0], "http://") + ":8080"
|
||||
_ = ctx
|
||||
_ = base
|
||||
fmt.Println("modules: hit the HTTP dashboard's /api/modules or visit the UI")
|
||||
fmt.Println("(gRPC ListModules RPC not wired yet — mirroring only via HTTP for now)")
|
||||
}
|
||||
|
||||
func cmdBackup(ctx context.Context, c panelv1.PanelClient, controllerAddr string, args []string) {
|
||||
if len(args) < 1 {
|
||||
die("usage: panelctl backup <list|create|restore> ...")
|
||||
}
|
||||
switch args[0] {
|
||||
case "create":
|
||||
if len(args) < 3 {
|
||||
die("usage: panelctl backup create <agent-id> <instance-id> [description]")
|
||||
}
|
||||
desc := ""
|
||||
if len(args) >= 4 {
|
||||
desc = strings.Join(args[3:], " ")
|
||||
}
|
||||
resp, err := c.CreateBackup(ctx, &panelv1.CreateBackupRequest{
|
||||
AgentId: args[1],
|
||||
InstanceId: args[2],
|
||||
Description: desc,
|
||||
})
|
||||
if err != nil {
|
||||
die("backup: %v", err)
|
||||
}
|
||||
fmt.Printf("backup %s %d bytes path=%s\n", resp.BackupId, resp.SizeBytes, resp.Path)
|
||||
case "restore":
|
||||
if len(args) < 3 {
|
||||
die("usage: panelctl backup restore <instance-id> <backup-id>")
|
||||
}
|
||||
resp, err := c.RestoreBackup(ctx, &panelv1.RestoreBackupRequest{
|
||||
InstanceId: args[1],
|
||||
BackupId: args[2],
|
||||
})
|
||||
if err != nil {
|
||||
die("restore: %v", err)
|
||||
}
|
||||
fmt.Printf("restore ok — %d bytes restored\n", resp.BytesRestored)
|
||||
case "list":
|
||||
fmt.Println("backup list: hit /api/instances/<id>/backups via HTTP (no gRPC ListBackups RPC yet)")
|
||||
default:
|
||||
die("unknown backup subcommand %q", args[0])
|
||||
}
|
||||
_ = controllerAddr
|
||||
}
|
||||
|
||||
func die(format string, args ...interface{}) {
|
||||
fmt.Fprintf(os.Stderr, "panelctl: "+format+"\n", args...)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// defaultCertDir returns /home/refuge/panel/data/certs when it exists (the
|
||||
// standard prod path), otherwise empty string. This lets `panelctl agents`
|
||||
// just work when run on princess without operators having to specify a flag.
|
||||
func defaultCertDir() string {
|
||||
const prodPath = "/home/refuge/panel/data/certs"
|
||||
if _, err := os.Stat(filepath.Join(prodPath, "agent.crt")); err == nil {
|
||||
return prodPath
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// buildCreds returns mTLS credentials when certDir contains the expected
|
||||
// agent material; falls back to insecure (plaintext) when certDir is empty.
|
||||
// The mTLS path mirrors the agent's loadClientTLS so panelctl can talk to
|
||||
// the same prod gRPC endpoint without a special-cased "operator cert".
|
||||
func buildCreds(certDir, controllerAddr string) (credentials.TransportCredentials, error) {
|
||||
if certDir == "" {
|
||||
return insecure.NewCredentials(), nil
|
||||
}
|
||||
caPath := filepath.Join(certDir, "ca.crt")
|
||||
certPath := filepath.Join(certDir, "agent.crt")
|
||||
keyPath := filepath.Join(certDir, "agent.key")
|
||||
caPEM, err := os.ReadFile(caPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read ca: %w", err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(caPEM) {
|
||||
return nil, fmt.Errorf("bad CA PEM")
|
||||
}
|
||||
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load cert+key: %w", err)
|
||||
}
|
||||
serverName := controllerAddr
|
||||
if i := strings.LastIndex(serverName, ":"); i > 0 {
|
||||
serverName = serverName[:i]
|
||||
}
|
||||
return credentials.NewTLS(&tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
RootCAs: pool,
|
||||
ServerName: serverName,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}), nil
|
||||
}
|
||||
Reference in New Issue
Block a user