feat(backend): nodectl bootstrap-token 子命令 + 补全 REALITY snapshot 监听/handshake 字段

档 3 真机准备的后端代码部分:

- nodectl 新增 bootstrap-token -node=<uuid> 子命令:复用
  mtls.NewBootstrapTokenManager(rdb).IssueToken 签发 agent enroll 用的一次性
  token,只读 Redis(REDIS_ADDR/REDIS_PASSWORD),stdout 仅打印 token 便于
  部署脚本直接捕获。

- 修复 Register 下发的 ConfigSnapshot.Reality 缺字段:此前只填 PrivateKey/
  ShortID/ServerName,而 agent 的 render.go 还要读 ListenPort/HandshakeServer/
  HandshakePort——缺这三个会渲染出 listen_port:0 + 空 handshake 的无效 sing-box
  server 配置,隧道起不来。现从节点 endpoint 解析监听端口、以 reality_sni 作
  handshake 目标:443 填齐,与 httpapi.BuildClientConfig 的客户端口径一致。

- 强化 TestRegister_OK 断言新字段(ListenPort/ServerName/HandshakeServer/
  HandshakePort/PrivateKey),防止该缺口回归。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-17 00:32:29 +08:00
parent 981f79aca9
commit 0b6173d9cd
3 changed files with 107 additions and 12 deletions
+42 -3
View File
@@ -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_<VENDOR>_* 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_<VENDOR>_* credentials.`))
Config via env: DB_DSN, REDIS_ADDR, REDIS_PASSWORD, PROVISION_CONTROL_PLANE_URL,
PROVISION_CLOUD_INIT_TMPL, PROVISION_<VENDOR>_* 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
}