mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-04-12 19:57:29 +08:00
feat: add guarded webrtc node transport
This commit is contained in:
@@ -129,6 +129,20 @@ func gatewayCmd() {
|
||||
}
|
||||
|
||||
registryServer := api.NewServer(cfg.Gateway.Host, cfg.Gateway.Port, cfg.Gateway.Token, nodes.DefaultManager())
|
||||
configureGatewayNodeP2P := func(loop *agent.AgentLoop, server *api.Server, runtimeCfg *config.Config) {
|
||||
if loop == nil || server == nil || runtimeCfg == nil {
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case runtimeCfg.Gateway.Nodes.P2P.Enabled && strings.EqualFold(strings.TrimSpace(runtimeCfg.Gateway.Nodes.P2P.Transport), "webrtc"):
|
||||
webrtcTransport := nodes.NewWebRTCTransport(runtimeCfg.Gateway.Nodes.P2P.STUNServers)
|
||||
loop.SetNodeP2PTransport(webrtcTransport)
|
||||
server.SetNodeWebRTCTransport(webrtcTransport)
|
||||
default:
|
||||
server.SetNodeWebRTCTransport(nil)
|
||||
}
|
||||
}
|
||||
configureGatewayNodeP2P(agentLoop, registryServer, cfg)
|
||||
registryServer.SetGatewayVersion(version)
|
||||
registryServer.SetWebUIVersion(version)
|
||||
registryServer.SetConfigPath(getConfigPath())
|
||||
@@ -343,7 +357,8 @@ func gatewayCmd() {
|
||||
runtimeSame := reflect.DeepEqual(cfg.Agents, newCfg.Agents) &&
|
||||
reflect.DeepEqual(cfg.Providers, newCfg.Providers) &&
|
||||
reflect.DeepEqual(cfg.Tools, newCfg.Tools) &&
|
||||
reflect.DeepEqual(cfg.Channels, newCfg.Channels)
|
||||
reflect.DeepEqual(cfg.Channels, newCfg.Channels) &&
|
||||
reflect.DeepEqual(cfg.Gateway.Nodes, newCfg.Gateway.Nodes)
|
||||
|
||||
if runtimeSame {
|
||||
configureLogging(newCfg)
|
||||
@@ -369,6 +384,7 @@ func gatewayCmd() {
|
||||
}
|
||||
cfg = newCfg
|
||||
runtimecfg.Set(cfg)
|
||||
configureGatewayNodeP2P(agentLoop, registryServer, cfg)
|
||||
fmt.Println("✓ Config hot-reload applied (logging/metadata only)")
|
||||
return
|
||||
}
|
||||
@@ -386,6 +402,7 @@ func gatewayCmd() {
|
||||
agentLoop = newAgentLoop
|
||||
cfg = newCfg
|
||||
runtimecfg.Set(cfg)
|
||||
configureGatewayNodeP2P(agentLoop, registryServer, cfg)
|
||||
sentinelService.Stop()
|
||||
sentinelService = sentinel.NewService(
|
||||
getConfigPath(),
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"clawgo/pkg/config"
|
||||
"clawgo/pkg/nodes"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/pion/webrtc/v4"
|
||||
)
|
||||
|
||||
type nodeRegisterOptions struct {
|
||||
@@ -42,6 +43,17 @@ type nodeHeartbeatOptions struct {
|
||||
ID string
|
||||
}
|
||||
|
||||
type nodeWebRTCSession struct {
|
||||
info nodes.NodeInfo
|
||||
opts nodeRegisterOptions
|
||||
client *http.Client
|
||||
writeJSON func(interface{}) error
|
||||
|
||||
mu sync.Mutex
|
||||
pc *webrtc.PeerConnection
|
||||
dc *webrtc.DataChannel
|
||||
}
|
||||
|
||||
func nodeCmd() {
|
||||
args := os.Args[2:]
|
||||
if len(args) == 0 {
|
||||
@@ -464,6 +476,7 @@ func waitNodeAck(ctx context.Context, acks <-chan nodes.WireAck, errs <-chan err
|
||||
func readNodeSocketLoop(ctx context.Context, conn *websocket.Conn, writeJSON func(interface{}) error, client *http.Client, info nodes.NodeInfo, opts nodeRegisterOptions, acks chan<- nodes.WireAck, errs chan<- error) {
|
||||
defer close(acks)
|
||||
defer close(errs)
|
||||
rtc := &nodeWebRTCSession{info: info, opts: opts, client: client, writeJSON: writeJSON}
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -496,46 +509,128 @@ func readNodeSocketLoop(ctx context.Context, conn *websocket.Conn, writeJSON fun
|
||||
case "node_request":
|
||||
go handleNodeWireRequest(ctx, writeJSON, client, info, opts, msg)
|
||||
case "signal_offer", "signal_answer", "signal_candidate":
|
||||
fmt.Printf("ℹ Signal received: type=%s from=%s session=%s\n", msg.Type, strings.TrimSpace(msg.From), strings.TrimSpace(msg.Session))
|
||||
if err := rtc.handleSignal(ctx, msg); err != nil {
|
||||
fmt.Printf("Warning: node webrtc signal failed: %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleNodeWireRequest(ctx context.Context, writeJSON func(interface{}) error, client *http.Client, info nodes.NodeInfo, opts nodeRegisterOptions, msg nodes.WireMessage) {
|
||||
resp := nodes.Response{
|
||||
OK: false,
|
||||
Code: "invalid_request",
|
||||
Node: info.ID,
|
||||
Action: "",
|
||||
Error: "request missing",
|
||||
}
|
||||
if msg.Request != nil {
|
||||
req := *msg.Request
|
||||
resp.Action = req.Action
|
||||
if strings.TrimSpace(opts.Endpoint) == "" {
|
||||
resp.Error = "node endpoint not configured"
|
||||
resp.Code = "endpoint_missing"
|
||||
} else {
|
||||
if req.Node == "" {
|
||||
req.Node = info.ID
|
||||
}
|
||||
execResp, err := nodes.DoEndpointRequest(ctx, client, opts.Endpoint, opts.NodeToken, req)
|
||||
if err != nil {
|
||||
resp = nodes.Response{
|
||||
OK: false,
|
||||
Code: "transport_error",
|
||||
Node: info.ID,
|
||||
Action: req.Action,
|
||||
Error: err.Error(),
|
||||
}
|
||||
} else {
|
||||
resp = execResp
|
||||
if strings.TrimSpace(resp.Node) == "" {
|
||||
resp.Node = info.ID
|
||||
}
|
||||
}
|
||||
func (s *nodeWebRTCSession) handleSignal(ctx context.Context, msg nodes.WireMessage) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
switch strings.ToLower(strings.TrimSpace(msg.Type)) {
|
||||
case "signal_offer":
|
||||
pc, err := s.ensurePeerConnectionLocked()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var desc webrtc.SessionDescription
|
||||
if err := mapWirePayload(msg.Payload, &desc); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := pc.SetRemoteDescription(desc); err != nil {
|
||||
return err
|
||||
}
|
||||
answer, err := pc.CreateAnswer(nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := pc.SetLocalDescription(answer); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.writeJSON(nodes.WireMessage{
|
||||
Type: "signal_answer",
|
||||
From: s.info.ID,
|
||||
To: "gateway",
|
||||
Session: strings.TrimSpace(msg.Session),
|
||||
Payload: structToWirePayload(*pc.LocalDescription()),
|
||||
})
|
||||
case "signal_candidate":
|
||||
if s.pc == nil {
|
||||
return nil
|
||||
}
|
||||
var candidate webrtc.ICECandidateInit
|
||||
if err := mapWirePayload(msg.Payload, &candidate); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.pc.AddICECandidate(candidate)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *nodeWebRTCSession) ensurePeerConnectionLocked() (*webrtc.PeerConnection, error) {
|
||||
if s.pc != nil {
|
||||
return s.pc, nil
|
||||
}
|
||||
config := webrtc.Configuration{}
|
||||
pc, err := webrtc.NewPeerConnection(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pc.OnICECandidate(func(candidate *webrtc.ICECandidate) {
|
||||
if candidate == nil {
|
||||
return
|
||||
}
|
||||
_ = s.writeJSON(nodes.WireMessage{
|
||||
Type: "signal_candidate",
|
||||
From: s.info.ID,
|
||||
To: "gateway",
|
||||
Session: s.info.ID,
|
||||
Payload: structToWirePayload(candidate.ToJSON()),
|
||||
})
|
||||
})
|
||||
pc.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
|
||||
switch state {
|
||||
case webrtc.PeerConnectionStateFailed, webrtc.PeerConnectionStateClosed, webrtc.PeerConnectionStateDisconnected:
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.pc != nil {
|
||||
_ = s.pc.Close()
|
||||
}
|
||||
s.pc = nil
|
||||
s.dc = nil
|
||||
}
|
||||
})
|
||||
pc.OnDataChannel(func(dc *webrtc.DataChannel) {
|
||||
s.mu.Lock()
|
||||
s.dc = dc
|
||||
s.mu.Unlock()
|
||||
dc.OnMessage(func(message webrtc.DataChannelMessage) {
|
||||
var msg nodes.WireMessage
|
||||
if err := json.Unmarshal(message.Data, &msg); err != nil {
|
||||
return
|
||||
}
|
||||
if strings.ToLower(strings.TrimSpace(msg.Type)) != "node_request" || msg.Request == nil {
|
||||
return
|
||||
}
|
||||
go s.handleDataChannelRequest(context.Background(), dc, msg)
|
||||
})
|
||||
})
|
||||
s.pc = pc
|
||||
return pc, nil
|
||||
}
|
||||
|
||||
func (s *nodeWebRTCSession) handleDataChannelRequest(ctx context.Context, dc *webrtc.DataChannel, msg nodes.WireMessage) {
|
||||
resp := executeNodeRequest(ctx, s.client, s.info, s.opts, msg.Request)
|
||||
b, err := json.Marshal(nodes.WireMessage{
|
||||
Type: "node_response",
|
||||
ID: msg.ID,
|
||||
From: s.info.ID,
|
||||
To: "gateway",
|
||||
Session: strings.TrimSpace(msg.Session),
|
||||
Response: &resp,
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = dc.Send(b)
|
||||
}
|
||||
|
||||
func handleNodeWireRequest(ctx context.Context, writeJSON func(interface{}) error, client *http.Client, info nodes.NodeInfo, opts nodeRegisterOptions, msg nodes.WireMessage) {
|
||||
resp := executeNodeRequest(ctx, client, info, opts, msg.Request)
|
||||
_ = writeJSON(nodes.WireMessage{
|
||||
Type: "node_response",
|
||||
ID: msg.ID,
|
||||
@@ -546,6 +641,64 @@ func handleNodeWireRequest(ctx context.Context, writeJSON func(interface{}) erro
|
||||
})
|
||||
}
|
||||
|
||||
func executeNodeRequest(ctx context.Context, client *http.Client, info nodes.NodeInfo, opts nodeRegisterOptions, req *nodes.Request) nodes.Response {
|
||||
resp := nodes.Response{
|
||||
OK: false,
|
||||
Code: "invalid_request",
|
||||
Node: info.ID,
|
||||
Action: "",
|
||||
Error: "request missing",
|
||||
}
|
||||
if req == nil {
|
||||
return resp
|
||||
}
|
||||
next := *req
|
||||
resp.Action = next.Action
|
||||
if strings.TrimSpace(opts.Endpoint) == "" {
|
||||
resp.Error = "node endpoint not configured"
|
||||
resp.Code = "endpoint_missing"
|
||||
return resp
|
||||
}
|
||||
if next.Node == "" {
|
||||
next.Node = info.ID
|
||||
}
|
||||
execResp, err := nodes.DoEndpointRequest(ctx, client, opts.Endpoint, opts.NodeToken, next)
|
||||
if err != nil {
|
||||
return nodes.Response{
|
||||
OK: false,
|
||||
Code: "transport_error",
|
||||
Node: info.ID,
|
||||
Action: next.Action,
|
||||
Error: err.Error(),
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(execResp.Node) == "" {
|
||||
execResp.Node = info.ID
|
||||
}
|
||||
return execResp
|
||||
}
|
||||
|
||||
func structToWirePayload(v interface{}) map[string]interface{} {
|
||||
b, _ := json.Marshal(v)
|
||||
var out map[string]interface{}
|
||||
_ = json.Unmarshal(b, &out)
|
||||
if out == nil {
|
||||
out = map[string]interface{}{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mapWirePayload(in map[string]interface{}, out interface{}) error {
|
||||
if len(in) == 0 {
|
||||
return fmt.Errorf("empty payload")
|
||||
}
|
||||
b, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(b, out)
|
||||
}
|
||||
|
||||
func postNodeRegister(ctx context.Context, client *http.Client, gatewayBase, token string, info nodes.NodeInfo) error {
|
||||
return postNodeJSON(ctx, client, gatewayBase, token, "/nodes/register", info)
|
||||
}
|
||||
|
||||
@@ -279,7 +279,8 @@
|
||||
"nodes": {
|
||||
"p2p": {
|
||||
"enabled": false,
|
||||
"transport": "websocket_tunnel"
|
||||
"transport": "websocket_tunnel",
|
||||
"stun_servers": []
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
17
go.mod
17
go.mod
@@ -29,6 +29,22 @@ require (
|
||||
github.com/grbit/go-json v0.11.0 // indirect
|
||||
github.com/klauspost/compress v1.18.2 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
|
||||
github.com/pion/datachannel v1.5.10 // indirect
|
||||
github.com/pion/dtls/v3 v3.0.7 // indirect
|
||||
github.com/pion/ice/v4 v4.0.10 // indirect
|
||||
github.com/pion/interceptor v0.1.41 // indirect
|
||||
github.com/pion/logging v0.2.4 // indirect
|
||||
github.com/pion/mdns/v2 v2.0.7 // indirect
|
||||
github.com/pion/randutil v0.1.0 // indirect
|
||||
github.com/pion/rtcp v1.2.15 // indirect
|
||||
github.com/pion/rtp v1.8.23 // indirect
|
||||
github.com/pion/sctp v1.8.40 // indirect
|
||||
github.com/pion/sdp/v3 v3.0.16 // indirect
|
||||
github.com/pion/srtp/v3 v3.0.8 // indirect
|
||||
github.com/pion/stun/v3 v3.0.0 // indirect
|
||||
github.com/pion/transport/v3 v3.0.8 // indirect
|
||||
github.com/pion/turn/v4 v4.1.1 // indirect
|
||||
github.com/pion/webrtc/v4 v4.1.6 // indirect
|
||||
github.com/tidwall/gjson v1.18.0 // indirect
|
||||
github.com/tidwall/match v1.2.0 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
@@ -36,6 +52,7 @@ require (
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasthttp v1.69.0 // indirect
|
||||
github.com/valyala/fastjson v1.6.7 // indirect
|
||||
github.com/wlynxg/anet v0.0.5 // indirect
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/net v0.50.0 // indirect
|
||||
|
||||
34
go.sum
34
go.sum
@@ -86,6 +86,38 @@ github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1y
|
||||
github.com/onsi/gomega v1.16.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 h1:Lb/Uzkiw2Ugt2Xf03J5wmv81PdkYOiWbI8CNBi1boC8=
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
|
||||
github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o=
|
||||
github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M=
|
||||
github.com/pion/dtls/v3 v3.0.7 h1:bItXtTYYhZwkPFk4t1n3Kkf5TDrfj6+4wG+CZR8uI9Q=
|
||||
github.com/pion/dtls/v3 v3.0.7/go.mod h1:uDlH5VPrgOQIw59irKYkMudSFprY9IEFCqz/eTz16f8=
|
||||
github.com/pion/ice/v4 v4.0.10 h1:P59w1iauC/wPk9PdY8Vjl4fOFL5B+USq1+xbDcN6gT4=
|
||||
github.com/pion/ice/v4 v4.0.10/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw=
|
||||
github.com/pion/interceptor v0.1.41 h1:NpvX3HgWIukTf2yTBVjVGFXtpSpWgXjqz7IIpu7NsOw=
|
||||
github.com/pion/interceptor v0.1.41/go.mod h1:nEt4187unvRXJFyjiw00GKo+kIuXMWQI9K89fsosDLY=
|
||||
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
|
||||
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
|
||||
github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM=
|
||||
github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA=
|
||||
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
|
||||
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
|
||||
github.com/pion/rtcp v1.2.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo=
|
||||
github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0=
|
||||
github.com/pion/rtp v1.8.23 h1:kxX3bN4nM97DPrVBGq5I/Xcl332HnTHeP1Swx3/MCnU=
|
||||
github.com/pion/rtp v1.8.23/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM=
|
||||
github.com/pion/sctp v1.8.40 h1:bqbgWYOrUhsYItEnRObUYZuzvOMsVplS3oNgzedBlG8=
|
||||
github.com/pion/sctp v1.8.40/go.mod h1:SPBBUENXE6ThkEksN5ZavfAhFYll+h+66ZiG6IZQuzo=
|
||||
github.com/pion/sdp/v3 v3.0.16 h1:0dKzYO6gTAvuLaAKQkC02eCPjMIi4NuAr/ibAwrGDCo=
|
||||
github.com/pion/sdp/v3 v3.0.16/go.mod h1:9tyKzznud3qiweZcD86kS0ff1pGYB3VX+Bcsmkx6IXo=
|
||||
github.com/pion/srtp/v3 v3.0.8 h1:RjRrjcIeQsilPzxvdaElN0CpuQZdMvcl9VZ5UY9suUM=
|
||||
github.com/pion/srtp/v3 v3.0.8/go.mod h1:2Sq6YnDH7/UDCvkSoHSDNDeyBcFgWL0sAVycVbAsXFg=
|
||||
github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw=
|
||||
github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU=
|
||||
github.com/pion/transport/v3 v3.0.8 h1:oI3myyYnTKUSTthu/NZZ8eu2I5sHbxbUNNFW62olaYc=
|
||||
github.com/pion/transport/v3 v3.0.8/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ=
|
||||
github.com/pion/turn/v4 v4.1.1 h1:9UnY2HB99tpDyz3cVVZguSxcqkJ1DsTSZ+8TGruh4fc=
|
||||
github.com/pion/turn/v4 v4.1.1/go.mod h1:2123tHk1O++vmjI5VSD0awT50NywDAq5A2NNNU4Jjs8=
|
||||
github.com/pion/webrtc/v4 v4.1.6 h1:srHH2HwvCGwPba25EYJgUzgLqCQoXl1VCUnrGQMSzUw=
|
||||
github.com/pion/webrtc/v4 v4.1.6/go.mod h1:wKecGRlkl3ox/As/MYghJL+b/cVXMEhoPMJWPuGQFhU=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
@@ -124,6 +156,8 @@ github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZy
|
||||
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
|
||||
github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpBM=
|
||||
github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
|
||||
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
|
||||
@@ -79,6 +79,13 @@ func (al *AgentLoop) SetConfigPath(path string) {
|
||||
}
|
||||
}
|
||||
|
||||
func (al *AgentLoop) SetNodeP2PTransport(t nodes.Transport) {
|
||||
if al == nil || al.nodeRouter == nil {
|
||||
return
|
||||
}
|
||||
al.nodeRouter.P2P = t
|
||||
}
|
||||
|
||||
// StartupCompactionReport provides startup memory/session maintenance stats.
|
||||
type StartupCompactionReport struct {
|
||||
TotalSessions int `json:"total_sessions"`
|
||||
|
||||
@@ -40,6 +40,7 @@ type Server struct {
|
||||
nodeConnMu sync.Mutex
|
||||
nodeConnIDs map[string]string
|
||||
nodeSockets map[string]*nodeSocketConn
|
||||
nodeWebRTC *nodes.WebRTCTransport
|
||||
gatewayVersion string
|
||||
webuiVersion string
|
||||
configPath string
|
||||
@@ -116,6 +117,9 @@ func (s *Server) SetToolsCatalogHandler(fn func() interface{}) { s.onToolsCatalo
|
||||
func (s *Server) SetWebUIDir(dir string) { s.webUIDir = strings.TrimSpace(dir) }
|
||||
func (s *Server) SetGatewayVersion(v string) { s.gatewayVersion = strings.TrimSpace(v) }
|
||||
func (s *Server) SetWebUIVersion(v string) { s.webuiVersion = strings.TrimSpace(v) }
|
||||
func (s *Server) SetNodeWebRTCTransport(t *nodes.WebRTCTransport) {
|
||||
s.nodeWebRTC = t
|
||||
}
|
||||
|
||||
func (s *Server) rememberNodeConnection(nodeID, connID string) {
|
||||
nodeID = strings.TrimSpace(nodeID)
|
||||
@@ -142,6 +146,9 @@ func (s *Server) bindNodeSocket(nodeID, connID string, conn *websocket.Conn) {
|
||||
if s.mgr != nil {
|
||||
s.mgr.RegisterWireSender(nodeID, next)
|
||||
}
|
||||
if s.nodeWebRTC != nil {
|
||||
s.nodeWebRTC.BindSignaler(nodeID, next)
|
||||
}
|
||||
if prev != nil && prev.connID != connID {
|
||||
_ = prev.conn.Close()
|
||||
}
|
||||
@@ -165,6 +172,9 @@ func (s *Server) releaseNodeConnection(nodeID, connID string) bool {
|
||||
if s.mgr != nil {
|
||||
s.mgr.RegisterWireSender(nodeID, nil)
|
||||
}
|
||||
if s.nodeWebRTC != nil {
|
||||
s.nodeWebRTC.UnbindSignaler(nodeID)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -373,13 +383,23 @@ func (s *Server) handleNodeConnect(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
case "signal_offer", "signal_answer", "signal_candidate":
|
||||
targetID := strings.TrimSpace(msg.To)
|
||||
if s.nodeWebRTC != nil && (targetID == "" || strings.EqualFold(targetID, "gateway")) {
|
||||
if err := s.nodeWebRTC.HandleSignal(msg); err != nil {
|
||||
if err := writeAck(nodes.WireAck{OK: false, Type: msg.Type, ID: msg.ID, Error: err.Error()}); err != nil {
|
||||
return
|
||||
}
|
||||
} else if err := writeAck(nodes.WireAck{OK: true, Type: "signaled", ID: msg.ID}); err != nil {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(connectedID) == "" {
|
||||
if err := writeAck(nodes.WireAck{OK: false, Type: msg.Type, Error: "node not registered"}); err != nil {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
targetID := strings.TrimSpace(msg.To)
|
||||
if targetID == "" {
|
||||
if err := writeAck(nodes.WireAck{OK: false, Type: msg.Type, ID: msg.ID, Error: "target node required"}); err != nil {
|
||||
return
|
||||
|
||||
@@ -299,8 +299,9 @@ type GatewayNodesConfig struct {
|
||||
}
|
||||
|
||||
type GatewayNodesP2PConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Transport string `json:"transport,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Transport string `json:"transport,omitempty"`
|
||||
STUNServers []string `json:"stun_servers,omitempty"`
|
||||
}
|
||||
|
||||
type CronConfig struct {
|
||||
@@ -546,8 +547,9 @@ func DefaultConfig() *Config {
|
||||
Token: generateGatewayToken(),
|
||||
Nodes: GatewayNodesConfig{
|
||||
P2P: GatewayNodesP2PConfig{
|
||||
Enabled: false,
|
||||
Transport: "websocket_tunnel",
|
||||
Enabled: false,
|
||||
Transport: "websocket_tunnel",
|
||||
STUNServers: []string{},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -124,6 +124,7 @@ func Validate(cfg *Config) []error {
|
||||
default:
|
||||
errs = append(errs, fmt.Errorf("gateway.nodes.p2p.transport must be one of: websocket_tunnel, webrtc"))
|
||||
}
|
||||
errs = append(errs, validateNonEmptyStringList("gateway.nodes.p2p.stun_servers", cfg.Gateway.Nodes.P2P.STUNServers)...)
|
||||
if cfg.Cron.MinSleepSec <= 0 {
|
||||
errs = append(errs, fmt.Errorf("cron.min_sleep_sec must be > 0"))
|
||||
}
|
||||
|
||||
322
pkg/nodes/webrtc.go
Normal file
322
pkg/nodes/webrtc.go
Normal file
@@ -0,0 +1,322 @@
|
||||
package nodes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/pion/webrtc/v4"
|
||||
)
|
||||
|
||||
type gatewayRTCSession struct {
|
||||
nodeID string
|
||||
pc *webrtc.PeerConnection
|
||||
dc *webrtc.DataChannel
|
||||
ready chan struct{}
|
||||
readyMu sync.Once
|
||||
writeMu sync.Mutex
|
||||
pending map[string]chan Response
|
||||
mu sync.Mutex
|
||||
nextID uint64
|
||||
}
|
||||
|
||||
func (s *gatewayRTCSession) markReady() {
|
||||
s.readyMu.Do(func() { close(s.ready) })
|
||||
}
|
||||
|
||||
func (s *gatewayRTCSession) send(msg WireMessage) error {
|
||||
if s == nil || s.dc == nil {
|
||||
return fmt.Errorf("webrtc data channel unavailable")
|
||||
}
|
||||
b, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.writeMu.Lock()
|
||||
defer s.writeMu.Unlock()
|
||||
return s.dc.Send(b)
|
||||
}
|
||||
|
||||
func (s *gatewayRTCSession) nextRequestID() string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.nextID++
|
||||
return fmt.Sprintf("rtc-%s-%d", s.nodeID, s.nextID)
|
||||
}
|
||||
|
||||
type WebRTCTransport struct {
|
||||
stunServers []string
|
||||
|
||||
mu sync.Mutex
|
||||
sessions map[string]*gatewayRTCSession
|
||||
signal map[string]WireSender
|
||||
}
|
||||
|
||||
func NewWebRTCTransport(stunServers []string) *WebRTCTransport {
|
||||
out := make([]string, 0, len(stunServers))
|
||||
for _, server := range stunServers {
|
||||
if v := strings.TrimSpace(server); v != "" {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return &WebRTCTransport{
|
||||
stunServers: out,
|
||||
sessions: map[string]*gatewayRTCSession{},
|
||||
signal: map[string]WireSender{},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *WebRTCTransport) Name() string { return "p2p-webrtc" }
|
||||
|
||||
func (t *WebRTCTransport) BindSignaler(nodeID string, sender WireSender) {
|
||||
nodeID = strings.TrimSpace(nodeID)
|
||||
if nodeID == "" {
|
||||
return
|
||||
}
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if sender == nil {
|
||||
delete(t.signal, nodeID)
|
||||
return
|
||||
}
|
||||
t.signal[nodeID] = sender
|
||||
}
|
||||
|
||||
func (t *WebRTCTransport) UnbindSignaler(nodeID string) {
|
||||
t.BindSignaler(nodeID, nil)
|
||||
t.mu.Lock()
|
||||
session := t.sessions[nodeID]
|
||||
delete(t.sessions, nodeID)
|
||||
t.mu.Unlock()
|
||||
if session != nil && session.pc != nil {
|
||||
_ = session.pc.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (t *WebRTCTransport) currentSignaler(nodeID string) WireSender {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return t.signal[strings.TrimSpace(nodeID)]
|
||||
}
|
||||
|
||||
func (t *WebRTCTransport) HandleSignal(msg WireMessage) error {
|
||||
nodeID := strings.TrimSpace(msg.From)
|
||||
if nodeID == "" {
|
||||
return fmt.Errorf("signal missing from")
|
||||
}
|
||||
session, err := t.ensureSession(nodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(msg.Type)) {
|
||||
case "signal_answer":
|
||||
var desc webrtc.SessionDescription
|
||||
if err := mapInto(msg.Payload, &desc); err != nil {
|
||||
return err
|
||||
}
|
||||
return session.pc.SetRemoteDescription(desc)
|
||||
case "signal_candidate":
|
||||
var candidate webrtc.ICECandidateInit
|
||||
if err := mapInto(msg.Payload, &candidate); err != nil {
|
||||
return err
|
||||
}
|
||||
return session.pc.AddICECandidate(candidate)
|
||||
default:
|
||||
return fmt.Errorf("unsupported signal type: %s", msg.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *WebRTCTransport) Send(ctx context.Context, req Request) (Response, error) {
|
||||
session, err := t.ensureSession(req.Node)
|
||||
if err != nil {
|
||||
return Response{OK: false, Code: "p2p_unavailable", Node: req.Node, Action: req.Action, Error: err.Error()}, nil
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return Response{}, ctx.Err()
|
||||
case <-session.ready:
|
||||
case <-time.After(8 * time.Second):
|
||||
return Response{OK: false, Code: "p2p_timeout", Node: req.Node, Action: req.Action, Error: "webrtc session not ready"}, nil
|
||||
}
|
||||
|
||||
reqID := session.nextRequestID()
|
||||
respCh := make(chan Response, 1)
|
||||
session.mu.Lock()
|
||||
session.pending[reqID] = respCh
|
||||
session.mu.Unlock()
|
||||
|
||||
if err := session.send(WireMessage{
|
||||
Type: "node_request",
|
||||
ID: reqID,
|
||||
To: req.Node,
|
||||
Request: &req,
|
||||
}); err != nil {
|
||||
session.mu.Lock()
|
||||
delete(session.pending, reqID)
|
||||
session.mu.Unlock()
|
||||
return Response{OK: false, Code: "p2p_send_failed", Node: req.Node, Action: req.Action, Error: err.Error()}, nil
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
session.mu.Lock()
|
||||
delete(session.pending, reqID)
|
||||
session.mu.Unlock()
|
||||
return Response{}, ctx.Err()
|
||||
case resp := <-respCh:
|
||||
return resp, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (t *WebRTCTransport) ensureSession(nodeID string) (*gatewayRTCSession, error) {
|
||||
nodeID = strings.TrimSpace(nodeID)
|
||||
if nodeID == "" {
|
||||
return nil, fmt.Errorf("node id required")
|
||||
}
|
||||
|
||||
t.mu.Lock()
|
||||
if session := t.sessions[nodeID]; session != nil {
|
||||
t.mu.Unlock()
|
||||
return session, nil
|
||||
}
|
||||
t.mu.Unlock()
|
||||
if t.currentSignaler(nodeID) == nil {
|
||||
return nil, fmt.Errorf("node %s signaling unavailable", nodeID)
|
||||
}
|
||||
|
||||
config := webrtc.Configuration{}
|
||||
if len(t.stunServers) > 0 {
|
||||
config.ICEServers = []webrtc.ICEServer{{URLs: append([]string(nil), t.stunServers...)}}
|
||||
}
|
||||
pc, err := webrtc.NewPeerConnection(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dc, err := pc.CreateDataChannel("clawgo", nil)
|
||||
if err != nil {
|
||||
_ = pc.Close()
|
||||
return nil, err
|
||||
}
|
||||
session := &gatewayRTCSession{
|
||||
nodeID: nodeID,
|
||||
pc: pc,
|
||||
dc: dc,
|
||||
ready: make(chan struct{}),
|
||||
pending: map[string]chan Response{},
|
||||
}
|
||||
|
||||
pc.OnICECandidate(func(candidate *webrtc.ICECandidate) {
|
||||
if candidate == nil {
|
||||
return
|
||||
}
|
||||
sender := t.currentSignaler(nodeID)
|
||||
if sender == nil {
|
||||
return
|
||||
}
|
||||
_ = sender.Send(WireMessage{
|
||||
Type: "signal_candidate",
|
||||
From: "gateway",
|
||||
To: nodeID,
|
||||
Session: nodeID,
|
||||
Payload: structToMap(candidate.ToJSON()),
|
||||
})
|
||||
})
|
||||
pc.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
|
||||
switch state {
|
||||
case webrtc.PeerConnectionStateFailed, webrtc.PeerConnectionStateClosed, webrtc.PeerConnectionStateDisconnected:
|
||||
t.mu.Lock()
|
||||
if t.sessions[nodeID] == session {
|
||||
delete(t.sessions, nodeID)
|
||||
}
|
||||
t.mu.Unlock()
|
||||
}
|
||||
})
|
||||
dc.OnOpen(func() {
|
||||
session.markReady()
|
||||
})
|
||||
dc.OnMessage(func(message webrtc.DataChannelMessage) {
|
||||
var msg WireMessage
|
||||
if err := json.Unmarshal(message.Data, &msg); err != nil {
|
||||
return
|
||||
}
|
||||
if strings.ToLower(strings.TrimSpace(msg.Type)) != "node_response" || msg.Response == nil {
|
||||
return
|
||||
}
|
||||
session.mu.Lock()
|
||||
respCh := session.pending[msg.ID]
|
||||
if respCh != nil {
|
||||
delete(session.pending, msg.ID)
|
||||
}
|
||||
session.mu.Unlock()
|
||||
if respCh != nil {
|
||||
respCh <- *msg.Response
|
||||
}
|
||||
})
|
||||
|
||||
offer, err := pc.CreateOffer(nil)
|
||||
if err != nil {
|
||||
_ = pc.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := pc.SetLocalDescription(offer); err != nil {
|
||||
_ = pc.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
t.mu.Lock()
|
||||
if existing := t.sessions[nodeID]; existing != nil {
|
||||
t.mu.Unlock()
|
||||
_ = pc.Close()
|
||||
return existing, nil
|
||||
}
|
||||
t.sessions[nodeID] = session
|
||||
t.mu.Unlock()
|
||||
|
||||
sender := t.currentSignaler(nodeID)
|
||||
if sender == nil {
|
||||
t.mu.Lock()
|
||||
delete(t.sessions, nodeID)
|
||||
t.mu.Unlock()
|
||||
_ = pc.Close()
|
||||
return nil, fmt.Errorf("node %s signaling unavailable", nodeID)
|
||||
}
|
||||
if err := sender.Send(WireMessage{
|
||||
Type: "signal_offer",
|
||||
From: "gateway",
|
||||
To: nodeID,
|
||||
Session: nodeID,
|
||||
Payload: structToMap(*pc.LocalDescription()),
|
||||
}); err != nil {
|
||||
t.mu.Lock()
|
||||
delete(t.sessions, nodeID)
|
||||
t.mu.Unlock()
|
||||
_ = pc.Close()
|
||||
return nil, err
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func structToMap(v interface{}) map[string]interface{} {
|
||||
b, _ := json.Marshal(v)
|
||||
var out map[string]interface{}
|
||||
_ = json.Unmarshal(b, &out)
|
||||
if out == nil {
|
||||
out = map[string]interface{}{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mapInto(in map[string]interface{}, out interface{}) error {
|
||||
if len(in) == 0 {
|
||||
return fmt.Errorf("empty payload")
|
||||
}
|
||||
b, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(b, out)
|
||||
}
|
||||
Reference in New Issue
Block a user