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
+20 -2
View File
@@ -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")
}
}
+45 -7
View File
@@ -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 {