panel v0.9.2 — public release

Self-hostable game server control panel: Go controller + agent, 26 game
modules, embedded web UI. One-line install via install.sh / install.ps1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 00:43:35 -07:00
commit 4ccccc6fe2
2164 changed files with 301480 additions and 0 deletions
+483
View File
@@ -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
}