端口管理
状态:Current
最后核对:2026-05-15
路由:/nodes/ports
端口管理用于展示 Nexus Agent 已知的 L4 监听端口、请求每个端口的 L4/L3 服务,以及 Agent 当前上报的运行时 listener 状态。
它不是创建上游目标的地方。上游目标仍由 L4 Upstreams 管理。它也不是创建转发链的地方。真实业务流量规则仍由 L4 Chains 和 L3 服务生成。本页管理的是 listener namespace:节点、transport、绑定地址、端口、关联服务、意图路由和运行时 listener 状态。
核心模型
| 区域 | 负责内容 | 示例 |
|---|---|---|
| L4 上游 | 可复用目标端点 | 10.0.0.10:443、service.internal:8443 |
| L4 链 | 可部署转发规则 | TCP TLS 链、UDP Noise 链、QUIC REALITY hop |
| 端口管理 | listener 归属、服务列表、路由可视化和运行时状态 | node-a tcp :::443,L4/L3 服务 |
一个端口不是一个服务。一个端口是一个 listener namespace。服务来自 L4 链或 L3 路由能力。端口管理会把同一个 namespace 上的服务全部展示出来,并帮助操作员对比控制平面意图和 Agent 当前运行时 listener 表。
后端仍保留 listener priority policy,用于 API 兼容和自动化部署,但当前端口管理 UI 不再提供保存该策略的按钮。Agent 不会简单地把所有“非 winner”服务丢掉;它会先判断协议是否具备可靠早期分流信号,能合并就构建同端口 route table,只有无法分流的重复项才会被丢弃,例如同一个 listener 上第二个 RAW fallback。
同协议内部能否复用同一个端口,取决于协议是否有可靠的早期分流信号:
- TCP REALITY 在线路上是 TLS-like:先按 TLS SNI 分流,同一个 SNI 下还可以通过不同 short ID 保留多个 REALITY route。
- TCP TLS 可以通过 TLS SNI 分流。
- UDP Noise 可以通过能解开首个 datagram 的本地 X25519 key 分流。
- QUIC REALITY 可以和 UDP RAW 或 UDP Noise 共用 UDP socket;Agent 只把看起来像 QUIC 的包送给嵌入的 QUIC endpoint,普通 UDP 流量继续走 UDP router。
- RAW 没有分流信号,无法可靠地让多个独立 RAW 服务共用同一个 listener。
控制平面流程
- 操作员创建或修改 L4 链。
- 后端把链定义保存到
tunnel_chains。 - 操作员部署链或服务变更。
- 后端仍可把可选 listener 策略保存到
node_port_policies,用于兼容和 API 管理的部署。 - 部署链或更新策略时,后端对该节点 replay 所有启用的 L4 链。
- 后端下发一个
PingoraConfig,其中包含 upstream、TCP rules、QUIC rules、UDP rules、TLS config 和listener_policies。 - Agent 检查本机端口可绑定性,合并同端口 route table,只丢弃不可分流冲突,并 reconcile 运行时 listener。
- Agent heartbeat 把最终运行时 listener 表上报给后端。
Agent 做最终裁决,因为只有 Agent 知道本机 socket 状态。后端保存意图,Agent 负责安全地应用。
Listener 标识
相同数字端口可以出现多次,因为 TCP 和 UDP 是不同的内核 namespace。
| 字段 | 含义 |
|---|---|
node_id | 拥有该 listener 的 Agent 节点。 |
transport | tcp 或 udp;QUIC 属于 UDP namespace。 |
listen_addr | 绑定地址。当前 L4 链部署通常下发 ::。 |
listen_port | 数字端口,范围 1 到 65535。 |
示例:
| 场景 | 是否同一个 listener | 说明 |
|---|---|---|
tcp :::443 和 udp :::443 | 不是 | TCP TLS 和 UDP Noise 可以使用同一个数字端口,因为 TCP 和 UDP 分开绑定。 |
udp :::443 UDP RAW 和 udp :::443 QUIC REALITY | 是 | QUIC 使用 UDP,但 QUIC REALITY 可以嵌入选中的 UDP socket,RAW fallback 仍接收非 QUIC 流量。 |
tcp :::443 TCP TLS 和 tcp :::443 TCP REALITY | 是 | 二者都使用 TCP。Agent 会构建 TLS/SNI-family listener,并按握手信号分流 TLS、REALITY 和 RAW fallback。 |
兼容策略
默认顺序:
REALITY > TLS > Noise > RAWAPI 和数据库中使用小写标签:
["reality", "tls", "noise", "raw"]优先级顺序是兼容策略,只用于仍无法合并到一个 route table 的场景。只有当前 listener 上实际存在的协议类型会参与选择。如果保存的策略漏掉某个合法标签,后端和 Agent 会按默认顺序补齐缺失标签。
当前前端不再暴露策略保存 icon。API client 仍可保存策略;保存策略会触发该节点的 L4 配置 replay。策略本身不会创建业务规则。没有匹配的 L4 链规则时,它只是一个 policy-only listener。
冲突解析
Agent 行为可简化为:
for each listener namespace:
collect TCP rules by tcp listen address
collect UDP rules and QUIC rules by udp listen address
if TCP has REALITY, TLS, or SSLPREREAD routes:
build one TLS/SNI-family listener
add all matching REALITY routes, including same-SNI routes with different short IDs
add TLS/SSLPREREAD routes by SNI
keep at most one TCP RAW fallback for non-TLS or unmatched-SNI traffic
else:
keep the single selected TCP route class
if UDP has RAW or Noise routes:
build one UDP listener
add Noise routes by local X25519 key
keep at most one UDP RAW fallback
embed same-address QUIC REALITY routes into the same UDP socket
else:
build a standalone QUIC listener when only QUIC routes request the namespace详细行为:
| 获胜类型 | 复用行为 |
|---|---|
| TCP REALITY | 支持多个 REALITY route 共用一个 TCP listener。REALITY 被当成 TLS-like:listener 先按 SNI 分流,再对所有 SNI 命中的 REALITY route 逐个尝试 short ID。 |
| TCP TLS | 支持多个 TLS route 共用一个 TCP listener,通过预读 TLS ClientHello 并按 SNI 分流。也可以保留一个 TCP RAW route,作为非 TLS 或 SNI 未命中流量的默认 fallback。 |
| UDP Noise | 支持多个 UDP Noise route 共用一个 UDP listener,通过首个 datagram 尝试候选本地 X25519 key,然后把 UDP session 固定到选中的 route。 |
| QUIC REALITY | QUIC 属于 UDP namespace。QUIC REALITY route 可以嵌入 UDP RAW 或 UDP Noise 使用的同一个 UDP socket,同时 QUIC 自己的 REALITY 路由仍可按 server name 和 short ID 分流。 |
| RAW | 没有分流信号。同一个 selected listener 最多只能有一个 RAW route。多个 RAW route 共用同一 listener 没有可靠语义。 |
TCP RAW Fallback
TCP 的 TLS/SNI-family listener 可以保留一个 RAW route 作为明确的默认 fallback。TLS/SNI router 会先读取 TLS ClientHello:
- 如果 SNI 命中已配置的 TLS 或 SSLPREREAD route,流量走该 route;
- 如果 SNI 命中 REALITY route,Agent 会逐个尝试候选 REALITY route,直到 short ID 鉴权成功;
- 如果首包不是 TLS,或 SNI 没命中,流量落到 RAW route;
- 如果同一个 listener 上有多个 RAW rule,只能保留一个,因为 RAW 没有分流信号。
这就是 TCP TLS/SNI 控制 listener 和 SSH RAW listener 共用同一个 TCP 端口的语义。REALITY 能共存于同一个 listener,是因为它在 REALITY short ID 检查前就能从 TLS-like ClientHello 中识别出来。
UDP 共享 Socket
UDP 下,同一个 socket 可以同时承载 UDP RAW fallback、UDP Noise route 和嵌入式 QUIC REALITY route,只要它们请求的是同一个 udp address:port。
分发规则:
- 已知 QUIC peer 的 QUIC 包继续交给 Quinn;
- 未知 peer 只有在包看起来是 QUIC long-header Initial 时才交给 Quinn;
- Noise 候选 route 使用配置的本地 key 尝试解密首个 datagram;
- 如果没有 QUIC 或 Noise route 接受该 datagram,则交给唯一 UDP RAW fallback。
这样普通 UDP RAW 流量不会被 QUIC endpoint 吞掉,同时 QUIC REALITY 仍能与普通 UDP listener 共用一个端口。
协议分流矩阵
| Listener namespace | 协议类型 | 分流键 | 同端口 route | 说明 |
|---|---|---|---|---|
| TCP | REALITY | TLS-like ClientHello SNI,然后 REALITY short ID | 多个 | 同 SNI 下 short ID 不同的多个 REALITY route 是合法的。 |
| TCP | TLS | TLS ClientHello SNI | 多个,每个 SNI 一个 | 空 cert_domain 不能用于 SNI 分流。可保留一个 RAW fallback。 |
| TCP | RAW | 无 | 一个 | 无协议层分流信号。可作为 TLS/SNI listener 下的默认 fallback。 |
| UDP | QUIC REALITY | QUIC Initial 检测,然后 REALITY route 材料 | 内部多个 | 可以嵌入共享 UDP socket,与 UDP RAW 和 UDP Noise 共存。 |
| UDP | Noise | 首包用本地 X25519 key 解密匹配 | 多个,每个本地 key 一个 | 首包后 session 固定到 route。 |
| UDP | RAW | 无 | 一个 | 可作为共享 UDP socket 的 fallback;两个 RAW UDP 服务仍不能共用同一个 listener。 |
Noise 和 TLS 能否共用端口
可以共用同一个数字端口,但前提是一个在 UDP namespace,一个在 TCP namespace。
例如 tcp :::443 的 TCP TLS 和 udp :::443 的 UDP Noise 可以同时存在。它们不是同一个 listener,也不会互相参与优先级裁剪。
QUIC REALITY 和 UDP Noise 都在 UDP namespace,但 QUIC REALITY 可以嵌入选中的 UDP socket executor,所以 QUIC REALITY、UDP Noise 和一个 UDP RAW fallback 可以共用同一个 udp address:port。TCP TLS 和 TCP REALITY 都在 TCP namespace;当它们的 SNI 和 REALITY short ID 材料明确时,可以共存于同一个 TLS/SNI-family listener。
Validator 风险
这里的 validator 风险指控制平面校验、前端展示、Agent 实际运行三者之间的语义漂移。
典型情况:
- 后端允许保存一组规则,但 Agent 因系统占用或不可分流的重复项丢弃其中一部分,例如多个 RAW fallback。
- 前端展示的是控制平面意图,但 Agent runtime 还没有上报最终 reconcile 后的 listener 表。
- 默认优先级只存在于隐藏的 Agent 逻辑里,后端和前端状态里没有表达。
当前实现的缓解措施:
| 缓解措施 | 说明 |
|---|---|
| 统一策略存储 | node_port_policies 仍是 API 管理的每节点 listener 优先级兼容存储。 |
| Proto 下发 | PingoraConfig.listener_policies 把策略下发给 Agent。 |
| Agent 最终裁决 | Agent 仍做最终绑定和冲突裁决,因为只有它知道本机 socket 状态。 |
| Runtime heartbeat 状态 | HeartbeatRequest.runtime_listeners 上报 Agent 实际应用后的 listener 状态。 |
| Runtime preview | 前端预览按钮加载 /api/nodes/ports/runtime,不再依赖本地草稿计算。 |
| 人类可读日志 | Agent 日志包含 selected priority、dropped priority、listener address、cluster 和 rule fingerprint。 |
| 聚焦测试 | 测试覆盖 TCP SNI-family 共存、UDP Noise key 分流、UDP RAW fallback,以及 QUIC REALITY 和普通 UDP route 共用 UDP socket。 |
系统已占用端口
如果某个 address:port 已经被非 Nexus 进程监听,Agent 不会自动换端口,也不会静默跳过 listener。该次配置应用会被拒绝,并通过 ConfigApplyReport 回报。
对 TCP,Agent 检查 TCP address:port。对 UDP 和 QUIC,Agent 检查 UDP address:port namespace。已由 Nexus 管理的现有 listener 可以热更新,不算系统占用。
前端操作流程
- 打开 节点 > 端口管理。
- 从下拉框选择节点,或保持 All nodes。
- 查看每一行:
- 节点名和 id 前缀;
- transport,例如
tcp或udp; - listener namespace,例如
:::443; - 请求该 listener 的 L4 或 L3 服务来源;
- 意图 route 数量,以及可用时的运行时状态。
- 点击 service/order 按钮打开可拖拽服务列表。弹窗会逐个列出必须参与处理的 L4 或 L3 服务;当前 UI 不会从这个弹窗保存优先级策略。
- 点击 runtime preview 按钮,从 Agent 上报表获取当前 listener。弹窗调用
/api/nodes/ports/runtime时会显示 loading。 - 对比 runtime route list 和意图服务列表。缺少 runtime 数据通常表示 Agent 未连接、尚未 heartbeat 上报 runtime listener,或最近一次配置应用被拒绝。
- 如果端口被系统占用或不可分流重复项被丢弃,查看 Agent 日志。
REST API
列出 L4 端口
GET /api/nodes/ports
GET /api/nodes/ports?node_id=<uuid>返回所有节点选项和 listener 项。node_id 只过滤 listener 项,不会让节点下拉框只剩当前节点。
响应结构:
{
"success": true,
"data": {
"nodes": [
{ "id": "node-uuid", "name": "hk-edge-1", "status": "online" }
],
"items": [
{
"nodeId": "node-uuid",
"nodeName": "hk-edge-1",
"transport": "tcp",
"listenAddr": "::",
"listenPort": 443,
"priorityOrder": ["reality", "tls", "noise", "raw"],
"hasPolicy": true,
"policyId": "policy-uuid",
"routes": [
{
"chainId": "chain-uuid",
"chainName": "tls-edge",
"source": "l4_chain",
"sourceLabel": "L4 Chain",
"payload": "tcp",
"outer": "tcp",
"security": "tls",
"matchKind": "sni",
"matchValue": "example.com",
"credentialId": "credential-uuid",
"nextHop": "target.example.com:443"
},
{
"chainId": "chain-uuid",
"chainName": "tls-edge",
"source": "l4_upstream",
"sourceLabel": "api-origin",
"payload": "tcp",
"outer": "tcp",
"security": "tls",
"matchKind": "sni",
"matchValue": "api.example.com",
"credentialId": null,
"nextHop": "10.0.0.5:443"
}
]
}
],
"defaultPriorityOrder": ["reality", "tls", "noise", "raw"]
},
"error": null
}/api/nodes/ports 响应的是控制平面意图,由 chains、upstreams、policies 和 service definitions 计算得到。如果问题是“Agent 现在实际监听了什么”,使用下面的 runtime endpoint。
列出运行时 L4 端口
GET /api/nodes/ports/runtime
GET /api/nodes/ports/runtime?node_id=<uuid>&transport=tcp&listen_addr=::&listen_port=443返回 Agent 最近一次通过 heartbeat 上报的运行时 listener 表。query 参数均为可选过滤条件。
响应结构:
{
"success": true,
"data": {
"nodes": [
{
"nodeId": "node-uuid",
"nodeName": "hk-edge-1",
"status": "online",
"reportedAt": "2026-05-15T10:20:30Z",
"listeners": [
{
"transport": "tcp",
"listenAddr": "::",
"listenPort": 443,
"state": "running",
"routes": [
{
"service": "154.88.64.117:55443",
"payload": "tcp",
"outer": "tcp",
"security": "reality",
"matchKind": "reality",
"matchValue": "SNI img.alicdn.com / short_id 0123456789abcdef",
"nextHop": "154.88.64.117:55443"
},
{
"service": "127.0.0.1:22",
"payload": "tcp",
"outer": "tcp",
"security": "raw",
"matchKind": "default",
"matchValue": "default",
"nextHop": "127.0.0.1:22"
}
],
"bytesIn": 0,
"bytesOut": 0,
"activeSessions": 0,
"totalSessions": 0,
"errorSessions": 0
}
]
}
]
},
"error": null
}runtime preview 弹窗使用这个 endpoint。由于数据依赖 Agent 连接和 heartbeat 新鲜度,弹窗必须处理 loading 和 error 状态。
保存 Listener 策略
PUT /api/nodes/ports/policies需要角色:admin 或 operator。
该 endpoint 保留给 API 兼容和 operator automation。当前端口管理页不再提供该策略的保存 icon。
请求:
{
"nodeId": "node-uuid",
"transport": "udp",
"listenAddr": "::",
"listenPort": 443,
"priorityOrder": ["raw", "reality", "noise", "tls"]
}行为:
| 字段 | 规则 |
|---|---|
listenPort | 必须在 1 到 65535。 |
transport | 必须是 tcp 或 udp。 |
priorityOrder | 只能包含 reality、tls、noise、raw。 |
后端会去重、规范化并补齐缺失协议。保存后会 replay 该 node 的 L4 chain config。
runtime preview 不再应用前端本地 draft 顺序。它展示 /api/nodes/ports/runtime 返回的 Agent 运行时 route table。
数据库
策略表是 node_port_policies。
| 字段 | 含义 |
|---|---|
id | 策略 ID。 |
node_id | 所属节点,引用 nodes(id)。 |
transport | tcp 或 udp。 |
listen_addr | listener 绑定地址。 |
listen_port | listener 端口,1 到 65535。 |
priority_order | 协议标签 JSONB 数组。 |
enabled | 策略是否启用。 |
created_at、updated_at | 时间戳。 |
唯一约束:
UNIQUE (node_id, transport, listen_addr, listen_port)这意味着同一节点可以分别拥有 tcp :::443 和 udp :::443 的策略。
Agent 控制协议
PingoraConfig 携带 listener 策略:
message PingoraConfig {
repeated UpstreamCluster upstreams = 1;
repeated TcpProxyRule tcp_rules = 2;
repeated HttpProxyRule http_rules = 3;
TlsConfig tls_config = 4;
repeated QuicRule quic_rules = 5;
repeated UdpRule udp_rules = 6;
repeated ListenerPolicy listener_policies = 7;
}
message ListenerPolicy {
string transport = 1;
string listen_addr = 2;
int32 listen_port = 3;
repeated string priority_order = 4;
}后端和 Agent 仓库各有一份 common/proto/agent.proto;两份必须保持一致。
Agent heartbeat 也会携带当前运行时 listener 表:
message HeartbeatRequest {
string node_id = 1;
uint64 sequence = 2;
google.protobuf.Timestamp timestamp = 3;
double cpu_usage = 4;
double memory_usage = 5;
uint64 uptime_seconds = 6;
repeated LinkMetric link_metrics = 7;
repeated RuntimeListener runtime_listeners = 8;
}
message RuntimeListener {
string transport = 1;
string listen_addr = 2;
int32 listen_port = 3;
string state = 4;
repeated RuntimeRoute routes = 5;
uint64 bytes_in = 6;
uint64 bytes_out = 7;
uint64 active_sessions = 8;
uint64 total_sessions = 9;
uint64 error_sessions = 10;
}
message RuntimeRoute {
string service = 1;
string payload = 2;
string outer = 3;
string security = 4;
string match_kind = 5;
string match_value = 6;
string next_hop = 7;
}日志与排障
常见 Agent 日志模式:
| 日志含义 | 检查点 |
|---|---|
Gateway config validated | 对比 requested 和 selected 的 TCP/QUIC/UDP 规则数量。 |
TCP SNI-family port reuse enabled | TCP REALITY、TLS/SSLPREREAD 和 RAW fallback 已合并到同一个 listener。 |
UDP same-port route table enabled | UDP RAW、UDP Noise 或嵌入式 QUIC REALITY route 已合并到同一个 UDP listener。 |
Embedding QUIC REALITY route into the selected UDP socket executor | QUIC REALITY 正在共享选中的 UDP socket,而不是打开独立 QUIC socket。 |
Dropping duplicate TCP RAW fallback because another RAW route already owns this listener fallback | 同一个 TCP SNI-family listener 只能有一个 TCP RAW fallback。 |
Dropping duplicate UDP RAW fallback because another RAW route already owns this listener fallback | 同一个 UDP listener 只能有一个 UDP RAW fallback。 |
already bound outside this manager | 其他进程占用了端口;该快照会被拒绝。停止该进程或换端口。 |
排障清单:
- 确认 L4 链已启用。
- 确认最近修改后已重新 deploy。 3.打开端口管理,确认对应 listener 行存在。
- 打开 runtime preview,确认 Agent 上报了该 listener 和预期 route。
- 检查 Agent 主机上是否有非 Nexus 进程占用端口。
- 检查凭据是否缺失或不匹配:
- TLS 需要带明确 domain 的 TLS certificate。
- REALITY 需要 server names 和 short IDs。
- UDP Noise 需要有效 X25519 key material。
- 查看 Agent 日志中的 dropped rules。
性能说明
listener 合并的主要成本发生在配置应用阶段:按 listener 分组、校验 route 是否可分流、在需要时保留单个 fallback,并构建运行时 route table。运行时成本取决于 listener 组成:
- TCP TLS SNI 分流会预读 TLS ClientHello 的首个 TLS record,然后把该 record 回填给 rustls 继续正常握手。
- TCP REALITY 选择先使用 SNI,再对多个 SNI 命中的 REALITY route 尝试 short ID 鉴权。
- UDP Noise 分流只在新 UDP session 的首个 datagram 上尝试候选 key;选中后 session 固定到 route。
- QUIC 共享 UDP socket 时,未知 peer 只有 QUIC long-header Initial 才会交给 Quinn;已知 QUIC peer 可以继续使用 fixed-bit QUIC 包。非 QUIC UDP 会继续落到 Noise 或 RAW。
- runtime preview 的新鲜度受 Agent heartbeat 周期限制。
- REALITY、TLS、Noise、QUIC 的同端口复用不是零成本,但正常 route 数量下成本很小。
如果在一个端口上放入大量 SNI、REALITY 或 Noise key,应关注首包/握手延迟和日志中的 route 数量。
设计规则
| 应该 | 避免 |
|---|---|
| 多个 TLS 服务需要共用 TCP 端口时使用 TCP TLS SNI。 | 不要把两个 RAW 服务放到同一 listener 并期待可分流。 |
| 多个 UDP Noise 服务需要共用 UDP 端口时使用 key 分流。 | 不要假设 TCP 和 UDP 优先级会互相影响,它们是不同 namespace。 |
| QUIC 和普通 UDP 必须共用 UDP 端口时,使用 QUIC REALITY 共享 UDP 分发。 | 不要假设所有 QUIC 模式都能共享 UDP socket;当前语义针对共享 listener 处理的 QUIC REALITY route material。 |
| 部署后用 runtime preview 对比控制平面意图。 | 不要依赖自动换端口,当前明确禁用。 |
| 使用 policy API 时,在变更评审中同时检查 listener policy 和 chain deployment。 | 不要把 policy-only listener 当成已有流量 listener。 |