feat: configure distributed bridge topology

This commit is contained in:
Haitao Pan 2026-06-01 11:27:38 +08:00
parent 26560afa5c
commit 995e89cece
4 changed files with 149 additions and 14 deletions

View File

@ -54,21 +54,33 @@
## 3.1 Lightweight Distributed Task Forwarding
bridge 可以把本机收到的 HTTP 任务提交转发到另一个 bridge endpoint用于轻量分布式部署。例如
bridge 可以把本机收到的 HTTP 任务提交转发到另一个 bridge endpoint用于轻量分布式部署。当前实现落地的是双节点分布式拓扑
- `cn-xworkmate-bridge.svc.plus` 负责本地入口和鉴权
- `xworkmate-bridge.svc.plus` 负责实际 OpenClaw task runtime
- cn bridge 配置 peer endpoint 后,`POST /gateway/openclaw` 的 `session.start` / `session.message` 会转发到 peer 的同一路径
- cn bridge 配置 `task_forward_peer_id` 后,`POST /acp/rpc` 的 `session.start` / `session.message` 会转发到 peer 的同一路径
- 主 bridge 不配置 `task_forward_peer_id`,因此不会把任务反向转发到 CN
配置:
```yaml
distributed:
task_forward_endpoint: "https://xworkmate-bridge.svc.plus"
topology: "dual-node"
local_node_id: "cn-xworkmate-bridge"
task_forward_peer_id: "xworkmate-bridge"
nodes:
- id: "xworkmate-bridge"
role: "primary"
public_base_url: "https://xworkmate-bridge.svc.plus"
bridge_endpoint: "http://172.29.10.1:8787"
- id: "cn-xworkmate-bridge"
role: "edge"
public_base_url: "https://cn-xworkmate-bridge.svc.plus"
bridge_endpoint: "http://172.29.10.2:8787"
task_forward_token: ""
```
等价环境变量:
单 endpoint 覆盖仍可用于临时验证
```text
XWORKMATE_BRIDGE_TASK_FORWARD_ENDPOINT=https://xworkmate-bridge.svc.plus
@ -77,10 +89,10 @@ XWORKMATE_BRIDGE_TASK_FORWARD_TOKEN=$PEER_BRIDGE_AUTH_TOKEN
规则:
- endpoint 是 peer bridge base URLbridge 会按当前请求路径拼接 `/gateway/openclaw` `/acp/rpc`
- `task_forward_peer_id` 指向的节点 `bridge_endpoint` 是 peer bridge base URLbridge 会按当前请求路径拼接 `/acp/rpc`
- 同步消息不能走公网明文:公网 endpoint 必须使用 `https://`
- `http://` 只允许 loopback、private、link-local 这类本机或 VPN 内网地址,用于 WireGuard 等隧道已经提供加密的场景
- endpoint 可以是公网 HTTPS也可以是 VPN 内网 HTTP(S)。bridge 明确支持把 `task_forward_endpoint` 配成 WireGuard、WireGuard over VLESS/TCP/TLS、WebSocket/TLS 等隧道后的本机或私网地址
- endpoint 可以是公网 HTTPS也可以是 VPN 内网 HTTP(S)。bridge 明确支持把 `bridge_endpoint` 配成 WireGuard、WireGuard over VLESS/TCP/TLS、WebSocket/TLS 等隧道后的本机或私网地址
- 只要求本机网络能路由到 endpointbridge 不依赖 config center 或额外注册中心
- `task_forward_token` 为空时复用本机 `BRIDGE_AUTH_TOKEN`
- 转发请求会带 `X-XWorkmate-Bridge-Forwarded: 1`,收到该 header 后不会再次转发,避免 bridge 之间循环

View File

@ -18,14 +18,26 @@ openclaw_gateway:
queue_timeout: "10m"
# Lightweight distributed bridge forwarding.
# cn-xworkmate-bridge.svc.plus can forward task submissions to a peer bridge,
# including VPN-only endpoints such as WireGuard or WireGuard-over-VLESS
# addresses that are reachable from this host.
# A dual-node topology can keep both bridge nodes explicit while only enabling
# task forwarding from the node that sets task_forward_peer_id.
# Public plaintext HTTP is rejected; use HTTPS on public paths, or HTTP only on
# loopback/private/link-local VPN addresses where the tunnel provides encryption.
# The endpoint is the peer bridge base URL; /gateway/openclaw or /acp/rpc is
# appended from the inbound request path.
# bridge_endpoint is the peer bridge base URL; /gateway/openclaw or /acp/rpc is
# appended from the inbound request path when forwarding is enabled.
distributed:
topology: "dual-node"
local_node_id: "cn-xworkmate-bridge"
task_forward_peer_id: "xworkmate-bridge"
nodes:
- id: "xworkmate-bridge"
role: "primary"
public_base_url: "https://xworkmate-bridge.svc.plus"
bridge_endpoint: "http://172.29.10.1:8787"
- id: "cn-xworkmate-bridge"
role: "edge"
public_base_url: "https://cn-xworkmate-bridge.svc.plus"
bridge_endpoint: "http://172.29.10.2:8787"
# Optional direct override. Leave empty when using the topology above.
task_forward_endpoint: ""
# Optional. Defaults to BRIDGE_AUTH_TOKEN when omitted.
task_forward_token: ""

View File

@ -29,8 +29,19 @@ type BridgeConfig struct {
}
type DistributedConfig struct {
TaskForwardEndpoint string `yaml:"task_forward_endpoint"`
TaskForwardToken string `yaml:"task_forward_token"`
TaskForwardEndpoint string `yaml:"task_forward_endpoint"`
TaskForwardToken string `yaml:"task_forward_token"`
Topology string `yaml:"topology"`
LocalNodeID string `yaml:"local_node_id"`
TaskForwardPeerID string `yaml:"task_forward_peer_id"`
Nodes []DistributedNodeConfig `yaml:"nodes"`
}
type DistributedNodeConfig struct {
ID string `yaml:"id"`
Role string `yaml:"role"`
PublicBaseURL string `yaml:"public_base_url"`
BridgeEndpoint string `yaml:"bridge_endpoint"`
}
type OpenClawGatewayConfig struct {
@ -82,7 +93,11 @@ func resolveDistributedTaskForwardEndpoint(config *BridgeConfig) string {
if config != nil {
yamlVal = config.Distributed.TaskForwardEndpoint
}
return resolveURL(yamlVal, "XWORKMATE_BRIDGE_TASK_FORWARD_ENDPOINT", "BRIDGE_TASK_FORWARD_ENDPOINT")
return resolveURL(
firstNonEmpty(yamlVal, resolveDistributedTopologyTaskForwardEndpoint(config)),
"XWORKMATE_BRIDGE_TASK_FORWARD_ENDPOINT",
"BRIDGE_TASK_FORWARD_ENDPOINT",
)
}
func resolveDistributedTaskForwardToken(config *BridgeConfig) string {
@ -100,6 +115,40 @@ func resolveDistributedTaskForwardToken(config *BridgeConfig) string {
return bridgeSharedAuthToken()
}
func resolveDistributedTopologyTaskForwardEndpoint(config *BridgeConfig) string {
if config == nil {
return ""
}
distributed := config.Distributed
topology := strings.TrimSpace(distributed.Topology)
if topology == "" {
return ""
}
if !strings.EqualFold(topology, "dual-node") {
return ""
}
localNodeID := strings.TrimSpace(distributed.LocalNodeID)
peerNodeID := strings.TrimSpace(distributed.TaskForwardPeerID)
if localNodeID == "" || peerNodeID == "" || localNodeID == peerNodeID {
return ""
}
for _, node := range distributed.Nodes {
if strings.TrimSpace(node.ID) == peerNodeID {
return strings.TrimSpace(node.BridgeEndpoint)
}
}
return ""
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}
func newProductionProviderCatalog() (*BridgeConfig, map[string]syncedProvider, []string) {
return newProductionProviderCatalogFromConfig(loadBridgeConfig())
}

View File

@ -0,0 +1,62 @@
package acp
import "testing"
func TestResolveDistributedTaskForwardEndpointFromDualNodeTopology(t *testing.T) {
config := &BridgeConfig{}
config.Distributed.Topology = "dual-node"
config.Distributed.LocalNodeID = "cn-xworkmate-bridge"
config.Distributed.TaskForwardPeerID = "xworkmate-bridge"
config.Distributed.Nodes = []DistributedNodeConfig{
{
ID: "xworkmate-bridge",
BridgeEndpoint: "http://172.29.10.1:8787",
},
{
ID: "cn-xworkmate-bridge",
BridgeEndpoint: "http://172.29.10.2:8787",
},
}
if got := resolveDistributedTaskForwardEndpoint(config); got != "http://172.29.10.1:8787" {
t.Fatalf("resolveDistributedTaskForwardEndpoint() = %q, want %q", got, "http://172.29.10.1:8787")
}
}
func TestResolveDistributedTaskForwardEndpointDisabledWhenPeerUnset(t *testing.T) {
config := &BridgeConfig{}
config.Distributed.Topology = "dual-node"
config.Distributed.LocalNodeID = "xworkmate-bridge"
config.Distributed.Nodes = []DistributedNodeConfig{
{
ID: "xworkmate-bridge",
BridgeEndpoint: "http://172.29.10.1:8787",
},
{
ID: "cn-xworkmate-bridge",
BridgeEndpoint: "http://172.29.10.2:8787",
},
}
if got := resolveDistributedTaskForwardEndpoint(config); got != "" {
t.Fatalf("resolveDistributedTaskForwardEndpoint() = %q, want empty endpoint", got)
}
}
func TestResolveDistributedTaskForwardEndpointKeepsExplicitEndpoint(t *testing.T) {
config := &BridgeConfig{}
config.Distributed.TaskForwardEndpoint = "https://xworkmate-bridge.svc.plus"
config.Distributed.Topology = "dual-node"
config.Distributed.LocalNodeID = "cn-xworkmate-bridge"
config.Distributed.TaskForwardPeerID = "xworkmate-bridge"
config.Distributed.Nodes = []DistributedNodeConfig{
{
ID: "xworkmate-bridge",
BridgeEndpoint: "http://172.29.10.1:8787",
},
}
if got := resolveDistributedTaskForwardEndpoint(config); got != "https://xworkmate-bridge.svc.plus" {
t.Fatalf("resolveDistributedTaskForwardEndpoint() = %q, want explicit endpoint", got)
}
}