// 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 // panelctl [flags] start // panelctl [flags] stop [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 panelctl [flags] start panelctl [flags] stop [grace-seconds] panelctl [flags] watch [instance-id] panelctl [flags] rcon panelctl [flags] delete [--purge] panelctl [flags] update [provider-id] panelctl [flags] modules panelctl [flags] backup list panelctl [flags] backup create [description] panelctl [flags] backup restore panelctl admin 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 ") } cmdCreate(ctx, client, args[1], args[2], args[3]) case "start": if len(args) < 3 { die("usage: panelctl start ") } cmdStart(ctx, client, args[1], args[2]) case "stop": if len(args) < 3 { die("usage: panelctl stop [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 ") } 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 [--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 [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 ...") } 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 ...") } switch args[0] { case "create": if len(args) < 3 { die("usage: panelctl backup create [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 ") } 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//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 }