diff --git a/server/cmd/nodectl/main.go b/server/cmd/nodectl/main.go index dc1f0c4..d1353b1 100644 --- a/server/cmd/nodectl/main.go +++ b/server/cmd/nodectl/main.go @@ -10,10 +10,13 @@ // 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 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/) @@ -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() { @@ -61,9 +66,10 @@ nodectl — elastic-node control plane CLI 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, - PROVISION__* credentials.`)) +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) { @@ -110,6 +116,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 +273,34 @@ func cmdProviders(ctx context.Context, args []string) error { } 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 +} diff --git a/server/internal/nodes/grpc_test.go b/server/internal/nodes/grpc_test.go index a90e9d2..f26dd20 100644 --- a/server/internal/nodes/grpc_test.go +++ b/server/internal/nodes/grpc_test.go @@ -466,9 +466,27 @@ func TestRegister_OK(t *testing.T) { if snap.ConfigVersion != 7 { t.Errorf("ConfigVersion=%d, want 7", snap.ConfigVersion) } - // The mock store returns a node with RealityPBK set. + // The mock store returns a node with RealityPBK set (endpoint 1.2.3.4:443, + // SNI www.example.com). The snapshot must carry a renderable inbound: a + // non-zero listen port (parsed from endpoint) and a handshake target, or the + // agent's sing-box config is invalid. if snap.Reality == nil { - t.Error("Reality is nil, want non-nil") + t.Fatal("Reality is nil, want non-nil") + } + if snap.Reality.ListenPort != 443 { + t.Errorf("Reality.ListenPort=%d, want 443 (from endpoint)", snap.Reality.ListenPort) + } + if snap.Reality.ServerName != "www.example.com" { + t.Errorf("Reality.ServerName=%q, want www.example.com", snap.Reality.ServerName) + } + if snap.Reality.HandshakeServer != "www.example.com" { + t.Errorf("Reality.HandshakeServer=%q, want www.example.com", snap.Reality.HandshakeServer) + } + if snap.Reality.HandshakePort != 443 { + t.Errorf("Reality.HandshakePort=%d, want 443", snap.Reality.HandshakePort) + } + if snap.Reality.PrivateKey == "" { + t.Error("Reality.PrivateKey is empty, want the node's REALITY key") } } diff --git a/server/internal/nodes/handler_grpc.go b/server/internal/nodes/handler_grpc.go index 4842fe6..0337fbf 100644 --- a/server/internal/nodes/handler_grpc.go +++ b/server/internal/nodes/handler_grpc.go @@ -3,6 +3,8 @@ package nodes import ( "context" "log/slog" + "net" + "strconv" "time" "google.golang.org/grpc/codes" @@ -12,6 +14,29 @@ import ( agentv1 "github.com/wangjia/pangolin/server/internal/pb/agentv1" ) +// defaultRealityListenPort is the fallback REALITY inbound port when a node's +// endpoint carries no explicit port. Matches the client default in +// httpapi.BuildClientConfig. +const defaultRealityListenPort = 11443 + +// realityHandshakePort is the port the REALITY inbound dials on the masquerade +// site for its TLS handshake (always 443 — a real HTTPS endpoint). +const realityHandshakePort = 443 + +// endpointPort extracts the numeric port from a "host:port" endpoint, returning +// the fallback when the endpoint has no parseable port. +func endpointPort(endpoint string, fallback int) int32 { + _, portStr, err := net.SplitHostPort(endpoint) + if err != nil { + return int32(fallback) + } + p, err := strconv.Atoi(portStr) + if err != nil || p <= 0 || p > 65535 { + return int32(fallback) + } + return int32(p) +} + // Handler implements agentv1.AgentServiceServer. // Construct via NewHandler after wiring the individual components. type Handler struct { @@ -108,18 +133,31 @@ func (h *Handler) Register(ctx context.Context, req *agentv1.RegisterRequest) (* // Populate inbound configs from the nodes row. // reality_prk is the PRIVATE key the agent's VLESS inbound needs; // reality_pbk is the PUBLIC key sent to clients in the connect config. - if node.RealityPRK != "" { + // + // The inbound must listen on the same port the client connects to (parsed + // from endpoint, matching httpapi.BuildClientConfig) and masquerade as the + // node's reality_sni, dialing that host:443 for the TLS handshake — without + // these fields the rendered sing-box config has listen_port 0 / empty + // handshake and is rejected. + listenPort := endpointPort(node.Endpoint, defaultRealityListenPort) + if key := node.RealityPRK; key != "" { snap.Reality = &agentv1.RealityInbound{ - PrivateKey: node.RealityPRK, - ShortID: node.RealityShortID, - ServerName: node.RealitySNI, + ListenPort: listenPort, + PrivateKey: key, + ShortID: node.RealityShortID, + ServerName: node.RealitySNI, + HandshakeServer: node.RealitySNI, + HandshakePort: realityHandshakePort, } } else if node.RealityPBK != "" { // Fallback for nodes seeded before migration 000011: use pbk field. snap.Reality = &agentv1.RealityInbound{ - PrivateKey: node.RealityPBK, - ShortID: node.RealityShortID, - ServerName: node.RealitySNI, + ListenPort: listenPort, + PrivateKey: node.RealityPBK, + ShortID: node.RealityShortID, + ServerName: node.RealitySNI, + HandshakeServer: node.RealitySNI, + HandshakePort: realityHandshakePort, } } if node.Hy2Port.Valid {