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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user