diff --git a/server/cmd/nodectl/bootstrap_token_test.go b/server/cmd/nodectl/bootstrap_token_test.go new file mode 100644 index 0000000..45c5c2f --- /dev/null +++ b/server/cmd/nodectl/bootstrap_token_test.go @@ -0,0 +1,113 @@ +package main + +import ( + "context" + "strings" + "testing" + + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" + + "github.com/wangjia/pangolin/server/internal/mtls" +) + +// newTestBootstrapManager spins up an in-process miniredis instance and returns +// a BootstrapTokenManager backed by it, plus a cleanup function. +func newTestBootstrapManager(t *testing.T) (*mtls.BootstrapTokenManager, *miniredis.Miniredis) { + t.Helper() + mr := miniredis.RunT(t) + rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + t.Cleanup(func() { rdb.Close() }) + return mtls.NewBootstrapTokenManager(rdb), mr +} + +// TestBootstrapTokenCmd_IssuesHexToken verifies that the token manager (the +// same one wired into cmdBootstrapToken) produces a well-formed 64-char hex token. +func TestBootstrapTokenCmd_IssuesHexToken(t *testing.T) { + mgr, _ := newTestBootstrapManager(t) + ctx := context.Background() + + const nodeUUID = "11111111-2222-3333-4444-555555555555" + token, err := mgr.IssueToken(ctx, nodeUUID) + if err != nil { + t.Fatalf("IssueToken: %v", err) + } + if len(token) != 64 { + t.Errorf("token length = %d; want 64 hex chars", len(token)) + } + // Verify it's pure lowercase hex. + const hexChars = "0123456789abcdef" + for _, c := range token { + if !strings.ContainsRune(hexChars, c) { + t.Errorf("token contains non-hex character %q in %q", c, token) + } + } +} + +// TestBootstrapTokenCmd_ConsumeOnce verifies the one-time guarantee: the token +// can be consumed exactly once, and a second call returns an error. +func TestBootstrapTokenCmd_ConsumeOnce(t *testing.T) { + mgr, _ := newTestBootstrapManager(t) + ctx := context.Background() + + const nodeUUID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + token, err := mgr.IssueToken(ctx, nodeUUID) + if err != nil { + t.Fatalf("IssueToken: %v", err) + } + + // First consume must return the correct UUID. + got, err := mgr.ConsumeToken(ctx, token) + if err != nil { + t.Fatalf("ConsumeToken (1st): %v", err) + } + if got != nodeUUID { + t.Errorf("ConsumeToken returned %q; want %q", got, nodeUUID) + } + + // Second consume must fail (one-time guarantee). + _, err = mgr.ConsumeToken(ctx, token) + if err == nil { + t.Fatal("ConsumeToken (2nd): expected error, got nil") + } +} + +// TestBootstrapTokenCmd_MissingNode verifies that cmdBootstrapToken returns an +// error when -node is not supplied, without touching Redis. +func TestBootstrapTokenCmd_MissingNode(t *testing.T) { + ctx := context.Background() + err := run(ctx, "bootstrap-token", []string{}) + if err == nil { + t.Fatal("expected error when -node is empty, got nil") + } + if !strings.Contains(err.Error(), "-node") { + t.Errorf("error message %q should mention -node flag", err.Error()) + } +} + +// TestBootstrapTokenCmd_UnknownRedis verifies that cmdBootstrapToken returns an +// error when REDIS_ADDR points to an unreachable address. +func TestBootstrapTokenCmd_UnknownRedis(t *testing.T) { + t.Setenv("REDIS_ADDR", "127.0.0.1:1") // port 1 is always unreachable + + ctx := context.Background() + err := run(ctx, "bootstrap-token", []string{"-node", "some-uuid"}) + if err == nil { + t.Fatal("expected error for unreachable Redis, got nil") + } + if !strings.Contains(err.Error(), "redis") && !strings.Contains(err.Error(), "connect") { + t.Errorf("error %q should mention redis/connect", err.Error()) + } +} + +// TestRun_UnknownSubcommand verifies that run returns an error for unrecognised +// subcommands (regression guard on the switch statement). +func TestRun_UnknownSubcommand(t *testing.T) { + err := run(context.Background(), "nonexistent", nil) + if err == nil { + t.Fatal("expected error for unknown subcommand, got nil") + } + if !strings.Contains(err.Error(), "unknown subcommand") { + t.Errorf("error %q should mention 'unknown subcommand'", err.Error()) + } +} diff --git a/server/cmd/nodectl/main.go b/server/cmd/nodectl/main.go index dc1f0c4..8e298f5 100644 --- a/server/cmd/nodectl/main.go +++ b/server/cmd/nodectl/main.go @@ -4,16 +4,19 @@ // // 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 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) +// DB_DSN MySQL DSN (required for most subcommands) +// REDIS_ADDR Redis address (default 127.0.0.1:6379, required for bootstrap-token) +// 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/) @@ -32,8 +35,10 @@ import ( "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() { @@ -55,14 +60,16 @@ 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 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, PROVISION_CONTROL_PLANE_URL, PROVISION_CLOUD_INIT_TMPL, +Config via env: DB_DSN, REDIS_ADDR, REDIS_PASSWORD, + PROVISION_CONTROL_PLANE_URL, PROVISION_CLOUD_INIT_TMPL, PROVISION__* credentials.`)) } @@ -110,6 +117,8 @@ func run(ctx context.Context, cmd string, args []string) error { return cmdRotatePool(ctx, args) case "providers": return cmdProviders(ctx, args) + case "bootstrap-token": + return cmdBootstrapToken(ctx, args) case "-h", "--help", "help": usage() return nil @@ -265,3 +274,51 @@ func cmdProviders(ctx context.Context, args []string) error { } return w.Flush() } + +// cmdBootstrapToken issues a new one-time bootstrap enrollment token for a node +// identified by its UUID. The token is stored in Redis with a 15-minute TTL and +// printed to stdout so the operator can inject it into cloud-init or pass it +// directly to the agent's --bootstrap-token flag for manual re-enrollment. +// +// Usage: +// +// REDIS_ADDR=127.0.0.1:6379 nodectl bootstrap-token -node= +// +// The node UUID must already exist in the provision database; this command does +// NOT create a node — use "nodectl create" for that. +func cmdBootstrapToken(ctx context.Context, args []string) error { + fs := flag.NewFlagSet("bootstrap-token", flag.ExitOnError) + nodeUUID := fs.String("node", "", "node UUID (required)") + _ = fs.Parse(args) + if *nodeUUID == "" { + return fmt.Errorf("bootstrap-token: -node (UUID) is required") + } + + mgr, closeFn, err := buildBootstrapManager() + if err != nil { + return err + } + defer closeFn() + + token, err := mgr.IssueToken(ctx, *nodeUUID) + if err != nil { + return fmt.Errorf("bootstrap-token: issue token: %w", err) + } + fmt.Println(token) + return nil +} + +// buildBootstrapManager connects to Redis and returns a BootstrapTokenManager. +// The connection uses REDIS_ADDR (default 127.0.0.1:6379) and REDIS_PASSWORD. +func buildBootstrapManager() (*mtls.BootstrapTokenManager, func(), error) { + 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 nil, nil, fmt.Errorf("bootstrap-token: redis connect: %w", err) + } + mgr := mtls.NewBootstrapTokenManager(rdb) + return mgr, func() { rdb.Close() }, nil +}