// Command nodectl is the operator CLI for the elastic-node control plane // (task #14). It drives the same provision.Service that the admin panel (#8) // calls behind its buttons, so CLI and panel share one idempotent code path. // // Subcommands: // // nodectl create -provider=ID -region=hkg -tier=free|pro [-plan=...] [-key=...] // nodectl destroy -node=ID // nodectl rotate-ip -node=ID // nodectl replace -node=ID [-replacement=UUID] // nodectl rotate-pool -pool=consumable|premium [-concurrency=1|2] // nodectl providers [-pool=consumable|premium] // nodectl bootstrap-token -node=UUID // // Configuration (env, never flags — credentials must not land in shell history): // // DB_DSN MySQL DSN (required for create/destroy/...) // REDIS_ADDR Redis addr (bootstrap-token; default 127.0.0.1:6379) // REDIS_PASSWORD Redis password (optional) // PROVISION_CONTROL_PLANE_URL agent enrollment URL injected into cloud-init // PROVISION_CLOUD_INIT_TMPL path to infra/cloud-init/node.yaml.tmpl // PROVISION__* per-vendor credentials (see providers/) // // nodectl performs NO operation against the production EC2 host: it only knows // the providers/nodes registered in the new vendor pools (doc/06 §2 red line). package main import ( "context" "flag" "fmt" "os" "strings" "text/tabwriter" "time" "github.com/wangjia/pangolin/server/internal/db" "github.com/wangjia/pangolin/server/internal/mtls" "github.com/wangjia/pangolin/server/internal/provision" "github.com/wangjia/pangolin/server/internal/provision/providers" "github.com/wangjia/pangolin/server/internal/redisutil" ) func main() { if len(os.Args) < 2 { usage() os.Exit(2) } cmd := os.Args[1] args := os.Args[2:] ctx := context.Background() if err := run(ctx, cmd, args); err != nil { fmt.Fprintln(os.Stderr, "nodectl: "+err.Error()) os.Exit(1) } } func usage() { fmt.Fprintln(os.Stderr, strings.TrimSpace(` nodectl — elastic-node control plane CLI nodectl create -provider=ID -region=hkg -tier=free|pro [-plan=...] nodectl destroy -node=ID nodectl rotate-ip -node=ID nodectl replace -node=ID [-replacement=UUID] nodectl rotate-pool -pool=consumable|premium [-concurrency=1] nodectl providers [-pool=consumable|premium] nodectl bootstrap-token -node=UUID Config via env: DB_DSN, REDIS_ADDR, REDIS_PASSWORD, PROVISION_CONTROL_PLANE_URL, PROVISION_CLOUD_INIT_TMPL, PROVISION__* credentials.`)) } func buildService(ctx context.Context) (*provision.Service, func(), error) { dsn := os.Getenv("DB_DSN") if dsn == "" { return nil, nil, fmt.Errorf("DB_DSN is required") } conn, err := db.Open(dsn) if err != nil { return nil, nil, err } cfg := provision.Config{ Store: provision.NewMySQLStore(conn), Adapters: providers.NewRegistry(), ControlPlaneURL: os.Getenv("PROVISION_CONTROL_PLANE_URL"), } if tmpl := os.Getenv("PROVISION_CLOUD_INIT_TMPL"); tmpl != "" { r, err := provision.NewTemplateRenderer(tmpl) if err != nil { conn.Close() return nil, nil, err } cfg.Renderer = r } svc, err := provision.NewService(cfg) if err != nil { conn.Close() return nil, nil, err } return svc, func() { conn.Close() }, nil } func run(ctx context.Context, cmd string, args []string) error { switch cmd { case "create": return cmdCreate(ctx, args) case "destroy": return cmdDestroy(ctx, args) case "rotate-ip": return cmdRotateIP(ctx, args) case "replace": return cmdReplace(ctx, args) case "rotate-pool": return cmdRotatePool(ctx, args) case "providers": return cmdProviders(ctx, args) case "bootstrap-token": return cmdBootstrapToken(ctx, args) case "-h", "--help", "help": usage() return nil default: usage() return fmt.Errorf("unknown subcommand %q", cmd) } } func cmdCreate(ctx context.Context, args []string) error { fs := flag.NewFlagSet("create", flag.ExitOnError) provider := fs.Int64("provider", 0, "provider ID (required)") region := fs.String("region", "", "vendor region ID (required)") tier := fs.String("tier", "free", "free|pro") plan := fs.String("plan", "", "vendor instance size") role := fs.String("role", "entry", "entry|relay|exit") pbk := fs.String("reality-pbk", "", "REALITY public key") sni := fs.String("reality-sni", "www.cloudflare.com", "REALITY masquerade SNI") hy2 := fs.Int("hy2-port", 443, "Hysteria2 port") key := fs.String("key", "", "idempotency key (default: generated time-based)") _ = fs.Parse(args) if *provider == 0 || *region == "" { return fmt.Errorf("create: -provider and -region are required") } idem := *key if idem == "" { idem = fmt.Sprintf("cli-create-%d", time.Now().UnixNano()) } svc, closeFn, err := buildService(ctx) if err != nil { return err } defer closeFn() n, err := svc.CreateNode(ctx, provision.NodeSpec{ Region: *region, Role: provision.Role(*role), Tier: provision.Tier(*tier), ProviderID: *provider, Plan: *plan, RealityPBK: *pbk, RealitySNI: *sni, HY2Port: *hy2, }, idem) if err != nil { return err } fmt.Printf("created node id=%d uuid=%s status=%s endpoint=%s\n", n.ID, n.UUID, n.Status, n.Endpoint) return nil } func cmdDestroy(ctx context.Context, args []string) error { fs := flag.NewFlagSet("destroy", flag.ExitOnError) node := fs.Int64("node", 0, "node ID (required)") _ = fs.Parse(args) if *node == 0 { return fmt.Errorf("destroy: -node is required") } svc, closeFn, err := buildService(ctx) if err != nil { return err } defer closeFn() if err := svc.DestroyNode(ctx, *node); err != nil { return err } fmt.Printf("destroyed node id=%d\n", *node) return nil } func cmdRotateIP(ctx context.Context, args []string) error { fs := flag.NewFlagSet("rotate-ip", flag.ExitOnError) node := fs.Int64("node", 0, "node ID (required)") _ = fs.Parse(args) if *node == 0 { return fmt.Errorf("rotate-ip: -node is required") } svc, closeFn, err := buildService(ctx) if err != nil { return err } defer closeFn() n, err := svc.RotateIP(ctx, *node) if err != nil { return err } fmt.Printf("rotated IP node id=%d new endpoint=%s\n", n.ID, n.Endpoint) return nil } func cmdReplace(ctx context.Context, args []string) error { fs := flag.NewFlagSet("replace", flag.ExitOnError) node := fs.Int64("node", 0, "node ID (required)") repl := fs.String("replacement", "", "replacement UUID (to resume a crashed replace)") _ = fs.Parse(args) if *node == 0 { return fmt.Errorf("replace: -node is required") } svc, closeFn, err := buildService(ctx) if err != nil { return err } defer closeFn() res, err := svc.Replace(ctx, *node, *repl) if err != nil { return err } fmt.Printf("replaced node id=%d → new id=%d (replacement=%s)\n", res.OldNodeID, res.NewNodeID, res.ReplacementUUID) return nil } func cmdRotatePool(ctx context.Context, args []string) error { fs := flag.NewFlagSet("rotate-pool", flag.ExitOnError) pool := fs.String("pool", "", "consumable|premium (required)") conc := fs.Int("concurrency", 1, "1 or 2") _ = fs.Parse(args) if *pool == "" { return fmt.Errorf("rotate-pool: -pool is required") } svc, closeFn, err := buildService(ctx) if err != nil { return err } defer closeFn() results, err := svc.RotatePool(ctx, provision.Pool(*pool), *conc) if err != nil { return fmt.Errorf("rotate-pool (%d succeeded before error): %w", len(results), err) } fmt.Printf("rotated %d node(s) in pool %s\n", len(results), *pool) for _, r := range results { fmt.Printf(" node %d → %d\n", r.OldNodeID, r.NewNodeID) } return nil } func cmdProviders(ctx context.Context, args []string) error { fs := flag.NewFlagSet("providers", flag.ExitOnError) pool := fs.String("pool", "", "consumable|premium (default: all)") _ = fs.Parse(args) svc, closeFn, err := buildService(ctx) if err != nil { return err } defer closeFn() ps, err := svc.ListProviders(ctx, provision.Pool(*pool)) if err != nil { return err } w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) fmt.Fprintln(w, "ID\tNAME\tAPI_KIND\tPOOL\tREGIONS") for _, p := range ps { fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\n", p.ID, p.Name, p.APIKind, p.Pool, strings.Join(p.Regions, ",")) } return w.Flush() } // cmdBootstrapToken issues a one-time agent enrollment token for a node UUID, // reusing the same BootstrapTokenManager the gRPC Enroll RPC consumes. Used by // single-node deploys to hand the agent its bootstrap credential out-of-band. // Reads Redis from env (REDIS_ADDR/REDIS_PASSWORD); does not touch the DB. func cmdBootstrapToken(ctx context.Context, args []string) error { fs := flag.NewFlagSet("bootstrap-token", flag.ExitOnError) node := fs.String("node", "", "node UUID (required)") _ = fs.Parse(args) if *node == "" { return fmt.Errorf("bootstrap-token: -node (UUID) is required") } addr := os.Getenv("REDIS_ADDR") if addr == "" { addr = "127.0.0.1:6379" } rdb, err := redisutil.New(addr, os.Getenv("REDIS_PASSWORD"), 0) if err != nil { return fmt.Errorf("bootstrap-token: redis connect: %w", err) } defer rdb.Close() token, err := mtls.NewBootstrapTokenManager(rdb).IssueToken(ctx, *node) if err != nil { return err } // Print only the token on stdout so it can be captured directly // (`TOKEN=$(nodectl bootstrap-token -node=…)` in deploy scripts). fmt.Println(token) return nil }