mirror of
https://github.com/shtorm-7/sing-box-extended.git
synced 2026-08-07 06:15:15 +03:00
Add call protocol, Rmux. Update AmneziaWG. Fixes and improvements
This commit is contained in:
@@ -25,6 +25,7 @@ builds:
|
|||||||
- with_mtproxy
|
- with_mtproxy
|
||||||
- with_openvpn
|
- with_openvpn
|
||||||
- with_trusttunnel
|
- with_trusttunnel
|
||||||
|
- with_call
|
||||||
- with_sudoku
|
- with_sudoku
|
||||||
- with_snell
|
- with_snell
|
||||||
- with_manager
|
- with_manager
|
||||||
@@ -64,6 +65,7 @@ builds:
|
|||||||
- with_mtproxy
|
- with_mtproxy
|
||||||
- with_openvpn
|
- with_openvpn
|
||||||
- with_trusttunnel
|
- with_trusttunnel
|
||||||
|
- with_call
|
||||||
- with_sudoku
|
- with_sudoku
|
||||||
- with_snell
|
- with_snell
|
||||||
- with_profiler
|
- with_profiler
|
||||||
@@ -124,6 +126,7 @@ builds:
|
|||||||
- with_mtproxy
|
- with_mtproxy
|
||||||
- with_openvpn
|
- with_openvpn
|
||||||
- with_trusttunnel
|
- with_trusttunnel
|
||||||
|
- with_call
|
||||||
- with_sudoku
|
- with_sudoku
|
||||||
- with_snell
|
- with_snell
|
||||||
- with_manager
|
- with_manager
|
||||||
@@ -158,6 +161,7 @@ builds:
|
|||||||
- with_mtproxy
|
- with_mtproxy
|
||||||
- with_openvpn
|
- with_openvpn
|
||||||
- with_trusttunnel
|
- with_trusttunnel
|
||||||
|
- with_call
|
||||||
- with_sudoku
|
- with_sudoku
|
||||||
- with_snell
|
- with_snell
|
||||||
- with_manager
|
- with_manager
|
||||||
@@ -192,6 +196,7 @@ builds:
|
|||||||
- with_mtproxy
|
- with_mtproxy
|
||||||
- with_openvpn
|
- with_openvpn
|
||||||
- with_trusttunnel
|
- with_trusttunnel
|
||||||
|
- with_call
|
||||||
- with_sudoku
|
- with_sudoku
|
||||||
- with_snell
|
- with_snell
|
||||||
- with_manager
|
- with_manager
|
||||||
@@ -226,6 +231,7 @@ builds:
|
|||||||
- with_mtproxy
|
- with_mtproxy
|
||||||
- with_openvpn
|
- with_openvpn
|
||||||
- with_trusttunnel
|
- with_trusttunnel
|
||||||
|
- with_call
|
||||||
- with_sudoku
|
- with_sudoku
|
||||||
- with_snell
|
- with_snell
|
||||||
- with_manager
|
- with_manager
|
||||||
@@ -260,6 +266,7 @@ builds:
|
|||||||
- with_mtproxy
|
- with_mtproxy
|
||||||
- with_openvpn
|
- with_openvpn
|
||||||
- with_trusttunnel
|
- with_trusttunnel
|
||||||
|
- with_call
|
||||||
- with_sudoku
|
- with_sudoku
|
||||||
- with_snell
|
- with_snell
|
||||||
- with_manager
|
- with_manager
|
||||||
@@ -310,6 +317,7 @@ builds:
|
|||||||
- with_mtproxy
|
- with_mtproxy
|
||||||
- with_openvpn
|
- with_openvpn
|
||||||
- with_trusttunnel
|
- with_trusttunnel
|
||||||
|
- with_call
|
||||||
- with_sudoku
|
- with_sudoku
|
||||||
- with_snell
|
- with_snell
|
||||||
- with_manager
|
- with_manager
|
||||||
@@ -368,6 +376,7 @@ builds:
|
|||||||
- with_mtproxy
|
- with_mtproxy
|
||||||
- with_openvpn
|
- with_openvpn
|
||||||
- with_trusttunnel
|
- with_trusttunnel
|
||||||
|
- with_call
|
||||||
- with_sudoku
|
- with_sudoku
|
||||||
- with_snell
|
- with_snell
|
||||||
- with_profiler
|
- with_profiler
|
||||||
@@ -441,6 +450,7 @@ builds:
|
|||||||
- with_ocm
|
- with_ocm
|
||||||
- with_openvpn
|
- with_openvpn
|
||||||
- with_trusttunnel
|
- with_trusttunnel
|
||||||
|
- with_call
|
||||||
- with_sudoku
|
- with_sudoku
|
||||||
- with_snell
|
- with_snell
|
||||||
- with_profiler
|
- with_profiler
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ Sing-box with extended features.
|
|||||||
- **Sudoku** — Traffic obfuscation protocol based on 4×4 Sudoku puzzles with low-entropy fingerprints
|
- **Sudoku** — Traffic obfuscation protocol based on 4×4 Sudoku puzzles with low-entropy fingerprints
|
||||||
- **Snell** — Lightweight encrypted proxy (v1–v5) with TLS / HTTP obfuscation
|
- **Snell** — Lightweight encrypted proxy (v1–v5) with TLS / HTTP obfuscation
|
||||||
- **SSH** — SSH client and server with certificate authentication and upstream fallback
|
- **SSH** — SSH client and server with certificate authentication and upstream fallback
|
||||||
|
- **Call** — Traffic tunneling through video-call platforms (VK, Dion, Telemost, WBStream)
|
||||||
- **VPN** — Routed tunnel over any TCP sing-box protocol
|
- **VPN** — Routed tunnel over any TCP sing-box protocol
|
||||||
- **Bond** — Link aggregation for increasing throughput
|
- **Bond** — Link aggregation for increasing throughput
|
||||||
- **Fallback** — Outbound group with priority-based switching
|
- **Fallback** — Outbound group with priority-based switching
|
||||||
@@ -35,12 +36,13 @@ Sing-box with extended features.
|
|||||||
- **Rate Limiter** — Request rate limiting
|
- **Rate Limiter** — Request rate limiting
|
||||||
|
|
||||||
### Encryption & Obfuscation
|
### Encryption & Obfuscation
|
||||||
- **Amnezia 2.0** — WireGuard traffic obfuscation
|
- **Amnezia 3.0** — WireGuard traffic obfuscation
|
||||||
- **VLESS encryption** — XRAY encryption for VLESS protocol
|
- **VLESS encryption** — XRAY encryption for VLESS protocol
|
||||||
|
|
||||||
### Transports
|
### Transports
|
||||||
- **mKCP** — Reliable UDP-based transport
|
- **mKCP** — Reliable UDP-based transport
|
||||||
- **XHTTP** — Modern XRAY transport
|
- **XHTTP** — Modern XRAY transport
|
||||||
|
- **rmux** — Improved smux multiplex protocol
|
||||||
|
|
||||||
### Services
|
### Services
|
||||||
- **Admin Panel** — Web-based management interface
|
- **Admin Panel** — Web-based management interface
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ func init() {
|
|||||||
sharedFlags = append(sharedFlags, "-ldflags", "-X github.com/sagernet/sing-box/constant.Version="+currentTag+" -X internal/godebug.defaultGODEBUG=multipathtcp=0 -s -w -buildid= -checklinkname=0")
|
sharedFlags = append(sharedFlags, "-ldflags", "-X github.com/sagernet/sing-box/constant.Version="+currentTag+" -X internal/godebug.defaultGODEBUG=multipathtcp=0 -s -w -buildid= -checklinkname=0")
|
||||||
debugFlags = append(debugFlags, "-ldflags", "-X github.com/sagernet/sing-box/constant.Version="+currentTag+" -X internal/godebug.defaultGODEBUG=multipathtcp=0 -checklinkname=0")
|
debugFlags = append(debugFlags, "-ldflags", "-X github.com/sagernet/sing-box/constant.Version="+currentTag+" -X internal/godebug.defaultGODEBUG=multipathtcp=0 -checklinkname=0")
|
||||||
|
|
||||||
sharedTags = append(sharedTags, "with_gvisor", "with_quic", "with_wireguard", "with_masque", "with_mtproxy", "with_trusttunnel", "with_openvpn", "with_sudoku", "with_snell", "with_utls", "with_naive_outbound", "with_clash_api", "badlinkname", "tfogo_checklinkname0")
|
sharedTags = append(sharedTags, "with_gvisor", "with_quic", "with_wireguard", "with_masque", "with_mtproxy", "with_trusttunnel", "with_call", "with_openvpn", "with_sudoku", "with_snell", "with_utls", "with_naive_outbound", "with_clash_api", "badlinkname", "tfogo_checklinkname0")
|
||||||
darwinTags = append(darwinTags, "with_dhcp", "grpcnotrace")
|
darwinTags = append(darwinTags, "with_dhcp", "grpcnotrace")
|
||||||
// memcTags = append(memcTags, "with_tailscale")
|
// memcTags = append(memcTags, "with_tailscale")
|
||||||
sharedTags = append(sharedTags, "with_tailscale", "ts_omit_logtail", "ts_omit_ssh", "ts_omit_drive", "ts_omit_taildrop", "ts_omit_webclient", "ts_omit_doctor", "ts_omit_capture", "ts_omit_kube", "ts_omit_aws", "ts_omit_synology", "ts_omit_bird")
|
sharedTags = append(sharedTags, "with_tailscale", "ts_omit_logtail", "ts_omit_ssh", "ts_omit_drive", "ts_omit_taildrop", "ts_omit_webclient", "ts_omit_doctor", "ts_omit_capture", "ts_omit_kube", "ts_omit_aws", "ts_omit_synology", "ts_omit_bird")
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const (
|
|||||||
TypeAnyTLS = "anytls"
|
TypeAnyTLS = "anytls"
|
||||||
TypeSudoku = "sudoku"
|
TypeSudoku = "sudoku"
|
||||||
TypeSnell = "snell"
|
TypeSnell = "snell"
|
||||||
|
TypeCall = "call"
|
||||||
TypeShadowsocksR = "shadowsocksr"
|
TypeShadowsocksR = "shadowsocksr"
|
||||||
TypeVLESS = "vless"
|
TypeVLESS = "vless"
|
||||||
TypeTUIC = "tuic"
|
TypeTUIC = "tuic"
|
||||||
@@ -133,6 +134,8 @@ func ProxyDisplayName(proxyType string) string {
|
|||||||
return "Sudoku"
|
return "Sudoku"
|
||||||
case TypeSnell:
|
case TypeSnell:
|
||||||
return "Snell"
|
return "Snell"
|
||||||
|
case TypeCall:
|
||||||
|
return "Call"
|
||||||
case TypeFallback:
|
case TypeFallback:
|
||||||
return "Fallback"
|
return "Fallback"
|
||||||
case TypeTailscale:
|
case TypeTailscale:
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ Multiplex protocol.
|
|||||||
| smux | https://github.com/xtaci/smux |
|
| smux | https://github.com/xtaci/smux |
|
||||||
| yamux | https://github.com/hashicorp/yamux |
|
| yamux | https://github.com/hashicorp/yamux |
|
||||||
| h2mux | https://golang.org/x/net/http2 |
|
| h2mux | https://golang.org/x/net/http2 |
|
||||||
|
| rmux | https://github.com/shtorm-7/rmux |
|
||||||
|
|
||||||
h2mux is used by default.
|
h2mux is used by default.
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,7 @@
|
|||||||
| smux | https://github.com/xtaci/smux |
|
| smux | https://github.com/xtaci/smux |
|
||||||
| yamux | https://github.com/hashicorp/yamux |
|
| yamux | https://github.com/hashicorp/yamux |
|
||||||
| h2mux | https://golang.org/x/net/http2 |
|
| h2mux | https://golang.org/x/net/http2 |
|
||||||
|
| rmux | https://github.com/shtorm-7/rmux |
|
||||||
|
|
||||||
默认使用 h2mux。
|
默认使用 h2mux。
|
||||||
|
|
||||||
|
|||||||
34
examples/call/dion/creator.json
Normal file
34
examples/call/dion/creator.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"log": {
|
||||||
|
"level": "info"
|
||||||
|
},
|
||||||
|
"dns": {
|
||||||
|
"servers": [
|
||||||
|
{ "type": "local", "tag": "default" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"inbounds": [
|
||||||
|
{
|
||||||
|
"type": "call",
|
||||||
|
"tag": "call-in",
|
||||||
|
"platform": "dion",
|
||||||
|
"read_buffer": 32768,
|
||||||
|
"cookies": [
|
||||||
|
{ "name": "vc-refresh-token", "value": "" }
|
||||||
|
],
|
||||||
|
// optional: re-login if refresh cookie fails
|
||||||
|
"email": "",
|
||||||
|
"password": "",
|
||||||
|
// empty = create new call
|
||||||
|
"join_link": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outbounds": [
|
||||||
|
{ "type": "direct", "tag": "direct" }
|
||||||
|
],
|
||||||
|
"route": {
|
||||||
|
"final": "direct",
|
||||||
|
"default_domain_resolver": "default",
|
||||||
|
"auto_detect_interface": true
|
||||||
|
}
|
||||||
|
}
|
||||||
31
examples/call/dion/joiner.json
Normal file
31
examples/call/dion/joiner.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"log": {
|
||||||
|
"level": "info"
|
||||||
|
},
|
||||||
|
"dns": {
|
||||||
|
"servers": [
|
||||||
|
{ "type": "local", "tag": "default" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"inbounds": [
|
||||||
|
{
|
||||||
|
"type": "socks",
|
||||||
|
"listen": "127.0.0.1",
|
||||||
|
"listen_port": 1080
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outbounds": [
|
||||||
|
{
|
||||||
|
"type": "call",
|
||||||
|
"tag": "call-out",
|
||||||
|
"platform": "dion",
|
||||||
|
"read_buffer": 32768,
|
||||||
|
"join_link": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"route": {
|
||||||
|
"final": "call-out",
|
||||||
|
"default_domain_resolver": "default",
|
||||||
|
"auto_detect_interface": true
|
||||||
|
}
|
||||||
|
}
|
||||||
32
examples/call/telemost/creator.json
Normal file
32
examples/call/telemost/creator.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"log": {
|
||||||
|
"level": "info"
|
||||||
|
},
|
||||||
|
"dns": {
|
||||||
|
"servers": [
|
||||||
|
{ "type": "local", "tag": "default" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"inbounds": [
|
||||||
|
{
|
||||||
|
"type": "call",
|
||||||
|
"tag": "call-in",
|
||||||
|
"platform": "telemost",
|
||||||
|
"read_buffer": 32768,
|
||||||
|
"cookies": [
|
||||||
|
{ "name": "Session_id", "value": "" },
|
||||||
|
{ "name": "sessionid2", "value": "" }
|
||||||
|
],
|
||||||
|
// empty = create new call
|
||||||
|
"join_link": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outbounds": [
|
||||||
|
{ "type": "direct", "tag": "direct" }
|
||||||
|
],
|
||||||
|
"route": {
|
||||||
|
"final": "direct",
|
||||||
|
"default_domain_resolver": "default",
|
||||||
|
"auto_detect_interface": true
|
||||||
|
}
|
||||||
|
}
|
||||||
31
examples/call/telemost/joiner.json
Normal file
31
examples/call/telemost/joiner.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"log": {
|
||||||
|
"level": "info"
|
||||||
|
},
|
||||||
|
"dns": {
|
||||||
|
"servers": [
|
||||||
|
{ "type": "local", "tag": "default" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"inbounds": [
|
||||||
|
{
|
||||||
|
"type": "socks",
|
||||||
|
"listen": "127.0.0.1",
|
||||||
|
"listen_port": 1080
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outbounds": [
|
||||||
|
{
|
||||||
|
"type": "call",
|
||||||
|
"tag": "call-out",
|
||||||
|
"platform": "telemost",
|
||||||
|
"read_buffer": 32768,
|
||||||
|
"join_link": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"route": {
|
||||||
|
"final": "call-out",
|
||||||
|
"default_domain_resolver": "default",
|
||||||
|
"auto_detect_interface": true
|
||||||
|
}
|
||||||
|
}
|
||||||
33
examples/call/vk/creator.json
Normal file
33
examples/call/vk/creator.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"log": {
|
||||||
|
"level": "info"
|
||||||
|
},
|
||||||
|
"dns": {
|
||||||
|
"servers": [
|
||||||
|
{ "type": "local", "tag": "default" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"inbounds": [
|
||||||
|
{
|
||||||
|
"type": "call",
|
||||||
|
"tag": "call-in",
|
||||||
|
"platform": "vk",
|
||||||
|
"read_buffer": 32768,
|
||||||
|
"cookies": [
|
||||||
|
{ "name": "remixsid", "value": "" },
|
||||||
|
{ "name": "remixnsid", "value": "" },
|
||||||
|
{ "name": "p", "value": "" }
|
||||||
|
],
|
||||||
|
// empty = create new call
|
||||||
|
"join_link": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outbounds": [
|
||||||
|
{ "type": "direct", "tag": "direct" }
|
||||||
|
],
|
||||||
|
"route": {
|
||||||
|
"final": "direct",
|
||||||
|
"default_domain_resolver": "default",
|
||||||
|
"auto_detect_interface": true
|
||||||
|
}
|
||||||
|
}
|
||||||
31
examples/call/vk/joiner.json
Normal file
31
examples/call/vk/joiner.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"log": {
|
||||||
|
"level": "info"
|
||||||
|
},
|
||||||
|
"dns": {
|
||||||
|
"servers": [
|
||||||
|
{ "type": "local", "tag": "default" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"inbounds": [
|
||||||
|
{
|
||||||
|
"type": "socks",
|
||||||
|
"listen": "127.0.0.1",
|
||||||
|
"listen_port": 1080
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outbounds": [
|
||||||
|
{
|
||||||
|
"type": "call",
|
||||||
|
"tag": "call-out",
|
||||||
|
"platform": "vk",
|
||||||
|
"read_buffer": 32768,
|
||||||
|
"join_link": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"route": {
|
||||||
|
"final": "call-out",
|
||||||
|
"default_domain_resolver": "default",
|
||||||
|
"auto_detect_interface": true
|
||||||
|
}
|
||||||
|
}
|
||||||
37
examples/call/wbstream/creator.json
Normal file
37
examples/call/wbstream/creator.json
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"log": {
|
||||||
|
"level": "info"
|
||||||
|
},
|
||||||
|
"dns": {
|
||||||
|
"servers": [
|
||||||
|
{ "type": "local", "tag": "default" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"inbounds": [
|
||||||
|
{
|
||||||
|
"type": "call",
|
||||||
|
"tag": "call-in",
|
||||||
|
"platform": "wbstream",
|
||||||
|
// dc = fast data channel, video = slow VP8-smuggled (default)
|
||||||
|
"mode": "dc",
|
||||||
|
"read_buffer": 32768,
|
||||||
|
"cookies": [
|
||||||
|
{ "name": "__wb_device_id", "value": "" },
|
||||||
|
{ "name": "wbx-refresh", "value": "" },
|
||||||
|
{ "name": "x_wbaas_token", "value": "" },
|
||||||
|
{ "name": "_wbauid", "value": "" },
|
||||||
|
{ "name": "wbx-validation-key", "value": "" }
|
||||||
|
],
|
||||||
|
// empty = create new call
|
||||||
|
"join_link": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outbounds": [
|
||||||
|
{ "type": "direct", "tag": "direct" }
|
||||||
|
],
|
||||||
|
"route": {
|
||||||
|
"final": "direct",
|
||||||
|
"default_domain_resolver": "default",
|
||||||
|
"auto_detect_interface": true
|
||||||
|
}
|
||||||
|
}
|
||||||
33
examples/call/wbstream/joiner.json
Normal file
33
examples/call/wbstream/joiner.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"log": {
|
||||||
|
"level": "info"
|
||||||
|
},
|
||||||
|
"dns": {
|
||||||
|
"servers": [
|
||||||
|
{ "type": "local", "tag": "default" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"inbounds": [
|
||||||
|
{
|
||||||
|
"type": "socks",
|
||||||
|
"listen": "127.0.0.1",
|
||||||
|
"listen_port": 1080
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outbounds": [
|
||||||
|
{
|
||||||
|
"type": "call",
|
||||||
|
"tag": "call-out",
|
||||||
|
"platform": "wbstream",
|
||||||
|
// dc = fast data channel, video = slow VP8-smuggled (default)
|
||||||
|
"mode": "dc",
|
||||||
|
"read_buffer": 32768,
|
||||||
|
"join_link": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"route": {
|
||||||
|
"final": "call-out",
|
||||||
|
"default_domain_resolver": "default",
|
||||||
|
"auto_detect_interface": true
|
||||||
|
}
|
||||||
|
}
|
||||||
44
examples/rmux/client.json
Normal file
44
examples/rmux/client.json
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"log": {
|
||||||
|
"level": "error"
|
||||||
|
},
|
||||||
|
"dns": {
|
||||||
|
"servers": [
|
||||||
|
{
|
||||||
|
"type": "local",
|
||||||
|
"tag": "default"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"inbounds": [
|
||||||
|
{
|
||||||
|
"type": "mixed",
|
||||||
|
"tag": "mixed-in",
|
||||||
|
"listen_port": 7897
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outbounds": [
|
||||||
|
{
|
||||||
|
"type": "direct",
|
||||||
|
"tag": "direct"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "vless",
|
||||||
|
"tag": "vless-out",
|
||||||
|
"server": "example.com",
|
||||||
|
"server_port": 443,
|
||||||
|
"uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937",
|
||||||
|
"multiplex": {
|
||||||
|
"enabled": true,
|
||||||
|
"protocol": "rmux",
|
||||||
|
"max_connections": 4,
|
||||||
|
"min_streams": 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"route": {
|
||||||
|
"final": "vless-out",
|
||||||
|
"default_domain_resolver": "default",
|
||||||
|
"auto_detect_interface": true
|
||||||
|
}
|
||||||
|
}
|
||||||
41
examples/rmux/server.json
Normal file
41
examples/rmux/server.json
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"log": {
|
||||||
|
"level": "error"
|
||||||
|
},
|
||||||
|
"dns": {
|
||||||
|
"servers": [
|
||||||
|
{
|
||||||
|
"type": "local",
|
||||||
|
"tag": "default"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"inbounds": [
|
||||||
|
{
|
||||||
|
"type": "vless",
|
||||||
|
"tag": "vless-in",
|
||||||
|
"listen": "0.0.0.0",
|
||||||
|
"listen_port": 443,
|
||||||
|
"users": [
|
||||||
|
{
|
||||||
|
"name": "user",
|
||||||
|
"uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"multiplex": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outbounds": [
|
||||||
|
{
|
||||||
|
"type": "direct",
|
||||||
|
"tag": "direct"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"route": {
|
||||||
|
"final": "direct",
|
||||||
|
"default_domain_resolver": "default",
|
||||||
|
"auto_detect_interface": true
|
||||||
|
}
|
||||||
|
}
|
||||||
37
go.mod
37
go.mod
@@ -20,6 +20,8 @@ require (
|
|||||||
github.com/godbus/dbus/v5 v5.2.2
|
github.com/godbus/dbus/v5 v5.2.2
|
||||||
github.com/gofrs/uuid/v5 v5.4.0
|
github.com/gofrs/uuid/v5 v5.4.0
|
||||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/huandu/go-sqlbuilder v1.39.1
|
github.com/huandu/go-sqlbuilder v1.39.1
|
||||||
github.com/insomniacslk/dhcp v0.0.0-20260220084031-5adc3eb26f91
|
github.com/insomniacslk/dhcp v0.0.0-20260220084031-5adc3eb26f91
|
||||||
github.com/jackc/pgx/v5 v5.8.0
|
github.com/jackc/pgx/v5 v5.8.0
|
||||||
@@ -33,6 +35,11 @@ require (
|
|||||||
github.com/miekg/dns v1.1.72
|
github.com/miekg/dns v1.1.72
|
||||||
github.com/openai/openai-go/v3 v3.26.0
|
github.com/openai/openai-go/v3 v3.26.0
|
||||||
github.com/oschwald/maxminddb-golang v1.13.1
|
github.com/oschwald/maxminddb-golang v1.13.1
|
||||||
|
github.com/pion/datachannel v1.6.0
|
||||||
|
github.com/pion/interceptor v0.1.44
|
||||||
|
github.com/pion/rtp v1.10.1
|
||||||
|
github.com/pion/webrtc/v4 v4.2.9
|
||||||
|
github.com/quic-go/quic-go v0.60.0
|
||||||
github.com/rasky/go-lzo v0.0.0-20200203143853-96a758eda86e
|
github.com/rasky/go-lzo v0.0.0-20200203143853-96a758eda86e
|
||||||
github.com/sagernet/asc-go v0.0.0-20241217030726-d563060fe4e1
|
github.com/sagernet/asc-go v0.0.0-20241217030726-d563060fe4e1
|
||||||
github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a
|
github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a
|
||||||
@@ -60,6 +67,7 @@ require (
|
|||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/vishvananda/netns v0.0.5
|
github.com/vishvananda/netns v0.0.5
|
||||||
|
github.com/xtaci/kcp-go/v5 v5.6.72
|
||||||
github.com/yosida95/uritemplate/v3 v3.0.2
|
github.com/yosida95/uritemplate/v3 v3.0.2
|
||||||
go.uber.org/zap v1.28.0
|
go.uber.org/zap v1.28.0
|
||||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba
|
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba
|
||||||
@@ -85,16 +93,30 @@ require (
|
|||||||
github.com/docker/go-connections v0.7.0 // indirect
|
github.com/docker/go-connections v0.7.0 // indirect
|
||||||
github.com/dunglas/httpsfv v1.1.0 // indirect
|
github.com/dunglas/httpsfv v1.1.0 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/klauspost/reedsolomon v1.12.0 // indirect
|
||||||
github.com/lib/pq v1.10.9 // indirect
|
github.com/lib/pq v1.10.9 // indirect
|
||||||
github.com/moby/term v0.5.2 // indirect
|
github.com/moby/term v0.5.2 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||||
github.com/panjf2000/ants/v2 v2.12.0 // indirect
|
github.com/panjf2000/ants/v2 v2.12.0 // indirect
|
||||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
github.com/pion/dtls/v3 v3.1.2 // indirect
|
||||||
|
github.com/pion/ice/v4 v4.2.1 // indirect
|
||||||
|
github.com/pion/logging v0.2.4 // indirect
|
||||||
|
github.com/pion/mdns/v2 v2.1.0 // indirect
|
||||||
|
github.com/pion/randutil v0.1.0 // indirect
|
||||||
|
github.com/pion/rtcp v1.2.16 // indirect
|
||||||
|
github.com/pion/sctp v1.9.2 // indirect
|
||||||
|
github.com/pion/sdp/v3 v3.0.18 // indirect
|
||||||
|
github.com/pion/srtp/v3 v3.0.10 // indirect
|
||||||
|
github.com/pion/stun/v3 v3.1.1 // indirect
|
||||||
|
github.com/pion/transport/v4 v4.0.1 // indirect
|
||||||
|
github.com/pion/turn/v4 v4.1.4 // indirect
|
||||||
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/redis/go-redis/v9 v9.8.0 // indirect
|
github.com/redis/go-redis/v9 v9.8.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/tjfoc/gmsm v1.4.1 // indirect
|
||||||
github.com/tylertreat/BoomFilters v0.0.0-20251117164519-53813c36cc1b // indirect
|
github.com/tylertreat/BoomFilters v0.0.0-20251117164519-53813c36cc1b // indirect
|
||||||
|
github.com/wlynxg/anet v0.0.5 // indirect
|
||||||
github.com/zeebo/assert v1.3.0 // indirect
|
github.com/zeebo/assert v1.3.0 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
|
||||||
go.opentelemetry.io/otel v1.44.0 // indirect
|
go.opentelemetry.io/otel v1.44.0 // indirect
|
||||||
@@ -193,6 +215,7 @@ require (
|
|||||||
github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260712142643-1e5048bd5587 // indirect
|
github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260712142643-1e5048bd5587 // indirect
|
||||||
github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a // indirect
|
github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a // indirect
|
||||||
github.com/sagernet/nftables v0.3.0-mod.2 // indirect
|
github.com/sagernet/nftables v0.3.0-mod.2 // indirect
|
||||||
|
github.com/shtorm-7/rmux v1.0.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.10 // indirect
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e // indirect
|
github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e // indirect
|
||||||
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect
|
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect
|
||||||
@@ -214,7 +237,7 @@ require (
|
|||||||
golang.org/x/oauth2 v0.34.0 // indirect
|
golang.org/x/oauth2 v0.34.0 // indirect
|
||||||
golang.org/x/term v0.44.0 // indirect
|
golang.org/x/term v0.44.0 // indirect
|
||||||
golang.org/x/text v0.38.0 // indirect
|
golang.org/x/text v0.38.0 // indirect
|
||||||
golang.org/x/time v0.12.0
|
golang.org/x/time v0.14.0
|
||||||
golang.org/x/tools v0.45.0 // indirect
|
golang.org/x/tools v0.45.0 // indirect
|
||||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||||
golang.zx2c4.com/wireguard/windows v0.5.3 // indirect
|
golang.zx2c4.com/wireguard/windows v0.5.3 // indirect
|
||||||
@@ -223,12 +246,10 @@ require (
|
|||||||
lukechampine.com/blake3 v1.4.1
|
lukechampine.com/blake3 v1.4.1
|
||||||
)
|
)
|
||||||
|
|
||||||
replace github.com/sagernet/wireguard-go => github.com/shtorm-7/wireguard-go v0.0.2-beta.1-extended-1.4.3
|
replace github.com/sagernet/wireguard-go => github.com/shtorm-7/wireguard-go v0.0.2-beta.1-extended-1.5.0
|
||||||
|
|
||||||
replace github.com/sagernet/tailscale => github.com/shtorm-7/tailscale v1.92.4-sing-box-1.13-mod.7-extended-1.0.2
|
replace github.com/sagernet/tailscale => github.com/shtorm-7/tailscale v1.92.4-sing-box-1.13-mod.7-extended-1.0.2
|
||||||
|
|
||||||
replace github.com/sagernet/sing-mux => github.com/shtorm-7/sing-mux v0.3.4-extended-1.0.0
|
|
||||||
|
|
||||||
replace github.com/ameshkov/dnscrypt/v2 => github.com/shtorm-7/dnscrypt/v2 v2.4.0-extended-1.0.0
|
replace github.com/ameshkov/dnscrypt/v2 => github.com/shtorm-7/dnscrypt/v2 v2.4.0-extended-1.0.0
|
||||||
|
|
||||||
replace github.com/sagernet/sing-vmess => github.com/shtorm-7/sing-vmess v0.2.8-extended-1.0.0
|
replace github.com/sagernet/sing-vmess => github.com/shtorm-7/sing-vmess v0.2.8-extended-1.0.0
|
||||||
@@ -239,4 +260,6 @@ replace github.com/Diniboy1123/connect-ip-go => github.com/shtorm-7/connect-ip-g
|
|||||||
|
|
||||||
replace github.com/shtorm-7/go-cache/v2 => github.com/shtorm-7/go-cache/v2 v2.1.0-extended-1.2.0
|
replace github.com/shtorm-7/go-cache/v2 => github.com/shtorm-7/go-cache/v2 v2.1.0-extended-1.2.0
|
||||||
|
|
||||||
replace github.com/sagernet/sing => github.com/shtorm-7/sing v0.8.12-extended-1.2.0
|
replace github.com/sagernet/sing => github.com/shtorm-7/sing v0.8.10-extended-1.2.0
|
||||||
|
|
||||||
|
replace github.com/sagernet/sing-mux => github.com/shtorm-7/sing-mux v0.3.4-extended-1.1.0
|
||||||
|
|||||||
136
go.sum
136
go.sum
@@ -1,3 +1,4 @@
|
|||||||
|
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||||
code.pfad.fr/check v1.1.0 h1:GWvjdzhSEgHvEHe2uJujDcpmZoySKuHQNrZMfzfO0bE=
|
code.pfad.fr/check v1.1.0 h1:GWvjdzhSEgHvEHe2uJujDcpmZoySKuHQNrZMfzfO0bE=
|
||||||
code.pfad.fr/check v1.1.0/go.mod h1:NiUH13DtYsb7xp5wll0U4SXx7KhXQVCtRgdC96IPfoM=
|
code.pfad.fr/check v1.1.0/go.mod h1:NiUH13DtYsb7xp5wll0U4SXx7KhXQVCtRgdC96IPfoM=
|
||||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
@@ -8,6 +9,7 @@ github.com/AliRizaAynaci/gorl/v2 v2.2.0 h1:E8oAwkordOwm9ItNNVJ5VKvGroDcHvWNvG11H
|
|||||||
github.com/AliRizaAynaci/gorl/v2 v2.2.0/go.mod h1:13wcj/W736v44b6uygUuwypMY9N3RXJuhAYXukIIdCo=
|
github.com/AliRizaAynaci/gorl/v2 v2.2.0/go.mod h1:13wcj/W736v44b6uygUuwypMY9N3RXJuhAYXukIIdCo=
|
||||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
|
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
|
||||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||||
|
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
github.com/OneOfOne/xxhash v1.2.8 h1:31czK/TI9sNkxIKfaUfGlU47BAxQ0ztGgd9vPyqimf8=
|
github.com/OneOfOne/xxhash v1.2.8 h1:31czK/TI9sNkxIKfaUfGlU47BAxQ0ztGgd9vPyqimf8=
|
||||||
@@ -40,10 +42,13 @@ github.com/caddyserver/zerossl v0.1.5 h1:dkvOjBAEEtY6LIGAHei7sw2UgqSD6TrWweXpV7l
|
|||||||
github.com/caddyserver/zerossl v0.1.5/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4=
|
github.com/caddyserver/zerossl v0.1.5/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4=
|
||||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||||
|
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/cilium/ebpf v0.15.0 h1:7NxJhNiBT3NG8pZJ3c+yfrVdHY8ScgKD27sScgjLMMk=
|
github.com/cilium/ebpf v0.15.0 h1:7NxJhNiBT3NG8pZJ3c+yfrVdHY8ScgKD27sScgjLMMk=
|
||||||
github.com/cilium/ebpf v0.15.0/go.mod h1:DHp1WyrLeiBh19Cf/tfiSMhqheEiK8fXFZ4No0P1Hso=
|
github.com/cilium/ebpf v0.15.0/go.mod h1:DHp1WyrLeiBh19Cf/tfiSMhqheEiK8fXFZ4No0P1Hso=
|
||||||
|
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||||
|
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||||
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
||||||
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||||
@@ -91,6 +96,9 @@ github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/
|
|||||||
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||||
github.com/enfein/mieru/v3 v3.33.0 h1:hv2jK8nqYHwpSG86U2rpZR2I8Aff1/J3ifRmd9NBbFc=
|
github.com/enfein/mieru/v3 v3.33.0 h1:hv2jK8nqYHwpSG86U2rpZR2I8Aff1/J3ifRmd9NBbFc=
|
||||||
github.com/enfein/mieru/v3 v3.33.0/go.mod h1:zJBUCsi5rxyvHM8fjFf+GLaEl4OEjjBXr1s5F6Qd3hM=
|
github.com/enfein/mieru/v3 v3.33.0/go.mod h1:zJBUCsi5rxyvHM8fjFf+GLaEl4OEjjBXr1s5F6Qd3hM=
|
||||||
|
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
|
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||||
|
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
github.com/florianl/go-nfqueue/v2 v2.0.2 h1:FL5lQTeetgpCvac1TRwSfgaXUn0YSO7WzGvWNIp3JPE=
|
github.com/florianl/go-nfqueue/v2 v2.0.2 h1:FL5lQTeetgpCvac1TRwSfgaXUn0YSO7WzGvWNIp3JPE=
|
||||||
@@ -139,12 +147,27 @@ github.com/gofrs/uuid/v5 v5.4.0 h1:EfbpCTjqMuGyq5ZJwxqzn3Cbr2d0rUZU7v5ycAk/e/0=
|
|||||||
github.com/gofrs/uuid/v5 v5.4.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8=
|
github.com/gofrs/uuid/v5 v5.4.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8=
|
||||||
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
|
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
|
||||||
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
|
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
|
||||||
|
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
||||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
|
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||||
|
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||||
|
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||||
|
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
|
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
|
||||||
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
|
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
|
||||||
|
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||||
|
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
|
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
|
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
@@ -156,6 +179,8 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k
|
|||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/hashicorp/golang-lru v0.6.0 h1:uL2shRDx7RTrOrTCUZEGP/wJUFiUI8QT6E7z5o8jga4=
|
github.com/hashicorp/golang-lru v0.6.0 h1:uL2shRDx7RTrOrTCUZEGP/wJUFiUI8QT6E7z5o8jga4=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
@@ -193,6 +218,8 @@ github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXD
|
|||||||
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
|
github.com/klauspost/reedsolomon v1.12.0 h1:I5FEp3xSwVCcEh3F5A7dofEfhXdF/bWhQWPH+XwBFno=
|
||||||
|
github.com/klauspost/reedsolomon v1.12.0/go.mod h1:EPLZJeh4l27pUGC3aXOjheaoh1I9yut7xTURiW3LQ9Y=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
@@ -255,6 +282,40 @@ github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaR
|
|||||||
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
|
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
|
||||||
github.com/pierrec/lz4/v4 v4.1.27 h1:+PhzhWDrjRj89TH2sw43nE3+4+W8lSxIuQadEHZyjUk=
|
github.com/pierrec/lz4/v4 v4.1.27 h1:+PhzhWDrjRj89TH2sw43nE3+4+W8lSxIuQadEHZyjUk=
|
||||||
github.com/pierrec/lz4/v4 v4.1.27/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
github.com/pierrec/lz4/v4 v4.1.27/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
||||||
|
github.com/pion/datachannel v1.6.0 h1:XecBlj+cvsxhAMZWFfFcPyUaDZtd7IJvrXqlXD/53i0=
|
||||||
|
github.com/pion/datachannel v1.6.0/go.mod h1:ur+wzYF8mWdC+Mkis5Thosk+u/VOL287apDNEbFpsIk=
|
||||||
|
github.com/pion/dtls/v3 v3.1.2 h1:gqEdOUXLtCGW+afsBLO0LtDD8GnuBBjEy6HRtyofZTc=
|
||||||
|
github.com/pion/dtls/v3 v3.1.2/go.mod h1:Hw/igcX4pdY69z1Hgv5x7wJFrUkdgHwAn/Q/uo7YHRo=
|
||||||
|
github.com/pion/ice/v4 v4.2.1 h1:XPRYXaLiFq3LFDG7a7bMrmr3mFr27G/gtXN3v/TVfxY=
|
||||||
|
github.com/pion/ice/v4 v4.2.1/go.mod h1:2quLV1S5v1tAx3VvAJaH//KGitRXvo4RKlX6D3tnN+c=
|
||||||
|
github.com/pion/interceptor v0.1.44 h1:sNlZwM8dWXU9JQAkJh8xrarC0Etn8Oolcniukmuy0/I=
|
||||||
|
github.com/pion/interceptor v0.1.44/go.mod h1:4atVlBkcgXuUP+ykQF0qOCGU2j7pQzX2ofvPRFsY5RY=
|
||||||
|
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.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY=
|
||||||
|
github.com/pion/mdns/v2 v2.1.0/go.mod h1:pcez23GdynwcfRU1977qKU0mDxSeucttSHbCSfFOd9A=
|
||||||
|
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.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo=
|
||||||
|
github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo=
|
||||||
|
github.com/pion/rtp v1.10.1 h1:xP1prZcCTUuhO2c83XtxyOHJteISg6o8iPsE2acaMtA=
|
||||||
|
github.com/pion/rtp v1.10.1/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM=
|
||||||
|
github.com/pion/sctp v1.9.2 h1:HxsOzEV9pWoeggv7T5kewVkstFNcGvhMPx0GvUOUQXo=
|
||||||
|
github.com/pion/sctp v1.9.2/go.mod h1:OTOlsQ5EDQ6mQ0z4MUGXt2CgQmKyafBEXhUVqLRB6G8=
|
||||||
|
github.com/pion/sdp/v3 v3.0.18 h1:l0bAXazKHpepazVdp+tPYnrsy9dfh7ZbT8DxesH5ZnI=
|
||||||
|
github.com/pion/sdp/v3 v3.0.18/go.mod h1:ZREGo6A9ZygQ9XkqAj5xYCQtQpif0i6Pa81HOiAdqQ8=
|
||||||
|
github.com/pion/srtp/v3 v3.0.10 h1:tFirkpBb3XccP5VEXLi50GqXhv5SKPxqrdlhDCJlZrQ=
|
||||||
|
github.com/pion/srtp/v3 v3.0.10/go.mod h1:3mOTIB0cq9qlbn59V4ozvv9ClW/BSEbRp4cY0VtaR7M=
|
||||||
|
github.com/pion/stun/v3 v3.1.1 h1:CkQxveJ4xGQjulGSROXbXq94TAWu8gIX2dT+ePhUkqw=
|
||||||
|
github.com/pion/stun/v3 v3.1.1/go.mod h1:qC1DfmcCTQjl9PBaMa5wSn3x9IPmKxSdcCsxBcDBndM=
|
||||||
|
github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM=
|
||||||
|
github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ=
|
||||||
|
github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o=
|
||||||
|
github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM=
|
||||||
|
github.com/pion/turn/v4 v4.1.4 h1:EU11yMXKIsK43FhcUnjLlrhE4nboHZq+TXBIi3QpcxQ=
|
||||||
|
github.com/pion/turn/v4 v4.1.4/go.mod h1:ES1DXVFKnOhuDkqn9hn5VJlSWmZPaRJLyBXoOeO/BmQ=
|
||||||
|
github.com/pion/webrtc/v4 v4.2.9 h1:DZIh1HAhPIL3RvwEDFsmL5hfPSLEpxsQk9/Jir2vkJE=
|
||||||
|
github.com/pion/webrtc/v4 v4.2.9/go.mod h1:9EmLZve0H76eTzf8v2FmchZ6tcBXtDgpfTEu+drW6SY=
|
||||||
github.com/pires/go-proxyproto v0.11.0 h1:gUQpS85X/VJMdUsYyEgyn59uLJvGqPhJV5YvG68wXH4=
|
github.com/pires/go-proxyproto v0.11.0 h1:gUQpS85X/VJMdUsYyEgyn59uLJvGqPhJV5YvG68wXH4=
|
||||||
github.com/pires/go-proxyproto v0.11.0/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
|
github.com/pires/go-proxyproto v0.11.0/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
@@ -264,10 +325,13 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
|
|||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/prometheus-community/pro-bing v0.4.0 h1:YMbv+i08gQz97OZZBwLyvmmQEEzyfyrrjEaAchdy3R4=
|
github.com/prometheus-community/pro-bing v0.4.0 h1:YMbv+i08gQz97OZZBwLyvmmQEEzyfyrrjEaAchdy3R4=
|
||||||
github.com/prometheus-community/pro-bing v0.4.0/go.mod h1:b7wRYZtCcPmt4Sz319BykUU241rWLe1VFXyiyWK/dH4=
|
github.com/prometheus-community/pro-bing v0.4.0/go.mod h1:b7wRYZtCcPmt4Sz319BykUU241rWLe1VFXyiyWK/dH4=
|
||||||
|
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||||
|
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
|
||||||
|
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
|
||||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0=
|
||||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk=
|
||||||
github.com/rasky/go-lzo v0.0.0-20200203143853-96a758eda86e h1:dCWirM5F3wMY+cmRda/B1BiPsFtmzXqV9b0hLWtVBMs=
|
github.com/rasky/go-lzo v0.0.0-20200203143853-96a758eda86e h1:dCWirM5F3wMY+cmRda/B1BiPsFtmzXqV9b0hLWtVBMs=
|
||||||
github.com/rasky/go-lzo v0.0.0-20200203143853-96a758eda86e/go.mod h1:9leZcVcItj6m9/CfHY5Em/iBrCz7js8LcRQGTKEEv2M=
|
github.com/rasky/go-lzo v0.0.0-20200203143853-96a758eda86e/go.mod h1:9leZcVcItj6m9/CfHY5Em/iBrCz7js8LcRQGTKEEv2M=
|
||||||
github.com/redis/go-redis/v9 v9.8.0 h1:q3nRvjrlge/6UD7eTu/DSg2uYiU2mCL0G/uzBWqhicI=
|
github.com/redis/go-redis/v9 v9.8.0 h1:q3nRvjrlge/6UD7eTu/DSg2uYiU2mCL0G/uzBWqhicI=
|
||||||
@@ -383,16 +447,18 @@ github.com/shtorm-7/go-cache/v2 v2.1.0-extended-1.2.0 h1:aOd9Vy2LGSwgMM+4805AgLB
|
|||||||
github.com/shtorm-7/go-cache/v2 v2.1.0-extended-1.2.0/go.mod h1:Ek4yz5OK6stwhLKgLsRRYDI+FA+ZWvRJiWLjsi/vMM4=
|
github.com/shtorm-7/go-cache/v2 v2.1.0-extended-1.2.0/go.mod h1:Ek4yz5OK6stwhLKgLsRRYDI+FA+ZWvRJiWLjsi/vMM4=
|
||||||
github.com/shtorm-7/mtg-multi v1.11.0-extended-1.0.0 h1:iBLll4ZZG8ULQcHWs6gGslZWtBN72Zo1zjySzMVHF7g=
|
github.com/shtorm-7/mtg-multi v1.11.0-extended-1.0.0 h1:iBLll4ZZG8ULQcHWs6gGslZWtBN72Zo1zjySzMVHF7g=
|
||||||
github.com/shtorm-7/mtg-multi v1.11.0-extended-1.0.0/go.mod h1:3rvdhwdPABkwKBdvgMt3VwMn9uSq8hpoHRezZ5jRJU0=
|
github.com/shtorm-7/mtg-multi v1.11.0-extended-1.0.0/go.mod h1:3rvdhwdPABkwKBdvgMt3VwMn9uSq8hpoHRezZ5jRJU0=
|
||||||
github.com/shtorm-7/sing v0.8.12-extended-1.2.0 h1:/CYLFBi+Xrj0R6hFvYoqmDfcLe3WB6McZq5t9UvVhrc=
|
github.com/shtorm-7/rmux v1.0.0 h1:oNXshcZVUEDU9u5ylqh3VBFQwus5LFH5yZD9KMTkSSw=
|
||||||
github.com/shtorm-7/sing v0.8.12-extended-1.2.0/go.mod h1:olXxWQNqRW/l2Q6JI3b2Qmz8iQnIFlOeeH8bx6JhgUA=
|
github.com/shtorm-7/rmux v1.0.0/go.mod h1:6tVwUpgjMcCTi3iami7le2/rqhfR6aFPhwuyFzGNQcw=
|
||||||
github.com/shtorm-7/sing-mux v0.3.4-extended-1.0.0 h1:a5OoXr3e2ACbM6vDIaaGL44IdHQ6wPjcSoU13vfC0Sw=
|
github.com/shtorm-7/sing v0.8.10-extended-1.2.0 h1:5yw9j0+P2QkRWvxBvb71wvNdpAlHmmpBv4hj2gqvass=
|
||||||
github.com/shtorm-7/sing-mux v0.3.4-extended-1.0.0/go.mod h1:QvlKMyNBNrQoyX4x+gq028uPbLM2XeRpWtDsWBJbFSk=
|
github.com/shtorm-7/sing v0.8.10-extended-1.2.0/go.mod h1:olXxWQNqRW/l2Q6JI3b2Qmz8iQnIFlOeeH8bx6JhgUA=
|
||||||
|
github.com/shtorm-7/sing-mux v0.3.4-extended-1.1.0 h1:rSDID4on/Z9OherBpvsvKqyuj7dM2b4q+YBJFcpLGpI=
|
||||||
|
github.com/shtorm-7/sing-mux v0.3.4-extended-1.1.0/go.mod h1:1qHy7cnbDQSY2OWL7CybproWC0t5Ia/DIuNHAzB6ZCA=
|
||||||
github.com/shtorm-7/sing-vmess v0.2.8-extended-1.0.0 h1:OjvqZOhYSi9eIJGYC0iPDPqvgo/asujvhAGkFzbZ5/Y=
|
github.com/shtorm-7/sing-vmess v0.2.8-extended-1.0.0 h1:OjvqZOhYSi9eIJGYC0iPDPqvgo/asujvhAGkFzbZ5/Y=
|
||||||
github.com/shtorm-7/sing-vmess v0.2.8-extended-1.0.0/go.mod h1:P11scgTxMxVVQ8dlM27yNm3Cro40mD0+gHbnqrNGDuY=
|
github.com/shtorm-7/sing-vmess v0.2.8-extended-1.0.0/go.mod h1:P11scgTxMxVVQ8dlM27yNm3Cro40mD0+gHbnqrNGDuY=
|
||||||
github.com/shtorm-7/tailscale v1.92.4-sing-box-1.13-mod.7-extended-1.0.2 h1:hSMjh97OszszOd8HrzpaYUQH9dWRRBluJCbwQyz8ZOk=
|
github.com/shtorm-7/tailscale v1.92.4-sing-box-1.13-mod.7-extended-1.0.2 h1:hSMjh97OszszOd8HrzpaYUQH9dWRRBluJCbwQyz8ZOk=
|
||||||
github.com/shtorm-7/tailscale v1.92.4-sing-box-1.13-mod.7-extended-1.0.2/go.mod h1:TYIIqO5sZpWq873rLIeO2usszSMUpR3h6WdqVVs65ug=
|
github.com/shtorm-7/tailscale v1.92.4-sing-box-1.13-mod.7-extended-1.0.2/go.mod h1:TYIIqO5sZpWq873rLIeO2usszSMUpR3h6WdqVVs65ug=
|
||||||
github.com/shtorm-7/wireguard-go v0.0.2-beta.1-extended-1.4.3 h1:jtOA73D4F5qRV70//ahOt20KBnWvQimAFjtIiOtt0ps=
|
github.com/shtorm-7/wireguard-go v0.0.2-beta.1-extended-1.5.0 h1:7YhTkPhDUK81OV9mRMH26Efobh0VCGlig/HgHAlsf5U=
|
||||||
github.com/shtorm-7/wireguard-go v0.0.2-beta.1-extended-1.4.3/go.mod h1:Me2JlCDYHxnd0mnuX7L5LXAeDHCltI7vSKq3eTE6SVE=
|
github.com/shtorm-7/wireguard-go v0.0.2-beta.1-extended-1.5.0/go.mod h1:Me2JlCDYHxnd0mnuX7L5LXAeDHCltI7vSKq3eTE6SVE=
|
||||||
github.com/shtorm-7/workerpool v0.5.0 h1:NPZuNgyH0EUm4aQsTL09xR1iV+7GCFw6jX9Z4aAVp2s=
|
github.com/shtorm-7/workerpool v0.5.0 h1:NPZuNgyH0EUm4aQsTL09xR1iV+7GCFw6jX9Z4aAVp2s=
|
||||||
github.com/shtorm-7/workerpool v0.5.0/go.mod h1:NI0pUZgmGu0BdKO9j3mct1DNZmgXbyTS9foorljdH6E=
|
github.com/shtorm-7/workerpool v0.5.0/go.mod h1:NI0pUZgmGu0BdKO9j3mct1DNZmgXbyTS9foorljdH6E=
|
||||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||||
@@ -434,6 +500,8 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
|||||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||||
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||||
|
github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho=
|
||||||
|
github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE=
|
||||||
github.com/txthinking/runnergroup v0.0.0-20250224021307-5864ffeb65ae h1:ArVM1jICfm7g4E4dBet+KHUFMLuxmj1Nxdp/tr3ByCU=
|
github.com/txthinking/runnergroup v0.0.0-20250224021307-5864ffeb65ae h1:ArVM1jICfm7g4E4dBet+KHUFMLuxmj1Nxdp/tr3ByCU=
|
||||||
github.com/txthinking/runnergroup v0.0.0-20250224021307-5864ffeb65ae/go.mod h1:cldYm15/XHcGt7ndItnEWHwFZo7dinU+2QoyjfErhsI=
|
github.com/txthinking/runnergroup v0.0.0-20250224021307-5864ffeb65ae/go.mod h1:cldYm15/XHcGt7ndItnEWHwFZo7dinU+2QoyjfErhsI=
|
||||||
github.com/txthinking/socks5 v0.0.0-20251011041537-5c31f201a10e h1:xA7GVlbz6teIF4FdvuqwbX6C4tiqNk2PH7FRPIDerao=
|
github.com/txthinking/socks5 v0.0.0-20251011041537-5c31f201a10e h1:xA7GVlbz6teIF4FdvuqwbX6C4tiqNk2PH7FRPIDerao=
|
||||||
@@ -445,8 +513,14 @@ github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17
|
|||||||
github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0=
|
github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0=
|
||||||
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
|
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
|
||||||
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
|
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
|
||||||
|
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/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
|
github.com/xtaci/kcp-go/v5 v5.6.72 h1:FLaQPalgpufJYQRk0OK+gErEhXGLUPjv6FSRPrFR8Lk=
|
||||||
|
github.com/xtaci/kcp-go/v5 v5.6.72/go.mod h1:9O3D8WR+cyyUjGiTILYfg17vn72otWuXK2AFfqIe6CM=
|
||||||
|
github.com/xtaci/lossyconn v0.0.0-20190602105132-8df528c0c9ae h1:J0GxkO96kL4WF+AIT3M4mfUVinOCPgf2uUWYFUzN0sM=
|
||||||
|
github.com/xtaci/lossyconn v0.0.0-20190602105132-8df528c0c9ae/go.mod h1:gXtu8J62kEgmN++bm9BVICuT/e8yiLI2KFobd/TRFsE=
|
||||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||||
github.com/yl2chen/cidranger v1.0.2 h1:lbOWZVCG1tCRX4u24kuM1Tb4nHqWkDxwLdoS+SevawU=
|
github.com/yl2chen/cidranger v1.0.2 h1:lbOWZVCG1tCRX4u24kuM1Tb4nHqWkDxwLdoS+SevawU=
|
||||||
@@ -489,26 +563,47 @@ go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4
|
|||||||
go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
|
go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
|
||||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
|
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
|
||||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
|
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
|
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
|
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
|
||||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||||
|
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
|
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
|
||||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
|
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
|
||||||
golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w=
|
golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w=
|
||||||
golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g=
|
golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g=
|
||||||
|
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
|
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||||
|
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||||
|
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||||
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
@@ -518,13 +613,18 @@ golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
|||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
||||||
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||||
|
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||||
|
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
@@ -538,10 +638,24 @@ golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus
|
|||||||
golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI=
|
golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
|
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||||
|
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
|
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||||
|
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||||
|
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||||
|
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||||
|
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||||
|
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||||
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||||
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||||
|
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||||
|
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||||
|
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
@@ -556,6 +670,8 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gvisor.dev/gvisor v0.0.0-20260408064518-65a410b0d584 h1:QyFROp5Ew7XZWKPtp8ap78z4gpY6xHpJIEdHgVA4bzA=
|
gvisor.dev/gvisor v0.0.0-20260408064518-65a410b0d584 h1:QyFROp5Ew7XZWKPtp8ap78z4gpY6xHpJIEdHgVA4bzA=
|
||||||
gvisor.dev/gvisor v0.0.0-20260408064518-65a410b0d584/go.mod h1:xQ2PWgHmWJA/Ph4i1q1jBm39BKhc3W0DXqWoDSyuBOY=
|
gvisor.dev/gvisor v0.0.0-20260408064518-65a410b0d584/go.mod h1:xQ2PWgHmWJA/Ph4i1q1jBm39BKhc3W0DXqWoDSyuBOY=
|
||||||
|
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
|
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
|
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
|
||||||
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
||||||
lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=
|
lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=
|
||||||
|
|||||||
17
include/call.go
Normal file
17
include/call.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
//go:build with_call
|
||||||
|
|
||||||
|
package include
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/sagernet/sing-box/adapter/inbound"
|
||||||
|
"github.com/sagernet/sing-box/adapter/outbound"
|
||||||
|
"github.com/sagernet/sing-box/protocol/call"
|
||||||
|
)
|
||||||
|
|
||||||
|
func registerCallInbound(registry *inbound.Registry) {
|
||||||
|
call.RegisterInbound(registry)
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerCallOutbound(registry *outbound.Registry) {
|
||||||
|
call.RegisterOutbound(registry)
|
||||||
|
}
|
||||||
27
include/call_stub.go
Normal file
27
include/call_stub.go
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
//go:build !with_call
|
||||||
|
|
||||||
|
package include
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/sagernet/sing-box/adapter"
|
||||||
|
"github.com/sagernet/sing-box/adapter/inbound"
|
||||||
|
"github.com/sagernet/sing-box/adapter/outbound"
|
||||||
|
C "github.com/sagernet/sing-box/constant"
|
||||||
|
"github.com/sagernet/sing-box/log"
|
||||||
|
"github.com/sagernet/sing-box/option"
|
||||||
|
E "github.com/sagernet/sing/common/exceptions"
|
||||||
|
)
|
||||||
|
|
||||||
|
func registerCallInbound(registry *inbound.Registry) {
|
||||||
|
inbound.Register[option.CallInboundOptions](registry, C.TypeCall, func(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.CallInboundOptions) (adapter.Inbound, error) {
|
||||||
|
return nil, E.New(`Call is not included in this build, rebuild with -tags with_call`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerCallOutbound(registry *outbound.Registry) {
|
||||||
|
outbound.Register[option.CallOutboundOptions](registry, C.TypeCall, func(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.CallOutboundOptions) (adapter.Outbound, error) {
|
||||||
|
return nil, E.New(`Call is not included in this build, rebuild with -tags with_call`)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -92,6 +92,7 @@ func InboundRegistry() *inbound.Registry {
|
|||||||
registerMTProxyInbound(registry)
|
registerMTProxyInbound(registry)
|
||||||
registerSudokuInbound(registry)
|
registerSudokuInbound(registry)
|
||||||
registerSnellInbound(registry)
|
registerSnellInbound(registry)
|
||||||
|
registerCallInbound(registry)
|
||||||
|
|
||||||
return registry
|
return registry
|
||||||
}
|
}
|
||||||
@@ -137,6 +138,7 @@ func OutboundRegistry() *outbound.Registry {
|
|||||||
registerStubForRemovedOutbounds(registry)
|
registerStubForRemovedOutbounds(registry)
|
||||||
registerSudokuOutbound(registry)
|
registerSudokuOutbound(registry)
|
||||||
registerSnellOutbound(registry)
|
registerSnellOutbound(registry)
|
||||||
|
registerCallOutbound(registry)
|
||||||
|
|
||||||
return registry
|
return registry
|
||||||
}
|
}
|
||||||
|
|||||||
78
option/call.go
Normal file
78
option/call.go
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
package option
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// CallCookie is a single cookie entry provided inline in the configuration as
|
||||||
|
// JSON, matching the browser-exported {name, value} format.
|
||||||
|
type CallCookie struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CallCookieList is the list of cookies provided in the configuration.
|
||||||
|
type CallCookieList []CallCookie
|
||||||
|
|
||||||
|
// Header renders the cookie list into a single "name=value; name=value"
|
||||||
|
// header string. Entries with an empty name are skipped.
|
||||||
|
func (l CallCookieList) Header() string {
|
||||||
|
if len(l) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
parts := make([]string, 0, len(l))
|
||||||
|
for _, c := range l {
|
||||||
|
if c.Name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts = append(parts, c.Name+"="+c.Value)
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "; ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseCookieHeader parses a "name=value; name=value" cookie header string into
|
||||||
|
// a CallCookieList.
|
||||||
|
func ParseCookieHeader(header string) CallCookieList {
|
||||||
|
header = strings.TrimSpace(header)
|
||||||
|
if header == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var list CallCookieList
|
||||||
|
for _, part := range strings.Split(header, ";") {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if part == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name, value, _ := strings.Cut(part, "=")
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
list = append(list, CallCookie{Name: name, Value: strings.TrimSpace(value)})
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
type CallCommonOptions struct {
|
||||||
|
Platform string `json:"platform,omitempty"`
|
||||||
|
Mode string `json:"mode,omitempty"`
|
||||||
|
ReadBuffer int `json:"read_buffer,omitempty"`
|
||||||
|
MaxBufferedAmount int `json:"max_buffered_amount,omitempty"`
|
||||||
|
MemoryLimit int64 `json:"memory_limit,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CallInboundOptions struct {
|
||||||
|
DialerOptions
|
||||||
|
CallCommonOptions
|
||||||
|
Cookies CallCookieList `json:"cookies,omitempty"`
|
||||||
|
JoinLink string `json:"join_link,omitempty"`
|
||||||
|
// Email and Password are used to re-authenticate with the dion.vc
|
||||||
|
// platform when the refresh cookie is missing or rejected.
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
Password string `json:"password,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CallOutboundOptions struct {
|
||||||
|
DialerOptions
|
||||||
|
CallCommonOptions
|
||||||
|
JoinLink string `json:"join_link"`
|
||||||
|
Cookies CallCookieList `json:"cookies,omitempty"`
|
||||||
|
}
|
||||||
@@ -7,16 +7,22 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type VPNClientEndpointOptions struct {
|
type VPNClientEndpointOptions struct {
|
||||||
Address netip.Addr `json:"address"`
|
Address netip.Addr `json:"address"`
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
Outbound Outbound `json:"outbound"`
|
Outbound Outbound `json:"outbound"`
|
||||||
|
PoolSize uint8 `json:"pool_size,omitempty"`
|
||||||
|
ReconnectDelay badoption.Duration `json:"reconnect_delay,omitempty"`
|
||||||
|
RejectDelay badoption.Duration `json:"reject_delay,omitempty"`
|
||||||
|
DefaultGateway netip.Addr `json:"default_gateway,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type VPNServerEndpointOptions struct {
|
type VPNServerEndpointOptions struct {
|
||||||
Address netip.Addr `json:"address"`
|
Address netip.Addr `json:"address"`
|
||||||
Users []VPNUser `json:"users"`
|
Users []VPNUser `json:"users"`
|
||||||
Inbounds []Inbound `json:"inbounds"`
|
Inbounds []Inbound `json:"inbounds"`
|
||||||
|
PoolSize uint8 `json:"pool_size,omitempty"`
|
||||||
ConnectTimeout badoption.Duration `json:"connect_timeout,omitempty"`
|
ConnectTimeout badoption.Duration `json:"connect_timeout,omitempty"`
|
||||||
|
DefaultGateway netip.Addr `json:"default_gateway,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type VPNUser struct {
|
type VPNUser struct {
|
||||||
|
|||||||
122
protocol/call/inbound.go
Normal file
122
protocol/call/inbound.go
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
package call
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
|
||||||
|
"github.com/sagernet/sing-box/adapter"
|
||||||
|
"github.com/sagernet/sing-box/adapter/inbound"
|
||||||
|
"github.com/sagernet/sing-box/common/dialer"
|
||||||
|
C "github.com/sagernet/sing-box/constant"
|
||||||
|
"github.com/sagernet/sing-box/log"
|
||||||
|
"github.com/sagernet/sing-box/option"
|
||||||
|
"github.com/sagernet/sing-box/transport/call"
|
||||||
|
"github.com/sagernet/sing/common/bufio"
|
||||||
|
"github.com/sagernet/sing/common/bufio/deadline"
|
||||||
|
E "github.com/sagernet/sing/common/exceptions"
|
||||||
|
"github.com/sagernet/sing/common/logger"
|
||||||
|
M "github.com/sagernet/sing/common/metadata"
|
||||||
|
N "github.com/sagernet/sing/common/network"
|
||||||
|
"github.com/sagernet/sing/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
func RegisterInbound(registry *inbound.Registry) {
|
||||||
|
inbound.Register[option.CallInboundOptions](registry, C.TypeCall, NewInbound)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Inbound struct {
|
||||||
|
inbound.Adapter
|
||||||
|
ctx context.Context
|
||||||
|
router adapter.ConnectionRouterEx
|
||||||
|
logger logger.ContextLogger
|
||||||
|
options option.CallInboundOptions
|
||||||
|
dialer N.Dialer
|
||||||
|
bridge *call.Bridge
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.CallInboundOptions) (adapter.Inbound, error) {
|
||||||
|
if options.Platform == "" {
|
||||||
|
return nil, E.New("missing platform")
|
||||||
|
}
|
||||||
|
outboundDialer, err := dialer.New(ctx, options.DialerOptions, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Inbound{
|
||||||
|
Adapter: inbound.NewAdapter(C.TypeCall, tag),
|
||||||
|
ctx: ctx,
|
||||||
|
router: router,
|
||||||
|
logger: logger,
|
||||||
|
options: options,
|
||||||
|
dialer: outboundDialer,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Inbound) Start(stage adapter.StartStage) error {
|
||||||
|
if stage != adapter.StartStatePostStart {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
go h.run()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Inbound) Close() error {
|
||||||
|
if h.bridge == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return h.bridge.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Inbound) run() {
|
||||||
|
dnsRouter := service.FromContext[adapter.DNSRouter](h.ctx)
|
||||||
|
bridge, err := call.Connect(h.ctx, call.Config{
|
||||||
|
Platform: h.options.Platform,
|
||||||
|
Mode: h.options.Mode,
|
||||||
|
JoinLink: h.options.JoinLink,
|
||||||
|
CookieString: h.options.Cookies.Header(),
|
||||||
|
Email: h.options.Email,
|
||||||
|
Password: h.options.Password,
|
||||||
|
ReadBuffer: h.options.ReadBuffer,
|
||||||
|
Role: call.RoleCreator,
|
||||||
|
Dialer: h.dialer,
|
||||||
|
DNSRouter: dnsRouter,
|
||||||
|
Logger: h.logger,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
h.logger.ErrorContext(h.ctx, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.bridge = bridge
|
||||||
|
bridge.SetAcceptHandler(func(conn net.Conn, destination string) {
|
||||||
|
h.handleConnection(conn, M.ParseSocksaddr(destination))
|
||||||
|
})
|
||||||
|
bridge.SetUDPAcceptHandler(func(conn net.Conn, destination string) {
|
||||||
|
h.handlePacketConnection(bufio.NewUnbindPacketConnWithAddr(conn, M.ParseSocksaddr(destination)), M.ParseSocksaddr(destination))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Inbound) handleConnection(conn net.Conn, destination M.Socksaddr) {
|
||||||
|
ctx := log.ContextWithNewID(h.ctx)
|
||||||
|
var metadata adapter.InboundContext
|
||||||
|
metadata.Inbound = h.Tag()
|
||||||
|
metadata.InboundType = h.Type()
|
||||||
|
metadata.Source = M.Socksaddr{}
|
||||||
|
metadata.Destination = destination
|
||||||
|
h.logger.InfoContext(ctx, "inbound connection to ", destination)
|
||||||
|
h.router.RouteConnectionEx(ctx, deadline.NewConn(conn), metadata, N.OnceClose(func(it error) {
|
||||||
|
conn.Close()
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Inbound) handlePacketConnection(conn N.PacketConn, destination M.Socksaddr) {
|
||||||
|
ctx := log.ContextWithNewID(h.ctx)
|
||||||
|
var metadata adapter.InboundContext
|
||||||
|
metadata.Inbound = h.Tag()
|
||||||
|
metadata.InboundType = h.Type()
|
||||||
|
metadata.Source = M.Socksaddr{}
|
||||||
|
metadata.Destination = destination
|
||||||
|
h.logger.InfoContext(ctx, "inbound packet connection to ", destination)
|
||||||
|
h.router.RoutePacketConnectionEx(ctx, conn, metadata, N.OnceClose(func(it error) {
|
||||||
|
conn.Close()
|
||||||
|
}))
|
||||||
|
}
|
||||||
134
protocol/call/outbound.go
Normal file
134
protocol/call/outbound.go
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
package call
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
|
||||||
|
"github.com/sagernet/sing-box/adapter"
|
||||||
|
"github.com/sagernet/sing-box/adapter/outbound"
|
||||||
|
"github.com/sagernet/sing-box/common/dialer"
|
||||||
|
C "github.com/sagernet/sing-box/constant"
|
||||||
|
"github.com/sagernet/sing-box/log"
|
||||||
|
"github.com/sagernet/sing-box/option"
|
||||||
|
"github.com/sagernet/sing-box/transport/call"
|
||||||
|
"github.com/sagernet/sing/common/bufio"
|
||||||
|
"github.com/sagernet/sing/common/bufio/deadline"
|
||||||
|
E "github.com/sagernet/sing/common/exceptions"
|
||||||
|
"github.com/sagernet/sing/common/logger"
|
||||||
|
M "github.com/sagernet/sing/common/metadata"
|
||||||
|
N "github.com/sagernet/sing/common/network"
|
||||||
|
"github.com/sagernet/sing/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
func RegisterOutbound(registry *outbound.Registry) {
|
||||||
|
outbound.Register[option.CallOutboundOptions](registry, C.TypeCall, NewOutbound)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Outbound struct {
|
||||||
|
outbound.Adapter
|
||||||
|
ctx context.Context
|
||||||
|
logger logger.ContextLogger
|
||||||
|
options option.CallOutboundOptions
|
||||||
|
dialer N.Dialer
|
||||||
|
bridge *call.Bridge
|
||||||
|
startHandler func()
|
||||||
|
await chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.CallOutboundOptions) (adapter.Outbound, error) {
|
||||||
|
if options.JoinLink == "" {
|
||||||
|
return nil, E.New("missing join_link")
|
||||||
|
}
|
||||||
|
if options.Platform == "" {
|
||||||
|
return nil, E.New("missing platform")
|
||||||
|
}
|
||||||
|
outboundDialer, err := dialer.New(ctx, options.DialerOptions, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ob := &Outbound{
|
||||||
|
Adapter: outbound.NewAdapterWithDialerOptions(C.TypeCall, tag, []string{N.NetworkTCP, N.NetworkUDP}, options.DialerOptions),
|
||||||
|
ctx: ctx,
|
||||||
|
logger: logger,
|
||||||
|
options: options,
|
||||||
|
dialer: outboundDialer,
|
||||||
|
await: make(chan struct{}),
|
||||||
|
}
|
||||||
|
dnsRouter := service.FromContext[adapter.DNSRouter](ctx)
|
||||||
|
ob.startHandler = func() {
|
||||||
|
defer close(ob.await)
|
||||||
|
bridge, err := call.Connect(ctx, call.Config{
|
||||||
|
Platform: options.Platform,
|
||||||
|
Mode: options.Mode,
|
||||||
|
JoinLink: options.JoinLink,
|
||||||
|
CookieString: options.Cookies.Header(),
|
||||||
|
ReadBuffer: options.ReadBuffer,
|
||||||
|
Role: call.RoleJoiner,
|
||||||
|
Dialer: outboundDialer,
|
||||||
|
DNSRouter: dnsRouter,
|
||||||
|
Logger: logger,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorContext(ctx, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ob.bridge = bridge
|
||||||
|
}
|
||||||
|
return ob, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *Outbound) Start(stage adapter.StartStage) error {
|
||||||
|
if stage != adapter.StartStatePostStart {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
go o.startHandler()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *Outbound) Close() error {
|
||||||
|
if o.bridge == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return o.bridge.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *Outbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) {
|
||||||
|
if err := o.awaitBridge(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
switch N.NetworkName(network) {
|
||||||
|
case N.NetworkTCP:
|
||||||
|
o.logger.InfoContext(ctx, "outbound connection to ", destination)
|
||||||
|
conn, err := o.bridge.DialContext(ctx, destination.String())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return deadline.NewConn(conn), nil
|
||||||
|
default:
|
||||||
|
return nil, E.New("call: unsupported network: ", network)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) {
|
||||||
|
if err := o.awaitBridge(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
o.logger.InfoContext(ctx, "outbound packet connection to ", destination)
|
||||||
|
conn, err := o.bridge.ListenPacket(ctx, destination.String())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return bufio.NewUnbindPacketConnWithAddr(conn, destination), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *Outbound) awaitBridge(ctx context.Context) error {
|
||||||
|
select {
|
||||||
|
case <-o.await:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
if o.bridge == nil {
|
||||||
|
return E.New("call: tunnel not initialized")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -20,19 +20,14 @@ func newConnWithUploadTrafficLimiter(ctx context.Context, conn net.Conn, limiter
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (conn *connWithTrafficLimiter) Write(p []byte) (int, error) {
|
func (conn *connWithTrafficLimiter) Write(p []byte) (int, error) {
|
||||||
err := conn.limiter.Can(uint64(len(p)))
|
reserved, err := conn.limiter.Reserve(uint64(len(p)))
|
||||||
if err != nil {
|
if reserved < uint64(len(p)) {
|
||||||
|
conn.limiter.Commit(reserved, 0)
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
n, err := conn.Conn.Write(p)
|
n, err := conn.Conn.Write(p)
|
||||||
if err != nil {
|
conn.limiter.Commit(reserved, uint64(n))
|
||||||
return 0, err
|
return n, err
|
||||||
}
|
|
||||||
err = conn.limiter.Add(uint64(n))
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return n, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type connWithUploadTrafficLimiter struct {
|
type connWithUploadTrafficLimiter struct {
|
||||||
@@ -42,19 +37,16 @@ type connWithUploadTrafficLimiter struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (conn *connWithUploadTrafficLimiter) Read(p []byte) (int, error) {
|
func (conn *connWithUploadTrafficLimiter) Read(p []byte) (int, error) {
|
||||||
err := conn.limiter.Can(1)
|
reserved, err := conn.limiter.Reserve(uint64(len(p)))
|
||||||
if err != nil {
|
if reserved == 0 {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
if reserved < uint64(len(p)) {
|
||||||
|
p = p[:reserved]
|
||||||
|
}
|
||||||
n, err := conn.Conn.Read(p)
|
n, err := conn.Conn.Read(p)
|
||||||
if err != nil {
|
conn.limiter.Commit(reserved, uint64(n))
|
||||||
return 0, err
|
return n, err
|
||||||
}
|
|
||||||
err = conn.limiter.Add(uint64(n))
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return n, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type packetConnWithTrafficLimiter struct {
|
type packetConnWithTrafficLimiter struct {
|
||||||
@@ -72,19 +64,14 @@ func newPacketConnWithUploadTrafficLimiter(ctx context.Context, conn net.PacketC
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (conn *packetConnWithTrafficLimiter) WriteTo(p []byte, addr net.Addr) (int, error) {
|
func (conn *packetConnWithTrafficLimiter) WriteTo(p []byte, addr net.Addr) (int, error) {
|
||||||
err := conn.limiter.Can(uint64(len(p)))
|
reserved, err := conn.limiter.Reserve(uint64(len(p)))
|
||||||
if err != nil {
|
if reserved < uint64(len(p)) {
|
||||||
|
conn.limiter.Commit(reserved, 0)
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
n, err := conn.PacketConn.WriteTo(p, addr)
|
n, err := conn.PacketConn.WriteTo(p, addr)
|
||||||
if err != nil {
|
conn.limiter.Commit(reserved, uint64(n))
|
||||||
return 0, err
|
return n, err
|
||||||
}
|
|
||||||
err = conn.limiter.Add(uint64(n))
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return n, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type packetConnWithUploadTrafficLimiter struct {
|
type packetConnWithUploadTrafficLimiter struct {
|
||||||
@@ -94,19 +81,16 @@ type packetConnWithUploadTrafficLimiter struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (conn *packetConnWithUploadTrafficLimiter) ReadFrom(p []byte) (int, net.Addr, error) {
|
func (conn *packetConnWithUploadTrafficLimiter) ReadFrom(p []byte) (int, net.Addr, error) {
|
||||||
err := conn.limiter.Can(1)
|
reserved, err := conn.limiter.Reserve(uint64(len(p)))
|
||||||
if err != nil {
|
if reserved == 0 {
|
||||||
return 0, nil, err
|
return 0, nil, err
|
||||||
}
|
}
|
||||||
|
if reserved < uint64(len(p)) {
|
||||||
|
p = p[:reserved]
|
||||||
|
}
|
||||||
n, addr, err := conn.PacketConn.ReadFrom(p)
|
n, addr, err := conn.PacketConn.ReadFrom(p)
|
||||||
if err != nil {
|
conn.limiter.Commit(reserved, uint64(n))
|
||||||
return n, nil, err
|
return n, addr, err
|
||||||
}
|
|
||||||
err = conn.limiter.Add(uint64(n))
|
|
||||||
if err != nil {
|
|
||||||
return 0, nil, err
|
|
||||||
}
|
|
||||||
return n, addr, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func connWithDownloadTrafficWrapper(ctx context.Context, conn net.Conn, limiter TrafficLimiter, reverse bool) net.Conn {
|
func connWithDownloadTrafficWrapper(ctx context.Context, conn net.Conn, limiter TrafficLimiter, reverse bool) net.Conn {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package traffic
|
package traffic
|
||||||
|
|
||||||
type TrafficLimiter interface {
|
type TrafficLimiter interface {
|
||||||
Can(n uint64) error
|
Reserve(n uint64) (uint64, error)
|
||||||
Add(n uint64) error
|
Commit(reserved uint64, n uint64)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ func (s *DefaultWrapStrategy) wrapConn(ctx context.Context, conn net.Conn, metad
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return conn, err
|
return conn, err
|
||||||
}
|
}
|
||||||
err = limiter.Can(1)
|
_, err = limiter.Reserve(0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return conn, err
|
return conn, err
|
||||||
}
|
}
|
||||||
@@ -51,7 +51,7 @@ func (s *DefaultWrapStrategy) wrapPacketConn(ctx context.Context, conn net.Packe
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return conn, err
|
return conn, err
|
||||||
}
|
}
|
||||||
err = limiter.Can(1)
|
_, err = limiter.Reserve(0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return conn, err
|
return conn, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ type Outbound struct {
|
|||||||
|
|
||||||
func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.MASQUEOutboundOptions) (adapter.Outbound, error) {
|
func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.MASQUEOutboundOptions) (adapter.Outbound, error) {
|
||||||
outbound := &Outbound{
|
outbound := &Outbound{
|
||||||
Adapter: outbound.NewAdapterWithDialerOptions(C.TypeMASQUE, tag, []string{N.NetworkTCP, N.NetworkUDP, N.NetworkICMP}, options.DialerOptions),
|
Adapter: outbound.NewAdapterWithDialerOptions(C.TypeMASQUE, tag, []string{N.NetworkTCP, N.NetworkUDP}, options.DialerOptions),
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
dnsRouter: service.FromContext[adapter.DNSRouter](ctx),
|
dnsRouter: service.FromContext[adapter.DNSRouter](ctx),
|
||||||
logger: logger,
|
logger: logger,
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_masque,with_mtproxy,with_ccm,with_ocm,with_openvpn,with_trusttunnel,with_sudoku,with_snell,with_naive_outbound,badlinkname,tfogo_checklinkname0
|
with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_masque,with_mtproxy,with_ccm,with_ocm,with_openvpn,with_trusttunnel,with_call,with_sudoku,with_snell,with_naive_outbound,badlinkname,tfogo_checklinkname0
|
||||||
@@ -1 +1 @@
|
|||||||
with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_manager,with_admin_panel,with_masque,with_mtproxy,with_ccm,with_ocm,with_openvpn,with_trusttunnel,with_sudoku,with_snell,with_naive_outbound,with_musl,badlinkname,tfogo_checklinkname0
|
with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_manager,with_admin_panel,with_masque,with_mtproxy,with_ccm,with_ocm,with_openvpn,with_trusttunnel,with_call,with_sudoku,with_snell,with_naive_outbound,with_musl,badlinkname,tfogo_checklinkname0
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_manager,with_admin_panel,with_masque,with_mtproxy,with_ccm,with_ocm,with_openvpn,with_trusttunnel,with_sudoku,with_snell,badlinkname,tfogo_checklinkname0
|
with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_manager,with_admin_panel,with_masque,with_mtproxy,with_ccm,with_ocm,with_openvpn,with_trusttunnel,with_call,with_sudoku,with_snell,badlinkname,tfogo_checklinkname0
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_masque,with_mtproxy,with_ccm,with_ocm,with_openvpn,with_trusttunnel,with_sudoku,with_snell,with_naive_outbound,with_purego,badlinkname,tfogo_checklinkname0
|
with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_masque,with_mtproxy,with_ccm,with_ocm,with_openvpn,with_trusttunnel,with_call,with_sudoku,with_snell,with_naive_outbound,with_purego,badlinkname,tfogo_checklinkname0
|
||||||
@@ -44,7 +44,13 @@ func NewTrafficLimiterManager(ctx context.Context, nodeManager CM.NodeManager, l
|
|||||||
}
|
}
|
||||||
select {
|
select {
|
||||||
case <-timer.C:
|
case <-timer.C:
|
||||||
|
manager.mtx.Lock()
|
||||||
|
strategyManagers := make([]*TrafficLimiterStrategyManager, 0, len(manager.managers))
|
||||||
for _, strategyManager := range manager.managers {
|
for _, strategyManager := range manager.managers {
|
||||||
|
strategyManagers = append(strategyManagers, strategyManager)
|
||||||
|
}
|
||||||
|
manager.mtx.Unlock()
|
||||||
|
for _, strategyManager := range strategyManagers {
|
||||||
strategyManager.mtx.Lock()
|
strategyManager.mtx.Lock()
|
||||||
for _, limiter := range strategyManager.limiters {
|
for _, limiter := range strategyManager.limiters {
|
||||||
err := limiter.UpdateRemainingTraffic()
|
err := limiter.UpdateRemainingTraffic()
|
||||||
@@ -154,9 +160,10 @@ func (i *TrafficLimiterStrategyManager) DeleteTrafficLimiter(username string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type TrafficLimiter struct {
|
type TrafficLimiter struct {
|
||||||
manager CM.NodeManager
|
manager CM.NodeManager
|
||||||
limiter CM.TrafficLimiter
|
limiter CM.TrafficLimiter
|
||||||
new uint64
|
new uint64
|
||||||
|
reserved uint64
|
||||||
|
|
||||||
mtx sync.Mutex
|
mtx sync.Mutex
|
||||||
}
|
}
|
||||||
@@ -165,34 +172,34 @@ func NewTrafficLimiter(manager CM.NodeManager, limiter CM.TrafficLimiter) *Traff
|
|||||||
return &TrafficLimiter{manager: manager, limiter: limiter}
|
return &TrafficLimiter{manager: manager, limiter: limiter}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *TrafficLimiter) Can(n uint64) error {
|
func (l *TrafficLimiter) Reserve(n uint64) (uint64, error) {
|
||||||
l.mtx.Lock()
|
l.mtx.Lock()
|
||||||
defer l.mtx.Unlock()
|
defer l.mtx.Unlock()
|
||||||
if l.limiter.RawUsed == l.limiter.RawQuota {
|
used := l.limiter.RawUsed + l.reserved
|
||||||
return E.New("traffic limit exceeded")
|
if used >= l.limiter.RawQuota {
|
||||||
|
return 0, E.New("traffic limit exceeded")
|
||||||
}
|
}
|
||||||
if l.limiter.RawUsed+n > l.limiter.RawQuota {
|
remaining := l.limiter.RawQuota - used
|
||||||
l.new += l.limiter.RawQuota - l.limiter.RawUsed
|
if n > remaining {
|
||||||
l.limiter.RawUsed = l.limiter.RawQuota
|
l.reserved += remaining
|
||||||
return E.New("traffic limit exceeded")
|
return remaining, E.New("traffic limit exceeded")
|
||||||
}
|
}
|
||||||
return nil
|
l.reserved += n
|
||||||
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *TrafficLimiter) Add(n uint64) error {
|
func (l *TrafficLimiter) Commit(reserved uint64, n uint64) {
|
||||||
|
if reserved == 0 && n == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
l.mtx.Lock()
|
l.mtx.Lock()
|
||||||
defer l.mtx.Unlock()
|
defer l.mtx.Unlock()
|
||||||
if l.limiter.RawUsed == l.limiter.RawQuota {
|
if reserved > l.reserved {
|
||||||
return E.New("traffic limit exceeded")
|
reserved = l.reserved
|
||||||
}
|
|
||||||
if l.limiter.RawUsed+n > l.limiter.RawQuota {
|
|
||||||
l.new += l.limiter.RawQuota - l.limiter.RawUsed
|
|
||||||
l.limiter.RawUsed = l.limiter.RawQuota
|
|
||||||
return E.New("traffic limit exceeded")
|
|
||||||
}
|
}
|
||||||
|
l.reserved -= reserved
|
||||||
l.limiter.RawUsed += n
|
l.limiter.RawUsed += n
|
||||||
l.new += n
|
l.new += n
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *TrafficLimiter) UpdateRemainingTraffic() error {
|
func (l *TrafficLimiter) UpdateRemainingTraffic() error {
|
||||||
|
|||||||
89
test/go.mod
89
test/go.mod
@@ -7,7 +7,7 @@ require (
|
|||||||
github.com/Diniboy1123/connect-ip-go v0.0.0-20260409225322-8d7bb0a858a2
|
github.com/Diniboy1123/connect-ip-go v0.0.0-20260409225322-8d7bb0a858a2
|
||||||
github.com/anthropics/anthropic-sdk-go v1.26.0
|
github.com/anthropics/anthropic-sdk-go v1.26.0
|
||||||
github.com/anytls/sing-anytls v0.0.11
|
github.com/anytls/sing-anytls v0.0.11
|
||||||
github.com/caddyserver/certmagic v0.25.2
|
github.com/caddyserver/certmagic v0.25.3-0.20260421143802-60d9d8b415d6
|
||||||
github.com/coder/websocket v1.8.14
|
github.com/coder/websocket v1.8.14
|
||||||
github.com/cretz/bine v0.2.0
|
github.com/cretz/bine v0.2.0
|
||||||
github.com/database64128/tfo-go/v2 v2.3.2
|
github.com/database64128/tfo-go/v2 v2.3.2
|
||||||
@@ -42,23 +42,23 @@ require (
|
|||||||
github.com/sagernet/asc-go v0.0.0-20241217030726-d563060fe4e1
|
github.com/sagernet/asc-go v0.0.0-20241217030726-d563060fe4e1
|
||||||
github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a
|
github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a
|
||||||
github.com/sagernet/cors v1.2.1
|
github.com/sagernet/cors v1.2.1
|
||||||
github.com/sagernet/cronet-go v0.0.0-20260620140045-05ab0dc17597
|
github.com/sagernet/cronet-go v0.0.0-20260712143338-d22f2ea3630e
|
||||||
github.com/sagernet/cronet-go/all v0.0.0-20260620140045-05ab0dc17597
|
github.com/sagernet/cronet-go/all v0.0.0-20260712143338-d22f2ea3630e
|
||||||
github.com/sagernet/fswatch v0.1.2
|
github.com/sagernet/fswatch v0.1.2
|
||||||
github.com/sagernet/gomobile v0.1.12
|
github.com/sagernet/gomobile v0.1.12
|
||||||
github.com/sagernet/gvisor v0.0.0-20250811.0-sing-box-mod.1
|
github.com/sagernet/gvisor v0.0.0-20250811.0-sing-box-mod.1
|
||||||
github.com/sagernet/quic-go v0.59.0-sing-box-mod.4
|
github.com/sagernet/quic-go v0.59.0-sing-box-mod.4
|
||||||
github.com/sagernet/sing v0.8.11
|
github.com/sagernet/sing v0.8.12-0.20260726145744-ef2df370afca
|
||||||
github.com/sagernet/sing-box v0.0.0-00010101000000-000000000000
|
github.com/sagernet/sing-box v0.0.0-00010101000000-000000000000
|
||||||
github.com/sagernet/sing-mux v0.3.5
|
github.com/sagernet/sing-mux v0.3.5
|
||||||
github.com/sagernet/sing-quic v0.6.1
|
github.com/sagernet/sing-quic v0.6.4-0.20260803041914-d83826c306d7
|
||||||
github.com/sagernet/sing-shadowsocks v0.2.8
|
github.com/sagernet/sing-shadowsocks v0.2.8
|
||||||
github.com/sagernet/sing-shadowsocks2 v0.2.1
|
github.com/sagernet/sing-shadowsocks2 v0.2.1
|
||||||
github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11
|
github.com/sagernet/sing-shadowtls v0.2.1
|
||||||
github.com/sagernet/sing-tun v0.8.11
|
github.com/sagernet/sing-tun v0.8.12-0.20260727151122-3a09076491df
|
||||||
github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1
|
github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1
|
||||||
github.com/sagernet/smux v1.5.50-sing-box-mod.1
|
github.com/sagernet/smux v1.5.50-sing-box-mod.1
|
||||||
github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7
|
github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.8
|
||||||
github.com/sagernet/wireguard-go v0.0.2-beta.1.0.20260224074747-506b7631853c
|
github.com/sagernet/wireguard-go v0.0.2-beta.1.0.20260224074747-506b7631853c
|
||||||
github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854
|
github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854
|
||||||
github.com/shtorm-7/go-cache/v2 v2.1.0-extended-1.0.2
|
github.com/shtorm-7/go-cache/v2 v2.1.0-extended-1.0.2
|
||||||
@@ -95,6 +95,7 @@ require (
|
|||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/go-faster/city v1.0.1 // indirect
|
github.com/go-faster/city v1.0.1 // indirect
|
||||||
github.com/go-faster/errors v0.7.1 // indirect
|
github.com/go-faster/errors v0.7.1 // indirect
|
||||||
|
github.com/klauspost/reedsolomon v1.12.0 // indirect
|
||||||
github.com/lib/pq v1.10.9 // indirect
|
github.com/lib/pq v1.10.9 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||||
@@ -112,13 +113,16 @@ require (
|
|||||||
github.com/pion/stun/v3 v3.1.1 // indirect
|
github.com/pion/stun/v3 v3.1.1 // indirect
|
||||||
github.com/pion/transport/v4 v4.0.1 // indirect
|
github.com/pion/transport/v4 v4.0.1 // indirect
|
||||||
github.com/pion/turn/v4 v4.1.4 // indirect
|
github.com/pion/turn/v4 v4.1.4 // indirect
|
||||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
|
github.com/quic-go/quic-go v0.60.0 // indirect
|
||||||
github.com/redis/go-redis/v9 v9.8.0 // indirect
|
github.com/redis/go-redis/v9 v9.8.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/segmentio/asm v1.2.1 // indirect
|
github.com/segmentio/asm v1.2.1 // indirect
|
||||||
github.com/shopspring/decimal v1.4.0 // indirect
|
github.com/shopspring/decimal v1.4.0 // indirect
|
||||||
|
github.com/tjfoc/gmsm v1.4.1 // indirect
|
||||||
github.com/tylertreat/BoomFilters v0.0.0-20251117164519-53813c36cc1b // indirect
|
github.com/tylertreat/BoomFilters v0.0.0-20251117164519-53813c36cc1b // indirect
|
||||||
github.com/wlynxg/anet v0.0.5 // indirect
|
github.com/wlynxg/anet v0.0.5 // indirect
|
||||||
|
github.com/xtaci/kcp-go/v5 v5.6.72 // indirect
|
||||||
github.com/zeebo/assert v1.3.0 // indirect
|
github.com/zeebo/assert v1.3.0 // indirect
|
||||||
go.opentelemetry.io/otel v1.44.0 // indirect
|
go.opentelemetry.io/otel v1.44.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
||||||
@@ -187,37 +191,38 @@ require (
|
|||||||
github.com/prometheus-community/pro-bing v0.4.0 // indirect
|
github.com/prometheus-community/pro-bing v0.4.0 // indirect
|
||||||
github.com/quic-go/qpack v0.6.0 // indirect
|
github.com/quic-go/qpack v0.6.0 // indirect
|
||||||
github.com/safchain/ethtool v0.3.0 // indirect
|
github.com/safchain/ethtool v0.3.0 // indirect
|
||||||
github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260620135226-def9ff0fb992 // indirect
|
github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260712142643-1e5048bd5587 // indirect
|
||||||
github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260620135226-def9ff0fb992 // indirect
|
github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260712142643-1e5048bd5587 // indirect
|
||||||
github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260620135226-def9ff0fb992 // indirect
|
github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260712142643-1e5048bd5587 // indirect
|
||||||
github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260620135226-def9ff0fb992 // indirect
|
github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260712142643-1e5048bd5587 // indirect
|
||||||
github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260620135226-def9ff0fb992 // indirect
|
github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260712142643-1e5048bd5587 // indirect
|
||||||
github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260620135226-def9ff0fb992 // indirect
|
github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260712142643-1e5048bd5587 // indirect
|
||||||
github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260620135226-def9ff0fb992 // indirect
|
github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260712142643-1e5048bd5587 // indirect
|
||||||
github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260620135226-def9ff0fb992 // indirect
|
github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260712142643-1e5048bd5587 // indirect
|
||||||
github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260620135226-def9ff0fb992 // indirect
|
github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260712142643-1e5048bd5587 // indirect
|
||||||
github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260620135226-def9ff0fb992 // indirect
|
github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260712142643-1e5048bd5587 // indirect
|
||||||
github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260620135226-def9ff0fb992 // indirect
|
github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260712142643-1e5048bd5587 // indirect
|
||||||
github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260620135226-def9ff0fb992 // indirect
|
github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260712142643-1e5048bd5587 // indirect
|
||||||
github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260620135226-def9ff0fb992 // indirect
|
github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260712142643-1e5048bd5587 // indirect
|
||||||
github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260620135226-def9ff0fb992 // indirect
|
github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260712142643-1e5048bd5587 // indirect
|
||||||
github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260620135226-def9ff0fb992 // indirect
|
github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260712142643-1e5048bd5587 // indirect
|
||||||
github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260620135226-def9ff0fb992 // indirect
|
github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260712142643-1e5048bd5587 // indirect
|
||||||
github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260620135226-def9ff0fb992 // indirect
|
github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260712142643-1e5048bd5587 // indirect
|
||||||
github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260620135226-def9ff0fb992 // indirect
|
github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260712142643-1e5048bd5587 // indirect
|
||||||
github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260620135226-def9ff0fb992 // indirect
|
github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260712142643-1e5048bd5587 // indirect
|
||||||
github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260620135226-def9ff0fb992 // indirect
|
github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260712142643-1e5048bd5587 // indirect
|
||||||
github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260620135226-def9ff0fb992 // indirect
|
github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260712142643-1e5048bd5587 // indirect
|
||||||
github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260620135226-def9ff0fb992 // indirect
|
github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260712142643-1e5048bd5587 // indirect
|
||||||
github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260620135226-def9ff0fb992 // indirect
|
github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260712142643-1e5048bd5587 // indirect
|
||||||
github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260620135226-def9ff0fb992 // indirect
|
github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260712142643-1e5048bd5587 // indirect
|
||||||
github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260620135226-def9ff0fb992 // indirect
|
github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260712142643-1e5048bd5587 // indirect
|
||||||
github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260620135226-def9ff0fb992 // indirect
|
github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260712142643-1e5048bd5587 // indirect
|
||||||
github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260620135226-def9ff0fb992 // indirect
|
github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260712142643-1e5048bd5587 // indirect
|
||||||
github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260620135226-def9ff0fb992 // indirect
|
github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260712142643-1e5048bd5587 // indirect
|
||||||
github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260620135226-def9ff0fb992 // indirect
|
github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260712142643-1e5048bd5587 // indirect
|
||||||
github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a // indirect
|
github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a // indirect
|
||||||
github.com/sagernet/nftables v0.3.0-mod.2 // indirect
|
github.com/sagernet/nftables v0.3.0-mod.2 // indirect
|
||||||
|
github.com/shtorm-7/rmux v1.0.0
|
||||||
github.com/spf13/pflag v1.0.10 // indirect
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e // indirect
|
github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e // indirect
|
||||||
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect
|
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect
|
||||||
@@ -239,7 +244,7 @@ require (
|
|||||||
golang.org/x/oauth2 v0.34.0 // indirect
|
golang.org/x/oauth2 v0.34.0 // indirect
|
||||||
golang.org/x/term v0.44.0 // indirect
|
golang.org/x/term v0.44.0 // indirect
|
||||||
golang.org/x/text v0.38.0 // indirect
|
golang.org/x/text v0.38.0 // indirect
|
||||||
golang.org/x/time v0.12.0
|
golang.org/x/time v0.14.0
|
||||||
golang.org/x/tools v0.45.0 // indirect
|
golang.org/x/tools v0.45.0 // indirect
|
||||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||||
golang.zx2c4.com/wireguard/windows v0.5.3 // indirect
|
golang.zx2c4.com/wireguard/windows v0.5.3 // indirect
|
||||||
@@ -252,8 +257,6 @@ replace github.com/sagernet/wireguard-go => github.com/shtorm-7/wireguard-go v0.
|
|||||||
|
|
||||||
replace github.com/sagernet/tailscale => github.com/shtorm-7/tailscale v1.92.4-sing-box-1.13-mod.7-extended-1.0.2
|
replace github.com/sagernet/tailscale => github.com/shtorm-7/tailscale v1.92.4-sing-box-1.13-mod.7-extended-1.0.2
|
||||||
|
|
||||||
replace github.com/sagernet/sing-mux => github.com/shtorm-7/sing-mux v0.3.4-extended-1.0.0
|
|
||||||
|
|
||||||
replace github.com/ameshkov/dnscrypt/v2 => github.com/shtorm-7/dnscrypt/v2 v2.4.0-extended-1.0.0
|
replace github.com/ameshkov/dnscrypt/v2 => github.com/shtorm-7/dnscrypt/v2 v2.4.0-extended-1.0.0
|
||||||
|
|
||||||
replace github.com/sagernet/sing-vmess => github.com/shtorm-7/sing-vmess v0.2.7-extended-1.0.0
|
replace github.com/sagernet/sing-vmess => github.com/shtorm-7/sing-vmess v0.2.7-extended-1.0.0
|
||||||
@@ -267,3 +270,7 @@ replace github.com/shtorm-7/go-cache/v2 => github.com/shtorm-7/go-cache/v2 v2.1.
|
|||||||
replace github.com/sagernet/sing => github.com/shtorm-7/sing v0.8.10-extended-1.2.0
|
replace github.com/sagernet/sing => github.com/shtorm-7/sing v0.8.10-extended-1.2.0
|
||||||
|
|
||||||
replace github.com/sagernet/sing-box => ../
|
replace github.com/sagernet/sing-box => ../
|
||||||
|
|
||||||
|
replace github.com/sagernet/sing-mux => ../../sing-mux
|
||||||
|
|
||||||
|
replace github.com/shtorm-7/rmux => ../../rmux
|
||||||
|
|||||||
114
test/go.sum
114
test/go.sum
@@ -1,3 +1,4 @@
|
|||||||
|
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||||
code.pfad.fr/check v1.1.0 h1:GWvjdzhSEgHvEHe2uJujDcpmZoySKuHQNrZMfzfO0bE=
|
code.pfad.fr/check v1.1.0 h1:GWvjdzhSEgHvEHe2uJujDcpmZoySKuHQNrZMfzfO0bE=
|
||||||
code.pfad.fr/check v1.1.0/go.mod h1:NiUH13DtYsb7xp5wll0U4SXx7KhXQVCtRgdC96IPfoM=
|
code.pfad.fr/check v1.1.0/go.mod h1:NiUH13DtYsb7xp5wll0U4SXx7KhXQVCtRgdC96IPfoM=
|
||||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
@@ -8,6 +9,7 @@ github.com/AliRizaAynaci/gorl/v2 v2.2.0 h1:E8oAwkordOwm9ItNNVJ5VKvGroDcHvWNvG11H
|
|||||||
github.com/AliRizaAynaci/gorl/v2 v2.2.0/go.mod h1:13wcj/W736v44b6uygUuwypMY9N3RXJuhAYXukIIdCo=
|
github.com/AliRizaAynaci/gorl/v2 v2.2.0/go.mod h1:13wcj/W736v44b6uygUuwypMY9N3RXJuhAYXukIIdCo=
|
||||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
||||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||||
|
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||||
github.com/ClickHouse/ch-go v0.73.0 h1:jsHiGRbQ3sz+gekvDFJF29LWDo5dzbJm5s1h8TWVP2M=
|
github.com/ClickHouse/ch-go v0.73.0 h1:jsHiGRbQ3sz+gekvDFJF29LWDo5dzbJm5s1h8TWVP2M=
|
||||||
github.com/ClickHouse/ch-go v0.73.0/go.mod h1:wkFIxrqlXeRJ9cn3r5Fz5Qen9jl5aTMPuGZeuJpANNY=
|
github.com/ClickHouse/ch-go v0.73.0/go.mod h1:wkFIxrqlXeRJ9cn3r5Fz5Qen9jl5aTMPuGZeuJpANNY=
|
||||||
github.com/ClickHouse/clickhouse-go v1.4.3 h1:iAFMa2UrQdR5bHJ2/yaSLffZkxpcOYQMCUuKeNXGdqc=
|
github.com/ClickHouse/clickhouse-go v1.4.3 h1:iAFMa2UrQdR5bHJ2/yaSLffZkxpcOYQMCUuKeNXGdqc=
|
||||||
@@ -43,14 +45,18 @@ github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
|||||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||||
github.com/caddyserver/certmagic v0.25.2 h1:D7xcS7ggX/WEY54x0czj7ioTkmDWKIgxtIi2OcQclUc=
|
github.com/caddyserver/certmagic v0.25.2 h1:D7xcS7ggX/WEY54x0czj7ioTkmDWKIgxtIi2OcQclUc=
|
||||||
github.com/caddyserver/certmagic v0.25.2/go.mod h1:llW/CvsNmza8S6hmsuggsZeiX+uS27dkqY27wDIuBWg=
|
github.com/caddyserver/certmagic v0.25.2/go.mod h1:llW/CvsNmza8S6hmsuggsZeiX+uS27dkqY27wDIuBWg=
|
||||||
|
github.com/caddyserver/certmagic v0.25.3-0.20260421143802-60d9d8b415d6/go.mod h1:llW/CvsNmza8S6hmsuggsZeiX+uS27dkqY27wDIuBWg=
|
||||||
github.com/caddyserver/zerossl v0.1.5 h1:dkvOjBAEEtY6LIGAHei7sw2UgqSD6TrWweXpV7lvEvE=
|
github.com/caddyserver/zerossl v0.1.5 h1:dkvOjBAEEtY6LIGAHei7sw2UgqSD6TrWweXpV7lvEvE=
|
||||||
github.com/caddyserver/zerossl v0.1.5/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4=
|
github.com/caddyserver/zerossl v0.1.5/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4=
|
||||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||||
|
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/cilium/ebpf v0.15.0 h1:7NxJhNiBT3NG8pZJ3c+yfrVdHY8ScgKD27sScgjLMMk=
|
github.com/cilium/ebpf v0.15.0 h1:7NxJhNiBT3NG8pZJ3c+yfrVdHY8ScgKD27sScgjLMMk=
|
||||||
github.com/cilium/ebpf v0.15.0/go.mod h1:DHp1WyrLeiBh19Cf/tfiSMhqheEiK8fXFZ4No0P1Hso=
|
github.com/cilium/ebpf v0.15.0/go.mod h1:DHp1WyrLeiBh19Cf/tfiSMhqheEiK8fXFZ4No0P1Hso=
|
||||||
|
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||||
|
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||||
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
||||||
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||||
@@ -101,6 +107,9 @@ github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLA
|
|||||||
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||||
github.com/enfein/mieru/v3 v3.33.0 h1:hv2jK8nqYHwpSG86U2rpZR2I8Aff1/J3ifRmd9NBbFc=
|
github.com/enfein/mieru/v3 v3.33.0 h1:hv2jK8nqYHwpSG86U2rpZR2I8Aff1/J3ifRmd9NBbFc=
|
||||||
github.com/enfein/mieru/v3 v3.33.0/go.mod h1:zJBUCsi5rxyvHM8fjFf+GLaEl4OEjjBXr1s5F6Qd3hM=
|
github.com/enfein/mieru/v3 v3.33.0/go.mod h1:zJBUCsi5rxyvHM8fjFf+GLaEl4OEjjBXr1s5F6Qd3hM=
|
||||||
|
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
|
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||||
|
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
github.com/florianl/go-nfqueue/v2 v2.0.2 h1:FL5lQTeetgpCvac1TRwSfgaXUn0YSO7WzGvWNIp3JPE=
|
github.com/florianl/go-nfqueue/v2 v2.0.2 h1:FL5lQTeetgpCvac1TRwSfgaXUn0YSO7WzGvWNIp3JPE=
|
||||||
@@ -154,12 +163,27 @@ github.com/gofrs/uuid/v5 v5.4.0 h1:EfbpCTjqMuGyq5ZJwxqzn3Cbr2d0rUZU7v5ycAk/e/0=
|
|||||||
github.com/gofrs/uuid/v5 v5.4.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8=
|
github.com/gofrs/uuid/v5 v5.4.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8=
|
||||||
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
|
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
|
||||||
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
|
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
|
||||||
|
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
||||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
|
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||||
|
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||||
|
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||||
|
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
|
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
|
||||||
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
|
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
|
||||||
|
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||||
|
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
|
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
|
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
@@ -212,6 +236,8 @@ github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXD
|
|||||||
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
|
github.com/klauspost/reedsolomon v1.12.0 h1:I5FEp3xSwVCcEh3F5A7dofEfhXdF/bWhQWPH+XwBFno=
|
||||||
|
github.com/klauspost/reedsolomon v1.12.0/go.mod h1:EPLZJeh4l27pUGC3aXOjheaoh1I9yut7xTURiW3LQ9Y=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
@@ -322,10 +348,13 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
|
|||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/prometheus-community/pro-bing v0.4.0 h1:YMbv+i08gQz97OZZBwLyvmmQEEzyfyrrjEaAchdy3R4=
|
github.com/prometheus-community/pro-bing v0.4.0 h1:YMbv+i08gQz97OZZBwLyvmmQEEzyfyrrjEaAchdy3R4=
|
||||||
github.com/prometheus-community/pro-bing v0.4.0/go.mod h1:b7wRYZtCcPmt4Sz319BykUU241rWLe1VFXyiyWK/dH4=
|
github.com/prometheus-community/pro-bing v0.4.0/go.mod h1:b7wRYZtCcPmt4Sz319BykUU241rWLe1VFXyiyWK/dH4=
|
||||||
|
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||||
|
github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0=
|
||||||
|
github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk=
|
||||||
github.com/rasky/go-lzo v0.0.0-20200203143853-96a758eda86e h1:dCWirM5F3wMY+cmRda/B1BiPsFtmzXqV9b0hLWtVBMs=
|
github.com/rasky/go-lzo v0.0.0-20200203143853-96a758eda86e h1:dCWirM5F3wMY+cmRda/B1BiPsFtmzXqV9b0hLWtVBMs=
|
||||||
github.com/rasky/go-lzo v0.0.0-20200203143853-96a758eda86e/go.mod h1:9leZcVcItj6m9/CfHY5Em/iBrCz7js8LcRQGTKEEv2M=
|
github.com/rasky/go-lzo v0.0.0-20200203143853-96a758eda86e/go.mod h1:9leZcVcItj6m9/CfHY5Em/iBrCz7js8LcRQGTKEEv2M=
|
||||||
github.com/redis/go-redis/v9 v9.8.0 h1:q3nRvjrlge/6UD7eTu/DSg2uYiU2mCL0G/uzBWqhicI=
|
github.com/redis/go-redis/v9 v9.8.0 h1:q3nRvjrlge/6UD7eTu/DSg2uYiU2mCL0G/uzBWqhicI=
|
||||||
@@ -346,66 +375,97 @@ github.com/sagernet/cors v1.2.1 h1:Cv5Z8y9YSD6Gm+qSpNrL3LO4lD3eQVvbFYJSG7JCMHQ=
|
|||||||
github.com/sagernet/cors v1.2.1/go.mod h1:O64VyOjjhrkLmQIjF4KGRrJO/5dVXFdpEmCW/eISRAI=
|
github.com/sagernet/cors v1.2.1/go.mod h1:O64VyOjjhrkLmQIjF4KGRrJO/5dVXFdpEmCW/eISRAI=
|
||||||
github.com/sagernet/cronet-go v0.0.0-20260620140045-05ab0dc17597 h1:QkwE/ZFnShDuPF+ExmAyZlQaMwFNgkYZMekrabiStfg=
|
github.com/sagernet/cronet-go v0.0.0-20260620140045-05ab0dc17597 h1:QkwE/ZFnShDuPF+ExmAyZlQaMwFNgkYZMekrabiStfg=
|
||||||
github.com/sagernet/cronet-go v0.0.0-20260620140045-05ab0dc17597/go.mod h1:T/mwtrpC4JlWfScw73CmSBvHzIvc7BatQ1MhRr+cYNw=
|
github.com/sagernet/cronet-go v0.0.0-20260620140045-05ab0dc17597/go.mod h1:T/mwtrpC4JlWfScw73CmSBvHzIvc7BatQ1MhRr+cYNw=
|
||||||
|
github.com/sagernet/cronet-go v0.0.0-20260712143338-d22f2ea3630e/go.mod h1:T/mwtrpC4JlWfScw73CmSBvHzIvc7BatQ1MhRr+cYNw=
|
||||||
github.com/sagernet/cronet-go/all v0.0.0-20260620140045-05ab0dc17597 h1:cLALmGKP9eOS8622gWQIiVbZlOfH29PGNsoxbEloIdk=
|
github.com/sagernet/cronet-go/all v0.0.0-20260620140045-05ab0dc17597 h1:cLALmGKP9eOS8622gWQIiVbZlOfH29PGNsoxbEloIdk=
|
||||||
github.com/sagernet/cronet-go/all v0.0.0-20260620140045-05ab0dc17597/go.mod h1:zVHZ5tgDTwbNvUGffAgLmouYs4in0grEzhSdaggoZOw=
|
github.com/sagernet/cronet-go/all v0.0.0-20260620140045-05ab0dc17597/go.mod h1:zVHZ5tgDTwbNvUGffAgLmouYs4in0grEzhSdaggoZOw=
|
||||||
|
github.com/sagernet/cronet-go/all v0.0.0-20260712143338-d22f2ea3630e/go.mod h1:WNl4xfTNuR+f7SObmuBtrk0p4MhlmvuuiWYoty3U52E=
|
||||||
github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260620135226-def9ff0fb992 h1:J9l8PP4vb79Wm5zKaMO6LNZ/AiP1FvyAWJBlkKHrRBU=
|
github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260620135226-def9ff0fb992 h1:J9l8PP4vb79Wm5zKaMO6LNZ/AiP1FvyAWJBlkKHrRBU=
|
||||||
github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:XXDwdjX/T8xftoeJxQmbBoYXZp8MAPFR2CwbFuTpEtw=
|
github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:XXDwdjX/T8xftoeJxQmbBoYXZp8MAPFR2CwbFuTpEtw=
|
||||||
|
github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:XXDwdjX/T8xftoeJxQmbBoYXZp8MAPFR2CwbFuTpEtw=
|
||||||
github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260620135226-def9ff0fb992 h1:wXDjUNeKuihv85Kg51FomkiEH7xGsDgRcfRLiyZxacQ=
|
github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260620135226-def9ff0fb992 h1:wXDjUNeKuihv85Kg51FomkiEH7xGsDgRcfRLiyZxacQ=
|
||||||
github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:iNiUGoLtnr8/JTuVNj7XJbmpOAp2C6+B81KDrPxwaZM=
|
github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:iNiUGoLtnr8/JTuVNj7XJbmpOAp2C6+B81KDrPxwaZM=
|
||||||
|
github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:iNiUGoLtnr8/JTuVNj7XJbmpOAp2C6+B81KDrPxwaZM=
|
||||||
github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260620135226-def9ff0fb992 h1:IF04nGyY3Q6Nbk9XJwTX1mckwhf12iIx4RhZ4TLOZIU=
|
github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260620135226-def9ff0fb992 h1:IF04nGyY3Q6Nbk9XJwTX1mckwhf12iIx4RhZ4TLOZIU=
|
||||||
github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260620135226-def9ff0fb992/go.mod h1:19ILNUOGIzRdOqa2mq+iY0JoHxuieB7/lnjYeaA2vEc=
|
github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260620135226-def9ff0fb992/go.mod h1:19ILNUOGIzRdOqa2mq+iY0JoHxuieB7/lnjYeaA2vEc=
|
||||||
|
github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260712142643-1e5048bd5587/go.mod h1:19ILNUOGIzRdOqa2mq+iY0JoHxuieB7/lnjYeaA2vEc=
|
||||||
github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260620135226-def9ff0fb992 h1:dlnG1E42xx8ms2fyZigYwsYJ1Gqoj2QT8WeGlpOAWK8=
|
github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260620135226-def9ff0fb992 h1:dlnG1E42xx8ms2fyZigYwsYJ1Gqoj2QT8WeGlpOAWK8=
|
||||||
github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:JxzGyQf94Cr6sBShKqODGDyRUlESfJK/Njcz9Lz6qMQ=
|
github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:JxzGyQf94Cr6sBShKqODGDyRUlESfJK/Njcz9Lz6qMQ=
|
||||||
|
github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:JxzGyQf94Cr6sBShKqODGDyRUlESfJK/Njcz9Lz6qMQ=
|
||||||
github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260620135226-def9ff0fb992 h1:aYAQQN3jZP89MmT1ZzpJz52jAsXx1WApmv5Qidc+ez4=
|
github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260620135226-def9ff0fb992 h1:aYAQQN3jZP89MmT1ZzpJz52jAsXx1WApmv5Qidc+ez4=
|
||||||
github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:KN+9T9TBycGOLzmKU4QdcHAJEj6Nlx48ifnlTvvHMvs=
|
github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:KN+9T9TBycGOLzmKU4QdcHAJEj6Nlx48ifnlTvvHMvs=
|
||||||
|
github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:KN+9T9TBycGOLzmKU4QdcHAJEj6Nlx48ifnlTvvHMvs=
|
||||||
github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260620135226-def9ff0fb992 h1:n81+aLphvjLpL2M6lI+BC8Ldw4S/FPA3CFDXTWL4g3I=
|
github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260620135226-def9ff0fb992 h1:n81+aLphvjLpL2M6lI+BC8Ldw4S/FPA3CFDXTWL4g3I=
|
||||||
github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:kojvtUc29KKnk8hs2QIANynVR59921SnGWA9kXohHc0=
|
github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:kojvtUc29KKnk8hs2QIANynVR59921SnGWA9kXohHc0=
|
||||||
|
github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:kojvtUc29KKnk8hs2QIANynVR59921SnGWA9kXohHc0=
|
||||||
github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260620135226-def9ff0fb992 h1:mOseesF+CxgPG2U1a2Yh2fUUMdOaPxSuk4eIL6g7EU8=
|
github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260620135226-def9ff0fb992 h1:mOseesF+CxgPG2U1a2Yh2fUUMdOaPxSuk4eIL6g7EU8=
|
||||||
github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260620135226-def9ff0fb992/go.mod h1:hkQzRE5GDbaH1/ioqYh0Taho4L6i0yLRCVEZ5xHz5M0=
|
github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260620135226-def9ff0fb992/go.mod h1:hkQzRE5GDbaH1/ioqYh0Taho4L6i0yLRCVEZ5xHz5M0=
|
||||||
|
github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260712142643-1e5048bd5587/go.mod h1:hkQzRE5GDbaH1/ioqYh0Taho4L6i0yLRCVEZ5xHz5M0=
|
||||||
github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260620135226-def9ff0fb992 h1:JK9kP72IxAoyVQTnh9gGkh8S9RjP4FBkj95WjrLPyKs=
|
github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260620135226-def9ff0fb992 h1:JK9kP72IxAoyVQTnh9gGkh8S9RjP4FBkj95WjrLPyKs=
|
||||||
github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:tzVJFTOm66UxLxy6K0ZN5Ic2PC79e+sKKnt+V9puEa4=
|
github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:tzVJFTOm66UxLxy6K0ZN5Ic2PC79e+sKKnt+V9puEa4=
|
||||||
|
github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:tzVJFTOm66UxLxy6K0ZN5Ic2PC79e+sKKnt+V9puEa4=
|
||||||
github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260620135226-def9ff0fb992 h1:3EtgLRsUpmeRaOBynTARxVC8tDegykigutmpAow2ayc=
|
github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260620135226-def9ff0fb992 h1:3EtgLRsUpmeRaOBynTARxVC8tDegykigutmpAow2ayc=
|
||||||
github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260620135226-def9ff0fb992/go.mod h1:M/pN6m3j0HFU6/y83n0HU6GLYys3tYdr/xTE8hVEGMo=
|
github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260620135226-def9ff0fb992/go.mod h1:M/pN6m3j0HFU6/y83n0HU6GLYys3tYdr/xTE8hVEGMo=
|
||||||
|
github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260712142643-1e5048bd5587/go.mod h1:M/pN6m3j0HFU6/y83n0HU6GLYys3tYdr/xTE8hVEGMo=
|
||||||
github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260620135226-def9ff0fb992 h1:Vr3I2sC9E/1FurpZwJXAL29C7jJROyN3JfulQWjhKuA=
|
github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260620135226-def9ff0fb992 h1:Vr3I2sC9E/1FurpZwJXAL29C7jJROyN3JfulQWjhKuA=
|
||||||
github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:cGh5hO6eljCo6KMQ/Cel8Xgq4+etL0awZLRBDVG1EZQ=
|
github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:cGh5hO6eljCo6KMQ/Cel8Xgq4+etL0awZLRBDVG1EZQ=
|
||||||
|
github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:cGh5hO6eljCo6KMQ/Cel8Xgq4+etL0awZLRBDVG1EZQ=
|
||||||
github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260620135226-def9ff0fb992 h1:flxwC8loz0C4LQ/tLK7LvNMKx4iqaXayPggzzCxCevI=
|
github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260620135226-def9ff0fb992 h1:flxwC8loz0C4LQ/tLK7LvNMKx4iqaXayPggzzCxCevI=
|
||||||
github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260620135226-def9ff0fb992/go.mod h1:JFE0/cxaKkx0wqPMZU7MgaplQlU0zudv82dROJjClKU=
|
github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260620135226-def9ff0fb992/go.mod h1:JFE0/cxaKkx0wqPMZU7MgaplQlU0zudv82dROJjClKU=
|
||||||
|
github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260712142643-1e5048bd5587/go.mod h1:JFE0/cxaKkx0wqPMZU7MgaplQlU0zudv82dROJjClKU=
|
||||||
github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260620135226-def9ff0fb992 h1:G4vwPmOVR/jXDngIUC9owbEtMKXLZl/BgUHhEWWl8ec=
|
github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260620135226-def9ff0fb992 h1:G4vwPmOVR/jXDngIUC9owbEtMKXLZl/BgUHhEWWl8ec=
|
||||||
github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:vU8VftFeSt7fURCa3JXD6+k6ss1YAX+idQjPvHmJ2tI=
|
github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:vU8VftFeSt7fURCa3JXD6+k6ss1YAX+idQjPvHmJ2tI=
|
||||||
|
github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:vU8VftFeSt7fURCa3JXD6+k6ss1YAX+idQjPvHmJ2tI=
|
||||||
github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260620135226-def9ff0fb992 h1:zHIxR2FlJOW5GRmgwyA2Gjgx7potOCtlOmdl8k4mwJI=
|
github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260620135226-def9ff0fb992 h1:zHIxR2FlJOW5GRmgwyA2Gjgx7potOCtlOmdl8k4mwJI=
|
||||||
github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260620135226-def9ff0fb992/go.mod h1:vCe4OUuL+XOUge9v3MyTD45BnuAXiH+DkjN9quDXJzQ=
|
github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260620135226-def9ff0fb992/go.mod h1:vCe4OUuL+XOUge9v3MyTD45BnuAXiH+DkjN9quDXJzQ=
|
||||||
|
github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260712142643-1e5048bd5587/go.mod h1:vCe4OUuL+XOUge9v3MyTD45BnuAXiH+DkjN9quDXJzQ=
|
||||||
github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260620135226-def9ff0fb992 h1:U1OrR5zP+lkOPqrDpZsn8sPK1XBWZ84isXaFBZLsOfs=
|
github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260620135226-def9ff0fb992 h1:U1OrR5zP+lkOPqrDpZsn8sPK1XBWZ84isXaFBZLsOfs=
|
||||||
github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260620135226-def9ff0fb992/go.mod h1:w9amBWrvjtohQzBGCKJ7LCh22LhTIJs4sE7cYaKQzM0=
|
github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260620135226-def9ff0fb992/go.mod h1:w9amBWrvjtohQzBGCKJ7LCh22LhTIJs4sE7cYaKQzM0=
|
||||||
|
github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260712142643-1e5048bd5587/go.mod h1:w9amBWrvjtohQzBGCKJ7LCh22LhTIJs4sE7cYaKQzM0=
|
||||||
github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260620135226-def9ff0fb992 h1:F59ptY4AdtKEg73OWaL+lTb5yoxp5b/gTuDbvA6xMyg=
|
github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260620135226-def9ff0fb992 h1:F59ptY4AdtKEg73OWaL+lTb5yoxp5b/gTuDbvA6xMyg=
|
||||||
github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:TqlsFtcYS/etTeck46kHBeT8Le0Igw1Q/AV88UnMS3s=
|
github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:TqlsFtcYS/etTeck46kHBeT8Le0Igw1Q/AV88UnMS3s=
|
||||||
|
github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:TqlsFtcYS/etTeck46kHBeT8Le0Igw1Q/AV88UnMS3s=
|
||||||
github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260620135226-def9ff0fb992 h1:Y5axK4sCWXH+2OCpKYPI8nX3OSBuRb/6yC+5xBi9/uo=
|
github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260620135226-def9ff0fb992 h1:Y5axK4sCWXH+2OCpKYPI8nX3OSBuRb/6yC+5xBi9/uo=
|
||||||
github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260620135226-def9ff0fb992/go.mod h1:B6Qd0vys8sv9OKVRN6J9RqDzYRGE938Fb2zrYdBDyTQ=
|
github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260620135226-def9ff0fb992/go.mod h1:B6Qd0vys8sv9OKVRN6J9RqDzYRGE938Fb2zrYdBDyTQ=
|
||||||
|
github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260712142643-1e5048bd5587/go.mod h1:B6Qd0vys8sv9OKVRN6J9RqDzYRGE938Fb2zrYdBDyTQ=
|
||||||
github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260620135226-def9ff0fb992 h1:QCBwCdv9y+RJj7p0b9Db2p9fFt1wtnawD0sn9oV3vRs=
|
github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260620135226-def9ff0fb992 h1:QCBwCdv9y+RJj7p0b9Db2p9fFt1wtnawD0sn9oV3vRs=
|
||||||
github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260620135226-def9ff0fb992/go.mod h1:3tXMMFY7AHugOVBZ5Al7cL7JKsnFOe5bMVr0hZPk3ow=
|
github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260620135226-def9ff0fb992/go.mod h1:3tXMMFY7AHugOVBZ5Al7cL7JKsnFOe5bMVr0hZPk3ow=
|
||||||
|
github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260712142643-1e5048bd5587/go.mod h1:3tXMMFY7AHugOVBZ5Al7cL7JKsnFOe5bMVr0hZPk3ow=
|
||||||
github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260620135226-def9ff0fb992 h1:6gXFFaAMiGCPZdfUs64qzj9cl7EleVs9HsNfRi8jWWw=
|
github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260620135226-def9ff0fb992 h1:6gXFFaAMiGCPZdfUs64qzj9cl7EleVs9HsNfRi8jWWw=
|
||||||
github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:Wt5uFdU3tnmm8YzobYewwdF7Mt6SucRQg6xeTNWC3Tk=
|
github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:Wt5uFdU3tnmm8YzobYewwdF7Mt6SucRQg6xeTNWC3Tk=
|
||||||
|
github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:Wt5uFdU3tnmm8YzobYewwdF7Mt6SucRQg6xeTNWC3Tk=
|
||||||
github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260620135226-def9ff0fb992 h1:VGoIX2u4CWVg9kiyjQdIpFMaFUNCW3yz0pyrEKI5X0o=
|
github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260620135226-def9ff0fb992 h1:VGoIX2u4CWVg9kiyjQdIpFMaFUNCW3yz0pyrEKI5X0o=
|
||||||
github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260620135226-def9ff0fb992/go.mod h1:lyIF6wKBLwWa5ZXaAKbAoewewl+yCHo2iYev39Mbj4E=
|
github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260620135226-def9ff0fb992/go.mod h1:lyIF6wKBLwWa5ZXaAKbAoewewl+yCHo2iYev39Mbj4E=
|
||||||
|
github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260712142643-1e5048bd5587/go.mod h1:lyIF6wKBLwWa5ZXaAKbAoewewl+yCHo2iYev39Mbj4E=
|
||||||
github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260620135226-def9ff0fb992 h1:SjPuqtUNCzIDaEm7iY86JZ7L+ixTmai4i2DIC++eUvw=
|
github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260620135226-def9ff0fb992 h1:SjPuqtUNCzIDaEm7iY86JZ7L+ixTmai4i2DIC++eUvw=
|
||||||
github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260620135226-def9ff0fb992/go.mod h1:H46PnSTTZNcZokLLiDeMDaHiS1l14PH3tzWi0eykjD8=
|
github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260620135226-def9ff0fb992/go.mod h1:H46PnSTTZNcZokLLiDeMDaHiS1l14PH3tzWi0eykjD8=
|
||||||
|
github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260712142643-1e5048bd5587/go.mod h1:H46PnSTTZNcZokLLiDeMDaHiS1l14PH3tzWi0eykjD8=
|
||||||
github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260620135226-def9ff0fb992 h1:ao2FrDzTYhu2MYsMri9nzqIdnAL7ooUWQN6/FFr+Lbk=
|
github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260620135226-def9ff0fb992 h1:ao2FrDzTYhu2MYsMri9nzqIdnAL7ooUWQN6/FFr+Lbk=
|
||||||
github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260620135226-def9ff0fb992/go.mod h1:RBhSUDAKWq7fswtV4nQUQhuaTLcX3ettR7teA7/yf2w=
|
github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260620135226-def9ff0fb992/go.mod h1:RBhSUDAKWq7fswtV4nQUQhuaTLcX3ettR7teA7/yf2w=
|
||||||
|
github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260712142643-1e5048bd5587/go.mod h1:RBhSUDAKWq7fswtV4nQUQhuaTLcX3ettR7teA7/yf2w=
|
||||||
github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260620135226-def9ff0fb992 h1:5gPMu6EUlX6gqCRTOJrJk1FMILO6ugtnopQF1c2R/lY=
|
github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260620135226-def9ff0fb992 h1:5gPMu6EUlX6gqCRTOJrJk1FMILO6ugtnopQF1c2R/lY=
|
||||||
github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260620135226-def9ff0fb992/go.mod h1:wRzoIOGG4xbpp3Gh3triLKwMwYriScXzFtunLYhY4w0=
|
github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260620135226-def9ff0fb992/go.mod h1:wRzoIOGG4xbpp3Gh3triLKwMwYriScXzFtunLYhY4w0=
|
||||||
|
github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260712142643-1e5048bd5587/go.mod h1:wRzoIOGG4xbpp3Gh3triLKwMwYriScXzFtunLYhY4w0=
|
||||||
github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260620135226-def9ff0fb992 h1:UaLOQKbjnLrO943Sm+ff/jm+NmemRuJXiImmtqShd8s=
|
github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260620135226-def9ff0fb992 h1:UaLOQKbjnLrO943Sm+ff/jm+NmemRuJXiImmtqShd8s=
|
||||||
github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:LNiZXmWil1OPwKCheqQjtakZlJuKGFz+iv2eGF76Hhs=
|
github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:LNiZXmWil1OPwKCheqQjtakZlJuKGFz+iv2eGF76Hhs=
|
||||||
|
github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:LNiZXmWil1OPwKCheqQjtakZlJuKGFz+iv2eGF76Hhs=
|
||||||
github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260620135226-def9ff0fb992 h1:wxlsDfxDowVk99Ay2hzfuIXPpXH1lGQxSL/2sX/D7jw=
|
github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260620135226-def9ff0fb992 h1:wxlsDfxDowVk99Ay2hzfuIXPpXH1lGQxSL/2sX/D7jw=
|
||||||
github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260620135226-def9ff0fb992/go.mod h1:YFDGKTkpkJGc5+hnX/RYosZyTWg9h+68VB55fYRRLYc=
|
github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260620135226-def9ff0fb992/go.mod h1:YFDGKTkpkJGc5+hnX/RYosZyTWg9h+68VB55fYRRLYc=
|
||||||
|
github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260712142643-1e5048bd5587/go.mod h1:YFDGKTkpkJGc5+hnX/RYosZyTWg9h+68VB55fYRRLYc=
|
||||||
github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260620135226-def9ff0fb992 h1:pgRpWh2JPE73mtedovPPu3gmmAqHz3Rfz79QVxciu2o=
|
github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260620135226-def9ff0fb992 h1:pgRpWh2JPE73mtedovPPu3gmmAqHz3Rfz79QVxciu2o=
|
||||||
github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260620135226-def9ff0fb992/go.mod h1:aaX0YGl8nhGmfRWI8bc3BtDjY8Vzx6O0cS/e1uqxDq4=
|
github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260620135226-def9ff0fb992/go.mod h1:aaX0YGl8nhGmfRWI8bc3BtDjY8Vzx6O0cS/e1uqxDq4=
|
||||||
|
github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260712142643-1e5048bd5587/go.mod h1:aaX0YGl8nhGmfRWI8bc3BtDjY8Vzx6O0cS/e1uqxDq4=
|
||||||
github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260620135226-def9ff0fb992 h1:9jtXyxukTS2ZdhMy3u1hg4StkZpgP48BOfgYhXf835w=
|
github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260620135226-def9ff0fb992 h1:9jtXyxukTS2ZdhMy3u1hg4StkZpgP48BOfgYhXf835w=
|
||||||
github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:EdzMKA96xITc42QEI+ct4SwqX8Dn3ltKK8wzdkLWpSc=
|
github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:EdzMKA96xITc42QEI+ct4SwqX8Dn3ltKK8wzdkLWpSc=
|
||||||
|
github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:EdzMKA96xITc42QEI+ct4SwqX8Dn3ltKK8wzdkLWpSc=
|
||||||
github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260620135226-def9ff0fb992 h1:gKgD1LJZbZzacaaqBQX/YKy4dyomhn8xtfmDKayVLW4=
|
github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260620135226-def9ff0fb992 h1:gKgD1LJZbZzacaaqBQX/YKy4dyomhn8xtfmDKayVLW4=
|
||||||
github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260620135226-def9ff0fb992/go.mod h1:qix4kv1TTAJ5tY4lJ9vjhe9EY4mM+B7H5giOhbxDVcc=
|
github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260620135226-def9ff0fb992/go.mod h1:qix4kv1TTAJ5tY4lJ9vjhe9EY4mM+B7H5giOhbxDVcc=
|
||||||
|
github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260712142643-1e5048bd5587/go.mod h1:qix4kv1TTAJ5tY4lJ9vjhe9EY4mM+B7H5giOhbxDVcc=
|
||||||
github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260620135226-def9ff0fb992 h1:Lv8gtKP4QRn6Yjv3h5L215aGQBgwCyAE5YqejmN9Bqc=
|
github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260620135226-def9ff0fb992 h1:Lv8gtKP4QRn6Yjv3h5L215aGQBgwCyAE5YqejmN9Bqc=
|
||||||
github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:lm9w/oCCRyBiUa3G8lDQTT8x/ONUvgVR2iV9fVzUZB8=
|
github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:lm9w/oCCRyBiUa3G8lDQTT8x/ONUvgVR2iV9fVzUZB8=
|
||||||
|
github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:lm9w/oCCRyBiUa3G8lDQTT8x/ONUvgVR2iV9fVzUZB8=
|
||||||
github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260620135226-def9ff0fb992 h1:2wsRAcqJHOTlj6zGc3oQyxAZHDnGwyB/pTdAOUtLgHY=
|
github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260620135226-def9ff0fb992 h1:2wsRAcqJHOTlj6zGc3oQyxAZHDnGwyB/pTdAOUtLgHY=
|
||||||
github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:n34YyLgapgjWdKa0IoeczjAFCwD3/dxbsH5sucKw0bw=
|
github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:n34YyLgapgjWdKa0IoeczjAFCwD3/dxbsH5sucKw0bw=
|
||||||
|
github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:n34YyLgapgjWdKa0IoeczjAFCwD3/dxbsH5sucKw0bw=
|
||||||
github.com/sagernet/fswatch v0.1.2 h1:/TT7k4mkce1qFPxamLO842WjqBgbTBiXP2mlUjp9PFk=
|
github.com/sagernet/fswatch v0.1.2 h1:/TT7k4mkce1qFPxamLO842WjqBgbTBiXP2mlUjp9PFk=
|
||||||
github.com/sagernet/fswatch v0.1.2/go.mod h1:5BpGmpUQVd3Mc5r313HRpvADHRg3/rKn5QbwFteB880=
|
github.com/sagernet/fswatch v0.1.2/go.mod h1:5BpGmpUQVd3Mc5r313HRpvADHRg3/rKn5QbwFteB880=
|
||||||
github.com/sagernet/gomobile v0.1.12/go.mod h1:A8l3FlHi2D/+mfcd4HHvk5DGFPW/ShFb9jHP5VmSiDY=
|
github.com/sagernet/gomobile v0.1.12/go.mod h1:A8l3FlHi2D/+mfcd4HHvk5DGFPW/ShFb9jHP5VmSiDY=
|
||||||
@@ -421,14 +481,20 @@ github.com/sagernet/quic-go v0.59.0-sing-box-mod.4 h1:6qvrUW79S+CrPwWz6cMePXohgj
|
|||||||
github.com/sagernet/quic-go v0.59.0-sing-box-mod.4/go.mod h1:OqILvS182CyOol5zNNo6bguvOGgXzV459+chpRaUC+4=
|
github.com/sagernet/quic-go v0.59.0-sing-box-mod.4/go.mod h1:OqILvS182CyOol5zNNo6bguvOGgXzV459+chpRaUC+4=
|
||||||
github.com/sagernet/sing-quic v0.6.1 h1:lx0tcm99wIA1RkyvILNzRSsMy1k7TTQYIhx71E/WBlw=
|
github.com/sagernet/sing-quic v0.6.1 h1:lx0tcm99wIA1RkyvILNzRSsMy1k7TTQYIhx71E/WBlw=
|
||||||
github.com/sagernet/sing-quic v0.6.1/go.mod h1:K5bWvITOm4vE10fwLfrWpw27bCoVJ+tfQ79tOWg+Ko8=
|
github.com/sagernet/sing-quic v0.6.1/go.mod h1:K5bWvITOm4vE10fwLfrWpw27bCoVJ+tfQ79tOWg+Ko8=
|
||||||
|
github.com/sagernet/sing-quic v0.6.4-0.20260803041914-d83826c306d7 h1:D46kmyvKMNVFvL3KXdq3T4vy8r29xCMmSqNuQHJBybE=
|
||||||
|
github.com/sagernet/sing-quic v0.6.4-0.20260803041914-d83826c306d7/go.mod h1:K5bWvITOm4vE10fwLfrWpw27bCoVJ+tfQ79tOWg+Ko8=
|
||||||
github.com/sagernet/sing-shadowsocks v0.2.8 h1:PURj5PRoAkqeHh2ZW205RWzN9E9RtKCVCzByXruQWfE=
|
github.com/sagernet/sing-shadowsocks v0.2.8 h1:PURj5PRoAkqeHh2ZW205RWzN9E9RtKCVCzByXruQWfE=
|
||||||
github.com/sagernet/sing-shadowsocks v0.2.8/go.mod h1:lo7TWEMDcN5/h5B8S0ew+r78ZODn6SwVaFhvB6H+PTI=
|
github.com/sagernet/sing-shadowsocks v0.2.8/go.mod h1:lo7TWEMDcN5/h5B8S0ew+r78ZODn6SwVaFhvB6H+PTI=
|
||||||
github.com/sagernet/sing-shadowsocks2 v0.2.1 h1:dWV9OXCeFPuYGHb6IRqlSptVnSzOelnqqs2gQ2/Qioo=
|
github.com/sagernet/sing-shadowsocks2 v0.2.1 h1:dWV9OXCeFPuYGHb6IRqlSptVnSzOelnqqs2gQ2/Qioo=
|
||||||
github.com/sagernet/sing-shadowsocks2 v0.2.1/go.mod h1:RnXS0lExcDAovvDeniJ4IKa2IuChrdipolPYWBv9hWQ=
|
github.com/sagernet/sing-shadowsocks2 v0.2.1/go.mod h1:RnXS0lExcDAovvDeniJ4IKa2IuChrdipolPYWBv9hWQ=
|
||||||
github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11 h1:tK+75l64tm9WvEFrYRE1t0YxoFdWQqw/h7Uhzj0vJ+w=
|
github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11 h1:tK+75l64tm9WvEFrYRE1t0YxoFdWQqw/h7Uhzj0vJ+w=
|
||||||
github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11/go.mod h1:sWqKnGlMipCHaGsw1sTTlimyUpgzP4WP3pjhCsYt9oA=
|
github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11/go.mod h1:sWqKnGlMipCHaGsw1sTTlimyUpgzP4WP3pjhCsYt9oA=
|
||||||
|
github.com/sagernet/sing-shadowtls v0.2.1 h1:ZiHZdnEnP+YS73NMsxiZmIFCwNd0M4k7PkGCKNXhbaM=
|
||||||
|
github.com/sagernet/sing-shadowtls v0.2.1/go.mod h1:sWqKnGlMipCHaGsw1sTTlimyUpgzP4WP3pjhCsYt9oA=
|
||||||
github.com/sagernet/sing-tun v0.8.11 h1:BFu4+8LNl2JiTQtto5f+5AbkH90qgdoZEAqUbGiEXCg=
|
github.com/sagernet/sing-tun v0.8.11 h1:BFu4+8LNl2JiTQtto5f+5AbkH90qgdoZEAqUbGiEXCg=
|
||||||
github.com/sagernet/sing-tun v0.8.11/go.mod h1:QvarqUtHfj1ULaRR+6kZOS/OoCE+pYGq67A5tyIy+dQ=
|
github.com/sagernet/sing-tun v0.8.11/go.mod h1:QvarqUtHfj1ULaRR+6kZOS/OoCE+pYGq67A5tyIy+dQ=
|
||||||
|
github.com/sagernet/sing-tun v0.8.12-0.20260727151122-3a09076491df h1:IVG68QmUeO9xlvrBMB/0u/prBot+jIiEjCsNterk7qs=
|
||||||
|
github.com/sagernet/sing-tun v0.8.12-0.20260727151122-3a09076491df/go.mod h1:QvarqUtHfj1ULaRR+6kZOS/OoCE+pYGq67A5tyIy+dQ=
|
||||||
github.com/sagernet/smux v1.5.50-sing-box-mod.1 h1:XkJcivBC9V4wBjiGXIXZ229aZCU1hzcbp6kSkkyQ478=
|
github.com/sagernet/smux v1.5.50-sing-box-mod.1 h1:XkJcivBC9V4wBjiGXIXZ229aZCU1hzcbp6kSkkyQ478=
|
||||||
github.com/sagernet/smux v1.5.50-sing-box-mod.1/go.mod h1:NjhsCEWedJm7eFLyhuBgIEzwfhRmytrUoiLluxs5Sk8=
|
github.com/sagernet/smux v1.5.50-sing-box-mod.1/go.mod h1:NjhsCEWedJm7eFLyhuBgIEzwfhRmytrUoiLluxs5Sk8=
|
||||||
github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 h1:6uUiZcDRnZSAegryaUGwPC/Fj13JSHwiTftrXhMmYOc=
|
github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 h1:6uUiZcDRnZSAegryaUGwPC/Fj13JSHwiTftrXhMmYOc=
|
||||||
@@ -499,6 +565,8 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
|||||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||||
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||||
|
github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho=
|
||||||
|
github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE=
|
||||||
github.com/txthinking/runnergroup v0.0.0-20250224021307-5864ffeb65ae h1:ArVM1jICfm7g4E4dBet+KHUFMLuxmj1Nxdp/tr3ByCU=
|
github.com/txthinking/runnergroup v0.0.0-20250224021307-5864ffeb65ae h1:ArVM1jICfm7g4E4dBet+KHUFMLuxmj1Nxdp/tr3ByCU=
|
||||||
github.com/txthinking/runnergroup v0.0.0-20250224021307-5864ffeb65ae/go.mod h1:cldYm15/XHcGt7ndItnEWHwFZo7dinU+2QoyjfErhsI=
|
github.com/txthinking/runnergroup v0.0.0-20250224021307-5864ffeb65ae/go.mod h1:cldYm15/XHcGt7ndItnEWHwFZo7dinU+2QoyjfErhsI=
|
||||||
github.com/txthinking/socks5 v0.0.0-20251011041537-5c31f201a10e h1:xA7GVlbz6teIF4FdvuqwbX6C4tiqNk2PH7FRPIDerao=
|
github.com/txthinking/socks5 v0.0.0-20251011041537-5c31f201a10e h1:xA7GVlbz6teIF4FdvuqwbX6C4tiqNk2PH7FRPIDerao=
|
||||||
@@ -514,6 +582,8 @@ 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/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
|
github.com/xtaci/kcp-go/v5 v5.6.72 h1:FLaQPalgpufJYQRk0OK+gErEhXGLUPjv6FSRPrFR8Lk=
|
||||||
|
github.com/xtaci/kcp-go/v5 v5.6.72/go.mod h1:9O3D8WR+cyyUjGiTILYfg17vn72otWuXK2AFfqIe6CM=
|
||||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||||
github.com/yl2chen/cidranger v1.0.2 h1:lbOWZVCG1tCRX4u24kuM1Tb4nHqWkDxwLdoS+SevawU=
|
github.com/yl2chen/cidranger v1.0.2 h1:lbOWZVCG1tCRX4u24kuM1Tb4nHqWkDxwLdoS+SevawU=
|
||||||
@@ -569,34 +639,55 @@ go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4
|
|||||||
go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
|
go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
|
||||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
|
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
|
||||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
|
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
|
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
|
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
|
||||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||||
|
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
|
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
|
||||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
|
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
|
||||||
golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w=
|
golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w=
|
||||||
golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g=
|
golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g=
|
||||||
|
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
|
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||||
|
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||||
|
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||||
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
@@ -609,6 +700,7 @@ golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9sn
|
|||||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||||
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||||
@@ -617,7 +709,13 @@ golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
|||||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||||
|
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||||
|
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||||
|
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||||
|
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||||
@@ -631,13 +729,27 @@ golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus
|
|||||||
golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI=
|
golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
|
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||||
|
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
|
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||||
|
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||||
google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4=
|
google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
|
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
|
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||||
|
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||||
|
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||||
|
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||||
|
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||||
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||||
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||||
|
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||||
|
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||||
|
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
@@ -654,6 +766,8 @@ gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
|
|||||||
gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
|
gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
|
||||||
gvisor.dev/gvisor v0.0.0-20260408064518-65a410b0d584 h1:QyFROp5Ew7XZWKPtp8ap78z4gpY6xHpJIEdHgVA4bzA=
|
gvisor.dev/gvisor v0.0.0-20260408064518-65a410b0d584 h1:QyFROp5Ew7XZWKPtp8ap78z4gpY6xHpJIEdHgVA4bzA=
|
||||||
gvisor.dev/gvisor v0.0.0-20260408064518-65a410b0d584/go.mod h1:xQ2PWgHmWJA/Ph4i1q1jBm39BKhc3W0DXqWoDSyuBOY=
|
gvisor.dev/gvisor v0.0.0-20260408064518-65a410b0d584/go.mod h1:xQ2PWgHmWJA/Ph4i1q1jBm39BKhc3W0DXqWoDSyuBOY=
|
||||||
|
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
|
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
||||||
lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=
|
lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=
|
||||||
lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo=
|
lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo=
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ var muxProtocols = []string{
|
|||||||
"h2mux",
|
"h2mux",
|
||||||
"smux",
|
"smux",
|
||||||
"yamux",
|
"yamux",
|
||||||
|
"rmux",
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestVMessSMux(t *testing.T) {
|
func TestVMessSMux(t *testing.T) {
|
||||||
|
|||||||
8
transport/call/common/buffers.go
Normal file
8
transport/call/common/buffers.go
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
const (
|
||||||
|
UDPBufSize = 4096
|
||||||
|
RTPBufSize = 65536
|
||||||
|
VP8BufSize = 1126
|
||||||
|
DCBufSize = 32768
|
||||||
|
)
|
||||||
15
transport/call/common/deps.go
Normal file
15
transport/call/common/deps.go
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/pion/webrtc/v4"
|
||||||
|
"github.com/sagernet/sing/common/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ResolveFunc func(hostname string) (string, error)
|
||||||
|
|
||||||
|
type PeerConnectionConfigurer interface {
|
||||||
|
ConfigureSettingEngine(settingEngine *webrtc.SettingEngine)
|
||||||
|
}
|
||||||
|
|
||||||
|
type AddTunnelTracksFunc func(pc *webrtc.PeerConnection, logger logger.ContextLogger, prefix string) *webrtc.TrackLocalStaticSample
|
||||||
|
type ReadTrackFunc func(track *webrtc.TrackRemote, handler func([]byte), logger logger.ContextLogger, prefix string)
|
||||||
87
transport/call/common/http.go
Normal file
87
transport/call/common/http.go
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
M "github.com/sagernet/sing/common/metadata"
|
||||||
|
N "github.com/sagernet/sing/common/network"
|
||||||
|
)
|
||||||
|
|
||||||
|
const UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36"
|
||||||
|
|
||||||
|
func LoadCookies(path string) (string, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("cannot read cookies: %w", err)
|
||||||
|
}
|
||||||
|
var cookies []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &cookies); err != nil {
|
||||||
|
return "", fmt.Errorf("cannot parse cookies: %w", err)
|
||||||
|
}
|
||||||
|
parts := make([]string, len(cookies))
|
||||||
|
for i, c := range cookies {
|
||||||
|
parts[i] = c.Name + "=" + c.Value
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "; "), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func HttpClient(dialer N.Dialer) *http.Client {
|
||||||
|
return &http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
|
return dialer.DialContext(ctx, network, M.ParseSocksaddr(addr))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func HttpGet(dialer N.Dialer, endpoint string) ([]byte, error) {
|
||||||
|
req, _ := http.NewRequest("GET", endpoint, nil)
|
||||||
|
req.Header.Set("User-Agent", UserAgent)
|
||||||
|
resp, err := HttpClient(dialer).Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
return io.ReadAll(resp.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func CookieValue(cookieHeader, name string) string {
|
||||||
|
for _, part := range strings.Split(cookieHeader, ";") {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
eq := strings.IndexByte(part, '=')
|
||||||
|
if eq != -1 && part[:eq] == name {
|
||||||
|
return part[eq+1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func FilterCookies(cookieHeader string, allow []string) string {
|
||||||
|
allowed := make(map[string]struct{}, len(allow))
|
||||||
|
for _, n := range allow {
|
||||||
|
allowed[n] = struct{}{}
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, part := range strings.Split(cookieHeader, ";") {
|
||||||
|
trimmed := strings.TrimSpace(part)
|
||||||
|
eq := strings.IndexByte(trimmed, '=')
|
||||||
|
if eq == -1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := allowed[trimmed[:eq]]; ok {
|
||||||
|
out = append(out, trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(out, "; ")
|
||||||
|
}
|
||||||
58
transport/call/common/ice.go
Normal file
58
transport/call/common/ice.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func FixICEURL(iceURL string) string {
|
||||||
|
idx := strings.Index(iceURL, ":")
|
||||||
|
if idx < 0 {
|
||||||
|
return iceURL
|
||||||
|
}
|
||||||
|
scheme := iceURL[:idx]
|
||||||
|
if scheme != "turn" && scheme != "stun" && scheme != "turns" && scheme != "stuns" {
|
||||||
|
return iceURL
|
||||||
|
}
|
||||||
|
rest := iceURL[idx+1:]
|
||||||
|
if strings.HasPrefix(rest, "[") {
|
||||||
|
return iceURL
|
||||||
|
}
|
||||||
|
if strings.Count(rest, ":") <= 1 {
|
||||||
|
return iceURL
|
||||||
|
}
|
||||||
|
params := ""
|
||||||
|
if qm := strings.Index(rest, "?"); qm >= 0 {
|
||||||
|
params = rest[qm:]
|
||||||
|
rest = rest[:qm]
|
||||||
|
}
|
||||||
|
lastColon := strings.LastIndex(rest, ":")
|
||||||
|
if lastColon > 0 {
|
||||||
|
host := rest[:lastColon]
|
||||||
|
port := rest[lastColon+1:]
|
||||||
|
if net.ParseIP(host) != nil {
|
||||||
|
return scheme + ":[" + host + "]:" + port + params
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if net.ParseIP(rest) != nil {
|
||||||
|
return scheme + ":[" + rest + "]" + params
|
||||||
|
}
|
||||||
|
return iceURL
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExtractICEHost(iceURL string) string {
|
||||||
|
idx := strings.Index(iceURL, ":")
|
||||||
|
if idx < 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
rest := iceURL[idx+1:]
|
||||||
|
params := strings.Index(rest, "?")
|
||||||
|
if params >= 0 {
|
||||||
|
rest = rest[:params]
|
||||||
|
}
|
||||||
|
host, _, err := net.SplitHostPort(rest)
|
||||||
|
if err != nil {
|
||||||
|
return rest
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
||||||
40
transport/call/common/jitter.go
Normal file
40
transport/call/common/jitter.go
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/rand/v2"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const backoffJitterFloorDivisor = 4
|
||||||
|
|
||||||
|
func BackoffWithJitter(attempt int, initialDelay, maxDelay time.Duration) time.Duration {
|
||||||
|
if initialDelay <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if maxDelay < initialDelay {
|
||||||
|
maxDelay = initialDelay
|
||||||
|
}
|
||||||
|
if attempt < 0 {
|
||||||
|
attempt = 0
|
||||||
|
}
|
||||||
|
ceiling := maxDelay
|
||||||
|
if shifted := initialDelay << uint(attempt); shifted > 0 && shifted < maxDelay {
|
||||||
|
ceiling = shifted
|
||||||
|
}
|
||||||
|
floor := ceiling / backoffJitterFloorDivisor
|
||||||
|
return floor + time.Duration(rand.Int64N(int64(ceiling-floor)+1))
|
||||||
|
}
|
||||||
|
|
||||||
|
func DurationInRange(minDuration, maxDuration time.Duration) time.Duration {
|
||||||
|
if maxDuration <= minDuration {
|
||||||
|
return minDuration
|
||||||
|
}
|
||||||
|
return minDuration + time.Duration(rand.Int64N(int64(maxDuration-minDuration)+1))
|
||||||
|
}
|
||||||
|
|
||||||
|
func IntInRange(minValue, maxValue int) int {
|
||||||
|
if maxValue <= minValue {
|
||||||
|
return minValue
|
||||||
|
}
|
||||||
|
return minValue + rand.IntN(maxValue-minValue+1)
|
||||||
|
}
|
||||||
68
transport/call/common/mask.go
Normal file
68
transport/call/common/mask.go
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
)
|
||||||
|
|
||||||
|
func MaskError(err error) string {
|
||||||
|
if err == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if !MaskingEnabled {
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
if opErr, ok := err.(*net.OpError); ok {
|
||||||
|
msg := opErr.Op
|
||||||
|
if opErr.Net != "" {
|
||||||
|
msg += " " + opErr.Net
|
||||||
|
}
|
||||||
|
if opErr.Source != nil {
|
||||||
|
msg += " " + MaskAddr(opErr.Source.String())
|
||||||
|
}
|
||||||
|
if opErr.Source != nil && opErr.Addr != nil {
|
||||||
|
msg += "->"
|
||||||
|
}
|
||||||
|
if opErr.Addr != nil {
|
||||||
|
msg += MaskAddr(opErr.Addr.String())
|
||||||
|
}
|
||||||
|
msg += ": " + opErr.Err.Error()
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
const MaskingEnabled = true
|
||||||
|
|
||||||
|
func MaskAddr(addr string) string {
|
||||||
|
if !MaskingEnabled {
|
||||||
|
return addr
|
||||||
|
}
|
||||||
|
host, port, err := net.SplitHostPort(addr)
|
||||||
|
if err != nil {
|
||||||
|
host = addr
|
||||||
|
port = ""
|
||||||
|
}
|
||||||
|
masked := maskHost(host)
|
||||||
|
if port != "" {
|
||||||
|
return net.JoinHostPort(masked, port)
|
||||||
|
}
|
||||||
|
return masked
|
||||||
|
}
|
||||||
|
|
||||||
|
func maskHost(host string) string {
|
||||||
|
if host == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
ip := net.ParseIP(host)
|
||||||
|
if ip != nil {
|
||||||
|
if ip4 := ip.To4(); ip4 != nil {
|
||||||
|
return fmt.Sprintf("%d.%d.x.x", ip4[0], ip4[1])
|
||||||
|
}
|
||||||
|
return "x::x"
|
||||||
|
}
|
||||||
|
if len(host) <= 1 {
|
||||||
|
return "*"
|
||||||
|
}
|
||||||
|
return string(host[0]) + "***"
|
||||||
|
}
|
||||||
89
transport/call/common/signaling.go
Normal file
89
transport/call/common/signaling.go
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/pion/rtp"
|
||||||
|
"github.com/pion/rtp/codecs"
|
||||||
|
"github.com/pion/webrtc/v4"
|
||||||
|
"github.com/sagernet/sing/common/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
func AddTunnelTracks(pc *webrtc.PeerConnection, logger logger.ContextLogger, prefix string) *webrtc.TrackLocalStaticSample {
|
||||||
|
sampleTrack, _ := webrtc.NewTrackLocalStaticSample(
|
||||||
|
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeVP8},
|
||||||
|
"video", "tunnel-video",
|
||||||
|
)
|
||||||
|
audioTrack, _ := webrtc.NewTrackLocalStaticRTP(
|
||||||
|
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeOpus},
|
||||||
|
"audio", "tunnel-audio",
|
||||||
|
)
|
||||||
|
audioSender, audioErr := pc.AddTrack(audioTrack)
|
||||||
|
videoSender, videoErr := pc.AddTrack(sampleTrack)
|
||||||
|
logger.Debug(fmt.Sprintf("%s: AddTrack audio: sender=%v err=%v", prefix, audioSender != nil, audioErr))
|
||||||
|
logger.Debug(fmt.Sprintf("%s: AddTrack video: sender=%v err=%v", prefix, videoSender != nil, videoErr))
|
||||||
|
logger.Debug(fmt.Sprintf("%s: senders count: %d", prefix, len(pc.GetSenders())))
|
||||||
|
return sampleTrack
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReadTrack(track *webrtc.TrackRemote, handler func([]byte), logger logger.ContextLogger, prefix string) {
|
||||||
|
if track.Codec().MimeType != webrtc.MimeTypeVP8 {
|
||||||
|
buf := make([]byte, UDPBufSize)
|
||||||
|
for {
|
||||||
|
if _, _, err := track.Read(buf); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var vp8Pkt codecs.VP8Packet
|
||||||
|
var pkt rtp.Packet
|
||||||
|
var frameBuf []byte
|
||||||
|
var lastSeq uint16
|
||||||
|
var haveLastSeq bool
|
||||||
|
frameValid := false
|
||||||
|
recvCount := 0
|
||||||
|
buf := make([]byte, RTPBufSize)
|
||||||
|
for {
|
||||||
|
n, _, err := track.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if pkt.Unmarshal(buf[:n]) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if haveLastSeq && pkt.SequenceNumber != lastSeq+1 {
|
||||||
|
frameValid = false
|
||||||
|
frameBuf = frameBuf[:0]
|
||||||
|
}
|
||||||
|
lastSeq = pkt.SequenceNumber
|
||||||
|
haveLastSeq = true
|
||||||
|
vp8Payload, err := vp8Pkt.Unmarshal(pkt.Payload)
|
||||||
|
if err != nil {
|
||||||
|
frameValid = false
|
||||||
|
frameBuf = frameBuf[:0]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if vp8Pkt.S == 1 {
|
||||||
|
frameBuf = frameBuf[:0]
|
||||||
|
frameValid = true
|
||||||
|
}
|
||||||
|
if !frameValid {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
frameBuf = append(frameBuf, vp8Payload...)
|
||||||
|
if !pkt.Marker {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
recvCount++
|
||||||
|
if recvCount <= 3 || recvCount%200 == 0 {
|
||||||
|
logger.Debug(fmt.Sprintf("%s: recv vp8 frame #%d %d bytes", prefix, recvCount, len(frameBuf)))
|
||||||
|
}
|
||||||
|
if handler != nil {
|
||||||
|
frame := make([]byte, len(frameBuf))
|
||||||
|
copy(frame, frameBuf)
|
||||||
|
handler(frame)
|
||||||
|
}
|
||||||
|
frameBuf = frameBuf[:0]
|
||||||
|
frameValid = false
|
||||||
|
}
|
||||||
|
}
|
||||||
17
transport/call/common/ws.go
Normal file
17
transport/call/common/ws.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
func CloseWS(ws *websocket.Conn) {
|
||||||
|
if ws == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ws.WriteControl(websocket.CloseMessage,
|
||||||
|
websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""),
|
||||||
|
time.Now().Add(time.Second))
|
||||||
|
ws.Close()
|
||||||
|
}
|
||||||
158
transport/call/config.go
Normal file
158
transport/call/config.go
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
package call
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
|
||||||
|
"github.com/sagernet/sing-box/adapter"
|
||||||
|
"github.com/sagernet/sing-box/transport/call/dion"
|
||||||
|
"github.com/sagernet/sing-box/transport/call/telemost"
|
||||||
|
"github.com/sagernet/sing-box/transport/call/tunnel"
|
||||||
|
"github.com/sagernet/sing-box/transport/call/vk"
|
||||||
|
"github.com/sagernet/sing-box/transport/call/wbstream"
|
||||||
|
E "github.com/sagernet/sing/common/exceptions"
|
||||||
|
"github.com/sagernet/sing/common/logger"
|
||||||
|
N "github.com/sagernet/sing/common/network"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Role int
|
||||||
|
|
||||||
|
const (
|
||||||
|
RoleCreator Role = iota
|
||||||
|
RoleJoiner
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Platform string
|
||||||
|
Mode string
|
||||||
|
JoinLink string
|
||||||
|
Cookies string
|
||||||
|
CookieString string
|
||||||
|
Email string
|
||||||
|
Password string
|
||||||
|
ReadBuffer int
|
||||||
|
Role Role
|
||||||
|
Dialer N.Dialer
|
||||||
|
DNSRouter adapter.DNSRouter
|
||||||
|
Logger logger.ContextLogger
|
||||||
|
}
|
||||||
|
|
||||||
|
func Connect(ctx context.Context, cfg Config) (*Bridge, error) {
|
||||||
|
readBuf := cfg.ReadBuffer
|
||||||
|
if readBuf <= 0 {
|
||||||
|
readBuf = 32768
|
||||||
|
}
|
||||||
|
log := cfg.Logger
|
||||||
|
if log == nil {
|
||||||
|
log = logger.NOP()
|
||||||
|
}
|
||||||
|
cookieStr := cfg.CookieString
|
||||||
|
if cookieStr == "" {
|
||||||
|
cookieStr = cfg.Cookies
|
||||||
|
}
|
||||||
|
switch cfg.Platform {
|
||||||
|
case "telemost":
|
||||||
|
switch cfg.Role {
|
||||||
|
case RoleCreator:
|
||||||
|
relay, joinLink, err := telemost.ConnectCreator(ctx, cookieStr, cfg.JoinLink, readBuf, cfg.Dialer, log)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
log.Notice(fmt.Sprintf("call[telemost]: join_link=%s", joinLink))
|
||||||
|
return &Bridge{relay: relay}, nil
|
||||||
|
case RoleJoiner:
|
||||||
|
tun, err := telemost.ConnectJoiner(ctx, cfg.JoinLink, "", readBuf, cfg.Dialer, cfg.DNSRouter, log)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
relay := tunnel.NewRelayBridge(tun, "joiner", readBuf, cfg.Dialer, log)
|
||||||
|
relay.MarkReady()
|
||||||
|
return &Bridge{relay: relay}, nil
|
||||||
|
}
|
||||||
|
case "wbstream":
|
||||||
|
switch cfg.Role {
|
||||||
|
case RoleCreator:
|
||||||
|
relay, joinLink, err := wbstream.ConnectCreator(ctx, cookieStr, cfg.JoinLink, cfg.Mode, readBuf, cfg.Dialer, log)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
log.Notice(fmt.Sprintf("call[wbstream]: join_link=%s", joinLink))
|
||||||
|
return &Bridge{relay: relay}, nil
|
||||||
|
case RoleJoiner:
|
||||||
|
tun, err := wbstream.ConnectJoiner(ctx, cfg.JoinLink, "", cfg.Mode, readBuf, cfg.Dialer, cfg.DNSRouter, log)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
relay := tunnel.NewRelayBridge(tun, "joiner", readBuf, cfg.Dialer, log)
|
||||||
|
relay.MarkReady()
|
||||||
|
return &Bridge{relay: relay}, nil
|
||||||
|
}
|
||||||
|
case "vk":
|
||||||
|
switch cfg.Role {
|
||||||
|
case RoleCreator:
|
||||||
|
relay, joinLink, err := vk.ConnectCreator(ctx, cookieStr, cfg.JoinLink, readBuf, cfg.Dialer, log)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
log.Notice(fmt.Sprintf("call[vk]: join_link=%s", joinLink))
|
||||||
|
return &Bridge{relay: relay}, nil
|
||||||
|
case RoleJoiner:
|
||||||
|
tun, err := vk.ConnectJoiner(ctx, cfg.JoinLink, "", readBuf, cfg.Dialer, cfg.DNSRouter, log)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
relay := tunnel.NewRelayBridge(tun, "joiner", readBuf, cfg.Dialer, log)
|
||||||
|
relay.MarkReady()
|
||||||
|
return &Bridge{relay: relay}, nil
|
||||||
|
}
|
||||||
|
case "dion":
|
||||||
|
switch cfg.Role {
|
||||||
|
case RoleCreator:
|
||||||
|
relay, joinLink, err := dion.ConnectCreator(ctx, cookieStr, cfg.JoinLink, cfg.Email, cfg.Password, readBuf, cfg.Dialer, log)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
log.Notice(fmt.Sprintf("call[dion]: join_link=%s", joinLink))
|
||||||
|
return &Bridge{relay: relay}, nil
|
||||||
|
case RoleJoiner:
|
||||||
|
tun, err := dion.ConnectJoiner(ctx, cfg.JoinLink, "", readBuf, cfg.Dialer, log)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
relay := tunnel.NewRelayBridge(tun, "joiner", readBuf, cfg.Dialer, log)
|
||||||
|
relay.MarkReady()
|
||||||
|
return &Bridge{relay: relay}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, E.New("call: unsupported platform ", cfg.Platform)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Bridge struct {
|
||||||
|
relay *tunnel.RelayBridge
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBridge(relay *tunnel.RelayBridge) *Bridge {
|
||||||
|
return &Bridge{relay: relay}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) Close() error {
|
||||||
|
b.relay.Close()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) DialContext(ctx context.Context, destination string) (net.Conn, error) {
|
||||||
|
return b.relay.DialContext(ctx, destination)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) ListenPacket(ctx context.Context, destination string) (net.Conn, error) {
|
||||||
|
return b.relay.ListenPacket(ctx, destination)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) SetAcceptHandler(fn func(conn net.Conn, destination string)) {
|
||||||
|
b.relay.SetAcceptHandler(fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) SetUDPAcceptHandler(fn func(conn net.Conn, destination string)) {
|
||||||
|
b.relay.SetUDPAcceptHandler(fn)
|
||||||
|
}
|
||||||
650
transport/call/dion/api.go
Normal file
650
transport/call/dion/api.go
Normal file
@@ -0,0 +1,650 @@
|
|||||||
|
package dion
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/cookiejar"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/sagernet/sing-box/transport/call/common"
|
||||||
|
N "github.com/sagernet/sing/common/network"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrSessionExpired = errors.New("dion: session expired, re-login required")
|
||||||
|
|
||||||
|
var errLoginEndpointMissing = errors.New("dion: login endpoint not available")
|
||||||
|
|
||||||
|
const (
|
||||||
|
accessCookieName = "vc-access-token"
|
||||||
|
refreshCookieName = "vc-refresh-token"
|
||||||
|
|
||||||
|
loginClientsPath = "/v2/users/login/web"
|
||||||
|
loginPlatformPath = "/platform/v2/auth/auth-providers/dion/login/password"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
refreshSkewSeconds = 60
|
||||||
|
refreshMaxAttempts = 3
|
||||||
|
refreshBaseDelay = 2 * time.Second
|
||||||
|
refreshDelayMultiply = 1.75
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
APIBase = "https://api.dion.vc"
|
||||||
|
APIClientsBase = "https://api-clients.dion.vc"
|
||||||
|
WebBase = "https://dion.vc"
|
||||||
|
Origin = "https://dion.vc"
|
||||||
|
CookieDomain = "dion.vc"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GuestUser struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Initials string `json:"initials"`
|
||||||
|
Position string `json:"position"`
|
||||||
|
AvatarHTTPPath string `json:"avatar_http_path"`
|
||||||
|
IsProfileFilledIn bool `json:"is_profile_filled_in"`
|
||||||
|
Roles []string `json:"roles"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GuestAuthResponse struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
AuthProvider string `json:"auth_provider"`
|
||||||
|
IsAuthBySSO bool `json:"is_auth_by_sso"`
|
||||||
|
User GuestUser `json:"user"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoginResponse struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
AuthProvider string `json:"auth_provider"`
|
||||||
|
IsAuthBySSO bool `json:"is_auth_by_sso"`
|
||||||
|
User GuestUser `json:"user"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type EventInfo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
OrgID string `json:"org_id"`
|
||||||
|
Admins []string `json:"admins"`
|
||||||
|
PSTN struct {
|
||||||
|
Number string `json:"number"`
|
||||||
|
Pin int `json:"pin"`
|
||||||
|
Prefix string `json:"prefix"`
|
||||||
|
} `json:"pstn"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WSSConnectResponse struct {
|
||||||
|
Host string `json:"host"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Schema string `json:"schema"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Params map[string]string `json:"params"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Session struct {
|
||||||
|
HTTPClient *http.Client
|
||||||
|
Device DeviceProfile
|
||||||
|
AccessToken string
|
||||||
|
AccessTokenExp time.Time
|
||||||
|
UserID string
|
||||||
|
SessionID string
|
||||||
|
cookiesPath string
|
||||||
|
email string
|
||||||
|
password string
|
||||||
|
refreshMu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthResult struct {
|
||||||
|
Session *Session
|
||||||
|
Event *EventInfo
|
||||||
|
WSS *WSSConnectResponse
|
||||||
|
SessionID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSession(dialer N.Dialer) (*Session, error) {
|
||||||
|
jar, err := cookiejar.New(nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cookiejar: %w", err)
|
||||||
|
}
|
||||||
|
httpClient := common.HttpClient(dialer)
|
||||||
|
httpClient.Jar = jar
|
||||||
|
return &Session{HTTPClient: httpClient, Device: RandomDeviceProfile()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) RegisterGuest() (*GuestAuthResponse, error) {
|
||||||
|
auth, err := s.callRefreshOnce()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.applyRefreshResult(auth)
|
||||||
|
return auth, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) RegisterAnonymousGuest(eventID, displayName string) (*GuestAuthResponse, error) {
|
||||||
|
if eventID == "" {
|
||||||
|
return nil, fmt.Errorf("empty event_id")
|
||||||
|
}
|
||||||
|
if displayName == "" {
|
||||||
|
displayName = "Guest"
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
"event_id": eventID,
|
||||||
|
"name": displayName,
|
||||||
|
})
|
||||||
|
req, err := http.NewRequest(http.MethodPost, APIBase+"/platform/v1/users/register/guest", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.setBaseHeaders(req, "")
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := s.HTTPClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("register/guest: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
raw, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||||
|
return nil, fmt.Errorf("register/guest: status %d: %s", resp.StatusCode, string(raw))
|
||||||
|
}
|
||||||
|
var auth GuestAuthResponse
|
||||||
|
if err := json.Unmarshal(raw, &auth); err != nil {
|
||||||
|
return nil, fmt.Errorf("register/guest decode: %w", err)
|
||||||
|
}
|
||||||
|
if auth.AccessToken == "" {
|
||||||
|
return nil, fmt.Errorf("register/guest: empty access_token: %s", string(raw))
|
||||||
|
}
|
||||||
|
s.applyRefreshResult(&auth)
|
||||||
|
return &auth, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCredentials stores an email/password pair used by refreshLocked to
|
||||||
|
// re-authenticate when the refresh cookie is missing or rejected.
|
||||||
|
func (s *Session) SetCredentials(email, password string) {
|
||||||
|
s.email = strings.TrimSpace(email)
|
||||||
|
s.password = password
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginWithPassword exchanges credentials for a fresh token pair. The web
|
||||||
|
// front-end posts to api-clients, and switches to the platform endpoint when
|
||||||
|
// the DION_PLATFORM_COOKIE_AUTH_ENABLED toggle is on, so both are tried.
|
||||||
|
func (s *Session) LoginWithPassword(email, password string) error {
|
||||||
|
if email == "" || password == "" {
|
||||||
|
return fmt.Errorf("login: email and password are required")
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(map[string]string{"email": email, "password": password})
|
||||||
|
login, err := s.postLogin(APIClientsBase+loginClientsPath, body)
|
||||||
|
if errors.Is(err, errLoginEndpointMissing) {
|
||||||
|
login, err = s.postLogin(APIBase+loginPlatformPath, body)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.email = email
|
||||||
|
s.password = password
|
||||||
|
s.applyLoginResult(login)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) postLogin(target string, body []byte) (*LoginResponse, error) {
|
||||||
|
req, err := http.NewRequest(http.MethodPost, target, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.setBaseHeaders(req, "")
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := s.HTTPClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("login: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
raw, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusMethodNotAllowed {
|
||||||
|
return nil, errLoginEndpointMissing
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||||
|
return nil, fmt.Errorf("login: status %d: %s", resp.StatusCode, string(raw))
|
||||||
|
}
|
||||||
|
var login LoginResponse
|
||||||
|
if err := json.Unmarshal(raw, &login); err != nil {
|
||||||
|
return nil, fmt.Errorf("login decode: %w", err)
|
||||||
|
}
|
||||||
|
if login.AccessToken == "" {
|
||||||
|
return nil, fmt.Errorf("login: empty access_token: %s", string(raw))
|
||||||
|
}
|
||||||
|
return &login, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) applyLoginResult(login *LoginResponse) {
|
||||||
|
s.AccessToken = login.AccessToken
|
||||||
|
s.UserID = login.User.ID
|
||||||
|
if exp, err := parseJWTExpiry(login.AccessToken); err == nil {
|
||||||
|
s.AccessTokenExp = exp
|
||||||
|
}
|
||||||
|
if login.RefreshToken != "" {
|
||||||
|
s.SetCookieInJar(refreshCookieName, login.RefreshToken)
|
||||||
|
}
|
||||||
|
s.SetCookieInJar(accessCookieName, login.AccessToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) Refresh() error {
|
||||||
|
s.refreshMu.Lock()
|
||||||
|
defer s.refreshMu.Unlock()
|
||||||
|
return s.refreshLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) EnsureValidToken() error {
|
||||||
|
s.refreshMu.Lock()
|
||||||
|
defer s.refreshMu.Unlock()
|
||||||
|
if s.AccessToken != "" && !s.AccessTokenExp.IsZero() &&
|
||||||
|
time.Until(s.AccessTokenExp) > time.Duration(refreshSkewSeconds)*time.Second {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.refreshLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) DoAuthenticated(buildRequest func() (*http.Request, error)) (*http.Response, error) {
|
||||||
|
if err := s.EnsureValidToken(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req, err := buildRequest()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.setBaseHeaders(req, s.AccessToken)
|
||||||
|
resp, err := s.HTTPClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusUnauthorized {
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
staleToken := s.AccessToken
|
||||||
|
io.Copy(io.Discard, resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
s.refreshMu.Lock()
|
||||||
|
if s.AccessToken == staleToken {
|
||||||
|
if err := s.refreshLocked(); err != nil {
|
||||||
|
s.refreshMu.Unlock()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.refreshMu.Unlock()
|
||||||
|
retryReq, err := buildRequest()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.setBaseHeaders(retryReq, s.AccessToken)
|
||||||
|
return s.HTTPClient.Do(retryReq)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) WhoAmI() (json.RawMessage, error) {
|
||||||
|
resp, err := s.DoAuthenticated(func() (*http.Request, error) {
|
||||||
|
return http.NewRequest(http.MethodGet, APIBase+"/platform/v1/whoami", nil)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("whoami: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
raw, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("whoami: status %d: %s", resp.StatusCode, string(raw))
|
||||||
|
}
|
||||||
|
return raw, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) GetEventBySlug(slug string) (*EventInfo, error) {
|
||||||
|
if slug == "" {
|
||||||
|
return nil, fmt.Errorf("empty room ID")
|
||||||
|
}
|
||||||
|
eventURL := fmt.Sprintf("%s/conference/v1/events/slug/%s", APIBase, slug)
|
||||||
|
resp, err := s.DoAuthenticated(func() (*http.Request, error) {
|
||||||
|
req, err := http.NewRequest(http.MethodGet, eventURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
return req, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get event: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
raw, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("get event: status %d: %s", resp.StatusCode, string(raw))
|
||||||
|
}
|
||||||
|
var event EventInfo
|
||||||
|
if err := json.Unmarshal(raw, &event); err != nil {
|
||||||
|
return nil, fmt.Errorf("get event decode: %w", err)
|
||||||
|
}
|
||||||
|
if event.ID == "" {
|
||||||
|
return nil, fmt.Errorf("get event: empty id: %s", string(raw))
|
||||||
|
}
|
||||||
|
return &event, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) GenerateSlug() (string, error) {
|
||||||
|
resp, err := s.DoAuthenticated(func() (*http.Request, error) {
|
||||||
|
return http.NewRequest(http.MethodGet, APIClientsBase+"/v2/events/slug/generate", nil)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("generate room ID: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
raw, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("generate room ID: status %d: %s", resp.StatusCode, string(raw))
|
||||||
|
}
|
||||||
|
var out struct {
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &out); err != nil {
|
||||||
|
return "", fmt.Errorf("generate room ID decode: %w", err)
|
||||||
|
}
|
||||||
|
if out.Slug == "" {
|
||||||
|
return "", fmt.Errorf("generate room ID: empty: %s", string(raw))
|
||||||
|
}
|
||||||
|
return out.Slug, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateEventOptions struct {
|
||||||
|
Slug string
|
||||||
|
EventParams []string
|
||||||
|
IsImpersonalSlug bool
|
||||||
|
IsOnCloud bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) CreateEvent(opts CreateEventOptions) (*EventInfo, error) {
|
||||||
|
if opts.Slug == "" {
|
||||||
|
return nil, fmt.Errorf("empty room ID")
|
||||||
|
}
|
||||||
|
if opts.EventParams == nil {
|
||||||
|
opts.EventParams = []string{"guest_access"}
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
"event_params": opts.EventParams,
|
||||||
|
"is_impersonal_slug": opts.IsImpersonalSlug,
|
||||||
|
"is_on_cloud": opts.IsOnCloud,
|
||||||
|
"slug": opts.Slug,
|
||||||
|
})
|
||||||
|
resp, err := s.DoAuthenticated(func() (*http.Request, error) {
|
||||||
|
req, err := http.NewRequest(http.MethodPost, APIBase+"/conference/v1/events", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
return req, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create event: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
raw, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||||
|
return nil, fmt.Errorf("create event: status %d: %s", resp.StatusCode, string(raw))
|
||||||
|
}
|
||||||
|
var event EventInfo
|
||||||
|
if err := json.Unmarshal(raw, &event); err != nil {
|
||||||
|
return nil, fmt.Errorf("create event decode: %w", err)
|
||||||
|
}
|
||||||
|
if event.ID == "" {
|
||||||
|
return nil, fmt.Errorf("create event: empty id: %s", string(raw))
|
||||||
|
}
|
||||||
|
return &event, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) CreateRoom() (*EventInfo, error) {
|
||||||
|
slug, err := s.GenerateSlug()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.CreateEvent(CreateEventOptions{
|
||||||
|
Slug: slug,
|
||||||
|
EventParams: []string{"guest_access"},
|
||||||
|
IsImpersonalSlug: true,
|
||||||
|
IsOnCloud: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) ConnectWSS(sessionID string) (*WSSConnectResponse, error) {
|
||||||
|
if sessionID == "" {
|
||||||
|
sessionID = uuid.New().String()
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(map[string]string{"session_id": sessionID})
|
||||||
|
resp, err := s.DoAuthenticated(func() (*http.Request, error) {
|
||||||
|
req, err := http.NewRequest(http.MethodPost, APIBase+"/conference/v1/connect/wss", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
return req, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("connect/wss: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
raw, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("connect/wss: status %d: %s", resp.StatusCode, string(raw))
|
||||||
|
}
|
||||||
|
var wss WSSConnectResponse
|
||||||
|
if err := json.Unmarshal(raw, &wss); err != nil {
|
||||||
|
return nil, fmt.Errorf("connect/wss decode: %w", err)
|
||||||
|
}
|
||||||
|
if wss.URL == "" {
|
||||||
|
return nil, fmt.Errorf("connect/wss: empty url: %s", string(raw))
|
||||||
|
}
|
||||||
|
s.SessionID = sessionID
|
||||||
|
return &wss, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) LookupEventBySlugAnonymous(slug string) (*EventInfo, error) {
|
||||||
|
if slug == "" {
|
||||||
|
return nil, fmt.Errorf("empty room ID")
|
||||||
|
}
|
||||||
|
eventURL := fmt.Sprintf("%s/conference/v1/events/slug/%s", APIBase, slug)
|
||||||
|
req, err := http.NewRequest(http.MethodGet, eventURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.setBaseHeaders(req, "")
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := s.HTTPClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get event anon: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
raw, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("get event anon: status %d: %s", resp.StatusCode, string(raw))
|
||||||
|
}
|
||||||
|
var event EventInfo
|
||||||
|
if err := json.Unmarshal(raw, &event); err != nil {
|
||||||
|
return nil, fmt.Errorf("get event anon decode: %w", err)
|
||||||
|
}
|
||||||
|
if event.ID == "" {
|
||||||
|
return nil, fmt.Errorf("get event anon: empty id: %s", string(raw))
|
||||||
|
}
|
||||||
|
return &event, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func JoinAsGuest(dialer N.Dialer, slug, displayName string) (*Session, *EventInfo, error) {
|
||||||
|
session, err := NewSession(dialer)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := session.PrimeCookies(slug); err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("prime cookies: %w", err)
|
||||||
|
}
|
||||||
|
event, err := session.LookupEventBySlugAnonymous(slug)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if _, err := session.RegisterAnonymousGuest(event.ID, displayName); err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("RegisterAnonymousGuest: %w", err)
|
||||||
|
}
|
||||||
|
return session, event, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func AuthAndGetTicket(dialer N.Dialer, slug string) (*AuthResult, error) {
|
||||||
|
session, err := NewSession(dialer)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := session.PrimeCookies(slug); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := session.RegisterGuest(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := session.WhoAmI(); err != nil {
|
||||||
|
return nil, fmt.Errorf("whoami after guest auth: %w", err)
|
||||||
|
}
|
||||||
|
event, err := session.GetEventBySlug(slug)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sessionID := uuid.New().String()
|
||||||
|
wss, err := session.ConnectWSS(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &AuthResult{
|
||||||
|
Session: session,
|
||||||
|
Event: event,
|
||||||
|
WSS: wss,
|
||||||
|
SessionID: sessionID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseRoom(input string) string {
|
||||||
|
trimmed := strings.TrimSpace(input)
|
||||||
|
if trimmed == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
trimmed = strings.TrimPrefix(trimmed, "dion://")
|
||||||
|
trimmed = strings.TrimPrefix(trimmed, "https://")
|
||||||
|
trimmed = strings.TrimPrefix(trimmed, "http://")
|
||||||
|
trimmed = strings.TrimPrefix(trimmed, "dion.vc/")
|
||||||
|
trimmed = strings.TrimPrefix(trimmed, "event/")
|
||||||
|
if idx := strings.Index(trimmed, "?"); idx >= 0 {
|
||||||
|
trimmed = trimmed[:idx]
|
||||||
|
}
|
||||||
|
if idx := strings.Index(trimmed, "/"); idx >= 0 {
|
||||||
|
trimmed = trimmed[:idx]
|
||||||
|
}
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) setBaseHeaders(req *http.Request, accessToken string) {
|
||||||
|
req.Header.Set("User-Agent", s.Device.UserAgent)
|
||||||
|
req.Header.Set("Origin", Origin)
|
||||||
|
req.Header.Set("Referer", Origin+"/")
|
||||||
|
req.Header.Set("Accept", "*/*")
|
||||||
|
req.Header.Set("Accept-Language", "en")
|
||||||
|
req.Header.Set("X-Request-Id", uuid.New().String())
|
||||||
|
for name, value := range s.Device.Headers() {
|
||||||
|
req.Header.Set(name, value)
|
||||||
|
}
|
||||||
|
if accessToken != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) callRefreshOnce() (*GuestAuthResponse, error) {
|
||||||
|
req, err := http.NewRequest(http.MethodPost, APIBase+"/platform/v1/auth/refresh/web", bytes.NewReader(nil))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.setBaseHeaders(req, "")
|
||||||
|
req.Header.Set("Content-Length", "0")
|
||||||
|
resp, err := s.HTTPClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("auth/refresh/web: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
raw, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode >= 400 && resp.StatusCode < 500 {
|
||||||
|
return nil, fmt.Errorf("%w: status %d: %s", ErrSessionExpired, resp.StatusCode, string(raw))
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("auth/refresh/web: status %d: %s", resp.StatusCode, string(raw))
|
||||||
|
}
|
||||||
|
var auth GuestAuthResponse
|
||||||
|
if err := json.Unmarshal(raw, &auth); err != nil {
|
||||||
|
return nil, fmt.Errorf("auth/refresh/web decode: %w", err)
|
||||||
|
}
|
||||||
|
if auth.AccessToken == "" {
|
||||||
|
return nil, fmt.Errorf("auth/refresh/web: empty access_token: %s", string(raw))
|
||||||
|
}
|
||||||
|
return &auth, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) applyRefreshResult(auth *GuestAuthResponse) {
|
||||||
|
s.AccessToken = auth.AccessToken
|
||||||
|
s.UserID = auth.User.ID
|
||||||
|
if exp, err := parseJWTExpiry(auth.AccessToken); err == nil {
|
||||||
|
s.AccessTokenExp = exp
|
||||||
|
}
|
||||||
|
s.SetCookieInJar(accessCookieName, auth.AccessToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) refreshLocked() error {
|
||||||
|
if !s.HasRefreshCookie() && s.HasCredentials() {
|
||||||
|
return s.LoginWithPassword(s.email, s.password)
|
||||||
|
}
|
||||||
|
var lastErr error
|
||||||
|
delay := refreshBaseDelay
|
||||||
|
for attempt := 1; attempt <= refreshMaxAttempts; attempt++ {
|
||||||
|
auth, err := s.callRefreshOnce()
|
||||||
|
if err == nil {
|
||||||
|
s.applyRefreshResult(auth)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if errors.Is(err, ErrSessionExpired) {
|
||||||
|
if s.HasCredentials() {
|
||||||
|
return s.LoginWithPassword(s.email, s.password)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
lastErr = err
|
||||||
|
if attempt < refreshMaxAttempts {
|
||||||
|
time.Sleep(delay)
|
||||||
|
delay = time.Duration(float64(delay) * refreshDelayMultiply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("refresh failed after %d attempts: %w", refreshMaxAttempts, lastErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseJWTExpiry(token string) (time.Time, error) {
|
||||||
|
parts := strings.Split(token, ".")
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return time.Time{}, fmt.Errorf("invalid JWT")
|
||||||
|
}
|
||||||
|
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, fmt.Errorf("decode payload: %w", err)
|
||||||
|
}
|
||||||
|
var claims struct {
|
||||||
|
Exp int64 `json:"exp"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||||
|
return time.Time{}, fmt.Errorf("parse claims: %w", err)
|
||||||
|
}
|
||||||
|
if claims.Exp == 0 {
|
||||||
|
return time.Time{}, fmt.Errorf("no exp claim")
|
||||||
|
}
|
||||||
|
return time.Unix(claims.Exp, 0), nil
|
||||||
|
}
|
||||||
732
transport/call/dion/call.go
Normal file
732
transport/call/dion/call.go
Normal file
@@ -0,0 +1,732 @@
|
|||||||
|
package dion
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/pion/rtp/codecs"
|
||||||
|
"github.com/pion/webrtc/v4"
|
||||||
|
"github.com/sagernet/sing-box/adapter"
|
||||||
|
"github.com/sagernet/sing/common/logger"
|
||||||
|
N "github.com/sagernet/sing/common/network"
|
||||||
|
|
||||||
|
"github.com/sagernet/sing-box/transport/call/tunnel"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
sendVideoMidIndex = 12
|
||||||
|
sendScreenShareMidIndex = 13
|
||||||
|
recvScreenShareMidStr = "14"
|
||||||
|
defaultRecvVideoMid = "1"
|
||||||
|
recvVideoMidCount = 9
|
||||||
|
|
||||||
|
creatorVP8FPS = 24
|
||||||
|
creatorVP8Batch = 10
|
||||||
|
joinerVP8FPS = 24
|
||||||
|
joinerVP8Batch = 5
|
||||||
|
)
|
||||||
|
|
||||||
|
type Role string
|
||||||
|
|
||||||
|
const (
|
||||||
|
RoleCreator Role = "creator"
|
||||||
|
RoleJoiner Role = "joiner"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CallConfig struct {
|
||||||
|
Auth *Session
|
||||||
|
Event *EventInfo
|
||||||
|
Obfuscator *tunnel.TunnelObfuscator
|
||||||
|
DisplayName string
|
||||||
|
Logger logger.ContextLogger
|
||||||
|
RecvMid string
|
||||||
|
Role Role
|
||||||
|
|
||||||
|
SettingEngine *webrtc.SettingEngine
|
||||||
|
Dialer N.Dialer
|
||||||
|
DNSRouter adapter.DNSRouter
|
||||||
|
}
|
||||||
|
|
||||||
|
type PeerEntry struct {
|
||||||
|
SessionID string
|
||||||
|
UserID string
|
||||||
|
Name string
|
||||||
|
CamState bool
|
||||||
|
JoinedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Call struct {
|
||||||
|
cfg CallConfig
|
||||||
|
signaling *SignalingClient
|
||||||
|
peer *PionPeer
|
||||||
|
sendTrack *webrtc.TrackLocalStaticSample
|
||||||
|
vp8tun *tunnel.VP8DataTunnel
|
||||||
|
mySessionID string
|
||||||
|
|
||||||
|
peersMu sync.Mutex
|
||||||
|
peersByID map[string]*PeerEntry
|
||||||
|
subscribed map[string]bool
|
||||||
|
peerToMid map[string]string
|
||||||
|
freeMids []string
|
||||||
|
pendingSubs []string
|
||||||
|
|
||||||
|
onConnectedFired atomic.Bool
|
||||||
|
|
||||||
|
OnConnected func(tunnel.DataTunnel)
|
||||||
|
OnPeerRestart func()
|
||||||
|
OnRemoteSDP func(sdp string)
|
||||||
|
|
||||||
|
done chan struct{}
|
||||||
|
closeOnce sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCall(cfg CallConfig) *Call {
|
||||||
|
if cfg.Logger == nil {
|
||||||
|
cfg.Logger = logger.NOP()
|
||||||
|
}
|
||||||
|
if cfg.Role == "" {
|
||||||
|
cfg.Role = RoleCreator
|
||||||
|
}
|
||||||
|
if cfg.RecvMid == "" {
|
||||||
|
cfg.RecvMid = defaultRecvVideoMid
|
||||||
|
}
|
||||||
|
var freeMids []string
|
||||||
|
if cfg.Role == RoleJoiner {
|
||||||
|
freeMids = []string{recvScreenShareMidStr}
|
||||||
|
} else {
|
||||||
|
freeMids = make([]string, 0, recvVideoMidCount)
|
||||||
|
for midIndex := 1; midIndex < recvVideoMidCount; midIndex++ {
|
||||||
|
freeMids = append(freeMids, fmt.Sprintf("%d", midIndex))
|
||||||
|
}
|
||||||
|
freeMids = append(freeMids, "0")
|
||||||
|
}
|
||||||
|
return &Call{
|
||||||
|
cfg: cfg,
|
||||||
|
peersByID: make(map[string]*PeerEntry),
|
||||||
|
subscribed: make(map[string]bool),
|
||||||
|
peerToMid: make(map[string]string),
|
||||||
|
freeMids: freeMids,
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Call) Done() <-chan struct{} { return c.done }
|
||||||
|
|
||||||
|
func (c *Call) SessionID() string { return c.mySessionID }
|
||||||
|
|
||||||
|
func (c *Call) Start() error {
|
||||||
|
sessionID := uuid.New().String()
|
||||||
|
c.mySessionID = sessionID
|
||||||
|
c.cfg.Logger.Debug(fmt.Sprintf("[call] my session_id=%s", sessionID))
|
||||||
|
|
||||||
|
wss, err := c.cfg.Auth.ConnectWSS(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ConnectWSS: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
signaling, err := DialSignaling(wss.URL, SignalingDialOptions{
|
||||||
|
UserAgent: c.cfg.Auth.Device.UserAgent,
|
||||||
|
Logger: c.cfg.Logger,
|
||||||
|
Dialer: c.cfg.Dialer,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("DialSignaling: %w", err)
|
||||||
|
}
|
||||||
|
c.signaling = signaling
|
||||||
|
if err := signaling.WaitConnected(15 * time.Second); err != nil {
|
||||||
|
return fmt.Errorf("WaitConnected: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
youJoinedChan := make(chan YouJoinedParams, 1)
|
||||||
|
sdpAnswerChan := make(chan SDPAnswerParams, 4)
|
||||||
|
var onceYouJoined sync.Once
|
||||||
|
|
||||||
|
signaling.OnYouJoined = func(params YouJoinedParams) {
|
||||||
|
onceYouJoined.Do(func() { youJoinedChan <- params })
|
||||||
|
}
|
||||||
|
signaling.OnSDPAnswer = func(answerSDP string, transceivers []TransceiverDesc) {
|
||||||
|
select {
|
||||||
|
case sdpAnswerChan <- SDPAnswerParams{Answer: answerSDP, Transceivers: transceivers}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
signaling.OnSpeakerJoined = c.handleSpeakerJoined
|
||||||
|
signaling.OnSpeakerDisconnected = c.handleSpeakerDisconnected
|
||||||
|
signaling.OnSpeakerCamStateChanged = c.handleSpeakerCamStateChanged
|
||||||
|
signaling.OnConfSpeakersState = c.handleConfSpeakersState
|
||||||
|
signaling.OnGetVideoFromUserResponse = c.handleGetVideoFromUserResponse
|
||||||
|
signaling.OnGetScreenSharingFromUserResponse = c.handleGetScreenSharingFromUserResponse
|
||||||
|
|
||||||
|
readLoopDone := make(chan error, 1)
|
||||||
|
go func() { readLoopDone <- signaling.ReadLoop() }()
|
||||||
|
|
||||||
|
if err := signaling.Subscribe(c.cfg.Event.ID, sessionID); err != nil {
|
||||||
|
return fmt.Errorf("Subscribe: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var youJoined YouJoinedParams
|
||||||
|
select {
|
||||||
|
case youJoined = <-youJoinedChan:
|
||||||
|
case err := <-readLoopDone:
|
||||||
|
return fmt.Errorf("read loop ended before you_joined: %v", err)
|
||||||
|
case <-time.After(15 * time.Second):
|
||||||
|
return fmt.Errorf("timeout waiting for you_joined")
|
||||||
|
}
|
||||||
|
c.cfg.Logger.Debug(fmt.Sprintf("[call] you_joined ice_servers=%d", len(youJoined.IceServers)))
|
||||||
|
|
||||||
|
pionAPI := NewPionAPI(c.cfg.SettingEngine)
|
||||||
|
iceServers := ResolveICEServerHosts(youJoined.IceServers, c.cfg.DNSRouter, c.cfg.Dialer, c.cfg.Logger)
|
||||||
|
peer, err := BuildPionPeer(pionAPI, iceServers)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("BuildPionPeer: %w", err)
|
||||||
|
}
|
||||||
|
c.peer = peer
|
||||||
|
|
||||||
|
sendMidIndex := sendVideoMidIndex
|
||||||
|
trackLabel := "dion-tunnel-" + sessionID
|
||||||
|
if c.cfg.Role == RoleCreator {
|
||||||
|
sendMidIndex = sendScreenShareMidIndex
|
||||||
|
trackLabel = "dion-tunnel-screen-" + sessionID
|
||||||
|
}
|
||||||
|
track, err := webrtc.NewTrackLocalStaticSample(
|
||||||
|
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeVP8, ClockRate: 90000},
|
||||||
|
"video", trackLabel,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("NewTrackLocalStaticSample: %w", err)
|
||||||
|
}
|
||||||
|
c.sendTrack = track
|
||||||
|
if len(peer.Transceivers) <= sendMidIndex {
|
||||||
|
return fmt.Errorf("transceiver layout short, have %d", len(peer.Transceivers))
|
||||||
|
}
|
||||||
|
sender := peer.Transceivers[sendMidIndex].Sender()
|
||||||
|
if sender == nil {
|
||||||
|
return fmt.Errorf("mid=%d sender nil", sendMidIndex)
|
||||||
|
}
|
||||||
|
if err := sender.ReplaceTrack(track); err != nil {
|
||||||
|
return fmt.Errorf("ReplaceTrack: %w", err)
|
||||||
|
}
|
||||||
|
c.cfg.Logger.Debug(fmt.Sprintf("[call] role=%s attached send track to mid=%d", c.cfg.Role, sendMidIndex))
|
||||||
|
|
||||||
|
peer.PC.OnTrack(func(remoteTrack *webrtc.TrackRemote, _ *webrtc.RTPReceiver) {
|
||||||
|
c.cfg.Logger.Debug(fmt.Sprintf("[call] OnTrack id=%q kind=%s codec=%s ssrc=%d",
|
||||||
|
remoteTrack.ID(), remoteTrack.Kind().String(), remoteTrack.Codec().MimeType, remoteTrack.SSRC()))
|
||||||
|
if remoteTrack.Codec().MimeType != webrtc.MimeTypeVP8 {
|
||||||
|
go drainTrack(remoteTrack)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go c.readVP8Track(remoteTrack)
|
||||||
|
})
|
||||||
|
|
||||||
|
var pendingMu sync.Mutex
|
||||||
|
pendingCandidates := make([]webrtc.ICECandidateInit, 0, 32)
|
||||||
|
remoteSet := false
|
||||||
|
sendCandidate := func(cand webrtc.ICECandidateInit) {
|
||||||
|
entry := ICECandidateJSON{Candidate: cand.Candidate}
|
||||||
|
if cand.SDPMid != nil {
|
||||||
|
m := *cand.SDPMid
|
||||||
|
entry.SDPMid = &m
|
||||||
|
}
|
||||||
|
if cand.SDPMLineIndex != nil {
|
||||||
|
i := *cand.SDPMLineIndex
|
||||||
|
entry.SDPMLineIndex = &i
|
||||||
|
}
|
||||||
|
if cand.UsernameFragment != nil {
|
||||||
|
entry.UsernameFragment = *cand.UsernameFragment
|
||||||
|
}
|
||||||
|
if err := signaling.SendICECandidates([]ICECandidateJSON{entry}); err != nil {
|
||||||
|
c.cfg.Logger.Warn(fmt.Sprintf("[ice] SendICECandidates: %v", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flushPending := func() {
|
||||||
|
pendingMu.Lock()
|
||||||
|
toFlush := pendingCandidates
|
||||||
|
pendingCandidates = nil
|
||||||
|
pendingMu.Unlock()
|
||||||
|
for _, cand := range toFlush {
|
||||||
|
sendCandidate(cand)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
peer.PC.OnICECandidate(func(cand *webrtc.ICECandidate) {
|
||||||
|
if cand == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
init := cand.ToJSON()
|
||||||
|
pendingMu.Lock()
|
||||||
|
alreadyRemote := remoteSet
|
||||||
|
if !alreadyRemote {
|
||||||
|
pendingCandidates = append(pendingCandidates, init)
|
||||||
|
}
|
||||||
|
pendingMu.Unlock()
|
||||||
|
if alreadyRemote {
|
||||||
|
sendCandidate(init)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
iceConnected := make(chan struct{}, 1)
|
||||||
|
iceDead := make(chan webrtc.ICEConnectionState, 1)
|
||||||
|
peer.PC.OnICEConnectionStateChange(func(state webrtc.ICEConnectionState) {
|
||||||
|
c.cfg.Logger.Debug(fmt.Sprintf("[ice] state=%s", state.String()))
|
||||||
|
switch state {
|
||||||
|
case webrtc.ICEConnectionStateConnected, webrtc.ICEConnectionStateCompleted:
|
||||||
|
select {
|
||||||
|
case iceConnected <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
case webrtc.ICEConnectionStateFailed, webrtc.ICEConnectionStateClosed:
|
||||||
|
select {
|
||||||
|
case iceDead <- state:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
envelope, _, err := peer.CreateAndSetOffer()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("CreateAndSetOffer: %w", err)
|
||||||
|
}
|
||||||
|
offerParams := SDPOfferParams{
|
||||||
|
MicState: false,
|
||||||
|
CamState: false,
|
||||||
|
NoiseSuppressionState: true,
|
||||||
|
ScreenSharingQuality: "default",
|
||||||
|
Datachannels: peer.DatachannelDescs,
|
||||||
|
Transceivers: peer.TransceiverDescs,
|
||||||
|
Offer: envelope,
|
||||||
|
}
|
||||||
|
if err := signaling.SendSDPOffer(offerParams); err != nil {
|
||||||
|
return fmt.Errorf("SendSDPOffer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var answer SDPAnswerParams
|
||||||
|
select {
|
||||||
|
case answer = <-sdpAnswerChan:
|
||||||
|
case err := <-readLoopDone:
|
||||||
|
return fmt.Errorf("read loop ended before sdp_answer: %v", err)
|
||||||
|
case <-time.After(20 * time.Second):
|
||||||
|
return fmt.Errorf("timeout waiting for sdp_answer")
|
||||||
|
}
|
||||||
|
if c.OnRemoteSDP != nil {
|
||||||
|
c.OnRemoteSDP(answer.Answer)
|
||||||
|
}
|
||||||
|
if err := peer.PC.SetRemoteDescription(webrtc.SessionDescription{
|
||||||
|
Type: webrtc.SDPTypeAnswer,
|
||||||
|
SDP: answer.Answer,
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("SetRemoteDescription: %w", err)
|
||||||
|
}
|
||||||
|
pendingMu.Lock()
|
||||||
|
remoteSet = true
|
||||||
|
pendingMu.Unlock()
|
||||||
|
flushPending()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-iceConnected:
|
||||||
|
case state := <-iceDead:
|
||||||
|
return fmt.Errorf("ICE died before connected: %s", state.String())
|
||||||
|
case err := <-readLoopDone:
|
||||||
|
return fmt.Errorf("read loop ended before ICE connected: %v", err)
|
||||||
|
case <-time.After(30 * time.Second):
|
||||||
|
return fmt.Errorf("timeout waiting for ICE connected; state=%s", peer.PC.ICEConnectionState().String())
|
||||||
|
}
|
||||||
|
c.cfg.Logger.Debug("[ice] connected")
|
||||||
|
|
||||||
|
fps, batch := joinerVP8FPS, joinerVP8Batch
|
||||||
|
if c.cfg.Role == RoleCreator {
|
||||||
|
fps, batch = creatorVP8FPS, creatorVP8Batch
|
||||||
|
}
|
||||||
|
c.vp8tun = tunnel.NewVP8DataTunnel(c.sendTrack, c.cfg.Obfuscator, c.cfg.Logger)
|
||||||
|
c.vp8tun.Start(fps, batch)
|
||||||
|
c.fireOnConnected(c.vp8tun)
|
||||||
|
|
||||||
|
if c.cfg.Role == RoleCreator {
|
||||||
|
if err := signaling.SendScreenSharingSwitchOn(); err != nil {
|
||||||
|
c.cfg.Logger.Warn(fmt.Sprintf("[call] SendScreenSharingSwitchOn: %v", err))
|
||||||
|
} else {
|
||||||
|
c.cfg.Logger.Debug("[call] sent screensharing_switch_on")
|
||||||
|
}
|
||||||
|
if err := signaling.SendScreensharingQualityChange("good"); err != nil {
|
||||||
|
c.cfg.Logger.Warn(fmt.Sprintf("[call] SendScreensharingQualityChange: %v", err))
|
||||||
|
} else {
|
||||||
|
c.cfg.Logger.Debug("[call] sent screensharing_quality_change=good")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := signaling.SendCamStateChange(true); err != nil {
|
||||||
|
c.cfg.Logger.Warn(fmt.Sprintf("[call] SendCamStateChange: %v", err))
|
||||||
|
} else {
|
||||||
|
c.cfg.Logger.Debug("[call] sent cam_state_change=true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
go c.discoverPeersAndSubscribe()
|
||||||
|
go c.runStatReporter()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer close(c.done)
|
||||||
|
select {
|
||||||
|
case state := <-iceDead:
|
||||||
|
c.cfg.Logger.Debug(fmt.Sprintf("[call] ICE went to %s", state.String()))
|
||||||
|
case err := <-readLoopDone:
|
||||||
|
c.cfg.Logger.Debug(fmt.Sprintf("[call] read loop ended: %v", err))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Call) Close() {
|
||||||
|
c.closeOnce.Do(func() {
|
||||||
|
if c.vp8tun != nil {
|
||||||
|
c.vp8tun.Stop()
|
||||||
|
}
|
||||||
|
if c.signaling != nil {
|
||||||
|
c.signaling.Close()
|
||||||
|
}
|
||||||
|
if c.peer != nil {
|
||||||
|
c.peer.Close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Call) fireOnConnected(tun tunnel.DataTunnel) {
|
||||||
|
if !c.onConnectedFired.CompareAndSwap(false, true) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if c.OnConnected != nil {
|
||||||
|
c.OnConnected(tun)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Call) handleSpeakerJoined(params SpeakerJoinedParams) {
|
||||||
|
if params.SessionID == c.mySessionID {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.peersMu.Lock()
|
||||||
|
_, wasKnown := c.peersByID[params.SessionID]
|
||||||
|
c.peersByID[params.SessionID] = &PeerEntry{
|
||||||
|
SessionID: params.SessionID,
|
||||||
|
UserID: params.UserID,
|
||||||
|
Name: params.Name,
|
||||||
|
CamState: params.CamState,
|
||||||
|
JoinedAt: time.Now(),
|
||||||
|
}
|
||||||
|
var toKick []string
|
||||||
|
if !wasKnown {
|
||||||
|
for id := range c.peersByID {
|
||||||
|
if id != params.SessionID {
|
||||||
|
toKick = append(toKick, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.peersMu.Unlock()
|
||||||
|
c.cfg.Logger.Debug(fmt.Sprintf("[call] speaker_joined session_id=%s name=%q cam=%v", params.SessionID, params.Name, params.CamState))
|
||||||
|
for _, staleID := range toKick {
|
||||||
|
if err := c.signaling.SendKickOne(staleID); err != nil {
|
||||||
|
c.cfg.Logger.Debug(fmt.Sprintf("[call] SendKickOne(%s): %v", staleID, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c.cfg.Logger.Debug(fmt.Sprintf("[call] kicked stale peer session_id=%s for newcomer=%s", staleID, params.SessionID))
|
||||||
|
c.peersMu.Lock()
|
||||||
|
delete(c.peersByID, staleID)
|
||||||
|
delete(c.subscribed, staleID)
|
||||||
|
c.releaseMidLocked(staleID)
|
||||||
|
c.peersMu.Unlock()
|
||||||
|
}
|
||||||
|
if len(toKick) > 0 && c.OnPeerRestart != nil {
|
||||||
|
c.cfg.Logger.Debug(fmt.Sprintf("[call] firing OnPeerRestart from kick path (kicked=%d newcomer=%s)", len(toKick), params.SessionID))
|
||||||
|
c.OnPeerRestart()
|
||||||
|
}
|
||||||
|
if c.cfg.Role == RoleJoiner || params.CamState {
|
||||||
|
c.subscribeIfNeeded(params.SessionID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Call) handleSpeakerDisconnected(params SpeakerDisconnectedParams) {
|
||||||
|
c.peersMu.Lock()
|
||||||
|
delete(c.peersByID, params.SessionID)
|
||||||
|
delete(c.subscribed, params.SessionID)
|
||||||
|
c.releaseMidLocked(params.SessionID)
|
||||||
|
var freshestUnsubscribed string
|
||||||
|
var freshestAt time.Time
|
||||||
|
for sid, entry := range c.peersByID {
|
||||||
|
if c.subscribed[sid] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if entry.JoinedAt.After(freshestAt) {
|
||||||
|
freshestAt = entry.JoinedAt
|
||||||
|
freshestUnsubscribed = sid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hasFreeMid := len(c.freeMids) > 0
|
||||||
|
c.peersMu.Unlock()
|
||||||
|
c.cfg.Logger.Debug(fmt.Sprintf("[call] speaker_disconnected session_id=%s", params.SessionID))
|
||||||
|
if freshestUnsubscribed != "" && hasFreeMid {
|
||||||
|
c.cfg.Logger.Debug(fmt.Sprintf("[call] claiming freed mid for unsubscribed peer %s", freshestUnsubscribed))
|
||||||
|
c.subscribeIfNeeded(freshestUnsubscribed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Call) handleSpeakerCamStateChanged(params SpeakerCamStateChangedParams) {
|
||||||
|
if params.SessionID == c.mySessionID {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.peersMu.Lock()
|
||||||
|
if entry, ok := c.peersByID[params.SessionID]; ok {
|
||||||
|
entry.CamState = params.CamState
|
||||||
|
} else {
|
||||||
|
c.peersByID[params.SessionID] = &PeerEntry{SessionID: params.SessionID, CamState: params.CamState, JoinedAt: time.Now()}
|
||||||
|
}
|
||||||
|
c.peersMu.Unlock()
|
||||||
|
c.cfg.Logger.Debug(fmt.Sprintf("[call] speaker_cam_state_changed session_id=%s cam=%v", params.SessionID, params.CamState))
|
||||||
|
if params.CamState {
|
||||||
|
c.subscribeIfNeeded(params.SessionID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Call) handleConfSpeakersState(response ConfSpeakersStateResponse) {
|
||||||
|
for _, entry := range response.Speakers {
|
||||||
|
if entry.SessionID == c.mySessionID || entry.SessionID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c.peersMu.Lock()
|
||||||
|
c.peersByID[entry.SessionID] = &PeerEntry{
|
||||||
|
SessionID: entry.SessionID,
|
||||||
|
UserID: entry.UserID,
|
||||||
|
Name: entry.Name,
|
||||||
|
CamState: entry.CamState,
|
||||||
|
JoinedAt: time.Now(),
|
||||||
|
}
|
||||||
|
c.peersMu.Unlock()
|
||||||
|
if c.cfg.Role == RoleJoiner || entry.CamState {
|
||||||
|
c.subscribeIfNeeded(entry.SessionID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Call) discoverPeersAndSubscribe() {
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
if err := c.signaling.SendConfSpeakersState(DefaultConfSpeakersStateRequest()); err != nil {
|
||||||
|
c.cfg.Logger.Debug(fmt.Sprintf("[call] SendConfSpeakersState: %v", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Call) subscribeIfNeeded(peerSessionID string) {
|
||||||
|
c.peersMu.Lock()
|
||||||
|
if c.subscribed[peerSessionID] {
|
||||||
|
c.peersMu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entry := c.peersByID[peerSessionID]
|
||||||
|
if entry == nil {
|
||||||
|
c.peersMu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(c.freeMids) == 0 {
|
||||||
|
c.peersMu.Unlock()
|
||||||
|
c.cfg.Logger.Debug(fmt.Sprintf("[call] no free recv mid for peer %s, ignoring", peerSessionID))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mid := c.freeMids[0]
|
||||||
|
c.freeMids = c.freeMids[1:]
|
||||||
|
c.peerToMid[peerSessionID] = mid
|
||||||
|
c.subscribed[peerSessionID] = true
|
||||||
|
c.pendingSubs = append(c.pendingSubs, peerSessionID)
|
||||||
|
c.peersMu.Unlock()
|
||||||
|
var sendErr error
|
||||||
|
if c.cfg.Role == RoleJoiner {
|
||||||
|
sendErr = c.signaling.SendGetScreenSharingFromUser(GetScreenSharingFromUserRequest{
|
||||||
|
SessionID: entry.SessionID,
|
||||||
|
TransceiverID: mid,
|
||||||
|
UserID: entry.UserID,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
sendErr = c.signaling.SendGetVideoFromUser(GetVideoFromUserRequest{
|
||||||
|
SessionID: entry.SessionID,
|
||||||
|
TransceiverID: mid,
|
||||||
|
UserID: entry.UserID,
|
||||||
|
Username: entry.Name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if sendErr != nil {
|
||||||
|
c.cfg.Logger.Debug(fmt.Sprintf("[call] subscribe to peer %s failed: %v", peerSessionID, sendErr))
|
||||||
|
c.peersMu.Lock()
|
||||||
|
delete(c.subscribed, peerSessionID)
|
||||||
|
delete(c.peerToMid, peerSessionID)
|
||||||
|
c.freeMids = append(c.freeMids, mid)
|
||||||
|
if len(c.pendingSubs) > 0 && c.pendingSubs[len(c.pendingSubs)-1] == peerSessionID {
|
||||||
|
c.pendingSubs = c.pendingSubs[:len(c.pendingSubs)-1]
|
||||||
|
}
|
||||||
|
c.peersMu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.cfg.Logger.Debug(fmt.Sprintf("[call] subscribed to %s on mid=%s", peerSessionID, mid))
|
||||||
|
if c.OnPeerRestart != nil {
|
||||||
|
c.cfg.Logger.Debug(fmt.Sprintf("[call] firing OnPeerRestart from subscribe path (peer=%s)", peerSessionID))
|
||||||
|
c.OnPeerRestart()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Call) handleGetVideoFromUserResponse(resp GetVideoFromUserResponse, errCode int, errMsg string) {
|
||||||
|
c.handleSubscribeResponse("get_video_from_user", resp.SessionID, resp.TransceiverID, errCode, errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Call) handleGetScreenSharingFromUserResponse(resp GetScreenSharingFromUserResponse, errCode int, errMsg string) {
|
||||||
|
c.handleSubscribeResponse("get_screensharing_from_user", resp.SessionID, resp.TransceiverID, errCode, errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Call) handleSubscribeResponse(rpc, sessionID, transceiverID string, errCode int, errMsg string) {
|
||||||
|
c.peersMu.Lock()
|
||||||
|
if sessionID == "" && len(c.pendingSubs) > 0 {
|
||||||
|
sessionID = c.pendingSubs[0]
|
||||||
|
c.pendingSubs = c.pendingSubs[1:]
|
||||||
|
} else if len(c.pendingSubs) > 0 {
|
||||||
|
for i, pending := range c.pendingSubs {
|
||||||
|
if pending == sessionID {
|
||||||
|
c.pendingSubs = append(c.pendingSubs[:i], c.pendingSubs[i+1:]...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if errCode != 0 {
|
||||||
|
mid := c.peerToMid[sessionID]
|
||||||
|
delete(c.subscribed, sessionID)
|
||||||
|
delete(c.peerToMid, sessionID)
|
||||||
|
if mid != "" {
|
||||||
|
c.freeMids = append(c.freeMids, mid)
|
||||||
|
}
|
||||||
|
c.peersMu.Unlock()
|
||||||
|
c.cfg.Logger.Debug(fmt.Sprintf("[call] %s FAILED session=%s mid=%s code=%d msg=%q", rpc, sessionID, mid, errCode, errMsg))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.peersMu.Unlock()
|
||||||
|
c.cfg.Logger.Debug(fmt.Sprintf("[call] %s OK session=%s mid=%s", rpc, sessionID, transceiverID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Call) releaseMidLocked(peerSessionID string) {
|
||||||
|
if mid, ok := c.peerToMid[peerSessionID]; ok {
|
||||||
|
delete(c.peerToMid, peerSessionID)
|
||||||
|
c.freeMids = append(c.freeMids, mid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Call) readVP8Track(track *webrtc.TrackRemote) {
|
||||||
|
var vp8Pkt codecs.VP8Packet
|
||||||
|
var frameBuf []byte
|
||||||
|
var lastSeq uint16
|
||||||
|
var haveLastSeq bool
|
||||||
|
frameValid := false
|
||||||
|
for {
|
||||||
|
pkt, _, err := track.ReadRTP()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if pkt == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if haveLastSeq && pkt.SequenceNumber != lastSeq+1 {
|
||||||
|
frameValid = false
|
||||||
|
frameBuf = frameBuf[:0]
|
||||||
|
}
|
||||||
|
lastSeq = pkt.SequenceNumber
|
||||||
|
haveLastSeq = true
|
||||||
|
vp8Payload, err := vp8Pkt.Unmarshal(pkt.Payload)
|
||||||
|
if err != nil {
|
||||||
|
frameValid = false
|
||||||
|
frameBuf = frameBuf[:0]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if vp8Pkt.S == 1 {
|
||||||
|
frameBuf = frameBuf[:0]
|
||||||
|
frameValid = true
|
||||||
|
}
|
||||||
|
if !frameValid {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
frameBuf = append(frameBuf, vp8Payload...)
|
||||||
|
if !pkt.Marker {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c.vp8tun != nil {
|
||||||
|
c.vp8tun.HandleFrame(frameBuf)
|
||||||
|
}
|
||||||
|
frameBuf = frameBuf[:0]
|
||||||
|
frameValid = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Call) runStatReporter() {
|
||||||
|
select {
|
||||||
|
case <-c.done:
|
||||||
|
return
|
||||||
|
case <-time.After(1500 * time.Millisecond):
|
||||||
|
}
|
||||||
|
if err := c.signaling.SendPCIceStat(); err != nil {
|
||||||
|
c.cfg.Logger.Debug(fmt.Sprintf("[call] SendPCIceStat: %v", err))
|
||||||
|
} else {
|
||||||
|
c.cfg.Logger.Debug("[call] sent pc_ice_stat")
|
||||||
|
}
|
||||||
|
c.sendStatReport()
|
||||||
|
ticker := time.NewTicker(10 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.done:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
c.sendStatReport()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Call) sendStatReport() {
|
||||||
|
report := ClientStatReport{
|
||||||
|
ReportTimeUnixMS: time.Now().UnixMilli(),
|
||||||
|
Connection: ClientStatConnection{},
|
||||||
|
}
|
||||||
|
report.Audio.In = ClientStatAudioIn{Codec: "opus", IsEnabled: true, Mid: 9}
|
||||||
|
report.Video.In = c.buildVideoInStats()
|
||||||
|
outStat := ClientStatVideoOut{
|
||||||
|
Mid: sendVideoMidIndex,
|
||||||
|
Codec: "VP8",
|
||||||
|
IsEnabled: true,
|
||||||
|
Resolution: ClientStatResolution{Width: 1280, Height: 720},
|
||||||
|
Framerate: c.vp8tun.FPS(),
|
||||||
|
ScalabilityMode: "L1T1",
|
||||||
|
}
|
||||||
|
report.Video.Out = outStat
|
||||||
|
report.Video.OutV2 = []ClientStatVideoOut{outStat}
|
||||||
|
if err := c.signaling.SendClientStatZip(report); err != nil {
|
||||||
|
c.cfg.Logger.Debug(fmt.Sprintf("[call] SendClientStatZip: %v", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Call) buildVideoInStats() []ClientStatVideoIn {
|
||||||
|
c.peersMu.Lock()
|
||||||
|
defer c.peersMu.Unlock()
|
||||||
|
out := make([]ClientStatVideoIn, 0, len(c.peerToMid))
|
||||||
|
for sessionID, midStr := range c.peerToMid {
|
||||||
|
midInt := 0
|
||||||
|
fmt.Sscanf(midStr, "%d", &midInt)
|
||||||
|
out = append(out, ClientStatVideoIn{
|
||||||
|
Codec: "VP8",
|
||||||
|
IsEnabled: true,
|
||||||
|
Mid: midInt,
|
||||||
|
Resolution: ClientStatResolution{Width: 1280, Height: 720},
|
||||||
|
Framerate: c.vp8tun.FPS(),
|
||||||
|
SessionID: sessionID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func drainTrack(track *webrtc.TrackRemote) {
|
||||||
|
buf := make([]byte, 1500)
|
||||||
|
for {
|
||||||
|
if _, _, err := track.Read(buf); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
139
transport/call/dion/connect.go
Normal file
139
transport/call/dion/connect.go
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
package dion
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sagernet/sing-box/transport/call/common"
|
||||||
|
"github.com/sagernet/sing-box/transport/call/tunnel"
|
||||||
|
"github.com/sagernet/sing/common/logger"
|
||||||
|
N "github.com/sagernet/sing/common/network"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ConnectCreator(ctx context.Context, cookieStr, roomID, email, password string, readBuf int, dialer N.Dialer, logger logger.ContextLogger) (*tunnel.RelayBridge, string, error) {
|
||||||
|
auth, err := NewSession(dialer)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("dion: new session: %w", err)
|
||||||
|
}
|
||||||
|
if err := auth.LoadCookieString(cookieStr); err != nil {
|
||||||
|
return nil, "", fmt.Errorf("dion: load cookies: %w", err)
|
||||||
|
}
|
||||||
|
auth.SetCredentials(email, password)
|
||||||
|
if err := auth.EnsureValidToken(); err != nil {
|
||||||
|
return nil, "", fmt.Errorf("dion: ensure valid token: %w", err)
|
||||||
|
}
|
||||||
|
requestedRoom := ParseRoom(roomID)
|
||||||
|
var event *EventInfo
|
||||||
|
if requestedRoom != "" {
|
||||||
|
event, err = auth.GetEventBySlug(requestedRoom)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("dion: get event by slug: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
event, err = auth.CreateRoom()
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("dion: create room: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
joinLink := WebBase + "/event/" + event.Slug
|
||||||
|
if readBuf <= 0 {
|
||||||
|
readBuf = 32768
|
||||||
|
}
|
||||||
|
obf, err := tunnel.NewTunnelObfuscator(tunnel.DeriveSecretFromJoinLink(event.Slug))
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("dion: obfuscator init: %w", err)
|
||||||
|
}
|
||||||
|
relayCh := make(chan *tunnel.RelayBridge, 1)
|
||||||
|
var activeRelay *tunnel.RelayBridge
|
||||||
|
call := NewCall(CallConfig{
|
||||||
|
Auth: auth,
|
||||||
|
Event: event,
|
||||||
|
Obfuscator: obf,
|
||||||
|
DisplayName: "Creator",
|
||||||
|
Logger: logger,
|
||||||
|
Dialer: dialer,
|
||||||
|
Role: RoleCreator,
|
||||||
|
})
|
||||||
|
call.OnConnected = func(tun tunnel.DataTunnel) {
|
||||||
|
if activeRelay != nil {
|
||||||
|
activeRelay.Reset()
|
||||||
|
}
|
||||||
|
bridgeReadBuf := common.VP8BufSize
|
||||||
|
if _, ok := tun.(*tunnel.DCTunnel); ok {
|
||||||
|
bridgeReadBuf = readBuf
|
||||||
|
}
|
||||||
|
activeRelay = tunnel.NewRelayBridge(tun, "creator", bridgeReadBuf, dialer, logger)
|
||||||
|
activeRelay.MarkReady()
|
||||||
|
select {
|
||||||
|
case relayCh <- activeRelay:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
call.OnPeerRestart = func() {
|
||||||
|
if activeRelay != nil {
|
||||||
|
activeRelay.Reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
if err := call.Start(); err != nil {
|
||||||
|
logger.Error(fmt.Sprintf("dion: call start failed: %v", err))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case relay := <-relayCh:
|
||||||
|
return relay, joinLink, nil
|
||||||
|
case <-ctx.Done():
|
||||||
|
call.Close()
|
||||||
|
return nil, "", ctx.Err()
|
||||||
|
case <-time.After(60 * time.Second):
|
||||||
|
call.Close()
|
||||||
|
return nil, "", fmt.Errorf("dion: creator tunnel timed out")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ConnectJoiner(ctx context.Context, roomID, displayName string, readBuf int, dialer N.Dialer, logger logger.ContextLogger) (tunnel.DataTunnel, error) {
|
||||||
|
if displayName == "" {
|
||||||
|
displayName = "Joiner"
|
||||||
|
}
|
||||||
|
slug := ParseRoom(roomID)
|
||||||
|
if slug == "" {
|
||||||
|
return nil, fmt.Errorf("dion: missing room")
|
||||||
|
}
|
||||||
|
auth, event, err := JoinAsGuest(dialer, slug, displayName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("dion: join as guest: %w", err)
|
||||||
|
}
|
||||||
|
obf, err := tunnel.NewTunnelObfuscator(tunnel.DeriveSecretFromJoinLink(event.Slug))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("dion: obfuscator init: %w", err)
|
||||||
|
}
|
||||||
|
call := NewCall(CallConfig{
|
||||||
|
Auth: auth,
|
||||||
|
Event: event,
|
||||||
|
Obfuscator: obf,
|
||||||
|
DisplayName: displayName,
|
||||||
|
Logger: logger,
|
||||||
|
Dialer: dialer,
|
||||||
|
Role: RoleJoiner,
|
||||||
|
})
|
||||||
|
tunCh := make(chan tunnel.DataTunnel, 1)
|
||||||
|
call.OnConnected = func(tun tunnel.DataTunnel) {
|
||||||
|
select {
|
||||||
|
case tunCh <- tun:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
if err := call.Start(); err != nil {
|
||||||
|
logger.Error(fmt.Sprintf("dion: call start failed: %v", err))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case tun := <-tunCh:
|
||||||
|
return tun, nil
|
||||||
|
case <-ctx.Done():
|
||||||
|
call.Close()
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
134
transport/call/dion/cookies.go
Normal file
134
transport/call/dion/cookies.go
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
package dion
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/cookiejar"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CookieEntry struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) LoadCookies(entries []CookieEntry) error {
|
||||||
|
if s.HTTPClient.Jar == nil {
|
||||||
|
jar, err := cookiejar.New(nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cookiejar: %w", err)
|
||||||
|
}
|
||||||
|
s.HTTPClient.Jar = jar
|
||||||
|
}
|
||||||
|
web, err := url.Parse(WebBase)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parse target %s: %w", WebBase, err)
|
||||||
|
}
|
||||||
|
cookies := make([]*http.Cookie, 0, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.Name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cookies = append(cookies, &http.Cookie{
|
||||||
|
Name: entry.Name,
|
||||||
|
Value: entry.Value,
|
||||||
|
Path: "/",
|
||||||
|
Domain: CookieDomain,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
s.HTTPClient.Jar.SetCookies(web, cookies)
|
||||||
|
s.seedAccessTokenFromCookies(entries)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) LoadCookieString(cookieStr string) error {
|
||||||
|
cookieStr = strings.TrimSpace(cookieStr)
|
||||||
|
if cookieStr == "" {
|
||||||
|
return fmt.Errorf("empty cookie string")
|
||||||
|
}
|
||||||
|
var entries []CookieEntry
|
||||||
|
for _, piece := range strings.Split(cookieStr, ";") {
|
||||||
|
piece = strings.TrimSpace(piece)
|
||||||
|
if piece == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
eq := strings.IndexByte(piece, '=')
|
||||||
|
if eq <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entries = append(entries, CookieEntry{Name: piece[:eq], Value: piece[eq+1:]})
|
||||||
|
}
|
||||||
|
return s.LoadCookies(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) SetCookieInJar(name, value string) {
|
||||||
|
if s.HTTPClient == nil || s.HTTPClient.Jar == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
web, err := url.Parse(WebBase)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.HTTPClient.Jar.SetCookies(web, []*http.Cookie{
|
||||||
|
{Name: name, Value: value, Path: "/", Domain: CookieDomain},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) HasCredentials() bool {
|
||||||
|
return s.email != "" && s.password != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) HasRefreshCookie() bool {
|
||||||
|
if s.HTTPClient == nil || s.HTTPClient.Jar == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
web, err := url.Parse(WebBase)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, c := range s.HTTPClient.Jar.Cookies(web) {
|
||||||
|
if c.Name == refreshCookieName && c.Value != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) PrimeCookies(slug string) error {
|
||||||
|
target := WebBase + "/"
|
||||||
|
if slug != "" {
|
||||||
|
target = fmt.Sprintf("%s/event/%s?showWeb=true", WebBase, slug)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest(http.MethodGet, target, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.setBaseHeaders(req, "")
|
||||||
|
resp, err := s.HTTPClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("prime cookies: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
io.Copy(io.Discard, resp.Body)
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return fmt.Errorf("prime cookies: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) seedAccessTokenFromCookies(entries []CookieEntry) {
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.Name != accessCookieName || entry.Value == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
exp, err := parseJWTExpiry(entry.Value)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.AccessToken = entry.Value
|
||||||
|
s.AccessTokenExp = exp
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
185
transport/call/dion/device.go
Normal file
185
transport/call/dion/device.go
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
package dion
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math/rand/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DeviceProfile struct {
|
||||||
|
UserAgent string
|
||||||
|
Platform string
|
||||||
|
BrowserType string
|
||||||
|
BrowserVersion string
|
||||||
|
DeviceBrand string
|
||||||
|
DeviceModel string
|
||||||
|
DeviceType string
|
||||||
|
OS string
|
||||||
|
OSVersion string
|
||||||
|
ScreenWidth int
|
||||||
|
ScreenHeight int
|
||||||
|
}
|
||||||
|
|
||||||
|
type deviceTemplate struct {
|
||||||
|
os string
|
||||||
|
osVersionPool []string
|
||||||
|
deviceBrandPool []string
|
||||||
|
deviceModelPool []string
|
||||||
|
browsers []browserTemplate
|
||||||
|
}
|
||||||
|
|
||||||
|
type browserTemplate struct {
|
||||||
|
browserType string
|
||||||
|
versionPool []string
|
||||||
|
userAgentFn func(osVersion, browserVersion string) string
|
||||||
|
}
|
||||||
|
|
||||||
|
var commonScreens = [][2]int{
|
||||||
|
{1280, 720}, {1366, 768}, {1440, 900}, {1536, 864},
|
||||||
|
{1600, 900}, {1680, 1050}, {1728, 1117}, {1920, 1080},
|
||||||
|
{2048, 1152}, {2560, 1440}, {2880, 1800}, {3840, 2160},
|
||||||
|
}
|
||||||
|
|
||||||
|
var deviceTemplates = []deviceTemplate{
|
||||||
|
{
|
||||||
|
os: "Mac OS",
|
||||||
|
osVersionPool: []string{"10.15.7", "11.7.10", "12.7.6", "13.6.9", "14.6.1", "15.1.0"},
|
||||||
|
deviceBrandPool: []string{"Apple"},
|
||||||
|
deviceModelPool: []string{"Macintosh"},
|
||||||
|
browsers: []browserTemplate{
|
||||||
|
{
|
||||||
|
browserType: "Chrome",
|
||||||
|
versionPool: []string{"141.0.0.0", "143.0.0.0", "145.0.0.0", "147.0.0.0", "149.0.0.0"},
|
||||||
|
userAgentFn: func(osVersion, browserVersion string) string {
|
||||||
|
return fmt.Sprintf("Mozilla/5.0 (Macintosh; Intel Mac OS X %s) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s Safari/537.36",
|
||||||
|
macOSVersionForUA(osVersion), browserVersion)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
browserType: "Safari",
|
||||||
|
versionPool: []string{"17.6", "18.0", "18.1", "18.2"},
|
||||||
|
userAgentFn: func(osVersion, browserVersion string) string {
|
||||||
|
return fmt.Sprintf("Mozilla/5.0 (Macintosh; Intel Mac OS X %s) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/%s Safari/605.1.15",
|
||||||
|
macOSVersionForUA(osVersion), browserVersion)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
browserType: "Firefox",
|
||||||
|
versionPool: []string{"128.0", "131.0", "133.0", "135.0"},
|
||||||
|
userAgentFn: func(osVersion, browserVersion string) string {
|
||||||
|
return fmt.Sprintf("Mozilla/5.0 (Macintosh; Intel Mac OS X %s; rv:%s) Gecko/20100101 Firefox/%s",
|
||||||
|
macOSVersionForUA(osVersion), browserVersion, browserVersion)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
os: "Windows",
|
||||||
|
osVersionPool: []string{"10", "11"},
|
||||||
|
deviceBrandPool: []string{"Dell", "Lenovo", "HP", "Asus", "Acer", "MSI"},
|
||||||
|
deviceModelPool: []string{"PC"},
|
||||||
|
browsers: []browserTemplate{
|
||||||
|
{
|
||||||
|
browserType: "Chrome",
|
||||||
|
versionPool: []string{"141.0.0.0", "143.0.0.0", "145.0.0.0", "147.0.0.0", "149.0.0.0"},
|
||||||
|
userAgentFn: func(osVersion, browserVersion string) string {
|
||||||
|
return fmt.Sprintf("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s Safari/537.36",
|
||||||
|
browserVersion)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
browserType: "Edge",
|
||||||
|
versionPool: []string{"141.0.0.0", "143.0.0.0", "145.0.0.0", "147.0.0.0"},
|
||||||
|
userAgentFn: func(osVersion, browserVersion string) string {
|
||||||
|
return fmt.Sprintf("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s Safari/537.36 Edg/%s",
|
||||||
|
browserVersion, browserVersion)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
browserType: "Firefox",
|
||||||
|
versionPool: []string{"128.0", "131.0", "133.0", "135.0"},
|
||||||
|
userAgentFn: func(osVersion, browserVersion string) string {
|
||||||
|
return fmt.Sprintf("Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:%s) Gecko/20100101 Firefox/%s",
|
||||||
|
browserVersion, browserVersion)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
os: "Linux",
|
||||||
|
osVersionPool: []string{"x86_64", "x86_64 GNU"},
|
||||||
|
deviceBrandPool: []string{"Dell", "Lenovo", "HP", "System76", "Framework"},
|
||||||
|
deviceModelPool: []string{"PC"},
|
||||||
|
browsers: []browserTemplate{
|
||||||
|
{
|
||||||
|
browserType: "Chrome",
|
||||||
|
versionPool: []string{"141.0.0.0", "143.0.0.0", "145.0.0.0", "147.0.0.0", "149.0.0.0"},
|
||||||
|
userAgentFn: func(osVersion, browserVersion string) string {
|
||||||
|
return fmt.Sprintf("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s Safari/537.36",
|
||||||
|
browserVersion)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
browserType: "Firefox",
|
||||||
|
versionPool: []string{"128.0", "131.0", "133.0", "135.0"},
|
||||||
|
userAgentFn: func(osVersion, browserVersion string) string {
|
||||||
|
return fmt.Sprintf("Mozilla/5.0 (X11; Linux x86_64; rv:%s) Gecko/20100101 Firefox/%s",
|
||||||
|
browserVersion, browserVersion)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func RandomDeviceProfile() DeviceProfile {
|
||||||
|
tmpl := deviceTemplates[rand.IntN(len(deviceTemplates))]
|
||||||
|
browser := tmpl.browsers[rand.IntN(len(tmpl.browsers))]
|
||||||
|
osVersion := tmpl.osVersionPool[rand.IntN(len(tmpl.osVersionPool))]
|
||||||
|
browserVersion := browser.versionPool[rand.IntN(len(browser.versionPool))]
|
||||||
|
screen := commonScreens[rand.IntN(len(commonScreens))]
|
||||||
|
return DeviceProfile{
|
||||||
|
UserAgent: browser.userAgentFn(osVersion, browserVersion),
|
||||||
|
Platform: "web",
|
||||||
|
BrowserType: browser.browserType,
|
||||||
|
BrowserVersion: browserVersion,
|
||||||
|
DeviceBrand: tmpl.deviceBrandPool[rand.IntN(len(tmpl.deviceBrandPool))],
|
||||||
|
DeviceModel: tmpl.deviceModelPool[rand.IntN(len(tmpl.deviceModelPool))],
|
||||||
|
DeviceType: "pc",
|
||||||
|
OS: tmpl.os,
|
||||||
|
OSVersion: osVersion,
|
||||||
|
ScreenWidth: screen[0],
|
||||||
|
ScreenHeight: screen[1],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p DeviceProfile) Headers() map[string]string {
|
||||||
|
return map[string]string{
|
||||||
|
"d-platform": p.Platform,
|
||||||
|
"d-browser-type": p.BrowserType,
|
||||||
|
"d-browser-version": p.BrowserVersion,
|
||||||
|
"d-device-brand": p.DeviceBrand,
|
||||||
|
"d-device-model": p.DeviceModel,
|
||||||
|
"d-device-type": p.DeviceType,
|
||||||
|
"d-os": p.OS,
|
||||||
|
"d-os-version": p.OSVersion,
|
||||||
|
"d-screen-height": fmt.Sprintf("%d", p.ScreenHeight),
|
||||||
|
"d-screen-width": fmt.Sprintf("%d", p.ScreenWidth),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func macOSVersionForUA(osVersion string) string {
|
||||||
|
switch osVersion {
|
||||||
|
case "10.15.7":
|
||||||
|
return "10_15_7"
|
||||||
|
case "11.7.10":
|
||||||
|
return "10_15_7"
|
||||||
|
case "12.7.6":
|
||||||
|
return "10_15_7"
|
||||||
|
case "13.6.9":
|
||||||
|
return "10_15_7"
|
||||||
|
case "14.6.1":
|
||||||
|
return "10_15_7"
|
||||||
|
case "15.1.0":
|
||||||
|
return "10_15_7"
|
||||||
|
}
|
||||||
|
return "10_15_7"
|
||||||
|
}
|
||||||
262
transport/call/dion/pion_offer.go
Normal file
262
transport/call/dion/pion_offer.go
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
package dion
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/netip"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/pion/webrtc/v4"
|
||||||
|
"github.com/sagernet/sing-box/adapter"
|
||||||
|
"github.com/sagernet/sing-box/common/dialer"
|
||||||
|
"github.com/sagernet/sing/common/logger"
|
||||||
|
N "github.com/sagernet/sing/common/network"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TransceiverPlan struct {
|
||||||
|
Mid int
|
||||||
|
Direction webrtc.RTPTransceiverDirection
|
||||||
|
Kind webrtc.RTPCodecType
|
||||||
|
Ctype string
|
||||||
|
}
|
||||||
|
|
||||||
|
type DataChannelPlan struct {
|
||||||
|
ID uint16
|
||||||
|
Label string
|
||||||
|
}
|
||||||
|
|
||||||
|
var DionTransceiverLayout = []TransceiverPlan{
|
||||||
|
{Mid: 0, Direction: webrtc.RTPTransceiverDirectionRecvonly, Kind: webrtc.RTPCodecTypeVideo, Ctype: "Video"},
|
||||||
|
{Mid: 1, Direction: webrtc.RTPTransceiverDirectionRecvonly, Kind: webrtc.RTPCodecTypeVideo, Ctype: "Video"},
|
||||||
|
{Mid: 2, Direction: webrtc.RTPTransceiverDirectionRecvonly, Kind: webrtc.RTPCodecTypeVideo, Ctype: "Video"},
|
||||||
|
{Mid: 3, Direction: webrtc.RTPTransceiverDirectionRecvonly, Kind: webrtc.RTPCodecTypeVideo, Ctype: "Video"},
|
||||||
|
{Mid: 4, Direction: webrtc.RTPTransceiverDirectionRecvonly, Kind: webrtc.RTPCodecTypeVideo, Ctype: "Video"},
|
||||||
|
{Mid: 5, Direction: webrtc.RTPTransceiverDirectionRecvonly, Kind: webrtc.RTPCodecTypeVideo, Ctype: "Video"},
|
||||||
|
{Mid: 6, Direction: webrtc.RTPTransceiverDirectionRecvonly, Kind: webrtc.RTPCodecTypeVideo, Ctype: "Video"},
|
||||||
|
{Mid: 7, Direction: webrtc.RTPTransceiverDirectionRecvonly, Kind: webrtc.RTPCodecTypeVideo, Ctype: "Video"},
|
||||||
|
{Mid: 8, Direction: webrtc.RTPTransceiverDirectionRecvonly, Kind: webrtc.RTPCodecTypeVideo, Ctype: "Video"},
|
||||||
|
{Mid: 9, Direction: webrtc.RTPTransceiverDirectionRecvonly, Kind: webrtc.RTPCodecTypeAudio, Ctype: "Audio"},
|
||||||
|
{Mid: 10, Direction: webrtc.RTPTransceiverDirectionSendonly, Kind: webrtc.RTPCodecTypeAudio, Ctype: "Audio"},
|
||||||
|
{Mid: 11, Direction: webrtc.RTPTransceiverDirectionSendonly, Kind: webrtc.RTPCodecTypeAudio, Ctype: "AudioScreenSharing"},
|
||||||
|
{Mid: 12, Direction: webrtc.RTPTransceiverDirectionSendonly, Kind: webrtc.RTPCodecTypeVideo, Ctype: "Video"},
|
||||||
|
{Mid: 13, Direction: webrtc.RTPTransceiverDirectionSendonly, Kind: webrtc.RTPCodecTypeVideo, Ctype: "ScreenSharing"},
|
||||||
|
{Mid: 14, Direction: webrtc.RTPTransceiverDirectionRecvonly, Kind: webrtc.RTPCodecTypeVideo, Ctype: "ScreenSharing"},
|
||||||
|
{Mid: 15, Direction: webrtc.RTPTransceiverDirectionRecvonly, Kind: webrtc.RTPCodecTypeVideo, Ctype: "Video"},
|
||||||
|
{Mid: 16, Direction: webrtc.RTPTransceiverDirectionRecvonly, Kind: webrtc.RTPCodecTypeVideo, Ctype: "Padding"},
|
||||||
|
}
|
||||||
|
|
||||||
|
var DionDataChannels = []DataChannelPlan{
|
||||||
|
{ID: 0, Label: "vad"},
|
||||||
|
{ID: 1, Label: "stats"},
|
||||||
|
{ID: 2, Label: "speed"},
|
||||||
|
{ID: 3, Label: "video_quality"},
|
||||||
|
{ID: 4, Label: "media_messages"},
|
||||||
|
}
|
||||||
|
|
||||||
|
type PionPeer struct {
|
||||||
|
PC *webrtc.PeerConnection
|
||||||
|
Transceivers []*webrtc.RTPTransceiver
|
||||||
|
DataChannels map[string]*webrtc.DataChannel
|
||||||
|
TransceiverDescs []TransceiverDesc
|
||||||
|
DatachannelDescs []DataChannelDesc
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPionAPI(customEngine ...*webrtc.SettingEngine) *webrtc.API {
|
||||||
|
mediaEngine := &webrtc.MediaEngine{}
|
||||||
|
if err := mediaEngine.RegisterDefaultCodecs(); err != nil {
|
||||||
|
panic(fmt.Errorf("dion: register default codecs: %w", err))
|
||||||
|
}
|
||||||
|
engine := webrtc.SettingEngine{}
|
||||||
|
if len(customEngine) > 0 && customEngine[0] != nil {
|
||||||
|
engine = *customEngine[0]
|
||||||
|
}
|
||||||
|
return webrtc.NewAPI(
|
||||||
|
webrtc.WithMediaEngine(mediaEngine),
|
||||||
|
webrtc.WithSettingEngine(engine),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResolveICEServerHosts(entries []ICEServerEntry, dnsRouter adapter.DNSRouter, d N.Dialer, logger logger.ContextLogger) []ICEServerEntry {
|
||||||
|
if dnsRouter == nil {
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
resolved := make(map[string]string)
|
||||||
|
out := make([]ICEServerEntry, 0, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
urls := make([]string, len(entry.URLs))
|
||||||
|
copy(urls, entry.URLs)
|
||||||
|
for k, raw := range urls {
|
||||||
|
host := extractICEHost(raw)
|
||||||
|
if host == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ip, ok := resolved[host]
|
||||||
|
if !ok {
|
||||||
|
var addrs []netip.Addr
|
||||||
|
var err error
|
||||||
|
addrs, err = dnsRouter.Lookup(context.Background(), host, d.(dialer.ResolveDialer).QueryOptions())
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn(fmt.Sprintf("[dion] resolve ICE host %s failed: %v", host, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ip = addrs[0].String()
|
||||||
|
resolved[host] = ip
|
||||||
|
logger.Debug(fmt.Sprintf("[dion] resolved ICE host %s -> %s", host, addrs[0]))
|
||||||
|
}
|
||||||
|
urls[k] = strings.Replace(raw, host, ip, 1)
|
||||||
|
}
|
||||||
|
out = append(out, ICEServerEntry{URLs: urls, Username: entry.Username, Credential: entry.Credential})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func IceServerEntriesToWebRTC(entries []ICEServerEntry) []webrtc.ICEServer {
|
||||||
|
out := make([]webrtc.ICEServer, 0, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
out = append(out, webrtc.ICEServer{
|
||||||
|
URLs: entry.URLs,
|
||||||
|
Username: entry.Username,
|
||||||
|
Credential: entry.Credential,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildPionPeer(api *webrtc.API, iceServers []ICEServerEntry) (*PionPeer, error) {
|
||||||
|
pc, err := api.NewPeerConnection(webrtc.Configuration{
|
||||||
|
ICEServers: IceServerEntriesToWebRTC(iceServers),
|
||||||
|
BundlePolicy: webrtc.BundlePolicyMaxBundle,
|
||||||
|
RTCPMuxPolicy: webrtc.RTCPMuxPolicyRequire,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("new peer connection: %w", err)
|
||||||
|
}
|
||||||
|
transceivers := make([]*webrtc.RTPTransceiver, 0, len(DionTransceiverLayout))
|
||||||
|
for _, plan := range DionTransceiverLayout {
|
||||||
|
transceiver, err := pc.AddTransceiverFromKind(plan.Kind, webrtc.RTPTransceiverInit{
|
||||||
|
Direction: plan.Direction,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
pc.Close()
|
||||||
|
return nil, fmt.Errorf("add transceiver mid=%d kind=%s dir=%s: %w", plan.Mid, plan.Kind, plan.Direction, err)
|
||||||
|
}
|
||||||
|
transceivers = append(transceivers, transceiver)
|
||||||
|
}
|
||||||
|
dataChannels := make(map[string]*webrtc.DataChannel, len(DionDataChannels))
|
||||||
|
for _, plan := range DionDataChannels {
|
||||||
|
negotiated := true
|
||||||
|
id := plan.ID
|
||||||
|
dc, err := pc.CreateDataChannel(plan.Label, &webrtc.DataChannelInit{
|
||||||
|
Negotiated: &negotiated,
|
||||||
|
ID: &id,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
pc.Close()
|
||||||
|
return nil, fmt.Errorf("create datachannel %s id=%d: %w", plan.Label, plan.ID, err)
|
||||||
|
}
|
||||||
|
dataChannels[plan.Label] = dc
|
||||||
|
}
|
||||||
|
dcDescs := make([]DataChannelDesc, 0, len(DionDataChannels))
|
||||||
|
for _, plan := range DionDataChannels {
|
||||||
|
dcDescs = append(dcDescs, DataChannelDesc{ID: int(plan.ID), Label: plan.Label})
|
||||||
|
}
|
||||||
|
return &PionPeer{
|
||||||
|
PC: pc,
|
||||||
|
Transceivers: transceivers,
|
||||||
|
DataChannels: dataChannels,
|
||||||
|
DatachannelDescs: dcDescs,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PionPeer) BuildOfferDescriptors() error {
|
||||||
|
if len(p.Transceivers) != len(DionTransceiverLayout) {
|
||||||
|
return fmt.Errorf("transceiver count drift: have %d want %d", len(p.Transceivers), len(DionTransceiverLayout))
|
||||||
|
}
|
||||||
|
descs := make([]TransceiverDesc, 0, len(p.Transceivers))
|
||||||
|
for index, transceiver := range p.Transceivers {
|
||||||
|
mid := transceiver.Mid()
|
||||||
|
if mid == "" {
|
||||||
|
return fmt.Errorf("transceiver index=%d has empty mid; call SetLocalDescription first", index)
|
||||||
|
}
|
||||||
|
plan := DionTransceiverLayout[index]
|
||||||
|
descs = append(descs, TransceiverDesc{
|
||||||
|
TransceiverID: mid,
|
||||||
|
SessionID: "",
|
||||||
|
Direction: directionToDion(plan.Direction),
|
||||||
|
Ctype: plan.Ctype,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
p.TransceiverDescs = descs
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PionPeer) CreateAndSetOffer() (offerEnvelope string, sdpOffer string, err error) {
|
||||||
|
offer, err := p.PC.CreateOffer(nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("create offer: %w", err)
|
||||||
|
}
|
||||||
|
if err := p.PC.SetLocalDescription(offer); err != nil {
|
||||||
|
return "", "", fmt.Errorf("set local description: %w", err)
|
||||||
|
}
|
||||||
|
if err := p.BuildOfferDescriptors(); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
envelope, err := BuildSDPOfferEnvelope(offer.SDP)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("build envelope: %w", err)
|
||||||
|
}
|
||||||
|
return envelope, offer.SDP, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PionPeer) ApplyAnswerEnvelope(answerEnvelope string) error {
|
||||||
|
sdp, err := DecodeSDPAnswerInner(answerEnvelope)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("decode answer envelope: %w", err)
|
||||||
|
}
|
||||||
|
return p.PC.SetRemoteDescription(webrtc.SessionDescription{
|
||||||
|
Type: webrtc.SDPTypeAnswer,
|
||||||
|
SDP: sdp,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PionPeer) Close() error {
|
||||||
|
if p.PC == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return p.PC.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func directionToDion(direction webrtc.RTPTransceiverDirection) string {
|
||||||
|
switch direction {
|
||||||
|
case webrtc.RTPTransceiverDirectionSendonly:
|
||||||
|
return "SendOnly"
|
||||||
|
case webrtc.RTPTransceiverDirectionRecvonly:
|
||||||
|
return "RecvOnly"
|
||||||
|
case webrtc.RTPTransceiverDirectionSendrecv:
|
||||||
|
return "SendRecv"
|
||||||
|
case webrtc.RTPTransceiverDirectionInactive:
|
||||||
|
return "Inactive"
|
||||||
|
}
|
||||||
|
return "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractICEHost(raw string) string {
|
||||||
|
value := raw
|
||||||
|
for _, prefix := range []string{"stun:", "turn:", "turns:"} {
|
||||||
|
value = strings.TrimPrefix(value, prefix)
|
||||||
|
}
|
||||||
|
if idx := strings.Index(value, "?"); idx >= 0 {
|
||||||
|
value = value[:idx]
|
||||||
|
}
|
||||||
|
if idx := strings.LastIndex(value, ":"); idx >= 0 {
|
||||||
|
value = value[:idx]
|
||||||
|
}
|
||||||
|
if value == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if net.ParseIP(value) != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
692
transport/call/dion/signaling.go
Normal file
692
transport/call/dion/signaling.go
Normal file
@@ -0,0 +1,692 @@
|
|||||||
|
package dion
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
"github.com/sagernet/sing-box/transport/call/common"
|
||||||
|
"github.com/sagernet/sing/common/logger"
|
||||||
|
M "github.com/sagernet/sing/common/metadata"
|
||||||
|
N "github.com/sagernet/sing/common/network"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
MethodServerConnected = "server:notify:main:connected"
|
||||||
|
MethodServerYouJoined = "server:you_joined"
|
||||||
|
MethodServerSubscribeResponse = "server:main:response:subscribe:conference"
|
||||||
|
MethodServerSDPAnswer = "server:sdp_answer"
|
||||||
|
MethodServerSpeakerJoined = "server:speaker_joined"
|
||||||
|
MethodServerSpeakerDisconnected = "server:speaker_disconnected"
|
||||||
|
MethodServerHeartbeat = "server:notify:main:heartbeat"
|
||||||
|
MethodServerSpeakersResponse = "server:response:speakers"
|
||||||
|
MethodServerSpeakersResponseZip = "server:response:speakers_zip"
|
||||||
|
|
||||||
|
MethodClientSubscribeConference = "client:main:request:subscribe:conference"
|
||||||
|
MethodClientSDPOffer = "client:request:media:sdp_offer"
|
||||||
|
MethodClientSendICECandidates = "client:request:send_ice_candidates_zip"
|
||||||
|
MethodClientPCICEStat = "client:request:pc_ice_stat"
|
||||||
|
MethodClientTrace = "client:trace"
|
||||||
|
MethodClientConfSpeakersState = "client:request:conf_speakers_state_zip"
|
||||||
|
MethodServerConfSpeakersState = "server:response:conf_speakers_state_zip"
|
||||||
|
MethodClientGetVideoFromUser = "client:request:get_video_from_user"
|
||||||
|
MethodClientStopVideoFromUser = "client:request:stop_video_from_user"
|
||||||
|
MethodServerGetVideoFromUser = "server:response:get_video_from_user"
|
||||||
|
MethodServerStopVideoFromUser = "server:response:stop_video_from_user"
|
||||||
|
MethodClientCamStateChange = "client:request:cam_state_change"
|
||||||
|
MethodClientMicStateChange = "client:request:mic_state_change"
|
||||||
|
MethodClientScreenSharingSwitchOn = "client:request:screensharing_switch_on"
|
||||||
|
MethodClientScreenSharingSwitchOff = "client:request:screensharing_switch_off"
|
||||||
|
MethodClientGetScreenSharingFromUser = "client:request:get_screensharing_from_user"
|
||||||
|
MethodClientStopScreenSharingFromUser = "client:request:stop_screensharing_from_user"
|
||||||
|
MethodClientScreensharingQualityChange = "client:request:screensharing_quality_change"
|
||||||
|
MethodServerGetScreenSharingFromUser = "server:response:get_screensharing_from_user"
|
||||||
|
MethodClientClientStatZip = "client:request:client_stat_zip"
|
||||||
|
MethodClientKickOne = "client:request:kick_one"
|
||||||
|
MethodServerKickOneResponse = "server:response:kick_one"
|
||||||
|
MethodServerYouKicked = "server:you_kicked"
|
||||||
|
MethodServerYourCamStateChanged = "server:response:your_cam_state_changed"
|
||||||
|
MethodServerYourMicStateChanged = "server:response:your_mic_state_changed"
|
||||||
|
MethodServerSpeakerCamStateChanged = "server:speaker_cam_state_changed"
|
||||||
|
MethodServerSpeakerMicStateChanged = "server:speaker_mic_state_changed"
|
||||||
|
|
||||||
|
ProductVersion = "6.14.0"
|
||||||
|
SubscriptionVersion = "2.0"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Frame struct {
|
||||||
|
JSONRPC string `json:"jsonrpc"`
|
||||||
|
Method string `json:"method,omitempty"`
|
||||||
|
Params json.RawMessage `json:"params,omitempty"`
|
||||||
|
Result json.RawMessage `json:"result,omitempty"`
|
||||||
|
Error *RPCError `json:"error,omitempty"`
|
||||||
|
ID json.RawMessage `json:"id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RPCError struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TransceiverDesc struct {
|
||||||
|
TransceiverID string `json:"transceiver_id"`
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
Direction string `json:"direction"`
|
||||||
|
Ctype string `json:"ctype"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DataChannelDesc struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SDPEnvelope struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
SDP string `json:"sdp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SDPOfferParams struct {
|
||||||
|
MicState bool `json:"mic_state"`
|
||||||
|
CamState bool `json:"cam_state"`
|
||||||
|
NoiseSuppressionState bool `json:"noise_suppression_state"`
|
||||||
|
VideoQuality *string `json:"video_quality"`
|
||||||
|
ScreenSharingQuality string `json:"screen_sharing_quality"`
|
||||||
|
Datachannels []DataChannelDesc `json:"datachannels"`
|
||||||
|
Transceivers []TransceiverDesc `json:"transceivers"`
|
||||||
|
Offer string `json:"offer"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SDPAnswerParams struct {
|
||||||
|
Answer string `json:"answer"`
|
||||||
|
Transceivers []TransceiverDesc `json:"transceivers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ICEServerEntry struct {
|
||||||
|
URLs []string `json:"urls"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Credential string `json:"credential"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type YouJoinedParams struct {
|
||||||
|
IcePolicy string `json:"ice_policy"`
|
||||||
|
IceServers []ICEServerEntry `json:"ice_servers"`
|
||||||
|
Event json.RawMessage `json:"event"`
|
||||||
|
EventParams json.RawMessage `json:"event_params"`
|
||||||
|
PreferredCodecs json.RawMessage `json:"preferred_codecs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SpeakerJoinedParams struct {
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
UserID string `json:"user_id,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
CamState bool `json:"cam_state"`
|
||||||
|
MicState bool `json:"mic_state"`
|
||||||
|
Extra json.RawMessage `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SpeakerCamStateChangedParams struct {
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
CamState bool `json:"cam_state"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SpeakerMicStateChangedParams struct {
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
MicState bool `json:"mic_state"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SpeakerEntry struct {
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
MicState bool `json:"mic_state"`
|
||||||
|
CamState bool `json:"cam_state"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
WebinarRole string `json:"webinar_role"`
|
||||||
|
IsGuest bool `json:"is_guest"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConfSpeakersStateResponse struct {
|
||||||
|
SpeakersCount int `json:"speakers_count"`
|
||||||
|
WebinarSpeakersCount int `json:"webinar_speakers_count"`
|
||||||
|
Speakers []SpeakerEntry `json:"speakers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConfSpeakersStateRequest struct {
|
||||||
|
SessionIDs []string `json:"session_ids"`
|
||||||
|
TileParams ConfSpeakersTileParams `json:"tile_params"`
|
||||||
|
InputVideoQuality string `json:"input_video_quality"`
|
||||||
|
ScreenParams ConfSpeakersScreenParams `json:"screen_params"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConfSpeakersTileParams struct {
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
MosaicParams ConfSpeakersMosaicParams `json:"mosaic_params"`
|
||||||
|
IsModeBlocked bool `json:"is_mode_blocked"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConfSpeakersMosaicParams struct {
|
||||||
|
MaxTilesCount int `json:"max_tiles_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConfSpeakersScreenParams struct {
|
||||||
|
Height int `json:"height"`
|
||||||
|
Width int `json:"width"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SpeakerDisconnectedParams struct {
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ICECandidateJSON struct {
|
||||||
|
Candidate string `json:"candidate"`
|
||||||
|
SDPMid *string `json:"sdpMid"`
|
||||||
|
SDPMLineIndex *uint16 `json:"sdpMLineIndex"`
|
||||||
|
UsernameFragment string `json:"usernameFragment,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetVideoFromUserRequest struct {
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
TransceiverID string `json:"transceiver_id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetVideoFromUserResponse struct {
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
TransceiverID string `json:"transceiver_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetScreenSharingFromUserRequest struct {
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
TransceiverID string `json:"transceiver_id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetScreenSharingFromUserResponse struct {
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
TransceiverID string `json:"transceiver_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClientStatVideoIn struct {
|
||||||
|
BytesReceived int64 `json:"bytes_received"`
|
||||||
|
Codec string `json:"codec"`
|
||||||
|
IsEnabled bool `json:"is_enabled"`
|
||||||
|
JitterBufferDelay float64 `json:"jitter_buffer_delay"`
|
||||||
|
JitterBufferEmittedCount int `json:"jitter_buffer_emitted_count"`
|
||||||
|
Jitter float64 `json:"jitter"`
|
||||||
|
Mid int `json:"mid"`
|
||||||
|
PacketsLost int `json:"packets_lost"`
|
||||||
|
PacketsReceived int `json:"packets_received"`
|
||||||
|
Framerate int `json:"framerate"`
|
||||||
|
FreezeCount int `json:"freeze_count"`
|
||||||
|
Resolution ClientStatResolution `json:"resolution"`
|
||||||
|
Rid string `json:"rid"`
|
||||||
|
TotalFreezesDuration int `json:"total_freezes_duration"`
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClientStatVideoOut struct {
|
||||||
|
Mid int `json:"mid"`
|
||||||
|
BytesSent int64 `json:"bytes_sent"`
|
||||||
|
Codec string `json:"codec"`
|
||||||
|
IsEnabled bool `json:"is_enabled"`
|
||||||
|
PacketsSent int `json:"packets_sent"`
|
||||||
|
RemoteStats ClientStatRemoteStats `json:"remote_stats"`
|
||||||
|
TargetBitrate int `json:"target_bitrate"`
|
||||||
|
Framerate int `json:"framerate"`
|
||||||
|
FreezeCount int `json:"freeze_count"`
|
||||||
|
Resolution ClientStatResolution `json:"resolution"`
|
||||||
|
Rid string `json:"rid"`
|
||||||
|
TotalFreezesDuration int `json:"total_freezes_duration"`
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
ScalabilityMode string `json:"scalability_mode"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClientStatResolution struct {
|
||||||
|
Height int `json:"height"`
|
||||||
|
Width int `json:"width"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClientStatRemoteStats struct {
|
||||||
|
Jitter float64 `json:"jitter"`
|
||||||
|
FractionPacketsLost float64 `json:"fraction_packets_lost"`
|
||||||
|
PacketsLost int `json:"packets_lost"`
|
||||||
|
RTT float64 `json:"rtt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClientStatAudioIn struct {
|
||||||
|
BytesReceived int64 `json:"bytes_received"`
|
||||||
|
Codec string `json:"codec"`
|
||||||
|
IsEnabled bool `json:"is_enabled"`
|
||||||
|
JitterBufferDelay float64 `json:"jitter_buffer_delay"`
|
||||||
|
JitterBufferEmittedCount int `json:"jitter_buffer_emitted_count"`
|
||||||
|
Jitter float64 `json:"jitter"`
|
||||||
|
Mid int `json:"mid"`
|
||||||
|
PacketsLost int `json:"packets_lost"`
|
||||||
|
PacketsReceived int `json:"packets_received"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClientStatConnection struct {
|
||||||
|
BytesReceived int64 `json:"bytes_received"`
|
||||||
|
BytesSent int64 `json:"bytes_sent"`
|
||||||
|
CurrentRTT float64 `json:"current_rtt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClientStatReport struct {
|
||||||
|
ReportTimeUnixMS int64 `json:"report_time_unix_ms"`
|
||||||
|
Connection ClientStatConnection `json:"connection"`
|
||||||
|
Audio struct {
|
||||||
|
In ClientStatAudioIn `json:"in"`
|
||||||
|
} `json:"audio"`
|
||||||
|
Video struct {
|
||||||
|
In []ClientStatVideoIn `json:"in"`
|
||||||
|
OutV2 []ClientStatVideoOut `json:"out_v2"`
|
||||||
|
Out ClientStatVideoOut `json:"out"`
|
||||||
|
} `json:"video"`
|
||||||
|
Screensharing struct{} `json:"screensharing"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SignalingDialOptions struct {
|
||||||
|
UserAgent string
|
||||||
|
Origin string
|
||||||
|
Logger logger.ContextLogger
|
||||||
|
Dialer N.Dialer
|
||||||
|
}
|
||||||
|
|
||||||
|
type SignalingClient struct {
|
||||||
|
conn *websocket.Conn
|
||||||
|
writeMu sync.Mutex
|
||||||
|
closed atomic.Bool
|
||||||
|
logger logger.ContextLogger
|
||||||
|
sessionID string
|
||||||
|
eventID string
|
||||||
|
|
||||||
|
OnYouJoined func(YouJoinedParams)
|
||||||
|
OnSubscribeResponse func()
|
||||||
|
OnSDPAnswer func(answerSDP string, transceivers []TransceiverDesc)
|
||||||
|
OnSpeakerJoined func(SpeakerJoinedParams)
|
||||||
|
OnSpeakerDisconnected func(SpeakerDisconnectedParams)
|
||||||
|
OnConfSpeakersState func(ConfSpeakersStateResponse)
|
||||||
|
OnSpeakerCamStateChanged func(SpeakerCamStateChangedParams)
|
||||||
|
OnSpeakerMicStateChanged func(SpeakerMicStateChangedParams)
|
||||||
|
OnGetVideoFromUserResponse func(resp GetVideoFromUserResponse, errCode int, errMessage string)
|
||||||
|
OnGetScreenSharingFromUserResponse func(resp GetScreenSharingFromUserResponse, errCode int, errMessage string)
|
||||||
|
OnHeartbeat func()
|
||||||
|
OnUnknown func(method string, params json.RawMessage)
|
||||||
|
OnDataChannelMessage func(method string, params json.RawMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DialSignaling(wssURL string, opts SignalingDialOptions) (*SignalingClient, error) {
|
||||||
|
if !strings.Contains(wssURL, "socket_version=") {
|
||||||
|
joiner := "&"
|
||||||
|
if !strings.Contains(wssURL, "?") {
|
||||||
|
joiner = "?"
|
||||||
|
}
|
||||||
|
wssURL = wssURL + joiner + "socket_version=2.0"
|
||||||
|
}
|
||||||
|
dialer := websocket.Dialer{HandshakeTimeout: 10 * time.Second}
|
||||||
|
if opts.Dialer != nil {
|
||||||
|
dialer.NetDialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
|
return opts.Dialer.DialContext(ctx, network, M.ParseSocksaddr(addr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
headers := http.Header{}
|
||||||
|
if opts.UserAgent != "" {
|
||||||
|
headers.Set("User-Agent", opts.UserAgent)
|
||||||
|
}
|
||||||
|
if opts.Origin != "" {
|
||||||
|
headers.Set("Origin", opts.Origin)
|
||||||
|
} else {
|
||||||
|
headers.Set("Origin", Origin)
|
||||||
|
}
|
||||||
|
log := opts.Logger
|
||||||
|
if log == nil {
|
||||||
|
log = logger.NOP()
|
||||||
|
}
|
||||||
|
conn, resp, err := dialer.Dial(wssURL, headers)
|
||||||
|
if err != nil {
|
||||||
|
status := 0
|
||||||
|
if resp != nil {
|
||||||
|
status = resp.StatusCode
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("ws dial: %w status=%d url=%s", err, status, wssURL)
|
||||||
|
}
|
||||||
|
if resp != nil {
|
||||||
|
log.Debug(fmt.Sprintf("dion: ws dial status=%d", resp.StatusCode))
|
||||||
|
}
|
||||||
|
return &SignalingClient{conn: conn, logger: log}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SignalingClient) Close() error {
|
||||||
|
if !c.closed.CompareAndSwap(false, true) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
common.CloseWS(c.conn)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SignalingClient) WaitConnected(timeout time.Duration) error {
|
||||||
|
c.conn.SetReadDeadline(time.Now().Add(timeout))
|
||||||
|
_, raw, err := c.conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read connected: %w", err)
|
||||||
|
}
|
||||||
|
var frame Frame
|
||||||
|
if err := json.Unmarshal(raw, &frame); err != nil {
|
||||||
|
return fmt.Errorf("decode connected: %w", err)
|
||||||
|
}
|
||||||
|
if frame.Method != MethodServerConnected {
|
||||||
|
return fmt.Errorf("expected %s, got %s", MethodServerConnected, frame.Method)
|
||||||
|
}
|
||||||
|
c.logger.Debug("dion: signaling connected")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SignalingClient) Subscribe(eventID, sessionID string) error {
|
||||||
|
c.eventID = eventID
|
||||||
|
c.sessionID = sessionID
|
||||||
|
return c.sendFrame(MethodClientSubscribeConference, map[string]any{
|
||||||
|
"event_id": eventID,
|
||||||
|
"conf_user_session_id": sessionID,
|
||||||
|
"main_user_session_id": nil,
|
||||||
|
"product_version": ProductVersion,
|
||||||
|
"subscription_version": SubscriptionVersion,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SignalingClient) SendTrace(deviceInfo map[string]any) error {
|
||||||
|
data, err := json.Marshal(deviceInfo)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal trace: %w", err)
|
||||||
|
}
|
||||||
|
return c.sendFrame(MethodClientTrace, map[string]any{"data": string(data)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SignalingClient) SendSDPOffer(params SDPOfferParams) error {
|
||||||
|
return c.sendFrame(MethodClientSDPOffer, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SignalingClient) SendConfSpeakersState(request ConfSpeakersStateRequest) error {
|
||||||
|
encoded, err := ZipEncode(request)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("zip conf_speakers_state: %w", err)
|
||||||
|
}
|
||||||
|
return c.sendFrame(MethodClientConfSpeakersState, encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SignalingClient) SendGetVideoFromUser(request GetVideoFromUserRequest) error {
|
||||||
|
return c.sendFrame(MethodClientGetVideoFromUser, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SignalingClient) SendStopVideoFromUser(request GetVideoFromUserRequest) error {
|
||||||
|
return c.sendFrame(MethodClientStopVideoFromUser, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SignalingClient) SendCamStateChange(state bool) error {
|
||||||
|
return c.sendFrame(MethodClientCamStateChange, map[string]any{"state": state})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SignalingClient) SendMicStateChange(state bool) error {
|
||||||
|
return c.sendFrame(MethodClientMicStateChange, map[string]any{"state": state})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SignalingClient) SendScreenSharingSwitchOn() error {
|
||||||
|
return c.sendFrame(MethodClientScreenSharingSwitchOn, map[string]any{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SignalingClient) SendScreenSharingSwitchOff() error {
|
||||||
|
return c.sendFrame(MethodClientScreenSharingSwitchOff, map[string]any{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SignalingClient) SendGetScreenSharingFromUser(request GetScreenSharingFromUserRequest) error {
|
||||||
|
return c.sendFrame(MethodClientGetScreenSharingFromUser, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SignalingClient) SendScreensharingQualityChange(quality string) error {
|
||||||
|
return c.sendFrame(MethodClientScreensharingQualityChange, map[string]any{"quality": quality})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SignalingClient) SendKickOne(sessionID string) error {
|
||||||
|
return c.sendFrame(MethodClientKickOne, map[string]any{"session_id": sessionID})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SignalingClient) SendPCIceStat() error {
|
||||||
|
return c.sendFrame(MethodClientPCICEStat, map[string]any{"device": "web"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SignalingClient) SendClientStatZip(report ClientStatReport) error {
|
||||||
|
encoded, err := ZipEncode(report)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("zip client_stat: %w", err)
|
||||||
|
}
|
||||||
|
return c.sendFrame(MethodClientClientStatZip, encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SignalingClient) SendICECandidates(candidates []ICECandidateJSON) error {
|
||||||
|
encoded := make([]string, 0, len(candidates))
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
raw, err := EncodeICECandidate(candidate)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encode candidate: %w", err)
|
||||||
|
}
|
||||||
|
encoded = append(encoded, raw)
|
||||||
|
}
|
||||||
|
zipped, err := ZipEncode(map[string]any{"candidates": encoded})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("zip candidates: %w", err)
|
||||||
|
}
|
||||||
|
return c.sendFrame(MethodClientSendICECandidates, zipped)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SignalingClient) ReadLoop() error {
|
||||||
|
for {
|
||||||
|
if c.closed.Load() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||||
|
_, raw, err := c.conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
if c.closed.Load() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("ws read: %w", err)
|
||||||
|
}
|
||||||
|
var frame Frame
|
||||||
|
if err := json.Unmarshal(raw, &frame); err != nil {
|
||||||
|
c.logger.Debug(fmt.Sprintf("dion: drop non-json frame: %v", err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if frame.Error != nil {
|
||||||
|
c.logger.Debug(fmt.Sprintf("dion: <- %s ERROR code=%d message=%q", frame.Method, frame.Error.Code, frame.Error.Message))
|
||||||
|
}
|
||||||
|
c.dispatch(frame)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func EncodeICECandidate(candidate ICECandidateJSON) (string, error) {
|
||||||
|
plain, err := json.Marshal(candidate)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.StdEncoding.EncodeToString(plain), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DecodeICECandidate(encoded string) (ICECandidateJSON, error) {
|
||||||
|
raw, err := base64.StdEncoding.DecodeString(encoded)
|
||||||
|
if err != nil {
|
||||||
|
return ICECandidateJSON{}, fmt.Errorf("base64: %w", err)
|
||||||
|
}
|
||||||
|
var out ICECandidateJSON
|
||||||
|
if err := json.Unmarshal(raw, &out); err != nil {
|
||||||
|
return ICECandidateJSON{}, fmt.Errorf("unmarshal: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildSDPOfferEnvelope(offerSDP string) (string, error) {
|
||||||
|
return ZipEncode(SDPEnvelope{Type: "offer", SDP: offerSDP})
|
||||||
|
}
|
||||||
|
|
||||||
|
func DecodeSDPAnswerInner(answerZipped string) (string, error) {
|
||||||
|
var inner SDPEnvelope
|
||||||
|
if err := ZipDecode(answerZipped, &inner); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return inner.SDP, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DefaultConfSpeakersStateRequest() ConfSpeakersStateRequest {
|
||||||
|
return ConfSpeakersStateRequest{
|
||||||
|
SessionIDs: []string{},
|
||||||
|
TileParams: ConfSpeakersTileParams{
|
||||||
|
Mode: "mosaic",
|
||||||
|
MosaicParams: ConfSpeakersMosaicParams{MaxTilesCount: 9},
|
||||||
|
IsModeBlocked: false,
|
||||||
|
},
|
||||||
|
InputVideoQuality: "auto",
|
||||||
|
ScreenParams: ConfSpeakersScreenParams{Height: 720, Width: 1280},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SignalingClient) sendFrame(method string, params any) error {
|
||||||
|
c.writeMu.Lock()
|
||||||
|
defer c.writeMu.Unlock()
|
||||||
|
if c.closed.Load() {
|
||||||
|
return fmt.Errorf("signaling closed")
|
||||||
|
}
|
||||||
|
payload := map[string]any{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": method,
|
||||||
|
"params": params,
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal frame: %w", err)
|
||||||
|
}
|
||||||
|
return c.conn.WriteMessage(websocket.TextMessage, raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SignalingClient) dispatch(frame Frame) {
|
||||||
|
switch frame.Method {
|
||||||
|
case MethodServerConnected:
|
||||||
|
c.logger.Debug("dion: late server:notify:main:connected")
|
||||||
|
case MethodServerYouJoined:
|
||||||
|
var youJoined YouJoinedParams
|
||||||
|
if err := json.Unmarshal(frame.Params, &youJoined); err != nil {
|
||||||
|
c.logger.Debug(fmt.Sprintf("dion: decode you_joined: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if c.OnYouJoined != nil {
|
||||||
|
c.OnYouJoined(youJoined)
|
||||||
|
}
|
||||||
|
case MethodServerSubscribeResponse:
|
||||||
|
if c.OnSubscribeResponse != nil {
|
||||||
|
c.OnSubscribeResponse()
|
||||||
|
}
|
||||||
|
case MethodServerSDPAnswer:
|
||||||
|
var answer SDPAnswerParams
|
||||||
|
if err := json.Unmarshal(frame.Params, &answer); err != nil {
|
||||||
|
c.logger.Debug(fmt.Sprintf("dion: decode sdp_answer: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var inner SDPEnvelope
|
||||||
|
if err := ZipDecode(answer.Answer, &inner); err != nil {
|
||||||
|
c.logger.Debug(fmt.Sprintf("dion: decode sdp_answer envelope: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if c.OnSDPAnswer != nil {
|
||||||
|
c.OnSDPAnswer(inner.SDP, answer.Transceivers)
|
||||||
|
}
|
||||||
|
case MethodServerSpeakerJoined:
|
||||||
|
var joined SpeakerJoinedParams
|
||||||
|
if err := json.Unmarshal(frame.Params, &joined); err != nil {
|
||||||
|
c.logger.Debug(fmt.Sprintf("dion: decode speaker_joined: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
joined.Extra = frame.Params
|
||||||
|
if c.OnSpeakerJoined != nil {
|
||||||
|
c.OnSpeakerJoined(joined)
|
||||||
|
}
|
||||||
|
case MethodServerSpeakerDisconnected:
|
||||||
|
var left SpeakerDisconnectedParams
|
||||||
|
if err := json.Unmarshal(frame.Params, &left); err != nil {
|
||||||
|
c.logger.Debug(fmt.Sprintf("dion: decode speaker_disconnected: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if c.OnSpeakerDisconnected != nil {
|
||||||
|
c.OnSpeakerDisconnected(left)
|
||||||
|
}
|
||||||
|
case MethodServerSpeakerCamStateChanged:
|
||||||
|
var changed SpeakerCamStateChangedParams
|
||||||
|
if err := json.Unmarshal(frame.Params, &changed); err != nil {
|
||||||
|
c.logger.Debug(fmt.Sprintf("dion: decode speaker_cam_state_changed: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if c.OnSpeakerCamStateChanged != nil {
|
||||||
|
c.OnSpeakerCamStateChanged(changed)
|
||||||
|
}
|
||||||
|
case MethodServerSpeakerMicStateChanged:
|
||||||
|
var changed SpeakerMicStateChangedParams
|
||||||
|
if err := json.Unmarshal(frame.Params, &changed); err != nil {
|
||||||
|
c.logger.Debug(fmt.Sprintf("dion: decode speaker_mic_state_changed: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if c.OnSpeakerMicStateChanged != nil {
|
||||||
|
c.OnSpeakerMicStateChanged(changed)
|
||||||
|
}
|
||||||
|
case MethodServerConfSpeakersState:
|
||||||
|
var encoded string
|
||||||
|
if err := json.Unmarshal(frame.Params, &encoded); err != nil {
|
||||||
|
c.logger.Debug(fmt.Sprintf("dion: decode conf_speakers_state envelope: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var response ConfSpeakersStateResponse
|
||||||
|
if err := ZipDecode(encoded, &response); err != nil {
|
||||||
|
c.logger.Debug(fmt.Sprintf("dion: decode conf_speakers_state body: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if c.OnConfSpeakersState != nil {
|
||||||
|
c.OnConfSpeakersState(response)
|
||||||
|
}
|
||||||
|
case MethodServerHeartbeat:
|
||||||
|
if c.OnHeartbeat != nil {
|
||||||
|
c.OnHeartbeat()
|
||||||
|
}
|
||||||
|
case MethodServerGetVideoFromUser:
|
||||||
|
var resp GetVideoFromUserResponse
|
||||||
|
_ = json.Unmarshal(frame.Params, &resp)
|
||||||
|
errCode := 0
|
||||||
|
errMsg := ""
|
||||||
|
if frame.Error != nil {
|
||||||
|
errCode = frame.Error.Code
|
||||||
|
errMsg = frame.Error.Message
|
||||||
|
}
|
||||||
|
if c.OnGetVideoFromUserResponse != nil {
|
||||||
|
c.OnGetVideoFromUserResponse(resp, errCode, errMsg)
|
||||||
|
}
|
||||||
|
case MethodServerGetScreenSharingFromUser:
|
||||||
|
var resp GetScreenSharingFromUserResponse
|
||||||
|
_ = json.Unmarshal(frame.Params, &resp)
|
||||||
|
errCode := 0
|
||||||
|
errMsg := ""
|
||||||
|
if frame.Error != nil {
|
||||||
|
errCode = frame.Error.Code
|
||||||
|
errMsg = frame.Error.Message
|
||||||
|
}
|
||||||
|
if c.OnGetScreenSharingFromUserResponse != nil {
|
||||||
|
c.OnGetScreenSharingFromUserResponse(resp, errCode, errMsg)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if c.OnUnknown != nil {
|
||||||
|
c.OnUnknown(frame.Method, frame.Params)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
49
transport/call/dion/zip.go
Normal file
49
transport/call/dion/zip.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package dion
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ZipEncode(value any) (string, error) {
|
||||||
|
plain, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("marshal: %w", err)
|
||||||
|
}
|
||||||
|
var compressed bytes.Buffer
|
||||||
|
gz := gzip.NewWriter(&compressed)
|
||||||
|
if _, err := gz.Write(plain); err != nil {
|
||||||
|
return "", fmt.Errorf("gzip write: %w", err)
|
||||||
|
}
|
||||||
|
if err := gz.Close(); err != nil {
|
||||||
|
return "", fmt.Errorf("gzip close: %w", err)
|
||||||
|
}
|
||||||
|
return base64.StdEncoding.EncodeToString(compressed.Bytes()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ZipDecode(encoded string, out any) error {
|
||||||
|
raw, err := base64.StdEncoding.DecodeString(encoded)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("base64: %w", err)
|
||||||
|
}
|
||||||
|
reader, err := gzip.NewReader(bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("gzip reader: %w", err)
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
plain, err := io.ReadAll(reader)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("gzip read: %w", err)
|
||||||
|
}
|
||||||
|
if out == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(plain, out); err != nil {
|
||||||
|
return fmt.Errorf("unmarshal: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
472
transport/call/livekit/client.go
Normal file
472
transport/call/livekit/client.go
Normal file
@@ -0,0 +1,472 @@
|
|||||||
|
package livekit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
"github.com/pion/webrtc/v4"
|
||||||
|
"github.com/sagernet/sing-box/adapter"
|
||||||
|
"github.com/sagernet/sing-box/common/dialer"
|
||||||
|
"github.com/sagernet/sing-box/transport/call/common"
|
||||||
|
"github.com/sagernet/sing/common/logger"
|
||||||
|
N "github.com/sagernet/sing/common/network"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ProtocolVersion = "15"
|
||||||
|
SDKName = "js"
|
||||||
|
SDKVersion = "2.7.0"
|
||||||
|
PingPeriod = 5 * time.Second
|
||||||
|
|
||||||
|
TargetPublisher = signalTargetPublisher
|
||||||
|
TargetSubscriber = signalTargetSubscriber
|
||||||
|
|
||||||
|
TrackTypeAudio = trackTypeAudio
|
||||||
|
TrackTypeVideo = trackTypeVideo
|
||||||
|
TrackTypeData = trackTypeData
|
||||||
|
TrackSourceCamera = trackSourceCamera
|
||||||
|
TrackSourceScreenShare = trackSourceScreenShare
|
||||||
|
)
|
||||||
|
|
||||||
|
type ICEServer = iceServer
|
||||||
|
type JoinResponse = joinResponse
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
ServerURL string
|
||||||
|
Token string
|
||||||
|
Origin string
|
||||||
|
UserAgent string
|
||||||
|
Logger logger.ContextLogger
|
||||||
|
SettingEngine *webrtc.SettingEngine
|
||||||
|
NetDialContext func(ctx context.Context, network, addr string) (net.Conn, error)
|
||||||
|
DNSRouter adapter.DNSRouter
|
||||||
|
Dialer N.Dialer
|
||||||
|
}
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
logger logger.ContextLogger
|
||||||
|
|
||||||
|
wsURL string
|
||||||
|
token string
|
||||||
|
origin string
|
||||||
|
ua string
|
||||||
|
|
||||||
|
settingEngine *webrtc.SettingEngine
|
||||||
|
netDialContext func(ctx context.Context, network, addr string) (net.Conn, error)
|
||||||
|
dnsRouter adapter.DNSRouter
|
||||||
|
dialer N.Dialer
|
||||||
|
|
||||||
|
ws *websocket.Conn
|
||||||
|
wsMu sync.Mutex
|
||||||
|
|
||||||
|
join JoinResponse
|
||||||
|
|
||||||
|
pubPC *webrtc.PeerConnection
|
||||||
|
subPC *webrtc.PeerConnection
|
||||||
|
pubMu sync.Mutex
|
||||||
|
subMu sync.Mutex
|
||||||
|
pubRemoteSet bool
|
||||||
|
subRemoteSet bool
|
||||||
|
|
||||||
|
closed atomic.Bool
|
||||||
|
|
||||||
|
OnReady func()
|
||||||
|
OnTrack func(*webrtc.TrackRemote, *webrtc.RTPReceiver)
|
||||||
|
OnDataChannel func(*webrtc.DataChannel)
|
||||||
|
OnPubConnected func()
|
||||||
|
OnParticipantUpdate func([]ParticipantInfo)
|
||||||
|
OnRemoteCandidate func(target int, candidate webrtc.ICECandidateInit)
|
||||||
|
OnRemoteSDP func(target int, sdpType, sdp string)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClient(cfg Config) *Client {
|
||||||
|
return &Client{
|
||||||
|
logger: cfg.Logger,
|
||||||
|
wsURL: cfg.ServerURL,
|
||||||
|
token: cfg.Token,
|
||||||
|
origin: cfg.Origin,
|
||||||
|
ua: cfg.UserAgent,
|
||||||
|
settingEngine: cfg.SettingEngine,
|
||||||
|
netDialContext: cfg.NetDialContext,
|
||||||
|
dnsRouter: cfg.DNSRouter,
|
||||||
|
dialer: cfg.Dialer,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Join() JoinResponse { return c.join }
|
||||||
|
func (c *Client) PubPC() *webrtc.PeerConnection { return c.pubPC }
|
||||||
|
func (c *Client) SubPC() *webrtc.PeerConnection { return c.subPC }
|
||||||
|
|
||||||
|
func (c *Client) Connect() error {
|
||||||
|
u, err := url.Parse(c.wsURL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parse url: %w", err)
|
||||||
|
}
|
||||||
|
u.Path = "/rtc"
|
||||||
|
q := u.Query()
|
||||||
|
q.Set("access_token", c.token)
|
||||||
|
q.Set("protocol", ProtocolVersion)
|
||||||
|
q.Set("sdk", SDKName)
|
||||||
|
q.Set("version", SDKVersion)
|
||||||
|
q.Set("auto_subscribe", "1")
|
||||||
|
q.Set("adaptive_stream", "true")
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
headers := http.Header{}
|
||||||
|
if c.ua != "" {
|
||||||
|
headers.Set("User-Agent", c.ua)
|
||||||
|
}
|
||||||
|
if c.origin != "" {
|
||||||
|
headers.Set("Origin", c.origin)
|
||||||
|
}
|
||||||
|
dialer := *websocket.DefaultDialer
|
||||||
|
if c.netDialContext != nil {
|
||||||
|
dialer.NetDialContext = c.netDialContext
|
||||||
|
}
|
||||||
|
conn, resp, err := dialer.Dial(u.String(), headers)
|
||||||
|
if err != nil {
|
||||||
|
if resp != nil {
|
||||||
|
return fmt.Errorf("ws dial: %w (status %d)", err, resp.StatusCode)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("ws dial: %w", err)
|
||||||
|
}
|
||||||
|
c.ws = conn
|
||||||
|
c.logger.Info("[lk] signaling connected")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) SendOffer(sdp string) error {
|
||||||
|
return c.sendSignal(encSignalRequestOffer(sessionDescription{Type: "offer", SDP: sdp}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) SendAnswer(sdp string) error {
|
||||||
|
return c.sendSignal(encSignalRequestAnswer(sessionDescription{Type: "answer", SDP: sdp}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) SendTrickle(candidate webrtc.ICECandidateInit, target int) error {
|
||||||
|
js, _ := json.Marshal(candidate)
|
||||||
|
return c.sendSignal(encSignalRequestTrickle(trickleMsg{
|
||||||
|
CandidateInit: string(js),
|
||||||
|
Target: target,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) SendAddTrack(cid, name string, trackType, source int, width, height uint32) error {
|
||||||
|
return c.sendSignal(encSignalRequestAddTrack(cid, name, trackType, source, width, height))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) SendLeave() error { return c.sendSignal(encSignalRequestLeave()) }
|
||||||
|
|
||||||
|
func (c *Client) SendPing() error {
|
||||||
|
return c.sendSignal(encSignalRequestPing(time.Now().UnixMilli()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Close() {
|
||||||
|
if !c.closed.CompareAndSwap(false, true) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.wsMu.Lock()
|
||||||
|
ws := c.ws
|
||||||
|
c.wsMu.Unlock()
|
||||||
|
common.CloseWS(ws)
|
||||||
|
if c.pubPC != nil {
|
||||||
|
_ = c.pubPC.Close()
|
||||||
|
}
|
||||||
|
if c.subPC != nil {
|
||||||
|
_ = c.subPC.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ReadLoop() error {
|
||||||
|
defer c.Close()
|
||||||
|
for {
|
||||||
|
mt, data, err := c.ws.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if mt != websocket.BinaryMessage {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c.handleSignal(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) PingLoop() {
|
||||||
|
period := PingPeriod
|
||||||
|
if c.join.PingIntervalSec > 0 {
|
||||||
|
period = time.Duration(c.join.PingIntervalSec) * time.Second
|
||||||
|
}
|
||||||
|
t := time.NewTicker(period)
|
||||||
|
defer t.Stop()
|
||||||
|
var sentN int
|
||||||
|
for range t.C {
|
||||||
|
if c.closed.Load() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := c.SendPing(); err != nil {
|
||||||
|
c.logger.Warn(fmt.Sprintf("[lk] ping send failed: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sentN++
|
||||||
|
if sentN <= 3 || sentN%12 == 0 {
|
||||||
|
c.logger.Debug(fmt.Sprintf("[lk] ping #%d sent", sentN))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) sendSignal(payload []byte) error {
|
||||||
|
c.wsMu.Lock()
|
||||||
|
defer c.wsMu.Unlock()
|
||||||
|
if c.ws == nil {
|
||||||
|
return fmt.Errorf("ws not connected")
|
||||||
|
}
|
||||||
|
return c.ws.WriteMessage(websocket.BinaryMessage, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) iceServersAsWebRTC() []webrtc.ICEServer {
|
||||||
|
out := make([]webrtc.ICEServer, 0, len(c.join.ICEServers))
|
||||||
|
resolved := make(map[string]string)
|
||||||
|
for _, s := range c.join.ICEServers {
|
||||||
|
urls := make([]string, len(s.URLs))
|
||||||
|
copy(urls, s.URLs)
|
||||||
|
for k, u := range urls {
|
||||||
|
host := common.ExtractICEHost(u)
|
||||||
|
if host == "" || net.ParseIP(host) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ip, ok := resolved[host]
|
||||||
|
if !ok {
|
||||||
|
rd, hasRD := c.dialer.(dialer.ResolveDialer)
|
||||||
|
if c.dnsRouter == nil || !hasRD {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var addrs []netip.Addr
|
||||||
|
var err error
|
||||||
|
addrs, err = c.dnsRouter.Lookup(context.Background(), host, rd.QueryOptions())
|
||||||
|
if err != nil {
|
||||||
|
c.logger.Warn(fmt.Sprintf("[lk] resolve ICE host %s failed: %v", host, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resolved[host] = addrs[0].String()
|
||||||
|
c.logger.Debug(fmt.Sprintf("[lk] resolved ICE host %s -> %s", host, addrs[0]))
|
||||||
|
}
|
||||||
|
urls[k] = strings.Replace(u, host, ip, 1)
|
||||||
|
}
|
||||||
|
ice := webrtc.ICEServer{URLs: urls}
|
||||||
|
if s.Username != "" {
|
||||||
|
ice.Username = s.Username
|
||||||
|
ice.Credential = s.Credential
|
||||||
|
}
|
||||||
|
out = append(out, ice)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) buildPeerConnections() error {
|
||||||
|
cfg := webrtc.Configuration{ICEServers: c.iceServersAsWebRTC()}
|
||||||
|
se := webrtc.SettingEngine{}
|
||||||
|
if c.settingEngine != nil {
|
||||||
|
se = *c.settingEngine
|
||||||
|
}
|
||||||
|
se.DetachDataChannels()
|
||||||
|
api := webrtc.NewAPI(webrtc.WithSettingEngine(se))
|
||||||
|
pubPC, err := api.NewPeerConnection(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create pub pc: %w", err)
|
||||||
|
}
|
||||||
|
subPC, err := api.NewPeerConnection(cfg)
|
||||||
|
if err != nil {
|
||||||
|
_ = pubPC.Close()
|
||||||
|
return fmt.Errorf("create sub pc: %w", err)
|
||||||
|
}
|
||||||
|
c.pubPC = pubPC
|
||||||
|
c.subPC = subPC
|
||||||
|
pubPC.OnICECandidate(func(cand *webrtc.ICECandidate) {
|
||||||
|
if cand == nil {
|
||||||
|
c.logger.Debug("[lk] pub ICE gathering complete")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.logger.Debug(fmt.Sprintf("[lk] pub local cand: %s", cand.String()))
|
||||||
|
_ = c.SendTrickle(cand.ToJSON(), TargetPublisher)
|
||||||
|
})
|
||||||
|
subPC.OnICECandidate(func(cand *webrtc.ICECandidate) {
|
||||||
|
if cand == nil {
|
||||||
|
c.logger.Debug("[lk] sub ICE gathering complete")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.logger.Debug(fmt.Sprintf("[lk] sub local cand: %s", cand.String()))
|
||||||
|
_ = c.SendTrickle(cand.ToJSON(), TargetSubscriber)
|
||||||
|
})
|
||||||
|
pubPC.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
|
||||||
|
c.logger.Debug(fmt.Sprintf("[lk] pub PC state: %s", state.String()))
|
||||||
|
if state == webrtc.PeerConnectionStateConnected && c.OnPubConnected != nil {
|
||||||
|
c.OnPubConnected()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
subPC.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
|
||||||
|
c.logger.Debug(fmt.Sprintf("[lk] sub PC state: %s", state.String()))
|
||||||
|
})
|
||||||
|
pubPC.OnICEConnectionStateChange(func(state webrtc.ICEConnectionState) {
|
||||||
|
c.logger.Debug(fmt.Sprintf("[lk] pub ICE state: %s", state.String()))
|
||||||
|
})
|
||||||
|
subPC.OnICEConnectionStateChange(func(state webrtc.ICEConnectionState) {
|
||||||
|
c.logger.Debug(fmt.Sprintf("[lk] sub ICE state: %s", state.String()))
|
||||||
|
})
|
||||||
|
subPC.OnTrack(func(track *webrtc.TrackRemote, receiver *webrtc.RTPReceiver) {
|
||||||
|
c.logger.Debug(fmt.Sprintf("[lk] sub remote track: %s", track.Codec().MimeType))
|
||||||
|
if c.OnTrack != nil {
|
||||||
|
c.OnTrack(track, receiver)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
subPC.OnDataChannel(func(dc *webrtc.DataChannel) {
|
||||||
|
c.logger.Debug(fmt.Sprintf("[lk] sub data channel: %s", dc.Label()))
|
||||||
|
if c.OnDataChannel != nil {
|
||||||
|
c.OnDataChannel(dc)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
c.logger.Debug(fmt.Sprintf("[lk] PCs created (%d ICE servers)", len(c.join.ICEServers)))
|
||||||
|
for i, s := range c.join.ICEServers {
|
||||||
|
c.logger.Debug(fmt.Sprintf("[lk] iceServer[%d]: urls=%v hasCred=%v", i, s.URLs, s.Username != ""))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) handleSignal(data []byte) {
|
||||||
|
sr, err := decSignalResponse(data)
|
||||||
|
if err != nil {
|
||||||
|
c.logger.Warn(fmt.Sprintf("[lk] decode signal: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch sr.Kind {
|
||||||
|
case signalRespJoin:
|
||||||
|
if sr.Join != nil {
|
||||||
|
c.join = *sr.Join
|
||||||
|
c.logger.Info(fmt.Sprintf("[lk] join: room=%s participant=%s subscriberPrimary=%v iceServers=%d pingTimeout=%ds pingInterval=%ds",
|
||||||
|
c.join.RoomName, c.join.ParticipantID, c.join.SubscriberPrimary, len(c.join.ICEServers),
|
||||||
|
c.join.PingTimeoutSec, c.join.PingIntervalSec))
|
||||||
|
if err := c.buildPeerConnections(); err != nil {
|
||||||
|
c.logger.Error(fmt.Sprintf("[lk] %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if c.OnReady != nil {
|
||||||
|
c.OnReady()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case signalRespAnswer:
|
||||||
|
c.logger.Debug(fmt.Sprintf("[lk] <- pub answer (%d bytes)", len(sr.SDP.SDP)))
|
||||||
|
if sr.SDP != nil {
|
||||||
|
c.applyPubAnswer(sr.SDP.SDP)
|
||||||
|
}
|
||||||
|
case signalRespOffer:
|
||||||
|
c.logger.Debug(fmt.Sprintf("[lk] <- sub offer (%d bytes)", len(sr.SDP.SDP)))
|
||||||
|
if sr.SDP != nil {
|
||||||
|
c.applySubOfferAndAnswer(sr.SDP.SDP)
|
||||||
|
}
|
||||||
|
case signalRespTrickle:
|
||||||
|
if sr.Trickle != nil {
|
||||||
|
c.logger.Debug(fmt.Sprintf("[lk] <- trickle target=%d", sr.Trickle.Target))
|
||||||
|
c.applyRemoteTrickle(*sr.Trickle)
|
||||||
|
}
|
||||||
|
case signalRespRefreshToken:
|
||||||
|
if sr.Token != "" {
|
||||||
|
c.token = sr.Token
|
||||||
|
c.logger.Debug("[lk] token refreshed")
|
||||||
|
}
|
||||||
|
case signalRespLeave:
|
||||||
|
if sr.Leave != nil {
|
||||||
|
c.logger.Debug(fmt.Sprintf("[lk] ignored leave reason=%s action=%s",
|
||||||
|
DisconnectReasonName(sr.Leave.Reason), LeaveActionName(sr.Leave.Action)))
|
||||||
|
} else {
|
||||||
|
c.logger.Debug("[lk] ignored leave")
|
||||||
|
}
|
||||||
|
case signalRespUpdate:
|
||||||
|
if c.OnParticipantUpdate != nil && len(sr.Participants) > 0 {
|
||||||
|
c.OnParticipantUpdate(sr.Participants)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
c.logger.Debug(fmt.Sprintf("[lk] <- signal kind=%d (%d bytes)", sr.Kind, len(data)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) applyPubAnswer(sdp string) {
|
||||||
|
if c.OnRemoteSDP != nil {
|
||||||
|
c.OnRemoteSDP(TargetPublisher, "answer", sdp)
|
||||||
|
}
|
||||||
|
c.pubMu.Lock()
|
||||||
|
defer c.pubMu.Unlock()
|
||||||
|
if c.pubPC == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := c.pubPC.SetRemoteDescription(webrtc.SessionDescription{Type: webrtc.SDPTypeAnswer, SDP: sdp}); err != nil {
|
||||||
|
c.logger.Warn(fmt.Sprintf("[lk] set pub remote answer: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.pubRemoteSet = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) applySubOfferAndAnswer(sdp string) {
|
||||||
|
if c.OnRemoteSDP != nil {
|
||||||
|
c.OnRemoteSDP(TargetSubscriber, "offer", sdp)
|
||||||
|
}
|
||||||
|
c.subMu.Lock()
|
||||||
|
defer c.subMu.Unlock()
|
||||||
|
if c.subPC == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := c.subPC.SetRemoteDescription(webrtc.SessionDescription{Type: webrtc.SDPTypeOffer, SDP: sdp}); err != nil {
|
||||||
|
c.logger.Warn(fmt.Sprintf("[lk] set sub remote offer: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.subRemoteSet = true
|
||||||
|
answer, err := c.subPC.CreateAnswer(nil)
|
||||||
|
if err != nil {
|
||||||
|
c.logger.Warn(fmt.Sprintf("[lk] create sub answer: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := c.subPC.SetLocalDescription(answer); err != nil {
|
||||||
|
c.logger.Warn(fmt.Sprintf("[lk] set sub local answer: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := c.SendAnswer(answer.SDP); err != nil {
|
||||||
|
c.logger.Warn(fmt.Sprintf("[lk] send answer: %v", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) applyRemoteTrickle(m trickleMsg) {
|
||||||
|
if m.CandidateInit == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var ic webrtc.ICECandidateInit
|
||||||
|
if err := json.Unmarshal([]byte(m.CandidateInit), &ic); err != nil {
|
||||||
|
c.logger.Warn(fmt.Sprintf("[lk] decode trickle candidate: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if c.OnRemoteCandidate != nil {
|
||||||
|
c.OnRemoteCandidate(m.Target, ic)
|
||||||
|
}
|
||||||
|
switch m.Target {
|
||||||
|
case TargetPublisher:
|
||||||
|
c.pubMu.Lock()
|
||||||
|
ready := c.pubRemoteSet
|
||||||
|
c.pubMu.Unlock()
|
||||||
|
if ready {
|
||||||
|
_ = c.pubPC.AddICECandidate(ic)
|
||||||
|
}
|
||||||
|
case TargetSubscriber:
|
||||||
|
c.subMu.Lock()
|
||||||
|
ready := c.subRemoteSet
|
||||||
|
c.subMu.Unlock()
|
||||||
|
if ready {
|
||||||
|
_ = c.subPC.AddICECandidate(ic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
708
transport/call/livekit/messages.go
Normal file
708
transport/call/livekit/messages.go
Normal file
@@ -0,0 +1,708 @@
|
|||||||
|
package livekit
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
const (
|
||||||
|
signalReqOffer = 1
|
||||||
|
signalReqAnswer = 2
|
||||||
|
signalReqTrickle = 3
|
||||||
|
signalReqAddTrack = 4
|
||||||
|
signalReqLeave = 8
|
||||||
|
signalReqPingLegacy = 14
|
||||||
|
signalReqPingReq = 16
|
||||||
|
|
||||||
|
signalRespJoin = 1
|
||||||
|
signalRespAnswer = 2
|
||||||
|
signalRespOffer = 3
|
||||||
|
signalRespTrickle = 4
|
||||||
|
signalRespUpdate = 5
|
||||||
|
signalRespTrackPublished = 6
|
||||||
|
signalRespLeave = 8
|
||||||
|
signalRespRoomUpdate = 11
|
||||||
|
signalRespRefreshToken = 16
|
||||||
|
signalRespPongResp = 20
|
||||||
|
signalRespRequestResponse = 22
|
||||||
|
signalRespTrackSubscribed = 23
|
||||||
|
|
||||||
|
sdpFieldType = 1
|
||||||
|
sdpFieldSDP = 2
|
||||||
|
sdpFieldID = 3
|
||||||
|
|
||||||
|
trickleFieldCandidate = 1
|
||||||
|
trickleFieldTarget = 2
|
||||||
|
trickleFieldFinal = 3
|
||||||
|
|
||||||
|
addTrackFieldCID = 1
|
||||||
|
addTrackFieldName = 2
|
||||||
|
addTrackFieldType = 3
|
||||||
|
addTrackFieldWidth = 4
|
||||||
|
addTrackFieldHeight = 5
|
||||||
|
addTrackFieldSource = 8
|
||||||
|
addTrackFieldLayers = 9
|
||||||
|
|
||||||
|
videoLayerFieldQuality = 1
|
||||||
|
videoLayerFieldWidth = 2
|
||||||
|
videoLayerFieldHeight = 3
|
||||||
|
|
||||||
|
videoQualityHigh = 2
|
||||||
|
|
||||||
|
joinFieldRoom = 1
|
||||||
|
joinFieldParticipant = 2
|
||||||
|
joinFieldOtherParticipants = 3
|
||||||
|
joinFieldServerVersion = 4
|
||||||
|
joinFieldICEServers = 5
|
||||||
|
joinFieldSubscriberPrimary = 6
|
||||||
|
joinFieldServerRegion = 9
|
||||||
|
joinFieldPingTimeout = 10
|
||||||
|
joinFieldPingInterval = 11
|
||||||
|
|
||||||
|
iceServerFieldURLs = 1
|
||||||
|
iceServerFieldUsername = 2
|
||||||
|
iceServerFieldCredential = 3
|
||||||
|
|
||||||
|
pingFieldTimestamp = 1
|
||||||
|
pingFieldRTT = 2
|
||||||
|
|
||||||
|
dataPacketFieldKind = 1
|
||||||
|
dataPacketFieldUser = 2
|
||||||
|
|
||||||
|
userPacketFieldPayload = 2
|
||||||
|
|
||||||
|
DataPacketKindReliable = 0
|
||||||
|
DataPacketKindLossy = 1
|
||||||
|
|
||||||
|
leaveFieldCanReconnect = 1
|
||||||
|
leaveFieldReason = 2
|
||||||
|
leaveFieldAction = 3
|
||||||
|
|
||||||
|
roomFieldSID = 1
|
||||||
|
roomFieldName = 2
|
||||||
|
|
||||||
|
participantFieldSID = 1
|
||||||
|
participantFieldIdentity = 2
|
||||||
|
participantFieldState = 3
|
||||||
|
participantFieldName = 9
|
||||||
|
|
||||||
|
trackTypeAudio = 0
|
||||||
|
trackTypeVideo = 1
|
||||||
|
trackTypeData = 2
|
||||||
|
|
||||||
|
signalTargetPublisher = 0
|
||||||
|
signalTargetSubscriber = 1
|
||||||
|
|
||||||
|
trackSourceCamera = 1
|
||||||
|
trackSourceScreenShare = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ParticipantStateJoining int32 = 0
|
||||||
|
ParticipantStateJoined int32 = 1
|
||||||
|
ParticipantStateActive int32 = 2
|
||||||
|
ParticipantStateDisconnected int32 = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
var disconnectReasonNames = map[int]string{
|
||||||
|
0: "UNKNOWN",
|
||||||
|
1: "CLIENT_INITIATED",
|
||||||
|
2: "DUPLICATE_IDENTITY",
|
||||||
|
3: "SERVER_SHUTDOWN",
|
||||||
|
4: "PARTICIPANT_REMOVED",
|
||||||
|
5: "ROOM_DELETED",
|
||||||
|
6: "STATE_MISMATCH",
|
||||||
|
7: "JOIN_FAILURE",
|
||||||
|
8: "MIGRATION",
|
||||||
|
9: "SIGNAL_CLOSE",
|
||||||
|
10: "ROOM_CLOSED",
|
||||||
|
11: "USER_UNAVAILABLE",
|
||||||
|
12: "USER_REJECTED",
|
||||||
|
13: "SIP_TRUNK_FAILURE",
|
||||||
|
14: "CONNECTION_TIMEOUT",
|
||||||
|
15: "MEDIA_FAILURE",
|
||||||
|
16: "AGENT_ERROR",
|
||||||
|
}
|
||||||
|
|
||||||
|
var leaveActionNames = map[int]string{
|
||||||
|
0: "DISCONNECT",
|
||||||
|
1: "RESUME",
|
||||||
|
2: "RECONNECT",
|
||||||
|
}
|
||||||
|
|
||||||
|
type LeaveInfo struct {
|
||||||
|
Reason int
|
||||||
|
Action int
|
||||||
|
}
|
||||||
|
|
||||||
|
type sessionDescription struct {
|
||||||
|
Type string
|
||||||
|
SDP string
|
||||||
|
ID uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
type trickleMsg struct {
|
||||||
|
CandidateInit string
|
||||||
|
Target int
|
||||||
|
Final bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type iceServer struct {
|
||||||
|
URLs []string
|
||||||
|
Username string
|
||||||
|
Credential string
|
||||||
|
}
|
||||||
|
|
||||||
|
type joinResponse struct {
|
||||||
|
RoomSID string
|
||||||
|
RoomName string
|
||||||
|
ParticipantSID string
|
||||||
|
ParticipantID string
|
||||||
|
ServerVersion string
|
||||||
|
ServerRegion string
|
||||||
|
ICEServers []iceServer
|
||||||
|
SubscriberPrimary bool
|
||||||
|
PingTimeoutSec int32
|
||||||
|
PingIntervalSec int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type signalResponse struct {
|
||||||
|
Kind int
|
||||||
|
Join *joinResponse
|
||||||
|
SDP *sessionDescription
|
||||||
|
Trickle *trickleMsg
|
||||||
|
Token string
|
||||||
|
PongTime int64
|
||||||
|
Leave *LeaveInfo
|
||||||
|
Participants []ParticipantInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
type ParticipantInfo struct {
|
||||||
|
SID string
|
||||||
|
Identity string
|
||||||
|
State int32
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
func DisconnectReasonName(code int) string {
|
||||||
|
if name, ok := disconnectReasonNames[code]; ok {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("CODE_%d", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func LeaveActionName(code int) string {
|
||||||
|
if name, ok := leaveActionNames[code]; ok {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("CODE_%d", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DecodeLeaveRequest(data []byte) LeaveInfo {
|
||||||
|
r := pbReader{buf: data}
|
||||||
|
var li LeaveInfo
|
||||||
|
for !r.eof() {
|
||||||
|
field, wire, err := r.tag()
|
||||||
|
if err != nil {
|
||||||
|
return li
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case field == leaveFieldReason && wire == wireVarint:
|
||||||
|
v, _ := r.varint()
|
||||||
|
li.Reason = int(v)
|
||||||
|
case field == leaveFieldAction && wire == wireVarint:
|
||||||
|
v, _ := r.varint()
|
||||||
|
li.Action = int(v)
|
||||||
|
default:
|
||||||
|
if err := r.skipWire(wire); err != nil {
|
||||||
|
return li
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return li
|
||||||
|
}
|
||||||
|
|
||||||
|
func EncodeDataPacketUser(payload []byte, kind int) []byte {
|
||||||
|
w := pbWriter{}
|
||||||
|
if kind != 0 {
|
||||||
|
w.int32(dataPacketFieldKind, int32(kind))
|
||||||
|
}
|
||||||
|
w.message(dataPacketFieldUser, encUserPacket(payload))
|
||||||
|
return w.buf
|
||||||
|
}
|
||||||
|
|
||||||
|
func DecodeDataPacketUser(data []byte) ([]byte, bool) {
|
||||||
|
r := pbReader{buf: data}
|
||||||
|
for !r.eof() {
|
||||||
|
field, wire, err := r.tag()
|
||||||
|
if err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if field == dataPacketFieldUser && wire == wireBytes {
|
||||||
|
inner, err := r.bytes()
|
||||||
|
if err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
ur := pbReader{buf: inner}
|
||||||
|
for !ur.eof() {
|
||||||
|
ufield, uwire, uerr := ur.tag()
|
||||||
|
if uerr != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if ufield == userPacketFieldPayload && uwire == wireBytes {
|
||||||
|
payload, perr := ur.bytes()
|
||||||
|
if perr != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
out := make([]byte, len(payload))
|
||||||
|
copy(out, payload)
|
||||||
|
return out, true
|
||||||
|
}
|
||||||
|
if err := ur.skipWire(uwire); err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if err := r.skipWire(wire); err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func DecodeParticipantInfo(data []byte) ParticipantInfo {
|
||||||
|
r := pbReader{buf: data}
|
||||||
|
var info ParticipantInfo
|
||||||
|
for !r.eof() {
|
||||||
|
field, wire, err := r.tag()
|
||||||
|
if err != nil {
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case field == participantFieldSID && wire == wireBytes:
|
||||||
|
b, err := r.bytes()
|
||||||
|
if err != nil {
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
info.SID = string(b)
|
||||||
|
case field == participantFieldIdentity && wire == wireBytes:
|
||||||
|
b, err := r.bytes()
|
||||||
|
if err != nil {
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
info.Identity = string(b)
|
||||||
|
case field == participantFieldState && wire == wireVarint:
|
||||||
|
v, err := r.varint()
|
||||||
|
if err != nil {
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
info.State = int32(v)
|
||||||
|
case field == participantFieldName && wire == wireBytes:
|
||||||
|
b, err := r.bytes()
|
||||||
|
if err != nil {
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
info.Name = string(b)
|
||||||
|
default:
|
||||||
|
if err := r.skipWire(wire); err != nil {
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
func DecodeParticipantUpdate(data []byte) []ParticipantInfo {
|
||||||
|
r := pbReader{buf: data}
|
||||||
|
var out []ParticipantInfo
|
||||||
|
for !r.eof() {
|
||||||
|
field, wire, err := r.tag()
|
||||||
|
if err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
if field == 1 && wire == wireBytes {
|
||||||
|
b, err := r.bytes()
|
||||||
|
if err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
out = append(out, DecodeParticipantInfo(b))
|
||||||
|
} else {
|
||||||
|
if err := r.skipWire(wire); err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func encSessionDescription(sd sessionDescription) []byte {
|
||||||
|
w := pbWriter{}
|
||||||
|
if sd.Type != "" {
|
||||||
|
w.string(sdpFieldType, sd.Type)
|
||||||
|
}
|
||||||
|
if sd.SDP != "" {
|
||||||
|
w.string(sdpFieldSDP, sd.SDP)
|
||||||
|
}
|
||||||
|
if sd.ID != 0 {
|
||||||
|
w.uint32(sdpFieldID, sd.ID)
|
||||||
|
}
|
||||||
|
return w.buf
|
||||||
|
}
|
||||||
|
|
||||||
|
func encTrickle(m trickleMsg) []byte {
|
||||||
|
w := pbWriter{}
|
||||||
|
w.string(trickleFieldCandidate, m.CandidateInit)
|
||||||
|
w.int32(trickleFieldTarget, int32(m.Target))
|
||||||
|
if m.Final {
|
||||||
|
w.bool(trickleFieldFinal, true)
|
||||||
|
}
|
||||||
|
return w.buf
|
||||||
|
}
|
||||||
|
|
||||||
|
func encVideoLayer(quality, width, height uint32) []byte {
|
||||||
|
w := pbWriter{}
|
||||||
|
if quality != 0 {
|
||||||
|
w.uint32(videoLayerFieldQuality, quality)
|
||||||
|
}
|
||||||
|
if width != 0 {
|
||||||
|
w.uint32(videoLayerFieldWidth, width)
|
||||||
|
}
|
||||||
|
if height != 0 {
|
||||||
|
w.uint32(videoLayerFieldHeight, height)
|
||||||
|
}
|
||||||
|
return w.buf
|
||||||
|
}
|
||||||
|
|
||||||
|
func encAddTrack(cid, name string, trackType, source int, width, height uint32) []byte {
|
||||||
|
w := pbWriter{}
|
||||||
|
w.string(addTrackFieldCID, cid)
|
||||||
|
w.string(addTrackFieldName, name)
|
||||||
|
w.int32(addTrackFieldType, int32(trackType))
|
||||||
|
if width != 0 {
|
||||||
|
w.uint32(addTrackFieldWidth, width)
|
||||||
|
}
|
||||||
|
if height != 0 {
|
||||||
|
w.uint32(addTrackFieldHeight, height)
|
||||||
|
}
|
||||||
|
w.int32(addTrackFieldSource, int32(source))
|
||||||
|
if trackType == trackTypeVideo {
|
||||||
|
w.message(addTrackFieldLayers, encVideoLayer(videoQualityHigh, width, height))
|
||||||
|
}
|
||||||
|
return w.buf
|
||||||
|
}
|
||||||
|
|
||||||
|
func encPing(timestamp int64) []byte {
|
||||||
|
w := pbWriter{}
|
||||||
|
w.int64(pingFieldTimestamp, timestamp)
|
||||||
|
return w.buf
|
||||||
|
}
|
||||||
|
|
||||||
|
func encUserPacket(payload []byte) []byte {
|
||||||
|
w := pbWriter{}
|
||||||
|
w.bytes(userPacketFieldPayload, payload)
|
||||||
|
return w.buf
|
||||||
|
}
|
||||||
|
|
||||||
|
func encSignalRequestOffer(sd sessionDescription) []byte {
|
||||||
|
w := pbWriter{}
|
||||||
|
w.message(signalReqOffer, encSessionDescription(sd))
|
||||||
|
return w.buf
|
||||||
|
}
|
||||||
|
|
||||||
|
func encSignalRequestAnswer(sd sessionDescription) []byte {
|
||||||
|
w := pbWriter{}
|
||||||
|
w.message(signalReqAnswer, encSessionDescription(sd))
|
||||||
|
return w.buf
|
||||||
|
}
|
||||||
|
|
||||||
|
func encSignalRequestTrickle(m trickleMsg) []byte {
|
||||||
|
w := pbWriter{}
|
||||||
|
w.message(signalReqTrickle, encTrickle(m))
|
||||||
|
return w.buf
|
||||||
|
}
|
||||||
|
|
||||||
|
func encSignalRequestAddTrack(cid, name string, trackType, source int, width, height uint32) []byte {
|
||||||
|
w := pbWriter{}
|
||||||
|
w.message(signalReqAddTrack, encAddTrack(cid, name, trackType, source, width, height))
|
||||||
|
return w.buf
|
||||||
|
}
|
||||||
|
|
||||||
|
func encSignalRequestLeave() []byte {
|
||||||
|
w := pbWriter{}
|
||||||
|
w.message(signalReqLeave, []byte{})
|
||||||
|
return w.buf
|
||||||
|
}
|
||||||
|
|
||||||
|
func encSignalRequestPing(timestamp int64) []byte {
|
||||||
|
w := pbWriter{}
|
||||||
|
w.int64(signalReqPingLegacy, timestamp)
|
||||||
|
w.message(signalReqPingReq, encPing(timestamp))
|
||||||
|
return w.buf
|
||||||
|
}
|
||||||
|
|
||||||
|
func decSessionDescription(data []byte) (sessionDescription, error) {
|
||||||
|
r := pbReader{buf: data}
|
||||||
|
var sd sessionDescription
|
||||||
|
for !r.eof() {
|
||||||
|
field, wire, err := r.tag()
|
||||||
|
if err != nil {
|
||||||
|
return sd, err
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case field == sdpFieldType && wire == wireBytes:
|
||||||
|
b, err := r.bytes()
|
||||||
|
if err != nil {
|
||||||
|
return sd, err
|
||||||
|
}
|
||||||
|
sd.Type = string(b)
|
||||||
|
case field == sdpFieldSDP && wire == wireBytes:
|
||||||
|
b, err := r.bytes()
|
||||||
|
if err != nil {
|
||||||
|
return sd, err
|
||||||
|
}
|
||||||
|
sd.SDP = string(b)
|
||||||
|
case field == sdpFieldID && wire == wireVarint:
|
||||||
|
v, err := r.varint()
|
||||||
|
if err != nil {
|
||||||
|
return sd, err
|
||||||
|
}
|
||||||
|
sd.ID = uint32(v)
|
||||||
|
default:
|
||||||
|
if err := r.skipWire(wire); err != nil {
|
||||||
|
return sd, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sd, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decTrickle(data []byte) (trickleMsg, error) {
|
||||||
|
r := pbReader{buf: data}
|
||||||
|
var m trickleMsg
|
||||||
|
for !r.eof() {
|
||||||
|
field, wire, err := r.tag()
|
||||||
|
if err != nil {
|
||||||
|
return m, err
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case field == trickleFieldCandidate && wire == wireBytes:
|
||||||
|
b, err := r.bytes()
|
||||||
|
if err != nil {
|
||||||
|
return m, err
|
||||||
|
}
|
||||||
|
m.CandidateInit = string(b)
|
||||||
|
case field == trickleFieldTarget && wire == wireVarint:
|
||||||
|
v, err := r.varint()
|
||||||
|
if err != nil {
|
||||||
|
return m, err
|
||||||
|
}
|
||||||
|
m.Target = int(v)
|
||||||
|
case field == trickleFieldFinal && wire == wireVarint:
|
||||||
|
v, err := r.varint()
|
||||||
|
if err != nil {
|
||||||
|
return m, err
|
||||||
|
}
|
||||||
|
m.Final = v != 0
|
||||||
|
default:
|
||||||
|
if err := r.skipWire(wire); err != nil {
|
||||||
|
return m, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decICEServer(data []byte) (iceServer, error) {
|
||||||
|
r := pbReader{buf: data}
|
||||||
|
var s iceServer
|
||||||
|
for !r.eof() {
|
||||||
|
field, wire, err := r.tag()
|
||||||
|
if err != nil {
|
||||||
|
return s, err
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case field == iceServerFieldURLs && wire == wireBytes:
|
||||||
|
b, err := r.bytes()
|
||||||
|
if err != nil {
|
||||||
|
return s, err
|
||||||
|
}
|
||||||
|
s.URLs = append(s.URLs, string(b))
|
||||||
|
case field == iceServerFieldUsername && wire == wireBytes:
|
||||||
|
b, err := r.bytes()
|
||||||
|
if err != nil {
|
||||||
|
return s, err
|
||||||
|
}
|
||||||
|
s.Username = string(b)
|
||||||
|
case field == iceServerFieldCredential && wire == wireBytes:
|
||||||
|
b, err := r.bytes()
|
||||||
|
if err != nil {
|
||||||
|
return s, err
|
||||||
|
}
|
||||||
|
s.Credential = string(b)
|
||||||
|
default:
|
||||||
|
if err := r.skipWire(wire); err != nil {
|
||||||
|
return s, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decRoom(data []byte) (string, string, error) {
|
||||||
|
r := pbReader{buf: data}
|
||||||
|
var sid, name string
|
||||||
|
for !r.eof() {
|
||||||
|
field, wire, err := r.tag()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case field == roomFieldSID && wire == wireBytes:
|
||||||
|
b, err := r.bytes()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
sid = string(b)
|
||||||
|
case field == roomFieldName && wire == wireBytes:
|
||||||
|
b, err := r.bytes()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
name = string(b)
|
||||||
|
default:
|
||||||
|
if err := r.skipWire(wire); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sid, name, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decParticipant(data []byte) (string, string, error) {
|
||||||
|
info := DecodeParticipantInfo(data)
|
||||||
|
return info.SID, info.Identity, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decJoinResponse(data []byte) (joinResponse, error) {
|
||||||
|
r := pbReader{buf: data}
|
||||||
|
var jr joinResponse
|
||||||
|
for !r.eof() {
|
||||||
|
field, wire, err := r.tag()
|
||||||
|
if err != nil {
|
||||||
|
return jr, err
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case field == joinFieldRoom && wire == wireBytes:
|
||||||
|
b, err := r.bytes()
|
||||||
|
if err != nil {
|
||||||
|
return jr, err
|
||||||
|
}
|
||||||
|
sid, name, _ := decRoom(b)
|
||||||
|
jr.RoomSID = sid
|
||||||
|
jr.RoomName = name
|
||||||
|
case field == joinFieldParticipant && wire == wireBytes:
|
||||||
|
b, err := r.bytes()
|
||||||
|
if err != nil {
|
||||||
|
return jr, err
|
||||||
|
}
|
||||||
|
sid, identity, _ := decParticipant(b)
|
||||||
|
jr.ParticipantSID = sid
|
||||||
|
jr.ParticipantID = identity
|
||||||
|
case field == joinFieldServerVersion && wire == wireBytes:
|
||||||
|
b, err := r.bytes()
|
||||||
|
if err != nil {
|
||||||
|
return jr, err
|
||||||
|
}
|
||||||
|
jr.ServerVersion = string(b)
|
||||||
|
case field == joinFieldICEServers && wire == wireBytes:
|
||||||
|
b, err := r.bytes()
|
||||||
|
if err != nil {
|
||||||
|
return jr, err
|
||||||
|
}
|
||||||
|
s, _ := decICEServer(b)
|
||||||
|
jr.ICEServers = append(jr.ICEServers, s)
|
||||||
|
case field == joinFieldSubscriberPrimary && wire == wireVarint:
|
||||||
|
v, err := r.varint()
|
||||||
|
if err != nil {
|
||||||
|
return jr, err
|
||||||
|
}
|
||||||
|
jr.SubscriberPrimary = v != 0
|
||||||
|
case field == joinFieldServerRegion && wire == wireBytes:
|
||||||
|
b, err := r.bytes()
|
||||||
|
if err != nil {
|
||||||
|
return jr, err
|
||||||
|
}
|
||||||
|
jr.ServerRegion = string(b)
|
||||||
|
case field == joinFieldPingTimeout && wire == wireVarint:
|
||||||
|
v, err := r.varint()
|
||||||
|
if err != nil {
|
||||||
|
return jr, err
|
||||||
|
}
|
||||||
|
jr.PingTimeoutSec = int32(v)
|
||||||
|
case field == joinFieldPingInterval && wire == wireVarint:
|
||||||
|
v, err := r.varint()
|
||||||
|
if err != nil {
|
||||||
|
return jr, err
|
||||||
|
}
|
||||||
|
jr.PingIntervalSec = int32(v)
|
||||||
|
case field == joinFieldOtherParticipants && wire == wireBytes:
|
||||||
|
if _, err := r.bytes(); err != nil {
|
||||||
|
return jr, err
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if err := r.skipWire(wire); err != nil {
|
||||||
|
return jr, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return jr, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decSignalResponse(data []byte) (signalResponse, error) {
|
||||||
|
r := pbReader{buf: data}
|
||||||
|
var sr signalResponse
|
||||||
|
for !r.eof() {
|
||||||
|
field, wire, err := r.tag()
|
||||||
|
if err != nil {
|
||||||
|
return sr, err
|
||||||
|
}
|
||||||
|
if wire != wireBytes {
|
||||||
|
if err := r.skipWire(wire); err != nil {
|
||||||
|
return sr, err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
inner, err := r.bytes()
|
||||||
|
if err != nil {
|
||||||
|
return sr, err
|
||||||
|
}
|
||||||
|
sr.Kind = int(field)
|
||||||
|
switch field {
|
||||||
|
case signalRespJoin:
|
||||||
|
jr, err := decJoinResponse(inner)
|
||||||
|
if err != nil {
|
||||||
|
return sr, err
|
||||||
|
}
|
||||||
|
sr.Join = &jr
|
||||||
|
case signalRespAnswer, signalRespOffer:
|
||||||
|
sd, err := decSessionDescription(inner)
|
||||||
|
if err != nil {
|
||||||
|
return sr, err
|
||||||
|
}
|
||||||
|
sr.SDP = &sd
|
||||||
|
case signalRespTrickle:
|
||||||
|
tm, err := decTrickle(inner)
|
||||||
|
if err != nil {
|
||||||
|
return sr, err
|
||||||
|
}
|
||||||
|
sr.Trickle = &tm
|
||||||
|
case signalRespRefreshToken:
|
||||||
|
sr.Token = string(inner)
|
||||||
|
case signalRespLeave:
|
||||||
|
li := DecodeLeaveRequest(inner)
|
||||||
|
sr.Leave = &li
|
||||||
|
case signalRespUpdate:
|
||||||
|
sr.Participants = DecodeParticipantUpdate(inner)
|
||||||
|
}
|
||||||
|
return sr, nil
|
||||||
|
}
|
||||||
|
return sr, nil
|
||||||
|
}
|
||||||
132
transport/call/livekit/wire.go
Normal file
132
transport/call/livekit/wire.go
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
package livekit
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
const (
|
||||||
|
wireVarint = 0
|
||||||
|
wireFixed64 = 1
|
||||||
|
wireBytes = 2
|
||||||
|
wireFixed32 = 5
|
||||||
|
)
|
||||||
|
|
||||||
|
type pbWriter struct{ buf []byte }
|
||||||
|
|
||||||
|
func (w *pbWriter) varint(v uint64) {
|
||||||
|
for v >= 0x80 {
|
||||||
|
w.buf = append(w.buf, byte(v)|0x80)
|
||||||
|
v >>= 7
|
||||||
|
}
|
||||||
|
w.buf = append(w.buf, byte(v))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *pbWriter) tag(field, wire uint64) { w.varint(field<<3 | wire) }
|
||||||
|
|
||||||
|
func (w *pbWriter) string(field uint64, s string) {
|
||||||
|
w.tag(field, wireBytes)
|
||||||
|
w.varint(uint64(len(s)))
|
||||||
|
w.buf = append(w.buf, s...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *pbWriter) bytes(field uint64, b []byte) {
|
||||||
|
w.tag(field, wireBytes)
|
||||||
|
w.varint(uint64(len(b)))
|
||||||
|
w.buf = append(w.buf, b...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *pbWriter) message(field uint64, b []byte) { w.bytes(field, b) }
|
||||||
|
|
||||||
|
func (w *pbWriter) int32(field uint64, v int32) {
|
||||||
|
w.tag(field, wireVarint)
|
||||||
|
w.varint(uint64(uint32(v)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *pbWriter) int64(field uint64, v int64) {
|
||||||
|
w.tag(field, wireVarint)
|
||||||
|
w.varint(uint64(v))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *pbWriter) uint32(field uint64, v uint32) {
|
||||||
|
w.tag(field, wireVarint)
|
||||||
|
w.varint(uint64(v))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *pbWriter) bool(field uint64, v bool) {
|
||||||
|
w.tag(field, wireVarint)
|
||||||
|
if v {
|
||||||
|
w.varint(1)
|
||||||
|
} else {
|
||||||
|
w.varint(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type pbReader struct {
|
||||||
|
buf []byte
|
||||||
|
pos int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pbReader) eof() bool { return r.pos >= len(r.buf) }
|
||||||
|
|
||||||
|
func (r *pbReader) varint() (uint64, error) {
|
||||||
|
var v uint64
|
||||||
|
var shift uint
|
||||||
|
for {
|
||||||
|
if r.pos >= len(r.buf) {
|
||||||
|
return 0, fmt.Errorf("varint: unexpected eof")
|
||||||
|
}
|
||||||
|
b := r.buf[r.pos]
|
||||||
|
r.pos++
|
||||||
|
v |= uint64(b&0x7f) << shift
|
||||||
|
if b < 0x80 {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
shift += 7
|
||||||
|
if shift >= 64 {
|
||||||
|
return 0, fmt.Errorf("varint: overflow")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pbReader) tag() (field, wire uint64, err error) {
|
||||||
|
t, err := r.varint()
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
return t >> 3, t & 7, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pbReader) bytes() ([]byte, error) {
|
||||||
|
n, err := r.varint()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if r.pos+int(n) > len(r.buf) {
|
||||||
|
return nil, fmt.Errorf("bytes: short read")
|
||||||
|
}
|
||||||
|
out := r.buf[r.pos : r.pos+int(n)]
|
||||||
|
r.pos += int(n)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pbReader) skipWire(wire uint64) error {
|
||||||
|
switch wire {
|
||||||
|
case wireVarint:
|
||||||
|
_, err := r.varint()
|
||||||
|
return err
|
||||||
|
case wireFixed64:
|
||||||
|
if r.pos+8 > len(r.buf) {
|
||||||
|
return fmt.Errorf("skip: short fixed64")
|
||||||
|
}
|
||||||
|
r.pos += 8
|
||||||
|
return nil
|
||||||
|
case wireBytes:
|
||||||
|
_, err := r.bytes()
|
||||||
|
return err
|
||||||
|
case wireFixed32:
|
||||||
|
if r.pos+4 > len(r.buf) {
|
||||||
|
return fmt.Errorf("skip: short fixed32")
|
||||||
|
}
|
||||||
|
r.pos += 4
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("unknown wire type %d", wire)
|
||||||
|
}
|
||||||
341
transport/call/telemost/api.go
Normal file
341
transport/call/telemost/api.go
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
package telemost
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
mathrand "math/rand"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/pion/interceptor"
|
||||||
|
"github.com/pion/webrtc/v4"
|
||||||
|
"github.com/sagernet/sing-box/transport/call/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
APIBase = "https://cloud-api.yandex.ru/telemost_front/v2/telemost"
|
||||||
|
Origin = "https://telemost.yandex.ru"
|
||||||
|
)
|
||||||
|
|
||||||
|
var CapabilitiesOffer = map[string][]string{
|
||||||
|
"offerAnswerMode": {"SEPARATE"},
|
||||||
|
"initialSubscriberOffer": {"ON_HELLO"},
|
||||||
|
"slotsMode": {"FROM_CONTROLLER"},
|
||||||
|
"simulcastMode": {"DISABLED", "STATIC"},
|
||||||
|
"selfVadStatus": {"FROM_SERVER", "FROM_CLIENT"},
|
||||||
|
"dataChannelSharing": {"TO_RTP"},
|
||||||
|
"videoEncoderConfig": {"NO_CONFIG", "ONLY_INIT_CONFIG", "RUNTIME_CONFIG"},
|
||||||
|
"dataChannelVideoCodec": {"VP8", "UNIQUE_CODEC_FROM_TRACK_DESCRIPTION"},
|
||||||
|
"bandwidthLimitationReason": {"BANDWIDTH_REASON_DISABLED", "BANDWIDTH_REASON_ENABLED"},
|
||||||
|
"sdkDefaultDeviceManagement": {"SDK_DEFAULT_DEVICE_MANAGEMENT_DISABLED", "SDK_DEFAULT_DEVICE_MANAGEMENT_ENABLED"},
|
||||||
|
"joinOrderLayout": {"JOIN_ORDER_LAYOUT_DISABLED", "JOIN_ORDER_LAYOUT_ENABLED"},
|
||||||
|
"pinLayout": {"PIN_LAYOUT_DISABLED"},
|
||||||
|
"sendSelfViewVideoSlot": {"SEND_SELF_VIEW_VIDEO_SLOT_DISABLED", "SEND_SELF_VIEW_VIDEO_SLOT_ENABLED"},
|
||||||
|
"serverLayoutTransition": {"SERVER_LAYOUT_TRANSITION_DISABLED"},
|
||||||
|
"sdkPublisherOptimizeBitrate": {"SDK_PUBLISHER_OPTIMIZE_BITRATE_DISABLED", "SDK_PUBLISHER_OPTIMIZE_BITRATE_FULL", "SDK_PUBLISHER_OPTIMIZE_BITRATE_ONLY_SELF"},
|
||||||
|
"sdkNetworkLostDetection": {"SDK_NETWORK_LOST_DETECTION_DISABLED"},
|
||||||
|
"sdkNetworkPathMonitor": {"SDK_NETWORK_PATH_MONITOR_DISABLED"},
|
||||||
|
"publisherVp9": {"PUBLISH_VP9_DISABLED", "PUBLISH_VP9_ENABLED"},
|
||||||
|
"svcMode": {"SVC_MODE_DISABLED", "SVC_MODE_L3T3", "SVC_MODE_L3T3_KEY"},
|
||||||
|
"subscriberOfferAsyncAck": {"SUBSCRIBER_OFFER_ASYNC_ACK_DISABLED", "SUBSCRIBER_OFFER_ASYNC_ACK_ENABLED"},
|
||||||
|
"subscriberDtlsPassiveMode": {"SUBSCRIBER_DTLS_PASSIVE_MODE_DISABLED", "SUBSCRIBER_DTLS_PASSIVE_MODE_ENABLED"},
|
||||||
|
"androidBluetoothRoutingFix": {"ANDROID_BLUETOOTH_ROUTING_FIX_DISABLED"},
|
||||||
|
"fixedIceCandidatesPoolSize": {"FIXED_ICE_CANDIDATES_POOL_SIZE_DISABLED"},
|
||||||
|
"sdkAndroidTelecomIntegration": {"SDK_ANDROID_TELECOM_INTEGRATION_DISABLED"},
|
||||||
|
"setActiveCodecsMode": {"SET_ACTIVE_CODECS_MODE_DISABLED", "SET_ACTIVE_CODECS_MODE_VIDEO_ONLY"},
|
||||||
|
"publisherOpusDred": {"PUBLISHER_OPUS_DRED_DISABLED"},
|
||||||
|
"publisherOpusLowBitrate": {"PUBLISHER_OPUS_LOW_BITRATE_DISABLED"},
|
||||||
|
"sdkAndroidDestroySessionOnTaskRemoved": {"SDK_ANDROID_DESTROY_SESSION_ON_TASK_REMOVED_DISABLED"},
|
||||||
|
"svcModes": {"FALSE"},
|
||||||
|
"reportTelemetryModes": {"TRUE"},
|
||||||
|
"keepDefaultDevicesModes": {"FALSE"},
|
||||||
|
}
|
||||||
|
|
||||||
|
var StartupSlotSizes = [][][2]int{
|
||||||
|
{{0, 0}, {0, 0}, {0, 0}, {0, 0}, {0, 0}, {0, 0}, {0, 0}, {0, 0}, {0, 0}, {0, 0}, {0, 0}, {0, 0}},
|
||||||
|
{{464, 261}, {464, 261}, {464, 261}, {336, 189}, {272, 153}, {272, 153}, {272, 153}, {272, 153}, {224, 126}, {224, 126}, {224, 126}, {224, 126}},
|
||||||
|
{{464, 261}, {464, 261}, {464, 261}, {336, 189}, {272, 153}, {272, 153}, {272, 153}, {272, 153}, {224, 126}, {224, 126}, {224, 126}, {224, 126}},
|
||||||
|
{{672, 378}, {672, 378}, {464, 261}, {336, 189}, {320, 180}, {320, 180}, {320, 180}, {320, 180}, {272, 153}, {272, 153}, {224, 126}, {224, 126}},
|
||||||
|
}
|
||||||
|
|
||||||
|
type SlotBindEvent struct {
|
||||||
|
Slot int
|
||||||
|
ParticipantID string
|
||||||
|
Mid string
|
||||||
|
Reason string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
HTTP *http.Client
|
||||||
|
Cookie string
|
||||||
|
UserAgent string
|
||||||
|
AppVersion string
|
||||||
|
InstanceID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Do(method, path string, body interface{}) ([]byte, int, error) {
|
||||||
|
var bodyReader io.Reader
|
||||||
|
if body != nil {
|
||||||
|
data, _ := json.Marshal(body)
|
||||||
|
bodyReader = strings.NewReader(string(data))
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest(method, APIBase+path, bodyReader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
ua := c.UserAgent
|
||||||
|
if ua == "" {
|
||||||
|
ua = common.UserAgent
|
||||||
|
}
|
||||||
|
instanceID := c.InstanceID
|
||||||
|
if instanceID == "" {
|
||||||
|
instanceID = uuid.New().String()
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", ua)
|
||||||
|
req.Header.Set("Origin", Origin)
|
||||||
|
req.Header.Set("Referer", Origin+"/")
|
||||||
|
req.Header.Set("Client-Instance-Id", instanceID)
|
||||||
|
if c.Cookie != "" {
|
||||||
|
req.Header.Set("Cookie", c.Cookie)
|
||||||
|
}
|
||||||
|
if c.AppVersion != "" {
|
||||||
|
req.Header.Set("X-Telemost-Client-Version", c.AppVersion)
|
||||||
|
}
|
||||||
|
if body != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
client := c.HTTP
|
||||||
|
if client == nil {
|
||||||
|
client = http.DefaultClient
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
data, err := io.ReadAll(resp.Body)
|
||||||
|
return data, resp.StatusCode, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) TMRequest(method, path string) ([]byte, int, error) {
|
||||||
|
return c.Do(method, path, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) RequestStates(joinURI, peerID string) error {
|
||||||
|
confURL := url.QueryEscape(joinURI)
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"peers": []map[string]string{{"peer_id": peerID}},
|
||||||
|
"permissions": map[string]interface{}{},
|
||||||
|
"conference": map[string]interface{}{"version": -1},
|
||||||
|
}
|
||||||
|
r, status, err := c.Do("POST", "/conferences/"+confURL+"/request-states", body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if status != 200 {
|
||||||
|
return fmt.Errorf("status %d: %s", status, string(r))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAPI(settingEngine *webrtc.SettingEngine) (*webrtc.API, error) {
|
||||||
|
mediaEngine := &webrtc.MediaEngine{}
|
||||||
|
if err := mediaEngine.RegisterDefaultCodecs(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, uri := range []string{
|
||||||
|
"urn:ietf:params:rtp-hdrext:toffset",
|
||||||
|
"http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time",
|
||||||
|
"urn:3gpp:video-orientation",
|
||||||
|
"http://www.webrtc.org/experiments/rtp-hdrext/playout-delay",
|
||||||
|
"http://www.webrtc.org/experiments/rtp-hdrext/video-content-type",
|
||||||
|
"http://www.webrtc.org/experiments/rtp-hdrext/video-timing",
|
||||||
|
"http://www.webrtc.org/experiments/rtp-hdrext/color-space",
|
||||||
|
} {
|
||||||
|
if err := mediaEngine.RegisterHeaderExtension(
|
||||||
|
webrtc.RTPHeaderExtensionCapability{URI: uri},
|
||||||
|
webrtc.RTPCodecTypeVideo,
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("register header extension %s: %w", uri, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
registry := &interceptor.Registry{}
|
||||||
|
if err := webrtc.RegisterDefaultInterceptors(mediaEngine, registry); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
opts := []func(*webrtc.API){
|
||||||
|
webrtc.WithMediaEngine(mediaEngine),
|
||||||
|
webrtc.WithInterceptorRegistry(registry),
|
||||||
|
}
|
||||||
|
if settingEngine != nil {
|
||||||
|
opts = append(opts, webrtc.WithSettingEngine(*settingEngine))
|
||||||
|
}
|
||||||
|
return webrtc.NewAPI(opts...), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPeerConnection(config webrtc.Configuration) (*webrtc.PeerConnection, error) {
|
||||||
|
api, err := NewAPI(nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return api.NewPeerConnection(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
func MungeSDPAddVideoContent(sdp string) string {
|
||||||
|
lines := strings.Split(sdp, "\r\n")
|
||||||
|
out := make([]string, 0, len(lines)+4)
|
||||||
|
inVideo := false
|
||||||
|
inserted := false
|
||||||
|
for _, line := range lines {
|
||||||
|
if strings.HasPrefix(line, "m=") {
|
||||||
|
if inVideo && !inserted {
|
||||||
|
out = append(out, "a=content:speaker,main")
|
||||||
|
inserted = true
|
||||||
|
}
|
||||||
|
inVideo = strings.HasPrefix(line, "m=video")
|
||||||
|
inserted = false
|
||||||
|
}
|
||||||
|
out = append(out, line)
|
||||||
|
if inVideo && !inserted && strings.HasPrefix(line, "a=mid:") {
|
||||||
|
out = append(out, "a=content:speaker,main")
|
||||||
|
inserted = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(out, "\r\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func SlotsConfigBindings(v interface{}) []SlotBindEvent {
|
||||||
|
m, ok := v.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
slots, _ := m["slots"].([]interface{})
|
||||||
|
var out []SlotBindEvent
|
||||||
|
for idx, s := range slots {
|
||||||
|
sm, _ := s.(map[string]interface{})
|
||||||
|
if pv, _ := sm["participantVideoByMid"].(map[string]interface{}); pv != nil {
|
||||||
|
pid, _ := pv["participantId"].(string)
|
||||||
|
mid, _ := pv["mid"].(string)
|
||||||
|
reason, _ := pv["limitationReason"].(string)
|
||||||
|
out = append(out, SlotBindEvent{Slot: idx, ParticipantID: pid, Mid: mid, Reason: reason})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if p, _ := sm["participant"].(map[string]interface{}); p != nil {
|
||||||
|
pid, _ := p["participantId"].(string)
|
||||||
|
out = append(out, SlotBindEvent{Slot: idx, ParticipantID: pid})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func BriefJSON(v interface{}) string {
|
||||||
|
const max = 240
|
||||||
|
b, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("<json err: %v>", err)
|
||||||
|
}
|
||||||
|
if len(b) > max {
|
||||||
|
return string(b[:max]) + "...(+" + fmt.Sprintf("%d", len(b)-max) + "B)"
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetSlotsMessage(key int) map[string]interface{} {
|
||||||
|
rnd := mathrand.New(mathrand.NewSource(time.Now().UnixNano()))
|
||||||
|
return slotsMessageWithSizes(key, StartupSlotSizes[len(StartupSlotSizes)-1], rnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func StartupSetSlotsMessage(i, key int) map[string]interface{} {
|
||||||
|
rnd := mathrand.New(mathrand.NewSource(time.Now().UnixNano() + int64(i)))
|
||||||
|
return slotsMessageWithSizes(key, StartupSlotSizes[i], rnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetSlotsOffsetMessage(offset int) map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"uid": uuid.New().String(),
|
||||||
|
"setSlotsOffset": map[string]interface{}{"offset": offset},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func SdkCodecsInfoMessage() map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"uid": uuid.New().String(),
|
||||||
|
"sdkCodecsInfo": map[string]interface{}{
|
||||||
|
"vp8": map[string]interface{}{
|
||||||
|
"supported": "CODEC_FEATURE_SUPPORTED",
|
||||||
|
"hwDecode": "CODEC_FEATURE_NOT_SUPPORTED",
|
||||||
|
"hwEncode": "CODEC_FEATURE_NOT_SUPPORTED",
|
||||||
|
"isoString": "vp8",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdatePublisherTrackDescriptionMessage(pc *webrtc.PeerConnection, audioLabel, videoLabel string) map[string]interface{} {
|
||||||
|
descs := []map[string]interface{}{}
|
||||||
|
for _, tr := range pc.GetTransceivers() {
|
||||||
|
sender := tr.Sender()
|
||||||
|
if sender == nil || sender.Track() == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kind := strings.ToUpper(sender.Track().Kind().String())
|
||||||
|
mid := tr.Mid()
|
||||||
|
label := videoLabel
|
||||||
|
groupId := 2
|
||||||
|
if kind == "AUDIO" {
|
||||||
|
label = audioLabel
|
||||||
|
groupId = 1
|
||||||
|
}
|
||||||
|
descs = append(descs, map[string]interface{}{
|
||||||
|
"mid": mid,
|
||||||
|
"transceiverMid": mid,
|
||||||
|
"kind": kind,
|
||||||
|
"priority": 0,
|
||||||
|
"label": label,
|
||||||
|
"codecs": map[string]interface{}{},
|
||||||
|
"groupId": groupId,
|
||||||
|
"description": "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return map[string]interface{}{
|
||||||
|
"uid": uuid.New().String(),
|
||||||
|
"updatePublisherTrackDescription": map[string]interface{}{
|
||||||
|
"publisherTrackDescriptions": descs,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func jitterSize(width int, rnd *mathrand.Rand) (int, int) {
|
||||||
|
if width == 0 {
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
w := width + rnd.Intn(11) - 5
|
||||||
|
return w, w * 9 / 16
|
||||||
|
}
|
||||||
|
|
||||||
|
func slotsMessageWithSizes(key int, template [][2]int, rnd *mathrand.Rand) map[string]interface{} {
|
||||||
|
slots := make([]map[string]interface{}, len(template))
|
||||||
|
for i, wh := range template {
|
||||||
|
w, h := wh[0], wh[1]
|
||||||
|
if rnd != nil {
|
||||||
|
w, h = jitterSize(wh[0], rnd)
|
||||||
|
}
|
||||||
|
slots[i] = map[string]interface{}{"width": w, "height": h}
|
||||||
|
}
|
||||||
|
return map[string]interface{}{
|
||||||
|
"uid": uuid.New().String(),
|
||||||
|
"setSlots": map[string]interface{}{
|
||||||
|
"slots": slots,
|
||||||
|
"audioSlotsCount": 0,
|
||||||
|
"key": key,
|
||||||
|
"shutdownAllVideo": nil,
|
||||||
|
"withSelfView": true,
|
||||||
|
"selfViewVisibility": "ON_LOADING_THEN_SHOW",
|
||||||
|
"gridConfig": map[string]interface{}{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
72
transport/call/telemost/config.go
Normal file
72
transport/call/telemost/config.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package telemost
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
|
||||||
|
"github.com/sagernet/sing-box/transport/call/common"
|
||||||
|
"github.com/sagernet/sing/common/logger"
|
||||||
|
N "github.com/sagernet/sing/common/network"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TMConfig struct {
|
||||||
|
AppVersion string
|
||||||
|
SDKVersion string
|
||||||
|
}
|
||||||
|
|
||||||
|
func FetchConfig(dialer N.Dialer, logger logger.ContextLogger) (TMConfig, error) {
|
||||||
|
var cfg TMConfig
|
||||||
|
page, err := common.HttpGet(dialer, "https://telemost.yandex.ru/")
|
||||||
|
if err != nil {
|
||||||
|
return cfg, fmt.Errorf("failed to fetch telemost.yandex.ru: %w", err)
|
||||||
|
}
|
||||||
|
stateRe := regexp.MustCompile(`<script[^>]*id="preloaded-state"[^>]*>([\s\S]*?)</script>`)
|
||||||
|
stateMatch := stateRe.FindSubmatch(page)
|
||||||
|
if stateMatch == nil {
|
||||||
|
return cfg, fmt.Errorf("preloaded-state not found in page")
|
||||||
|
}
|
||||||
|
var state struct {
|
||||||
|
Config struct {
|
||||||
|
AppVersion string `json:"appVersion"`
|
||||||
|
} `json:"config"`
|
||||||
|
AppVersion string `json:"appVersion"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(stateMatch[1], &state); err != nil {
|
||||||
|
return cfg, fmt.Errorf("failed to parse preloaded-state: %w", err)
|
||||||
|
}
|
||||||
|
cfg.AppVersion = state.Config.AppVersion
|
||||||
|
if cfg.AppVersion == "" {
|
||||||
|
cfg.AppVersion = state.AppVersion
|
||||||
|
}
|
||||||
|
if cfg.AppVersion == "" {
|
||||||
|
return cfg, fmt.Errorf("appVersion not found in preloaded-state")
|
||||||
|
}
|
||||||
|
logger.Debug(fmt.Sprintf("[config] appVersion=%s", cfg.AppVersion))
|
||||||
|
bundleRe := regexp.MustCompile(`https://telemost\.yastatic\.net/s3/telemost/_/main\.\w+\.[a-f0-9]+\.js`)
|
||||||
|
bundleURL := bundleRe.FindString(string(page))
|
||||||
|
if bundleURL == "" {
|
||||||
|
return cfg, fmt.Errorf("main bundle URL not found in page")
|
||||||
|
}
|
||||||
|
logger.Debug(fmt.Sprintf("[config] Found bundle: %s", bundleURL))
|
||||||
|
bundle, err := common.HttpGet(dialer, bundleURL)
|
||||||
|
if err != nil {
|
||||||
|
return cfg, fmt.Errorf("failed to fetch bundle: %w", err)
|
||||||
|
}
|
||||||
|
sdkVerPatterns := []*regexp.Regexp{
|
||||||
|
regexp.MustCompile(`goloom_sdk_version:"(\d+\.\d+\.\d+)"`),
|
||||||
|
regexp.MustCompile(`"@yandex-video-platform/goloom-sdk":"(\d+\.\d+\.\d+)"`),
|
||||||
|
regexp.MustCompile(`goloom-sdk\.(\d+\.\d+\.\d+)\.js`),
|
||||||
|
}
|
||||||
|
for _, re := range sdkVerPatterns {
|
||||||
|
if m := re.FindSubmatch(bundle); m != nil {
|
||||||
|
cfg.SDKVersion = string(m[1])
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cfg.SDKVersion == "" {
|
||||||
|
return cfg, fmt.Errorf("goloom SDK version not found in bundle")
|
||||||
|
}
|
||||||
|
logger.Debug(fmt.Sprintf("[config] app=%s sdk=%s", cfg.AppVersion, cfg.SDKVersion))
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
97
transport/call/telemost/connect.go
Normal file
97
transport/call/telemost/connect.go
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
package telemost
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sagernet/sing-box/adapter"
|
||||||
|
"github.com/sagernet/sing-box/transport/call/common"
|
||||||
|
"github.com/sagernet/sing-box/transport/call/tunnel"
|
||||||
|
"github.com/sagernet/sing/common/logger"
|
||||||
|
N "github.com/sagernet/sing/common/network"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ConnectCreator(ctx context.Context, cookieStr, joinLink string, readBuf int, dialer N.Dialer, logger logger.ContextLogger) (*tunnel.RelayBridge, string, error) {
|
||||||
|
cfg, err := FetchConfig(dialer, logger)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
var connInfo *ConnInfo
|
||||||
|
if joinLink != "" {
|
||||||
|
connInfo, err = joinExistingConference(dialer, cookieStr, joinLink, cfg, logger)
|
||||||
|
} else {
|
||||||
|
connInfo, err = CreateAndJoinCall(dialer, cookieStr, cfg, logger)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
if readBuf <= 0 {
|
||||||
|
readBuf = 32768
|
||||||
|
}
|
||||||
|
bridge := &Bridge{
|
||||||
|
connInfo: connInfo,
|
||||||
|
config: cfg,
|
||||||
|
cookieStr: cookieStr,
|
||||||
|
peers: make(map[string]string),
|
||||||
|
readBuf: readBuf,
|
||||||
|
dialer: dialer,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
go bridge.Run()
|
||||||
|
deadline := time.Now().Add(60 * time.Second)
|
||||||
|
for bridge.activeBridge == nil {
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
return nil, "", fmt.Errorf("telemost: creator tunnel timed out")
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, "", ctx.Err()
|
||||||
|
case <-time.After(200 * time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bridge.activeBridge, connInfo.ConferenceURI, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ConnectJoiner(ctx context.Context, joinLink, displayName string, readBuf int, dialer N.Dialer, dnsRouter adapter.DNSRouter, logger logger.ContextLogger) (tunnel.DataTunnel, error) {
|
||||||
|
if displayName == "" {
|
||||||
|
displayName = "Joiner"
|
||||||
|
}
|
||||||
|
joiner := NewTelemostJoiner(
|
||||||
|
logger,
|
||||||
|
dialer,
|
||||||
|
dnsRouter,
|
||||||
|
nil,
|
||||||
|
common.AddTunnelTracks,
|
||||||
|
common.ReadTrack,
|
||||||
|
)
|
||||||
|
tunCh := make(chan tunnel.DataTunnel, 1)
|
||||||
|
joiner.OnConnected = func(tun tunnel.DataTunnel) {
|
||||||
|
select {
|
||||||
|
case tunCh <- tun:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
params := fmt.Sprintf(`{"joinLink":%q,"displayName":%q}`, joinLink, displayName)
|
||||||
|
go joiner.RunWithParams(params)
|
||||||
|
select {
|
||||||
|
case tun := <-tunCh:
|
||||||
|
return tun, nil
|
||||||
|
case <-ctx.Done():
|
||||||
|
joiner.Close()
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateConferenceForTest(dialer N.Dialer, cookieStr string) (string, error) {
|
||||||
|
nop := logger.NOP()
|
||||||
|
cfg, err := FetchConfig(dialer, nop)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
connInfo, err := CreateAndJoinCall(dialer, cookieStr, cfg, nop)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return connInfo.ConferenceURI, nil
|
||||||
|
}
|
||||||
848
transport/call/telemost/creator.go
Normal file
848
transport/call/telemost/creator.go
Normal file
@@ -0,0 +1,848 @@
|
|||||||
|
package telemost
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
"github.com/pion/webrtc/v4"
|
||||||
|
"github.com/sagernet/sing-box/transport/call/common"
|
||||||
|
"github.com/sagernet/sing-box/transport/call/tunnel"
|
||||||
|
"github.com/sagernet/sing/common/logger"
|
||||||
|
M "github.com/sagernet/sing/common/metadata"
|
||||||
|
N "github.com/sagernet/sing/common/network"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
tmAPIBase = APIBase
|
||||||
|
tmOrigin = Origin
|
||||||
|
tmPingPeriod = 5 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
var clientInstanceID = uuid.New().String()
|
||||||
|
|
||||||
|
type ConnInfo struct {
|
||||||
|
ConferenceURI string
|
||||||
|
RoomID string
|
||||||
|
PeerID string
|
||||||
|
Credentials string
|
||||||
|
MediaServerURL string
|
||||||
|
ServiceName string
|
||||||
|
ICEServers []webrtc.ICEServer
|
||||||
|
StateCheckIntervalS int
|
||||||
|
}
|
||||||
|
|
||||||
|
type Bridge struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
ws *websocket.Conn
|
||||||
|
relay *SFURelay
|
||||||
|
connInfo *ConnInfo
|
||||||
|
config TMConfig
|
||||||
|
cookieStr string
|
||||||
|
pubSeq int
|
||||||
|
subSeq int
|
||||||
|
peers map[string]string
|
||||||
|
readBuf int
|
||||||
|
activeBridge *tunnel.RelayBridge
|
||||||
|
selfName string
|
||||||
|
dialer N.Dialer
|
||||||
|
logger logger.ContextLogger
|
||||||
|
|
||||||
|
setSlotsKey int
|
||||||
|
initBundleSent bool
|
||||||
|
pendingKicks map[string]chan struct{}
|
||||||
|
boundPeers map[string]bool
|
||||||
|
unboundPeers map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func tmRequest(dialer N.Dialer, method, path string, body interface{}, cookieStr string, cfg TMConfig) ([]byte, int, error) {
|
||||||
|
c := Client{HTTP: common.HttpClient(dialer), Cookie: cookieStr, AppVersion: cfg.AppVersion, InstanceID: clientInstanceID}
|
||||||
|
return c.Do(method, path, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseICEServersJSON(raw json.RawMessage) []webrtc.ICEServer {
|
||||||
|
var rawIce []struct {
|
||||||
|
URLs []string `json:"urls"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Credential string `json:"credential"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(raw, &rawIce)
|
||||||
|
var out []webrtc.ICEServer
|
||||||
|
for _, s := range rawIce {
|
||||||
|
ice := webrtc.ICEServer{URLs: s.URLs}
|
||||||
|
if s.Username != "" {
|
||||||
|
ice.Username = s.Username
|
||||||
|
ice.Credential = s.Credential
|
||||||
|
}
|
||||||
|
out = append(out, ice)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func getConnection(dialer N.Dialer, cookieStr, confURL string, cfg TMConfig) (*ConnInfo, error) {
|
||||||
|
r, status, err := tmRequest(dialer, "GET",
|
||||||
|
"/conferences/"+confURL+"/connection?next_gen_media_platform_allowed=true&display_name=Headless&waiting_room_supported=true",
|
||||||
|
nil, cookieStr, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get connection: %w", err)
|
||||||
|
}
|
||||||
|
if status != 200 {
|
||||||
|
return nil, fmt.Errorf("get connection: status %d: %s", status, string(r))
|
||||||
|
}
|
||||||
|
var conn struct {
|
||||||
|
PeerID string `json:"peer_id"`
|
||||||
|
RoomID string `json:"room_id"`
|
||||||
|
Credentials string `json:"credentials"`
|
||||||
|
ClientConfig struct {
|
||||||
|
MediaServerURL string `json:"media_server_url"`
|
||||||
|
ServiceName string `json:"service_name"`
|
||||||
|
ICEServers json.RawMessage `json:"ice_servers"`
|
||||||
|
StateCheckIntervalSecs int `json:"state_check_interval_seconds"`
|
||||||
|
} `json:"client_configuration"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(r, &conn)
|
||||||
|
if conn.ClientConfig.MediaServerURL == "" {
|
||||||
|
return nil, fmt.Errorf("empty media_server_url: %s", string(r))
|
||||||
|
}
|
||||||
|
return &ConnInfo{
|
||||||
|
RoomID: conn.RoomID,
|
||||||
|
PeerID: conn.PeerID,
|
||||||
|
Credentials: conn.Credentials,
|
||||||
|
MediaServerURL: conn.ClientConfig.MediaServerURL,
|
||||||
|
ServiceName: conn.ClientConfig.ServiceName,
|
||||||
|
ICEServers: parseICEServersJSON(conn.ClientConfig.ICEServers),
|
||||||
|
StateCheckIntervalS: conn.ClientConfig.StateCheckIntervalSecs,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinExistingConference(dialer N.Dialer, cookieStr, conferenceURI string, cfg TMConfig, logger logger.ContextLogger) (*ConnInfo, error) {
|
||||||
|
conferenceURI = strings.TrimSpace(conferenceURI)
|
||||||
|
if conferenceURI == "" {
|
||||||
|
return nil, fmt.Errorf("empty -tm-link")
|
||||||
|
}
|
||||||
|
logger.Info(fmt.Sprintf("[auth] Joining existing conference: %s", conferenceURI))
|
||||||
|
info, err := getConnection(dialer, cookieStr, url.QueryEscape(conferenceURI), cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
info.ConferenceURI = conferenceURI
|
||||||
|
logger.Debug(fmt.Sprintf("[auth] peer_id=%s room_id=%s", info.PeerID, info.RoomID))
|
||||||
|
logger.Debug(fmt.Sprintf("[auth] media_server=%s", info.MediaServerURL))
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateAndJoinCall(dialer N.Dialer, cookieStr string, cfg TMConfig, logger logger.ContextLogger) (*ConnInfo, error) {
|
||||||
|
logger.Info("[auth] Creating conference...")
|
||||||
|
r, status, err := tmRequest(dialer, "POST", "/conferences?next_gen_media_platform_allowed=true",
|
||||||
|
struct{}{}, cookieStr, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create conference: %w", err)
|
||||||
|
}
|
||||||
|
if status != 200 && status != 201 {
|
||||||
|
return nil, fmt.Errorf("create conference: status %d: %s", status, string(r))
|
||||||
|
}
|
||||||
|
var conf struct {
|
||||||
|
URI string `json:"uri"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(r, &conf)
|
||||||
|
if conf.URI == "" {
|
||||||
|
return nil, fmt.Errorf("empty conference URI: %s", string(r))
|
||||||
|
}
|
||||||
|
logger.Info(fmt.Sprintf("[auth] Conference: %s", conf.URI))
|
||||||
|
logger.Debug("[auth] Getting connection...")
|
||||||
|
info, err := getConnection(dialer, cookieStr, url.QueryEscape(conf.URI), cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
info.ConferenceURI = conf.URI
|
||||||
|
logger.Debug(fmt.Sprintf("[auth] peer_id=%s room_id=%s", info.PeerID, info.RoomID))
|
||||||
|
logger.Debug(fmt.Sprintf("[auth] media_server=%s", info.MediaServerURL))
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) wsSend(msg interface{}) {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
if b.ws == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(msg)
|
||||||
|
b.ws.WriteMessage(websocket.TextMessage, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) ack(uid string) {
|
||||||
|
b.wsSend(map[string]interface{}{
|
||||||
|
"uid": uid,
|
||||||
|
"ack": map[string]interface{}{
|
||||||
|
"status": map[string]interface{}{"code": "OK", "description": ""},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) sendHello() {
|
||||||
|
b.mu.Lock()
|
||||||
|
b.selfName = "Headless"
|
||||||
|
b.mu.Unlock()
|
||||||
|
b.wsSend(map[string]interface{}{
|
||||||
|
"uid": uuid.New().String(),
|
||||||
|
"hello": map[string]interface{}{
|
||||||
|
"participantMeta": map[string]interface{}{"name": "Headless", "role": "SPEAKER", "description": "", "sendAudio": false, "sendVideo": true},
|
||||||
|
"participantAttributes": map[string]interface{}{"name": "Headless", "role": "SPEAKER", "description": ""},
|
||||||
|
"sendAudio": false, "sendVideo": true, "sendSharing": false,
|
||||||
|
"participantId": b.connInfo.PeerID, "roomId": b.connInfo.RoomID,
|
||||||
|
"serviceName": b.connInfo.ServiceName, "credentials": b.connInfo.Credentials,
|
||||||
|
"capabilitiesOffer": CapabilitiesOffer,
|
||||||
|
"sdkInfo": map[string]interface{}{"implementation": "browser", "version": b.config.SDKVersion, "userAgent": common.UserAgent, "hwConcurrency": 8},
|
||||||
|
"sdkInitializationId": uuid.New().String(),
|
||||||
|
"disablePublisher": false, "disableSubscriber": false, "disableSubscriberAudio": false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
b.logger.Debug("[tm-ws] -> hello")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) sendPubOffer() {
|
||||||
|
offer, err := b.relay.CreatePubOffer()
|
||||||
|
if err != nil {
|
||||||
|
b.logger.Warn(fmt.Sprintf("[tm-ws] pub offer failed: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
audioMid, videoMid := parseMids(offer.SDP)
|
||||||
|
b.logger.Debug(fmt.Sprintf("[tm-ws] -> publisherSdpOffer pcSeq=%d", b.pubSeq))
|
||||||
|
var tracks []map[string]interface{}
|
||||||
|
if audioMid != "" {
|
||||||
|
tracks = append(tracks, map[string]interface{}{"mid": audioMid, "transceiverMid": audioMid, "kind": "AUDIO", "priority": 0, "label": "", "codecs": map[string]interface{}{}, "groupId": 1, "description": ""})
|
||||||
|
}
|
||||||
|
if videoMid != "" {
|
||||||
|
tracks = append(tracks, map[string]interface{}{"mid": videoMid, "transceiverMid": videoMid, "kind": "VIDEO", "priority": 0, "label": "", "codecs": map[string]interface{}{}, "groupId": 2, "description": ""})
|
||||||
|
}
|
||||||
|
b.wsSend(map[string]interface{}{
|
||||||
|
"uid": uuid.New().String(),
|
||||||
|
"publisherSdpOffer": map[string]interface{}{"pcSeq": b.pubSeq, "sdp": offer.SDP, "tracks": tracks},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) sendICE(cand *webrtc.ICECandidate, target string, pcSeq int) {
|
||||||
|
c := cand.ToJSON()
|
||||||
|
mid := ""
|
||||||
|
if c.SDPMid != nil {
|
||||||
|
mid = *c.SDPMid
|
||||||
|
}
|
||||||
|
var idx uint16
|
||||||
|
if c.SDPMLineIndex != nil {
|
||||||
|
idx = *c.SDPMLineIndex
|
||||||
|
}
|
||||||
|
b.wsSend(map[string]interface{}{
|
||||||
|
"uid": uuid.New().String(),
|
||||||
|
"webrtcIceCandidate": map[string]interface{}{
|
||||||
|
"candidate": c.Candidate, "sdpMid": mid,
|
||||||
|
"usernameFragment": extractUfrag(c.Candidate),
|
||||||
|
"sdpMlineIndex": idx, "target": target, "pcSeq": pcSeq,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) requestVideoSlots() {
|
||||||
|
b.setSlotsKey++
|
||||||
|
b.logger.Debug(fmt.Sprintf("[tm-ws] -> setSlots key=%d", b.setSlotsKey))
|
||||||
|
b.wsSend(SetSlotsMessage(b.setSlotsKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) forceReconnect(reason string) {
|
||||||
|
oldPeerID := b.connInfo.PeerID
|
||||||
|
b.logger.Info(fmt.Sprintf("[tm-ws] forcing reconnect: %s", reason))
|
||||||
|
if oldPeerID != "" {
|
||||||
|
b.logger.Debug(fmt.Sprintf("[tm-ws] kicking self pid=%s to leave call cleanly", oldPeerID))
|
||||||
|
if err := b.kickPeer(oldPeerID); err != nil {
|
||||||
|
b.logger.Warn(fmt.Sprintf("[tm-ws] self-kick failed: %v", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clientInstanceID = uuid.New().String()
|
||||||
|
b.logger.Debug(fmt.Sprintf("[tm-ws] new instance-id=%s", clientInstanceID))
|
||||||
|
b.mu.Lock()
|
||||||
|
ws := b.ws
|
||||||
|
b.mu.Unlock()
|
||||||
|
if ws != nil {
|
||||||
|
ws.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) sendInitBundle() {
|
||||||
|
if b.initBundleSent {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.initBundleSent = true
|
||||||
|
b.logger.Debug("[tm-ws] -> sdkCodecsInfo + updatePublisherTrackDescription")
|
||||||
|
b.wsSend(SdkCodecsInfoMessage())
|
||||||
|
b.wsSend(UpdatePublisherTrackDescriptionMessage(b.relay.pubPC, "Microphone", "MacBook Pro Camera (0000:0001)"))
|
||||||
|
b.sendStartupSlotsRamp()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) sendStartupSlotsRamp() {
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
b.setSlotsKey++
|
||||||
|
b.logger.Debug(fmt.Sprintf("[tm-ws] -> setSlots key=%d (startup %d/4)", b.setSlotsKey, i+1))
|
||||||
|
b.wsSend(StartupSetSlotsMessage(i, b.setSlotsKey))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) handleMessage(raw []byte) {
|
||||||
|
var msg map[string]interface{}
|
||||||
|
if err := json.Unmarshal(raw, &msg); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uid, _ := msg["uid"].(string)
|
||||||
|
if sh, ok := msg["serverHello"]; ok {
|
||||||
|
b.logger.Debug("[tm-ws] <- serverHello")
|
||||||
|
if shMap, ok := sh.(map[string]interface{}); ok {
|
||||||
|
b.parseICEServers(shMap)
|
||||||
|
}
|
||||||
|
b.ack(uid)
|
||||||
|
b.logger.Debug("[tm-ws] -> setSlotsOffset")
|
||||||
|
b.wsSend(SetSlotsOffsetMessage(0))
|
||||||
|
b.initRelay()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if pa, ok := msg["publisherSdpAnswer"]; ok {
|
||||||
|
paMap, _ := pa.(map[string]interface{})
|
||||||
|
sdp, _ := paMap["sdp"].(string)
|
||||||
|
b.logger.Debug(fmt.Sprintf("[tm-ws] <- publisherSdpAnswer %d bytes", len(sdp)))
|
||||||
|
if err := b.relay.SetPubAnswer(sdp); err != nil {
|
||||||
|
b.logger.Warn(fmt.Sprintf("[tm-ws] error: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.sendInitBundle()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if so, ok := msg["subscriberSdpOffer"]; ok {
|
||||||
|
soMap, _ := so.(map[string]interface{})
|
||||||
|
sdp, _ := soMap["sdp"].(string)
|
||||||
|
pcSeq, _ := soMap["pcSeq"].(float64)
|
||||||
|
b.subSeq = int(pcSeq)
|
||||||
|
b.logger.Debug(fmt.Sprintf("[tm-ws] <- subscriberSdpOffer pcSeq=%d", b.subSeq))
|
||||||
|
b.ack(uid)
|
||||||
|
answer, err := b.relay.SetSubOffer(sdp)
|
||||||
|
if err != nil {
|
||||||
|
b.logger.Warn(fmt.Sprintf("[tm-ws] error: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.logger.Debug(fmt.Sprintf("[tm-ws] -> subscriberSdpAnswer pcSeq=%d", b.subSeq))
|
||||||
|
b.wsSend(map[string]interface{}{
|
||||||
|
"uid": uuid.New().String(),
|
||||||
|
"subscriberSdpAnswer": map[string]interface{}{"sdp": answer.SDP, "pcSeq": b.subSeq},
|
||||||
|
})
|
||||||
|
b.sendPubOffer()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ic, ok := msg["webrtcIceCandidate"]; ok {
|
||||||
|
icMap, _ := ic.(map[string]interface{})
|
||||||
|
candidate, _ := icMap["candidate"].(string)
|
||||||
|
sdpMid, _ := icMap["sdpMid"].(string)
|
||||||
|
target, _ := icMap["target"].(string)
|
||||||
|
sdpIdx, _ := icMap["sdpMlineIndex"].(float64)
|
||||||
|
idx := uint16(sdpIdx)
|
||||||
|
cand := webrtc.ICECandidateInit{Candidate: candidate, SDPMid: &sdpMid, SDPMLineIndex: &idx}
|
||||||
|
if target == "PUBLISHER" {
|
||||||
|
b.relay.AddPubICECandidate(cand)
|
||||||
|
} else {
|
||||||
|
b.relay.AddSubICECandidate(cand)
|
||||||
|
}
|
||||||
|
b.ack(uid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ackData, ok := msg["ack"]; ok {
|
||||||
|
if ackMap, ok := ackData.(map[string]interface{}); ok {
|
||||||
|
if status, ok := ackMap["status"].(map[string]interface{}); ok {
|
||||||
|
if code, _ := status["code"].(string); code != "OK" {
|
||||||
|
desc, _ := status["description"].(string)
|
||||||
|
b.logger.Warn(fmt.Sprintf("[tm-ws] <- ack error: %s %s", code, desc))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ud, ok := msg["updateDescription"]; ok {
|
||||||
|
b.logger.Debug(fmt.Sprintf("[tm-ws] <- updateDescription %s", BriefJSON(ud)))
|
||||||
|
udMap, _ := ud.(map[string]interface{})
|
||||||
|
descs, _ := udMap["description"].([]interface{})
|
||||||
|
b.applyDescriptionSnapshot(descs)
|
||||||
|
b.ack(uid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ud, ok := msg["upsertDescription"]; ok {
|
||||||
|
udMap, _ := ud.(map[string]interface{})
|
||||||
|
descs, _ := udMap["description"].([]interface{})
|
||||||
|
for _, d := range descs {
|
||||||
|
dm, _ := d.(map[string]interface{})
|
||||||
|
b.applyDescriptionEntry(dm)
|
||||||
|
}
|
||||||
|
b.kickStaleSelves()
|
||||||
|
b.ack(uid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if rd, ok := msg["removeDescription"]; ok {
|
||||||
|
rdMap, _ := rd.(map[string]interface{})
|
||||||
|
ids, _ := rdMap["descriptionId"].([]interface{})
|
||||||
|
for _, id := range ids {
|
||||||
|
pid, _ := id.(string)
|
||||||
|
b.mu.Lock()
|
||||||
|
name := b.peers[pid]
|
||||||
|
delete(b.peers, pid)
|
||||||
|
remaining := len(b.peers)
|
||||||
|
ch, hadPendingKick := b.pendingKicks[pid]
|
||||||
|
if hadPendingKick {
|
||||||
|
delete(b.pendingKicks, pid)
|
||||||
|
}
|
||||||
|
b.mu.Unlock()
|
||||||
|
b.logger.Info(fmt.Sprintf("[tm-ws] Participant left: %s (%s) total=%d", name, pid, remaining))
|
||||||
|
if hadPendingKick {
|
||||||
|
close(ch)
|
||||||
|
}
|
||||||
|
if remaining == 0 {
|
||||||
|
go b.pollAndAdmit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.ack(uid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n, ok := msg["notification"]; ok {
|
||||||
|
b.logger.Debug(fmt.Sprintf("[tm-ws] <- notification %s", BriefJSON(n)))
|
||||||
|
b.ack(uid)
|
||||||
|
go b.pollAndAdmit()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if pc, ok := msg["participantsChanged"]; ok {
|
||||||
|
b.logger.Debug(fmt.Sprintf("[tm-ws] <- participantsChanged %s", BriefJSON(pc)))
|
||||||
|
b.ack(uid)
|
||||||
|
go b.pollAndAdmit()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if sc, ok := msg["slotsConfig"]; ok {
|
||||||
|
b.logger.Debug(fmt.Sprintf("[tm-ws] <- slotsConfig %s", BriefJSON(sc)))
|
||||||
|
needRebind := false
|
||||||
|
presentPids := make(map[string]bool)
|
||||||
|
for _, ev := range SlotsConfigBindings(sc) {
|
||||||
|
fullPid := ev.ParticipantID
|
||||||
|
if fullPid != "" {
|
||||||
|
presentPids[fullPid] = true
|
||||||
|
}
|
||||||
|
pid := fullPid
|
||||||
|
if len(pid) > 8 {
|
||||||
|
pid = pid[:8]
|
||||||
|
}
|
||||||
|
if ev.Reason == "NO_LIMITATION" && ev.Mid != "" {
|
||||||
|
b.logger.Debug(fmt.Sprintf("[bind] BOUND slot=%d pid=%s mid=%s", ev.Slot, pid, ev.Mid))
|
||||||
|
b.mu.Lock()
|
||||||
|
if b.boundPeers == nil {
|
||||||
|
b.boundPeers = make(map[string]bool)
|
||||||
|
}
|
||||||
|
b.boundPeers[fullPid] = true
|
||||||
|
delete(b.unboundPeers, fullPid)
|
||||||
|
b.mu.Unlock()
|
||||||
|
} else if fullPid != "" {
|
||||||
|
b.mu.Lock()
|
||||||
|
wasBound := b.boundPeers[fullPid]
|
||||||
|
if wasBound {
|
||||||
|
if b.unboundPeers == nil {
|
||||||
|
b.unboundPeers = make(map[string]bool)
|
||||||
|
}
|
||||||
|
b.unboundPeers[fullPid] = true
|
||||||
|
delete(b.boundPeers, fullPid)
|
||||||
|
}
|
||||||
|
b.mu.Unlock()
|
||||||
|
if wasBound {
|
||||||
|
b.logger.Debug(fmt.Sprintf("[bind] KILL slot=%d pid=%s reason=%s - rebinding", ev.Slot, pid, ev.Reason))
|
||||||
|
needRebind = true
|
||||||
|
} else {
|
||||||
|
b.logger.Debug(fmt.Sprintf("[bind] UNBOUND slot=%d pid=%s reason=%s mid=%q", ev.Slot, pid, ev.Reason, ev.Mid))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.mu.Lock()
|
||||||
|
for boundPid := range b.boundPeers {
|
||||||
|
if !presentPids[boundPid] {
|
||||||
|
short := boundPid
|
||||||
|
if len(short) > 8 {
|
||||||
|
short = short[:8]
|
||||||
|
}
|
||||||
|
b.logger.Debug(fmt.Sprintf("[bind] VANISHED pid=%s - rebinding", short))
|
||||||
|
delete(b.boundPeers, boundPid)
|
||||||
|
needRebind = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.mu.Unlock()
|
||||||
|
if needRebind {
|
||||||
|
go b.forceReconnect("slot binding killed")
|
||||||
|
}
|
||||||
|
b.ack(uid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for k, v := range msg {
|
||||||
|
if k == "uid" || k == "ack" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.logger.Debug(fmt.Sprintf("[tm-ws] <- %s (unhandled) %s", k, BriefJSON(v)))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if uid != "" {
|
||||||
|
b.ack(uid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) parseICEServers(sh map[string]interface{}) {
|
||||||
|
rtcCfg, ok := sh["rtcConfiguration"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
servers, ok := rtcCfg["iceServers"].([]interface{})
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var iceServers []webrtc.ICEServer
|
||||||
|
for _, s := range servers {
|
||||||
|
sm, _ := s.(map[string]interface{})
|
||||||
|
var urls []string
|
||||||
|
if u, ok := sm["urls"].([]interface{}); ok {
|
||||||
|
for _, v := range u {
|
||||||
|
if vs, ok := v.(string); ok {
|
||||||
|
urls = append(urls, vs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ice := webrtc.ICEServer{URLs: urls}
|
||||||
|
if u, ok := sm["username"].(string); ok && u != "" {
|
||||||
|
ice.Username = u
|
||||||
|
ice.Credential, _ = sm["credential"].(string)
|
||||||
|
}
|
||||||
|
iceServers = append(iceServers, ice)
|
||||||
|
}
|
||||||
|
b.connInfo.ICEServers = iceServers
|
||||||
|
b.logger.Debug(fmt.Sprintf("[tm-ws] %d ICE servers", len(iceServers)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) requestStates() error {
|
||||||
|
c := Client{HTTP: common.HttpClient(b.dialer), Cookie: b.cookieStr, AppVersion: b.config.AppVersion, InstanceID: clientInstanceID}
|
||||||
|
return c.RequestStates(b.connInfo.ConferenceURI, b.connInfo.PeerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) applyDescriptionEntry(dm map[string]interface{}) {
|
||||||
|
pid, _ := dm["id"].(string)
|
||||||
|
if pid == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := ""
|
||||||
|
if meta, ok := dm["meta"].(map[string]interface{}); ok {
|
||||||
|
name, _ = meta["name"].(string)
|
||||||
|
}
|
||||||
|
if pid == b.connInfo.PeerID {
|
||||||
|
b.mu.Lock()
|
||||||
|
if name != "" {
|
||||||
|
b.selfName = name
|
||||||
|
}
|
||||||
|
b.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, disconnected := dm["disconnectedAt"]
|
||||||
|
b.mu.Lock()
|
||||||
|
_, wasKnown := b.peers[pid]
|
||||||
|
if disconnected {
|
||||||
|
delete(b.peers, pid)
|
||||||
|
} else {
|
||||||
|
b.peers[pid] = name
|
||||||
|
}
|
||||||
|
total := len(b.peers)
|
||||||
|
b.mu.Unlock()
|
||||||
|
switch {
|
||||||
|
case disconnected && wasKnown:
|
||||||
|
b.logger.Info(fmt.Sprintf("[tm-ws] Participant left: %s (%s) total=%d", name, pid, total))
|
||||||
|
case disconnected:
|
||||||
|
b.logger.Debug(fmt.Sprintf("[tm-ws] Ghost participant: %s (%s) - kicking", name, pid))
|
||||||
|
go b.kickPeer(pid)
|
||||||
|
case !wasKnown:
|
||||||
|
b.logger.Info(fmt.Sprintf("[tm-ws] Participant joined: %s (%s) total=%d", name, pid, total))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) applyDescriptionSnapshot(descs []interface{}) {
|
||||||
|
b.mu.Lock()
|
||||||
|
b.peers = make(map[string]string)
|
||||||
|
b.mu.Unlock()
|
||||||
|
for _, d := range descs {
|
||||||
|
dm, _ := d.(map[string]interface{})
|
||||||
|
b.applyDescriptionEntry(dm)
|
||||||
|
}
|
||||||
|
b.kickStaleSelves()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) kickStaleSelves() {
|
||||||
|
b.mu.Lock()
|
||||||
|
selfName := b.selfName
|
||||||
|
stale := make([]string, 0)
|
||||||
|
if selfName != "" {
|
||||||
|
for pid, name := range b.peers {
|
||||||
|
if name == selfName {
|
||||||
|
stale = append(stale, pid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.mu.Unlock()
|
||||||
|
for _, pid := range stale {
|
||||||
|
b.logger.Debug(fmt.Sprintf("[tm-ws] Kicking stale self %s (name=%q)", pid, selfName))
|
||||||
|
b.kickPeer(pid)
|
||||||
|
b.mu.Lock()
|
||||||
|
delete(b.peers, pid)
|
||||||
|
b.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) kickPeer(peerID string) error {
|
||||||
|
confURL := url.QueryEscape(b.connInfo.ConferenceURI)
|
||||||
|
b.logger.Debug(fmt.Sprintf("[tm-ws] Kicking %s", peerID))
|
||||||
|
body, status, err := tmRequest(b.dialer, "POST", "/conferences/"+confURL+"/commands/kick?peer_id="+url.QueryEscape(peerID)+"&with_ban=false",
|
||||||
|
nil, b.cookieStr, b.config)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if status >= 400 {
|
||||||
|
return fmt.Errorf("kick %s status %d: %s", peerID, status, string(body))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) pollAndAdmit() {
|
||||||
|
confURL := url.QueryEscape(b.connInfo.ConferenceURI)
|
||||||
|
r, status, err := tmRequest(b.dialer, "GET", "/conferences/"+confURL+"/waiting-rooms/peers", nil, b.cookieStr, b.config)
|
||||||
|
if err != nil || status != 200 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var resp struct {
|
||||||
|
Peers []struct {
|
||||||
|
PeerID string `json:"peer_id"`
|
||||||
|
State struct {
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
} `json:"state"`
|
||||||
|
} `json:"peers"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(r, &resp)
|
||||||
|
if len(resp.Peers) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.mu.Lock()
|
||||||
|
if b.pendingKicks == nil {
|
||||||
|
b.pendingKicks = make(map[string]chan struct{})
|
||||||
|
}
|
||||||
|
toKick := make(map[string]string, len(b.peers))
|
||||||
|
waits := make(map[string]<-chan struct{}, len(b.peers))
|
||||||
|
for pid, name := range b.peers {
|
||||||
|
toKick[pid] = name
|
||||||
|
ch := make(chan struct{})
|
||||||
|
b.pendingKicks[pid] = ch
|
||||||
|
waits[pid] = ch
|
||||||
|
}
|
||||||
|
b.mu.Unlock()
|
||||||
|
for pid, name := range toKick {
|
||||||
|
b.logger.Debug(fmt.Sprintf("[tm-ws] Kicking %s (%s) for one-to-one", name, pid))
|
||||||
|
if err := b.kickPeer(pid); err != nil {
|
||||||
|
b.logger.Warn(fmt.Sprintf("[tm-ws] kick failed: %v", err))
|
||||||
|
b.mu.Lock()
|
||||||
|
delete(b.pendingKicks, pid)
|
||||||
|
b.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for pid, ch := range waits {
|
||||||
|
<-ch
|
||||||
|
b.logger.Debug(fmt.Sprintf("[tm-ws] kick confirmed for %s", pid))
|
||||||
|
}
|
||||||
|
p := resp.Peers[0]
|
||||||
|
b.logger.Debug(fmt.Sprintf("[tm-ws] Admitting %s (%s)", p.State.DisplayName, p.PeerID))
|
||||||
|
tmRequest(b.dialer, "PUT", "/conferences/"+confURL+"/commands/admit?peer_id="+url.QueryEscape(p.PeerID),
|
||||||
|
nil, b.cookieStr, b.config)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) initRelay() {
|
||||||
|
if b.relay != nil {
|
||||||
|
b.relay.Close()
|
||||||
|
}
|
||||||
|
b.pubSeq = 1
|
||||||
|
b.subSeq = 0
|
||||||
|
b.initBundleSent = false
|
||||||
|
relay := NewSFURelay(b.logger)
|
||||||
|
relay.readBufSize = b.readBuf
|
||||||
|
obf, err := tunnel.NewTunnelObfuscator(tunnel.DeriveSecretFromJoinLink(b.connInfo.ConferenceURI))
|
||||||
|
if err != nil {
|
||||||
|
b.logger.Fatal(fmt.Sprintf("[relay] obfuscator init failed: %v", err))
|
||||||
|
}
|
||||||
|
relay.SetObfuscator(obf)
|
||||||
|
b.logger.Debug(fmt.Sprintf("[relay] obfuscator localEpoch=0x%08x", obf.LocalEpoch()))
|
||||||
|
relay.OnPubReady = func() {
|
||||||
|
b.logger.Debug("[relay] pub PC connected")
|
||||||
|
}
|
||||||
|
relay.OnConnected = func(tun *tunnel.VP8DataTunnel) {
|
||||||
|
if b.activeBridge != nil {
|
||||||
|
b.activeBridge.Reset()
|
||||||
|
}
|
||||||
|
b.activeBridge = tunnel.NewRelayBridge(tun, "creator", common.VP8BufSize, b.dialer, b.logger)
|
||||||
|
b.logger.Debug("[relay] tunnel connected")
|
||||||
|
}
|
||||||
|
relay.OnPeerRestart = func() {
|
||||||
|
if b.activeBridge != nil {
|
||||||
|
b.logger.Info("[relay] new peer detected, resetting relay bridge")
|
||||||
|
b.activeBridge.Reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
relay.OnPubICE = func(cand *webrtc.ICECandidate) {
|
||||||
|
if cand == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.sendICE(cand, "PUBLISHER", b.pubSeq)
|
||||||
|
}
|
||||||
|
relay.OnSubICE = func(cand *webrtc.ICECandidate) {
|
||||||
|
if cand == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.sendICE(cand, "SUBSCRIBER", b.subSeq)
|
||||||
|
}
|
||||||
|
if err := relay.Init(b.connInfo.ICEServers); err != nil {
|
||||||
|
b.logger.Fatal(fmt.Sprintf("[relay] init failed: %v", err))
|
||||||
|
}
|
||||||
|
b.relay = relay
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) Run() {
|
||||||
|
wsHeader := http.Header{}
|
||||||
|
wsHeader.Set("User-Agent", common.UserAgent)
|
||||||
|
wsHeader.Set("Origin", tmOrigin)
|
||||||
|
wsDialer := websocket.Dialer{
|
||||||
|
NetDialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
|
return b.dialer.DialContext(ctx, network, M.ParseSocksaddr(addr))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
b.logger.Debug("[tm-ws] Connecting...")
|
||||||
|
ws, _, err := wsDialer.Dial(b.connInfo.MediaServerURL, wsHeader)
|
||||||
|
if err != nil {
|
||||||
|
b.logger.Warn(fmt.Sprintf("[tm-ws] Connect failed: %s, retrying in 5s...", common.MaskError(err)))
|
||||||
|
time.Sleep(5 * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.mu.Lock()
|
||||||
|
b.ws = ws
|
||||||
|
b.mu.Unlock()
|
||||||
|
b.logger.Debug("[tm-ws] Connected")
|
||||||
|
b.sendHello()
|
||||||
|
go b.pollAndAdmit()
|
||||||
|
stopWaitingRoomPoll := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(5 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-stopWaitingRoomPoll:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
b.pollAndAdmit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
stopPing := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(tmPingPeriod)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-stopPing:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
b.wsSend(map[string]interface{}{"uid": uuid.New().String(), "ping": map[string]interface{}{}})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
stopStateKeepalive := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
interval := b.connInfo.StateCheckIntervalS
|
||||||
|
if interval <= 0 {
|
||||||
|
interval = 30
|
||||||
|
}
|
||||||
|
if err := b.requestStates(); err != nil {
|
||||||
|
b.logger.Debug(fmt.Sprintf("[tm-state] initial request-states: %v", err))
|
||||||
|
}
|
||||||
|
ticker := time.NewTicker(time.Duration(interval) * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-stopStateKeepalive:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
if err := b.requestStates(); err != nil {
|
||||||
|
b.logger.Debug(fmt.Sprintf("[tm-state] request-states: %v", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
for {
|
||||||
|
_, raw, err := ws.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
b.logger.Debug(fmt.Sprintf("[tm-ws] Closed: %s", common.MaskError(err)))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
b.handleMessage(raw)
|
||||||
|
}
|
||||||
|
close(stopPing)
|
||||||
|
close(stopStateKeepalive)
|
||||||
|
close(stopWaitingRoomPoll)
|
||||||
|
b.mu.Lock()
|
||||||
|
b.ws = nil
|
||||||
|
b.mu.Unlock()
|
||||||
|
b.logger.Debug("[tm-ws] Rejoining in 3s...")
|
||||||
|
time.Sleep(3 * time.Second)
|
||||||
|
newConn, err := getConnection(b.dialer, b.cookieStr, url.QueryEscape(b.connInfo.ConferenceURI), b.config)
|
||||||
|
if err != nil {
|
||||||
|
b.logger.Warn(fmt.Sprintf("[rejoin] Failed: %v, retrying in 5s...", err))
|
||||||
|
time.Sleep(5 * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.connInfo.PeerID = newConn.PeerID
|
||||||
|
b.connInfo.Credentials = newConn.Credentials
|
||||||
|
b.connInfo.MediaServerURL = newConn.MediaServerURL
|
||||||
|
b.connInfo.ICEServers = newConn.ICEServers
|
||||||
|
b.connInfo.StateCheckIntervalS = newConn.StateCheckIntervalS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseMids(sdp string) (audioMid, videoMid string) {
|
||||||
|
var media string
|
||||||
|
for _, line := range strings.Split(sdp, "\r\n") {
|
||||||
|
if strings.HasPrefix(line, "m=audio") {
|
||||||
|
media = "audio"
|
||||||
|
} else if strings.HasPrefix(line, "m=video") {
|
||||||
|
media = "video"
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(line, "a=mid:") {
|
||||||
|
mid := strings.TrimPrefix(line, "a=mid:")
|
||||||
|
if media == "audio" && audioMid == "" {
|
||||||
|
audioMid = mid
|
||||||
|
} else if media == "video" && videoMid == "" {
|
||||||
|
videoMid = mid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractUfrag(candidate string) string {
|
||||||
|
parts := strings.Split(candidate, " ")
|
||||||
|
for i, p := range parts {
|
||||||
|
if p == "ufrag" && i+1 < len(parts) {
|
||||||
|
return parts[i+1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
933
transport/call/telemost/joiner.go
Normal file
933
transport/call/telemost/joiner.go
Normal file
@@ -0,0 +1,933 @@
|
|||||||
|
package telemost
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
"github.com/pion/webrtc/v4"
|
||||||
|
"github.com/sagernet/sing-box/adapter"
|
||||||
|
"github.com/sagernet/sing-box/common/dialer"
|
||||||
|
"github.com/sagernet/sing-box/transport/call/common"
|
||||||
|
"github.com/sagernet/sing-box/transport/call/tunnel"
|
||||||
|
"github.com/sagernet/sing/common/logger"
|
||||||
|
M "github.com/sagernet/sing/common/metadata"
|
||||||
|
N "github.com/sagernet/sing/common/network"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
TmAPIBase = APIBase
|
||||||
|
TmOrigin = Origin
|
||||||
|
TmPingPeriod = 5 * time.Second
|
||||||
|
telemostReconnectInitialDelay = time.Second
|
||||||
|
telemostReconnectMaxDelay = 16 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
type TelemostJoiner struct {
|
||||||
|
logger logger.ContextLogger
|
||||||
|
OnConnected func(tunnel.DataTunnel)
|
||||||
|
|
||||||
|
OnRemoteCandidate func(target int, candidateOrSDP string)
|
||||||
|
dialer N.Dialer
|
||||||
|
dnsRouter adapter.DNSRouter
|
||||||
|
PCConfig common.PeerConnectionConfigurer
|
||||||
|
AddTracks common.AddTunnelTracksFunc
|
||||||
|
ReadTrackFn common.ReadTrackFunc
|
||||||
|
|
||||||
|
joinLink string
|
||||||
|
displayName string
|
||||||
|
|
||||||
|
ws *websocket.Conn
|
||||||
|
wsMu sync.Mutex
|
||||||
|
|
||||||
|
subPC *webrtc.PeerConnection
|
||||||
|
subSeq int
|
||||||
|
subRemoteSet bool
|
||||||
|
subPending []webrtc.ICECandidateInit
|
||||||
|
|
||||||
|
pubPC *webrtc.PeerConnection
|
||||||
|
pubSeq int
|
||||||
|
pubRemoteSet bool
|
||||||
|
pubPending []webrtc.ICECandidateInit
|
||||||
|
|
||||||
|
sampleTrack *webrtc.TrackLocalStaticSample
|
||||||
|
vp8tunnel *tunnel.VP8DataTunnel
|
||||||
|
obf *tunnel.TunnelObfuscator
|
||||||
|
vp8FPS int
|
||||||
|
vp8Batch int
|
||||||
|
|
||||||
|
httpClient *http.Client
|
||||||
|
instanceID string
|
||||||
|
|
||||||
|
peerID string
|
||||||
|
roomID string
|
||||||
|
credentials string
|
||||||
|
serviceName string
|
||||||
|
mediaURL string
|
||||||
|
iceServers []webrtc.ICEServer
|
||||||
|
stateCheckIntervalS int
|
||||||
|
|
||||||
|
closeMu sync.Mutex
|
||||||
|
closed bool
|
||||||
|
|
||||||
|
stopCh chan struct{}
|
||||||
|
stopOnce sync.Once
|
||||||
|
configAck tunnel.ConfigAckTracker
|
||||||
|
reconnectAttempt atomic.Int32
|
||||||
|
|
||||||
|
setSlotsKey int
|
||||||
|
initBundleSent bool
|
||||||
|
boundPeers map[string]bool
|
||||||
|
unboundPeers map[string]bool
|
||||||
|
boundMu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTelemostJoiner(logger logger.ContextLogger, dialer N.Dialer, dnsRouter adapter.DNSRouter, pcConfig common.PeerConnectionConfigurer, addTracks common.AddTunnelTracksFunc, readTrackFn common.ReadTrackFunc) *TelemostJoiner {
|
||||||
|
return &TelemostJoiner{
|
||||||
|
logger: logger,
|
||||||
|
dialer: dialer,
|
||||||
|
dnsRouter: dnsRouter,
|
||||||
|
PCConfig: pcConfig,
|
||||||
|
AddTracks: addTracks,
|
||||||
|
ReadTrackFn: readTrackFn,
|
||||||
|
instanceID: uuid.New().String(),
|
||||||
|
stopCh: make(chan struct{}),
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: 15 * time.Second,
|
||||||
|
Transport: &http.Transport{
|
||||||
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||||
|
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
|
return dialer.DialContext(ctx, network, M.ParseSocksaddr(addr))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *TelemostJoiner) RunWithParams(jsonParams string) {
|
||||||
|
var params struct {
|
||||||
|
JoinLink string `json:"joinLink"`
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
VP8FPS int `json:"vp8Fps"`
|
||||||
|
VP8Batch int `json:"vp8Batch"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(jsonParams), ¶ms); err != nil {
|
||||||
|
j.logger.Error(fmt.Sprintf("telemost-joiner: failed to parse params: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
j.joinLink = params.JoinLink
|
||||||
|
j.displayName = params.DisplayName
|
||||||
|
if j.displayName == "" {
|
||||||
|
j.displayName = "Joiner"
|
||||||
|
}
|
||||||
|
obf, err := tunnel.NewTunnelObfuscator(tunnel.DeriveSecretFromJoinLink(params.JoinLink))
|
||||||
|
if err != nil {
|
||||||
|
j.logger.Error(fmt.Sprintf("telemost-joiner: obfuscator init failed: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
j.obf = obf
|
||||||
|
j.vp8FPS = params.VP8FPS
|
||||||
|
j.vp8Batch = params.VP8Batch
|
||||||
|
j.logger.Info(fmt.Sprintf("telemost-joiner: link=%s name=%s vp8Fps=%d vp8Batch=%d localEpoch=0x%08x",
|
||||||
|
j.joinLink, j.displayName, params.VP8FPS, params.VP8Batch, obf.LocalEpoch()))
|
||||||
|
j.logger.Info("telemost-joiner: connecting")
|
||||||
|
if err := j.runOnce(); err != nil {
|
||||||
|
j.logger.Error(fmt.Sprintf("telemost-joiner: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
if j.isClosed() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
j.logger.Info("telemost-joiner: tunnel lost")
|
||||||
|
j.resetSessionState()
|
||||||
|
if !j.waitBeforeRetry(int(j.reconnectAttempt.Load())) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
j.reconnectAttempt.Add(1)
|
||||||
|
if j.isClosed() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
j.logger.Info(fmt.Sprintf("telemost-joiner: reconnect attempt #%d", j.reconnectAttempt.Load()))
|
||||||
|
if err := j.runOnce(); err != nil {
|
||||||
|
j.logger.Warn(fmt.Sprintf("telemost-joiner: %v, will retry", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *TelemostJoiner) Close() {
|
||||||
|
j.closeMu.Lock()
|
||||||
|
j.closed = true
|
||||||
|
j.closeMu.Unlock()
|
||||||
|
j.stopOnce.Do(func() { close(j.stopCh) })
|
||||||
|
j.wsMu.Lock()
|
||||||
|
ws := j.ws
|
||||||
|
j.ws = nil
|
||||||
|
j.wsMu.Unlock()
|
||||||
|
common.CloseWS(ws)
|
||||||
|
if j.vp8tunnel != nil {
|
||||||
|
j.vp8tunnel.Stop()
|
||||||
|
}
|
||||||
|
if j.subPC != nil {
|
||||||
|
j.subPC.Close()
|
||||||
|
}
|
||||||
|
if j.pubPC != nil {
|
||||||
|
j.pubPC.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TmParseMids(sdp string) (audioMid, videoMid string) {
|
||||||
|
var media string
|
||||||
|
for _, line := range strings.Split(sdp, "\r\n") {
|
||||||
|
if strings.HasPrefix(line, "m=audio") {
|
||||||
|
media = "audio"
|
||||||
|
} else if strings.HasPrefix(line, "m=video") {
|
||||||
|
media = "video"
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(line, "a=mid:") {
|
||||||
|
mid := strings.TrimPrefix(line, "a=mid:")
|
||||||
|
if media == "audio" && audioMid == "" {
|
||||||
|
audioMid = mid
|
||||||
|
} else if media == "video" && videoMid == "" {
|
||||||
|
videoMid = mid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *TelemostJoiner) runOnce() error {
|
||||||
|
if err := j.getConnection(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
j.connectAndRun()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *TelemostJoiner) MarkConfigAcked() { j.configAck.Mark() }
|
||||||
|
|
||||||
|
func (j *TelemostJoiner) waitBeforeRetry(attempt int) bool {
|
||||||
|
delay := common.BackoffWithJitter(attempt, telemostReconnectInitialDelay, telemostReconnectMaxDelay)
|
||||||
|
j.logger.Debug(fmt.Sprintf("telemost-joiner: waiting %s before reconnect", delay))
|
||||||
|
timer := time.NewTimer(delay)
|
||||||
|
defer timer.Stop()
|
||||||
|
select {
|
||||||
|
case <-timer.C:
|
||||||
|
return !j.isClosed()
|
||||||
|
case <-j.stopCh:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *TelemostJoiner) resetSessionState() {
|
||||||
|
j.wsMu.Lock()
|
||||||
|
j.ws = nil
|
||||||
|
j.wsMu.Unlock()
|
||||||
|
j.subPC = nil
|
||||||
|
j.subSeq = 0
|
||||||
|
j.subRemoteSet = false
|
||||||
|
j.subPending = nil
|
||||||
|
j.pubPC = nil
|
||||||
|
j.pubSeq = 0
|
||||||
|
j.pubRemoteSet = false
|
||||||
|
j.pubPending = nil
|
||||||
|
j.sampleTrack = nil
|
||||||
|
j.vp8tunnel = nil
|
||||||
|
j.initBundleSent = false
|
||||||
|
j.boundMu.Lock()
|
||||||
|
j.boundPeers = nil
|
||||||
|
j.unboundPeers = nil
|
||||||
|
j.boundMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *TelemostJoiner) isClosed() bool {
|
||||||
|
j.closeMu.Lock()
|
||||||
|
defer j.closeMu.Unlock()
|
||||||
|
return j.closed
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *TelemostJoiner) apiClient() *Client {
|
||||||
|
return &Client{HTTP: j.httpClient, InstanceID: j.instanceID}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *TelemostJoiner) getConnection() error {
|
||||||
|
confURL := url.QueryEscape(j.joinLink)
|
||||||
|
name := url.QueryEscape(j.displayName)
|
||||||
|
if name == "" {
|
||||||
|
name = "Joiner"
|
||||||
|
}
|
||||||
|
connPath := "/conferences/" + confURL + "/connection?next_gen_media_platform_allowed=true&display_name=" + name + "&waiting_room_supported=true"
|
||||||
|
j.logger.Debug(fmt.Sprintf("telemost-joiner: getting connection for %s", j.joinLink))
|
||||||
|
responseBody, status, err := j.apiClient().TMRequest("GET", connPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get connection: %w", err)
|
||||||
|
}
|
||||||
|
if status != 200 {
|
||||||
|
return fmt.Errorf("get connection: status %d: %s", status, string(responseBody))
|
||||||
|
}
|
||||||
|
var initial struct {
|
||||||
|
ConnectionType string `json:"connection_type"`
|
||||||
|
ClientConfig struct {
|
||||||
|
CheckInterval int `json:"conference_check_access_interval_ms"`
|
||||||
|
} `json:"client_configuration"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(responseBody, &initial)
|
||||||
|
if initial.ConnectionType == "WAITING_ROOM" {
|
||||||
|
interval := initial.ClientConfig.CheckInterval
|
||||||
|
if interval <= 0 {
|
||||||
|
interval = 3000
|
||||||
|
}
|
||||||
|
checkPath := "/conferences/" + confURL + "/waiting-rooms/check-access"
|
||||||
|
j.logger.Info(fmt.Sprintf("telemost-joiner: in waiting room, polling check-access every %dms...", interval))
|
||||||
|
for {
|
||||||
|
time.Sleep(time.Duration(interval) * time.Millisecond)
|
||||||
|
checkBody, checkStatus, checkErr := j.apiClient().TMRequest("GET", checkPath)
|
||||||
|
if checkErr != nil {
|
||||||
|
return fmt.Errorf("waiting room check-access: %w", checkErr)
|
||||||
|
}
|
||||||
|
if checkStatus != 200 {
|
||||||
|
return fmt.Errorf("waiting room check-access: status %d", checkStatus)
|
||||||
|
}
|
||||||
|
var check struct {
|
||||||
|
Admitted bool `json:"admitted"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(checkBody, &check)
|
||||||
|
if check.Admitted {
|
||||||
|
j.logger.Info("telemost-joiner: admitted!")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
responseBody, status, err = j.apiClient().TMRequest("GET", connPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("post-admit connection: %w", err)
|
||||||
|
}
|
||||||
|
if status != 200 {
|
||||||
|
return fmt.Errorf("post-admit connection: status %d: %s", status, string(responseBody))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var conn struct {
|
||||||
|
PeerID string `json:"peer_id"`
|
||||||
|
RoomID string `json:"room_id"`
|
||||||
|
Credentials string `json:"credentials"`
|
||||||
|
ClientConfig struct {
|
||||||
|
MediaServerURL string `json:"media_server_url"`
|
||||||
|
ServiceName string `json:"service_name"`
|
||||||
|
ICEServers json.RawMessage `json:"ice_servers"`
|
||||||
|
StateCheckIntervalSecs int `json:"state_check_interval_seconds"`
|
||||||
|
} `json:"client_configuration"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(responseBody, &conn)
|
||||||
|
if conn.ClientConfig.MediaServerURL == "" {
|
||||||
|
return fmt.Errorf("empty media_server_url: %s", string(responseBody))
|
||||||
|
}
|
||||||
|
j.peerID = conn.PeerID
|
||||||
|
j.roomID = conn.RoomID
|
||||||
|
j.credentials = conn.Credentials
|
||||||
|
j.mediaURL = conn.ClientConfig.MediaServerURL
|
||||||
|
j.serviceName = conn.ClientConfig.ServiceName
|
||||||
|
j.stateCheckIntervalS = conn.ClientConfig.StateCheckIntervalSecs
|
||||||
|
var rawIce []struct {
|
||||||
|
URLs []string `json:"urls"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Credential string `json:"credential"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(conn.ClientConfig.ICEServers, &rawIce)
|
||||||
|
for _, s := range rawIce {
|
||||||
|
ice := webrtc.ICEServer{URLs: s.URLs}
|
||||||
|
if s.Username != "" {
|
||||||
|
ice.Username = s.Username
|
||||||
|
ice.Credential = s.Credential
|
||||||
|
}
|
||||||
|
j.iceServers = append(j.iceServers, ice)
|
||||||
|
}
|
||||||
|
j.logger.Debug(fmt.Sprintf("telemost-joiner: peer_id=%s room_id=%s media_url=%s", j.peerID, j.roomID, j.mediaURL))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *TelemostJoiner) wsSend(msg interface{}) {
|
||||||
|
j.wsMu.Lock()
|
||||||
|
defer j.wsMu.Unlock()
|
||||||
|
if j.ws != nil {
|
||||||
|
data, _ := json.Marshal(msg)
|
||||||
|
j.logger.Debug(fmt.Sprintf("telemost-joiner: [DIAG] -> %s", string(data)))
|
||||||
|
j.ws.WriteJSON(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *TelemostJoiner) ack(uid string) {
|
||||||
|
if uid == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
j.wsSend(map[string]interface{}{
|
||||||
|
"uid": uid,
|
||||||
|
"ack": map[string]interface{}{
|
||||||
|
"status": map[string]interface{}{"code": "OK", "description": ""},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *TelemostJoiner) sendHello() {
|
||||||
|
j.wsSend(map[string]interface{}{
|
||||||
|
"uid": uuid.New().String(),
|
||||||
|
"hello": map[string]interface{}{
|
||||||
|
"participantMeta": map[string]interface{}{"name": j.displayName, "role": "SPEAKER", "description": "", "sendAudio": false, "sendVideo": true},
|
||||||
|
"participantAttributes": map[string]interface{}{"name": j.displayName, "role": "SPEAKER", "description": ""},
|
||||||
|
"sendAudio": false, "sendVideo": true, "sendSharing": false,
|
||||||
|
"participantId": j.peerID,
|
||||||
|
"roomId": j.roomID,
|
||||||
|
"serviceName": j.serviceName,
|
||||||
|
"credentials": j.credentials,
|
||||||
|
"capabilitiesOffer": CapabilitiesOffer,
|
||||||
|
"sdkInfo": map[string]interface{}{"implementation": "browser", "version": "6.0.0", "userAgent": common.UserAgent, "hwConcurrency": 8},
|
||||||
|
"sdkInitializationId": uuid.New().String(),
|
||||||
|
"disablePublisher": false, "disableSubscriber": false, "disableSubscriberAudio": false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
j.logger.Debug("telemost-joiner: -> hello")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *TelemostJoiner) sendICE(cand *webrtc.ICECandidate, target string, pcSeq int) {
|
||||||
|
candidate := cand.ToJSON()
|
||||||
|
j.wsSend(map[string]interface{}{
|
||||||
|
"uid": uuid.New().String(),
|
||||||
|
"webrtcIceCandidate": map[string]interface{}{
|
||||||
|
"candidate": candidate.Candidate, "sdpMid": *candidate.SDPMid,
|
||||||
|
"sdpMlineIndex": *candidate.SDPMLineIndex, "target": target, "pcSeq": pcSeq,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *TelemostJoiner) initPC() {
|
||||||
|
config := webrtc.Configuration{ICEServers: j.iceServers}
|
||||||
|
settingEngine := webrtc.SettingEngine{}
|
||||||
|
settingEngine.DetachDataChannels()
|
||||||
|
if j.PCConfig != nil {
|
||||||
|
j.PCConfig.ConfigureSettingEngine(&settingEngine)
|
||||||
|
}
|
||||||
|
api, err := NewAPI(&settingEngine)
|
||||||
|
if err != nil {
|
||||||
|
j.logger.Error(fmt.Sprintf("telemost-joiner: ERROR: create webrtc API: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
subPC, err := api.NewPeerConnection(config)
|
||||||
|
if err != nil {
|
||||||
|
j.logger.Error(fmt.Sprintf("telemost-joiner: ERROR: create sub PC: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
j.subPC = subPC
|
||||||
|
subPC.OnICECandidate(func(cand *webrtc.ICECandidate) {
|
||||||
|
if cand != nil {
|
||||||
|
j.sendICE(cand, "SUBSCRIBER", j.subSeq)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
subPC.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
|
||||||
|
j.logger.Debug(fmt.Sprintf("telemost-joiner: sub PC state: %s", state.String()))
|
||||||
|
if state == webrtc.PeerConnectionStateFailed {
|
||||||
|
j.logger.Error("telemost-joiner: ERROR: subscriber connection failed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
subPC.OnTrack(func(track *webrtc.TrackRemote, receiver *webrtc.RTPReceiver) {
|
||||||
|
j.logger.Debug(fmt.Sprintf("telemost-joiner: sub remote track: %s", track.Codec().MimeType))
|
||||||
|
go j.ReadTrackFn(track, func(frame []byte) {
|
||||||
|
if j.vp8tunnel != nil {
|
||||||
|
j.vp8tunnel.HandleFrame(frame)
|
||||||
|
}
|
||||||
|
}, j.logger, "telemost-joiner")
|
||||||
|
})
|
||||||
|
pubPC, err := api.NewPeerConnection(config)
|
||||||
|
if err != nil {
|
||||||
|
j.logger.Error(fmt.Sprintf("telemost-joiner: ERROR: create pub PC: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
j.pubPC = pubPC
|
||||||
|
j.pubSeq = 1
|
||||||
|
j.sampleTrack = j.AddTracks(pubPC, j.logger, "telemost-joiner [pub]")
|
||||||
|
pubPC.OnICECandidate(func(cand *webrtc.ICECandidate) {
|
||||||
|
if cand != nil {
|
||||||
|
j.sendICE(cand, "PUBLISHER", j.pubSeq)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
pubPC.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
|
||||||
|
j.logger.Debug(fmt.Sprintf("telemost-joiner: pub PC state: %s", state.String()))
|
||||||
|
if state == webrtc.PeerConnectionStateConnected && j.vp8tunnel == nil {
|
||||||
|
j.reconnectAttempt.Store(0)
|
||||||
|
j.logger.Info("telemost-joiner: === VP8 TUNNEL CONNECTED ===")
|
||||||
|
j.vp8tunnel = tunnel.NewVP8DataTunnel(j.sampleTrack, j.obf, j.logger)
|
||||||
|
vp8tun := j.vp8tunnel
|
||||||
|
vp8tun.Start(j.vp8FPS, j.vp8Batch)
|
||||||
|
if !j.configAck.Acknowledged() {
|
||||||
|
acked, cancel := j.configAck.Arm()
|
||||||
|
go tunnel.SendVP8ConfigUntilAcked(acked, cancel, j.stopCh, vp8tun,
|
||||||
|
vp8tun.FPS(), vp8tun.Batch(), 1, j.logger, "telemost-joiner")
|
||||||
|
j.logger.Debug(fmt.Sprintf("telemost-joiner: pushed vp8 config to creator fps=%d batch=%d", vp8tun.FPS(), vp8tun.Batch()))
|
||||||
|
}
|
||||||
|
if j.OnConnected != nil {
|
||||||
|
j.OnConnected(j.vp8tunnel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
j.logger.Debug(fmt.Sprintf("telemost-joiner: sub+pub PCs created with %d ICE servers", len(j.iceServers)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *TelemostJoiner) sendPubOffer() {
|
||||||
|
if j.pubPC == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
offer, err := j.pubPC.CreateOffer(nil)
|
||||||
|
if err != nil {
|
||||||
|
j.logger.Warn(fmt.Sprintf("telemost-joiner: pub offer failed: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := j.pubPC.SetLocalDescription(offer); err != nil {
|
||||||
|
j.logger.Warn(fmt.Sprintf("telemost-joiner: set pub local desc: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
offer.SDP = MungeSDPAddVideoContent(offer.SDP)
|
||||||
|
audioMid, videoMid := TmParseMids(offer.SDP)
|
||||||
|
j.logger.Debug(fmt.Sprintf("telemost-joiner: -> publisherSdpOffer pcSeq=%d audioMid=%s videoMid=%s", j.pubSeq, audioMid, videoMid))
|
||||||
|
var tracks []map[string]interface{}
|
||||||
|
if audioMid != "" {
|
||||||
|
tracks = append(tracks, map[string]interface{}{"mid": audioMid, "transceiverMid": audioMid, "kind": "AUDIO", "priority": 0, "label": "", "codecs": map[string]interface{}{}, "groupId": 1, "description": ""})
|
||||||
|
}
|
||||||
|
if videoMid != "" {
|
||||||
|
tracks = append(tracks, map[string]interface{}{"mid": videoMid, "transceiverMid": videoMid, "kind": "VIDEO", "priority": 0, "label": "", "codecs": map[string]interface{}{}, "groupId": 2, "description": ""})
|
||||||
|
}
|
||||||
|
j.wsSend(map[string]interface{}{
|
||||||
|
"uid": uuid.New().String(),
|
||||||
|
"publisherSdpOffer": map[string]interface{}{"pcSeq": j.pubSeq, "sdp": offer.SDP, "tracks": tracks},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *TelemostJoiner) handlePubAnswer(sdp string) {
|
||||||
|
if j.pubPC == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if j.OnRemoteCandidate != nil {
|
||||||
|
j.OnRemoteCandidate(-1, sdp)
|
||||||
|
}
|
||||||
|
err := j.pubPC.SetRemoteDescription(webrtc.SessionDescription{
|
||||||
|
Type: webrtc.SDPTypeAnswer,
|
||||||
|
SDP: sdp,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
j.logger.Warn(fmt.Sprintf("telemost-joiner: set pub remote desc: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
j.pubRemoteSet = true
|
||||||
|
for _, candidate := range j.pubPending {
|
||||||
|
j.pubPC.AddICECandidate(candidate)
|
||||||
|
}
|
||||||
|
j.pubPending = nil
|
||||||
|
j.sendInitBundle()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *TelemostJoiner) sendInitBundle() {
|
||||||
|
if j.initBundleSent {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
j.initBundleSent = true
|
||||||
|
j.logger.Debug("telemost-joiner: -> sdkCodecsInfo + updatePublisherTrackDescription")
|
||||||
|
j.wsSend(SdkCodecsInfoMessage())
|
||||||
|
j.wsSend(UpdatePublisherTrackDescriptionMessage(j.pubPC, "Microphone", "MacBook Pro Camera (0000:0001)"))
|
||||||
|
j.sendStartupSlotsRamp()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *TelemostJoiner) requestVideoSlots() {
|
||||||
|
j.setSlotsKey++
|
||||||
|
j.logger.Debug(fmt.Sprintf("telemost-joiner: -> setSlots key=%d", j.setSlotsKey))
|
||||||
|
j.wsSend(SetSlotsMessage(j.setSlotsKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *TelemostJoiner) forceReconnect(reason string) {
|
||||||
|
j.reconnectAttempt.Store(0)
|
||||||
|
oldPeerID := j.peerID
|
||||||
|
j.logger.Info(fmt.Sprintf("telemost-joiner: forcing reconnect: %s", reason))
|
||||||
|
if oldPeerID != "" {
|
||||||
|
j.logger.Debug(fmt.Sprintf("telemost-joiner: kicking self pid=%s to leave call cleanly", oldPeerID))
|
||||||
|
confURL := url.QueryEscape(j.joinLink)
|
||||||
|
_, status, err := j.apiClient().TMRequest("POST", "/conferences/"+confURL+"/commands/kick?peer_id="+url.QueryEscape(oldPeerID)+"&with_ban=false")
|
||||||
|
if err != nil || status >= 400 {
|
||||||
|
j.logger.Warn(fmt.Sprintf("telemost-joiner: self-kick failed: status=%d err=%v", status, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
j.instanceID = uuid.New().String()
|
||||||
|
j.logger.Debug(fmt.Sprintf("telemost-joiner: new instance-id=%s", j.instanceID))
|
||||||
|
j.wsMu.Lock()
|
||||||
|
ws := j.ws
|
||||||
|
j.wsMu.Unlock()
|
||||||
|
common.CloseWS(ws)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *TelemostJoiner) sendStartupSlotsRamp() {
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
j.setSlotsKey++
|
||||||
|
j.logger.Debug(fmt.Sprintf("telemost-joiner: -> setSlots key=%d (startup %d/4)", j.setSlotsKey, i+1))
|
||||||
|
j.wsSend(StartupSetSlotsMessage(i, j.setSlotsKey))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *TelemostJoiner) handleSubOffer(sdp string, pcSeq int) {
|
||||||
|
j.subSeq = pcSeq
|
||||||
|
if j.subPC == nil {
|
||||||
|
j.logger.Warn("telemost-joiner: sub PC not ready for offer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if j.OnRemoteCandidate != nil {
|
||||||
|
j.OnRemoteCandidate(-1, sdp)
|
||||||
|
}
|
||||||
|
err := j.subPC.SetRemoteDescription(webrtc.SessionDescription{
|
||||||
|
Type: webrtc.SDPTypeOffer,
|
||||||
|
SDP: sdp,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
j.logger.Warn(fmt.Sprintf("telemost-joiner: set sub remote desc: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
j.subRemoteSet = true
|
||||||
|
for _, candidate := range j.subPending {
|
||||||
|
j.subPC.AddICECandidate(candidate)
|
||||||
|
}
|
||||||
|
j.subPending = nil
|
||||||
|
answer, err := j.subPC.CreateAnswer(nil)
|
||||||
|
if err != nil {
|
||||||
|
j.logger.Warn(fmt.Sprintf("telemost-joiner: create sub answer: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
j.subPC.SetLocalDescription(answer)
|
||||||
|
j.logger.Debug(fmt.Sprintf("telemost-joiner: -> subscriberSdpAnswer pcSeq=%d", pcSeq))
|
||||||
|
j.wsSend(map[string]interface{}{
|
||||||
|
"uid": uuid.New().String(),
|
||||||
|
"subscriberSdpAnswer": map[string]interface{}{"sdp": answer.SDP, "pcSeq": pcSeq},
|
||||||
|
})
|
||||||
|
j.sendPubOffer()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *TelemostJoiner) handleMessage(raw []byte) {
|
||||||
|
var msg map[string]interface{}
|
||||||
|
if err := json.Unmarshal(raw, &msg); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uid, _ := msg["uid"].(string)
|
||||||
|
if _, ok := msg["serverHello"]; ok {
|
||||||
|
j.logger.Debug("telemost-joiner: <- serverHello")
|
||||||
|
if sh, ok := msg["serverHello"].(map[string]interface{}); ok {
|
||||||
|
j.parseICEServersFromHello(sh)
|
||||||
|
}
|
||||||
|
j.ack(uid)
|
||||||
|
j.initPC()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if so, ok := msg["subscriberSdpOffer"]; ok {
|
||||||
|
soMap, _ := so.(map[string]interface{})
|
||||||
|
sdp, _ := soMap["sdp"].(string)
|
||||||
|
pcSeq, _ := soMap["pcSeq"].(float64)
|
||||||
|
j.logger.Debug(fmt.Sprintf("telemost-joiner: <- subscriberSdpOffer pcSeq=%d len=%d", int(pcSeq), len(sdp)))
|
||||||
|
j.ack(uid)
|
||||||
|
j.handleSubOffer(sdp, int(pcSeq))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if pa, ok := msg["publisherSdpAnswer"]; ok {
|
||||||
|
paMap, _ := pa.(map[string]interface{})
|
||||||
|
sdp, _ := paMap["sdp"].(string)
|
||||||
|
j.logger.Debug(fmt.Sprintf("telemost-joiner: <- publisherSdpAnswer %d bytes", len(sdp)))
|
||||||
|
j.handlePubAnswer(sdp)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ic, ok := msg["webrtcIceCandidate"]; ok {
|
||||||
|
icMap, _ := ic.(map[string]interface{})
|
||||||
|
candidate, _ := icMap["candidate"].(string)
|
||||||
|
sdpMid, _ := icMap["sdpMid"].(string)
|
||||||
|
target, _ := icMap["target"].(string)
|
||||||
|
sdpIdx, _ := icMap["sdpMlineIndex"].(float64)
|
||||||
|
idx := uint16(sdpIdx)
|
||||||
|
cand := webrtc.ICECandidateInit{Candidate: candidate, SDPMid: &sdpMid, SDPMLineIndex: &idx}
|
||||||
|
if j.OnRemoteCandidate != nil {
|
||||||
|
tgt := 1
|
||||||
|
if target == "SUBSCRIBER" {
|
||||||
|
tgt = 0
|
||||||
|
}
|
||||||
|
j.OnRemoteCandidate(tgt, candidate)
|
||||||
|
}
|
||||||
|
if target == "SUBSCRIBER" {
|
||||||
|
if j.subRemoteSet {
|
||||||
|
j.subPC.AddICECandidate(cand)
|
||||||
|
} else {
|
||||||
|
j.subPending = append(j.subPending, cand)
|
||||||
|
}
|
||||||
|
} else if target == "PUBLISHER" {
|
||||||
|
if j.pubRemoteSet {
|
||||||
|
j.pubPC.AddICECandidate(cand)
|
||||||
|
} else {
|
||||||
|
j.pubPending = append(j.pubPending, cand)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
j.ack(uid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ackData, ok := msg["ack"]; ok {
|
||||||
|
if ackMap, ok := ackData.(map[string]interface{}); ok {
|
||||||
|
if status, ok := ackMap["status"].(map[string]interface{}); ok {
|
||||||
|
if code, _ := status["code"].(string); code != "OK" {
|
||||||
|
desc, _ := status["description"].(string)
|
||||||
|
j.logger.Warn(fmt.Sprintf("telemost-joiner: ack error: %s %s", code, desc))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ud, ok := msg["upsertDescription"]; ok {
|
||||||
|
udMap, _ := ud.(map[string]interface{})
|
||||||
|
if descs, ok := udMap["description"].([]interface{}); ok {
|
||||||
|
for _, d := range descs {
|
||||||
|
dm, _ := d.(map[string]interface{})
|
||||||
|
pid, _ := dm["id"].(string)
|
||||||
|
if pid != "" && pid != j.peerID {
|
||||||
|
participantName := ""
|
||||||
|
if meta, ok := dm["meta"].(map[string]interface{}); ok {
|
||||||
|
participantName, _ = meta["name"].(string)
|
||||||
|
}
|
||||||
|
j.logger.Debug(fmt.Sprintf("telemost-joiner: participant: %s (%s)", participantName, pid))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
j.ack(uid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ud, ok := msg["updateDescription"]; ok {
|
||||||
|
j.logger.Debug(fmt.Sprintf("telemost-joiner: <- updateDescription %s", BriefJSON(ud)))
|
||||||
|
j.ack(uid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := msg["removeDescription"]; ok {
|
||||||
|
j.logger.Info("telemost-joiner: participant left")
|
||||||
|
j.ack(uid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if sc, ok := msg["slotsConfig"]; ok {
|
||||||
|
j.logger.Debug(fmt.Sprintf("telemost-joiner: <- slotsConfig %s", BriefJSON(sc)))
|
||||||
|
needRebind := false
|
||||||
|
presentPids := make(map[string]bool)
|
||||||
|
for _, ev := range SlotsConfigBindings(sc) {
|
||||||
|
fullPid := ev.ParticipantID
|
||||||
|
if fullPid != "" {
|
||||||
|
presentPids[fullPid] = true
|
||||||
|
}
|
||||||
|
pid := fullPid
|
||||||
|
if len(pid) > 8 {
|
||||||
|
pid = pid[:8]
|
||||||
|
}
|
||||||
|
if ev.Reason == "NO_LIMITATION" && ev.Mid != "" {
|
||||||
|
j.logger.Debug(fmt.Sprintf("telemost-joiner: [bind] BOUND slot=%d pid=%s mid=%s", ev.Slot, pid, ev.Mid))
|
||||||
|
j.boundMu.Lock()
|
||||||
|
if j.boundPeers == nil {
|
||||||
|
j.boundPeers = make(map[string]bool)
|
||||||
|
}
|
||||||
|
j.boundPeers[fullPid] = true
|
||||||
|
delete(j.unboundPeers, fullPid)
|
||||||
|
j.boundMu.Unlock()
|
||||||
|
} else if fullPid != "" {
|
||||||
|
j.boundMu.Lock()
|
||||||
|
wasBound := j.boundPeers[fullPid]
|
||||||
|
if wasBound {
|
||||||
|
if j.unboundPeers == nil {
|
||||||
|
j.unboundPeers = make(map[string]bool)
|
||||||
|
}
|
||||||
|
j.unboundPeers[fullPid] = true
|
||||||
|
delete(j.boundPeers, fullPid)
|
||||||
|
}
|
||||||
|
j.boundMu.Unlock()
|
||||||
|
if wasBound {
|
||||||
|
j.logger.Debug(fmt.Sprintf("telemost-joiner: [bind] KILL slot=%d pid=%s reason=%s - rebinding", ev.Slot, pid, ev.Reason))
|
||||||
|
needRebind = true
|
||||||
|
} else {
|
||||||
|
j.logger.Debug(fmt.Sprintf("telemost-joiner: [bind] UNBOUND slot=%d pid=%s reason=%s mid=%q", ev.Slot, pid, ev.Reason, ev.Mid))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
j.boundMu.Lock()
|
||||||
|
for boundPid := range j.boundPeers {
|
||||||
|
if !presentPids[boundPid] {
|
||||||
|
short := boundPid
|
||||||
|
if len(short) > 8 {
|
||||||
|
short = short[:8]
|
||||||
|
}
|
||||||
|
j.logger.Debug(fmt.Sprintf("telemost-joiner: [bind] VANISHED pid=%s - rebinding", short))
|
||||||
|
delete(j.boundPeers, boundPid)
|
||||||
|
needRebind = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
j.boundMu.Unlock()
|
||||||
|
if needRebind {
|
||||||
|
go j.forceReconnect("slot binding killed")
|
||||||
|
}
|
||||||
|
j.ack(uid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for k, v := range msg {
|
||||||
|
if k == "uid" || k == "ack" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
j.logger.Debug(fmt.Sprintf("telemost-joiner: <- %s (unhandled) %s", k, BriefJSON(v)))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if uid != "" {
|
||||||
|
j.ack(uid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *TelemostJoiner) parseICEServersFromHello(sh map[string]interface{}) {
|
||||||
|
rtcCfg, ok := sh["rtcConfiguration"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
servers, ok := rtcCfg["iceServers"].([]interface{})
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var iceServers []webrtc.ICEServer
|
||||||
|
for _, s := range servers {
|
||||||
|
sm, _ := s.(map[string]interface{})
|
||||||
|
var urls []string
|
||||||
|
if u, ok := sm["urls"].([]interface{}); ok {
|
||||||
|
for _, v := range u {
|
||||||
|
if vs, ok := v.(string); ok {
|
||||||
|
urls = append(urls, common.FixICEURL(vs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ice := webrtc.ICEServer{URLs: urls}
|
||||||
|
if u, ok := sm["username"].(string); ok && u != "" {
|
||||||
|
ice.Username = u
|
||||||
|
ice.Credential, _ = sm["credential"].(string)
|
||||||
|
}
|
||||||
|
iceServers = append(iceServers, ice)
|
||||||
|
}
|
||||||
|
resolved := make(map[string]string)
|
||||||
|
for i, s := range iceServers {
|
||||||
|
for k, u := range s.URLs {
|
||||||
|
host := common.ExtractICEHost(u)
|
||||||
|
if host == "" || net.ParseIP(host) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, ok := resolved[host]
|
||||||
|
if !ok {
|
||||||
|
rd, hasRD := j.dialer.(dialer.ResolveDialer)
|
||||||
|
if j.dnsRouter == nil || !hasRD {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
var addrs []netip.Addr
|
||||||
|
addrs, err = j.dnsRouter.Lookup(context.Background(), host, rd.QueryOptions())
|
||||||
|
if err != nil {
|
||||||
|
j.logger.Warn(fmt.Sprintf("telemost-joiner: resolve ICE host %s failed: %s", common.MaskAddr(host), common.MaskError(err)))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resolved[host] = addrs[0].String()
|
||||||
|
j.logger.Debug(fmt.Sprintf("telemost-joiner: resolved ICE host %s -> %s", host, addrs[0]))
|
||||||
|
}
|
||||||
|
iceServers[i].URLs[k] = strings.Replace(u, host, resolved[host], 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
j.iceServers = iceServers
|
||||||
|
for i, s := range iceServers {
|
||||||
|
j.logger.Debug(fmt.Sprintf("telemost-joiner: ICE server %d: urls=%v", i, s.URLs))
|
||||||
|
}
|
||||||
|
j.logger.Debug(fmt.Sprintf("telemost-joiner: %d ICE servers from serverHello", len(iceServers)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *TelemostJoiner) connectAndRun() {
|
||||||
|
parsed, err := url.Parse(j.mediaURL)
|
||||||
|
if err != nil {
|
||||||
|
j.logger.Error(fmt.Sprintf("telemost-joiner: ERROR: bad media URL: %s", common.MaskError(err)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hostname := parsed.Hostname()
|
||||||
|
wsHeader := http.Header{}
|
||||||
|
wsHeader.Set("User-Agent", common.UserAgent)
|
||||||
|
wsHeader.Set("Origin", TmOrigin)
|
||||||
|
j.logger.Debug(fmt.Sprintf("telemost-joiner: connecting to %s", j.mediaURL))
|
||||||
|
dialer := websocket.Dialer{
|
||||||
|
HandshakeTimeout: 10 * time.Second,
|
||||||
|
WriteBufferSize: 65536,
|
||||||
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true, ServerName: hostname},
|
||||||
|
NetDialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
|
return j.dialer.DialContext(ctx, network, M.ParseSocksaddr(addr))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ws, _, err := dialer.Dial(j.mediaURL, wsHeader)
|
||||||
|
if err != nil {
|
||||||
|
j.logger.Error(fmt.Sprintf("telemost-joiner: ERROR: ws connect: %s", common.MaskError(err)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
j.wsMu.Lock()
|
||||||
|
j.ws = ws
|
||||||
|
j.wsMu.Unlock()
|
||||||
|
j.logger.Debug("telemost-joiner: ws connected")
|
||||||
|
j.sendHello()
|
||||||
|
stopPing := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(TmPingPeriod)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-stopPing:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
j.wsSend(map[string]interface{}{"uid": uuid.New().String(), "ping": map[string]interface{}{}})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
stopStateKeepalive := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
interval := j.stateCheckIntervalS
|
||||||
|
if interval <= 0 {
|
||||||
|
interval = 30
|
||||||
|
}
|
||||||
|
if err := j.apiClient().RequestStates(j.joinLink, j.peerID); err != nil {
|
||||||
|
j.logger.Debug(fmt.Sprintf("telemost-joiner: initial request-states: %v", err))
|
||||||
|
}
|
||||||
|
ticker := time.NewTicker(time.Duration(interval) * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-stopStateKeepalive:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
if err := j.apiClient().RequestStates(j.joinLink, j.peerID); err != nil {
|
||||||
|
j.logger.Debug(fmt.Sprintf("telemost-joiner: request-states: %v", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
for {
|
||||||
|
_, raw, err := ws.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
j.logger.Debug(fmt.Sprintf("telemost-joiner: ws read error: %s", common.MaskError(err)))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
j.handleMessage(raw)
|
||||||
|
}
|
||||||
|
close(stopPing)
|
||||||
|
close(stopStateKeepalive)
|
||||||
|
if j.vp8tunnel != nil {
|
||||||
|
j.vp8tunnel.Stop()
|
||||||
|
}
|
||||||
|
if j.subPC != nil {
|
||||||
|
j.subPC.Close()
|
||||||
|
}
|
||||||
|
if j.pubPC != nil {
|
||||||
|
j.pubPC.Close()
|
||||||
|
}
|
||||||
|
j.logger.Info("telemost-joiner: disconnected")
|
||||||
|
}
|
||||||
267
transport/call/telemost/relay.go
Normal file
267
transport/call/telemost/relay.go
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
package telemost
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/pion/rtp"
|
||||||
|
"github.com/pion/rtp/codecs"
|
||||||
|
"github.com/pion/webrtc/v4"
|
||||||
|
"github.com/sagernet/sing-box/transport/call/common"
|
||||||
|
"github.com/sagernet/sing-box/transport/call/tunnel"
|
||||||
|
"github.com/sagernet/sing/common/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SFURelay struct {
|
||||||
|
pubPC *webrtc.PeerConnection
|
||||||
|
subPC *webrtc.PeerConnection
|
||||||
|
pubRemoteSet bool
|
||||||
|
subRemoteSet bool
|
||||||
|
pubPending []webrtc.ICECandidateInit
|
||||||
|
subPending []webrtc.ICECandidateInit
|
||||||
|
mu sync.Mutex
|
||||||
|
logger logger.ContextLogger
|
||||||
|
|
||||||
|
sampleTrack *webrtc.TrackLocalStaticSample
|
||||||
|
tun *tunnel.VP8DataTunnel
|
||||||
|
obf *tunnel.TunnelObfuscator
|
||||||
|
OnConnected func(*tunnel.VP8DataTunnel)
|
||||||
|
OnPubReady func()
|
||||||
|
OnPeerRestart func()
|
||||||
|
OnPubICE func(*webrtc.ICECandidate)
|
||||||
|
OnSubICE func(*webrtc.ICECandidate)
|
||||||
|
|
||||||
|
readBufSize int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SFURelay) SetObfuscator(o *tunnel.TunnelObfuscator) { r.obf = o }
|
||||||
|
|
||||||
|
func NewSFURelay(logger logger.ContextLogger) *SFURelay {
|
||||||
|
return &SFURelay{logger: logger}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SFURelay) Init(iceServers []webrtc.ICEServer) error {
|
||||||
|
config := webrtc.Configuration{ICEServers: iceServers}
|
||||||
|
pubPC, err := NewPeerConnection(config)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.pubPC = pubPC
|
||||||
|
sampleTrack, _ := webrtc.NewTrackLocalStaticSample(
|
||||||
|
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeVP8},
|
||||||
|
"video", "tunnel-video",
|
||||||
|
)
|
||||||
|
r.sampleTrack = sampleTrack
|
||||||
|
audioTrack, _ := webrtc.NewTrackLocalStaticRTP(
|
||||||
|
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeOpus},
|
||||||
|
"audio", "tunnel-audio",
|
||||||
|
)
|
||||||
|
pubPC.AddTransceiverFromTrack(audioTrack, webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionSendonly})
|
||||||
|
pubPC.AddTransceiverFromTrack(r.sampleTrack, webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionSendonly})
|
||||||
|
pubPC.OnICECandidate(func(cand *webrtc.ICECandidate) {
|
||||||
|
if cand == nil || r.OnPubICE == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.OnPubICE(cand)
|
||||||
|
})
|
||||||
|
pubPC.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
|
||||||
|
r.logger.Debug(fmt.Sprintf("[pub] connection state: %s", state.String()))
|
||||||
|
if state == webrtc.PeerConnectionStateConnected {
|
||||||
|
if r.tun == nil {
|
||||||
|
r.logger.Debug("[relay] starting VP8 publish tunnel on pub PC connected")
|
||||||
|
r.tun = tunnel.NewVP8DataTunnel(r.sampleTrack, r.obf, r.logger)
|
||||||
|
r.tun.Start(0, 0)
|
||||||
|
if r.OnConnected != nil {
|
||||||
|
r.OnConnected(r.tun)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r.OnPubReady != nil {
|
||||||
|
r.OnPubReady()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
subPC, err := NewPeerConnection(config)
|
||||||
|
if err != nil {
|
||||||
|
pubPC.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.subPC = subPC
|
||||||
|
subPC.OnICECandidate(func(cand *webrtc.ICECandidate) {
|
||||||
|
if cand == nil || r.OnSubICE == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.OnSubICE(cand)
|
||||||
|
})
|
||||||
|
subPC.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
|
||||||
|
r.logger.Debug(fmt.Sprintf("[sub] connection state: %s", state.String()))
|
||||||
|
})
|
||||||
|
subPC.OnTrack(func(track *webrtc.TrackRemote, receiver *webrtc.RTPReceiver) {
|
||||||
|
r.logger.Debug(fmt.Sprintf("[sub] remote track: %s", track.Codec().MimeType))
|
||||||
|
go r.readTrack(track)
|
||||||
|
})
|
||||||
|
r.logger.Debug(fmt.Sprintf("[relay] pub+sub PCs created (%d ICE servers)", len(iceServers)))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SFURelay) CreatePubOffer() (webrtc.SessionDescription, error) {
|
||||||
|
offer, err := r.pubPC.CreateOffer(nil)
|
||||||
|
if err != nil {
|
||||||
|
return offer, err
|
||||||
|
}
|
||||||
|
if err := r.pubPC.SetLocalDescription(offer); err != nil {
|
||||||
|
return offer, err
|
||||||
|
}
|
||||||
|
offer.SDP = MungeSDPAddVideoContent(offer.SDP)
|
||||||
|
return offer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SFURelay) SetPubAnswer(sdp string) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
err := r.pubPC.SetRemoteDescription(webrtc.SessionDescription{
|
||||||
|
Type: webrtc.SDPTypeAnswer, SDP: sdp,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.pubRemoteSet = true
|
||||||
|
for _, cand := range r.pubPending {
|
||||||
|
r.pubPC.AddICECandidate(cand)
|
||||||
|
}
|
||||||
|
r.pubPending = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SFURelay) SetSubOffer(sdp string) (webrtc.SessionDescription, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
err := r.subPC.SetRemoteDescription(webrtc.SessionDescription{
|
||||||
|
Type: webrtc.SDPTypeOffer, SDP: sdp,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return webrtc.SessionDescription{}, err
|
||||||
|
}
|
||||||
|
r.subRemoteSet = true
|
||||||
|
for _, cand := range r.subPending {
|
||||||
|
r.subPC.AddICECandidate(cand)
|
||||||
|
}
|
||||||
|
r.subPending = nil
|
||||||
|
answer, err := r.subPC.CreateAnswer(nil)
|
||||||
|
if err != nil {
|
||||||
|
return answer, err
|
||||||
|
}
|
||||||
|
r.subPC.SetLocalDescription(answer)
|
||||||
|
return answer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SFURelay) AddPubICECandidate(cand webrtc.ICECandidateInit) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if !r.pubRemoteSet {
|
||||||
|
r.pubPending = append(r.pubPending, cand)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.pubPC.AddICECandidate(cand)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SFURelay) AddSubICECandidate(cand webrtc.ICECandidateInit) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if !r.subRemoteSet {
|
||||||
|
r.subPending = append(r.subPending, cand)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.subPC.AddICECandidate(cand)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SFURelay) Close() {
|
||||||
|
if r.tun != nil {
|
||||||
|
r.tun.Stop()
|
||||||
|
r.tun = nil
|
||||||
|
}
|
||||||
|
if r.pubPC != nil {
|
||||||
|
r.pubPC.Close()
|
||||||
|
r.pubPC = nil
|
||||||
|
}
|
||||||
|
if r.subPC != nil {
|
||||||
|
r.subPC.Close()
|
||||||
|
r.subPC = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SFURelay) readTrack(track *webrtc.TrackRemote) {
|
||||||
|
if track.Codec().MimeType != webrtc.MimeTypeVP8 {
|
||||||
|
buf := make([]byte, common.UDPBufSize)
|
||||||
|
for {
|
||||||
|
if _, _, err := track.Read(buf); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var vp8Pkt codecs.VP8Packet
|
||||||
|
var pkt rtp.Packet
|
||||||
|
var frameBuf []byte
|
||||||
|
var lastSeq uint16
|
||||||
|
var haveLastSeq bool
|
||||||
|
frameValid := false
|
||||||
|
var recvCount int
|
||||||
|
bufSz := r.readBufSize
|
||||||
|
if bufSz <= 0 {
|
||||||
|
bufSz = common.RTPBufSize
|
||||||
|
}
|
||||||
|
buf := make([]byte, bufSz)
|
||||||
|
for {
|
||||||
|
n, _, err := track.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if pkt.Unmarshal(buf[:n]) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if haveLastSeq && pkt.SequenceNumber != lastSeq+1 {
|
||||||
|
frameValid = false
|
||||||
|
frameBuf = frameBuf[:0]
|
||||||
|
}
|
||||||
|
lastSeq = pkt.SequenceNumber
|
||||||
|
haveLastSeq = true
|
||||||
|
vp8Payload, err := vp8Pkt.Unmarshal(pkt.Payload)
|
||||||
|
if err != nil {
|
||||||
|
frameValid = false
|
||||||
|
frameBuf = frameBuf[:0]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if vp8Pkt.S == 1 {
|
||||||
|
frameBuf = frameBuf[:0]
|
||||||
|
frameValid = true
|
||||||
|
}
|
||||||
|
if !frameValid {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
frameBuf = append(frameBuf, vp8Payload...)
|
||||||
|
if !pkt.Marker {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
recvCount++
|
||||||
|
if recvCount <= 3 || recvCount%200 == 0 {
|
||||||
|
r.logger.Debug(fmt.Sprintf("[video] recv vp8 frame #%d %d bytes", recvCount, len(frameBuf)))
|
||||||
|
}
|
||||||
|
res := r.obf.Decode(frameBuf)
|
||||||
|
frameBuf = frameBuf[:0]
|
||||||
|
frameValid = false
|
||||||
|
if !res.HasFrame || res.SelfEcho {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if res.PeerRestart {
|
||||||
|
r.logger.Info(fmt.Sprintf("[video] peer restart detected, new epoch=0x%08x", res.PeerEpoch))
|
||||||
|
if r.OnPeerRestart != nil {
|
||||||
|
r.OnPeerRestart()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if res.Keepalive || len(res.Payload) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r.tun != nil && r.tun.OnData != nil {
|
||||||
|
r.tun.OnData(res.Payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
69
transport/call/tunnel/config_ack.go
Normal file
69
transport/call/tunnel/config_ack.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
package tunnel
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sagernet/sing/common/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
const configResendPeriod = 3 * time.Second
|
||||||
|
|
||||||
|
type ConfigAckTracker struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
acked chan struct{}
|
||||||
|
cancel chan struct{}
|
||||||
|
confirmed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ConfigAckTracker) Acknowledged() bool {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
return t.confirmed
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ConfigAckTracker) Arm() (acked, cancel chan struct{}) {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
if t.cancel != nil {
|
||||||
|
close(t.cancel)
|
||||||
|
}
|
||||||
|
t.acked = make(chan struct{})
|
||||||
|
t.cancel = make(chan struct{})
|
||||||
|
return t.acked, t.cancel
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ConfigAckTracker) Mark() {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
t.confirmed = true
|
||||||
|
if t.acked == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-t.acked:
|
||||||
|
default:
|
||||||
|
close(t.acked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func SendVP8ConfigUntilAcked(acked, cancel <-chan struct{}, stopCh <-chan struct{}, tun DataTunnel, fps, batch, trackCount int, logger logger.ContextLogger, logPrefix string) {
|
||||||
|
tun.SendData(EncodeVP8Config(fps, batch, trackCount))
|
||||||
|
ticker := time.NewTicker(configResendPeriod)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-acked:
|
||||||
|
return
|
||||||
|
case <-cancel:
|
||||||
|
return
|
||||||
|
case <-stopCh:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
logger.Debug(fmt.Sprintf("%s: resending vp8 config fps=%d batch=%d trackCount=%d, no ack yet",
|
||||||
|
logPrefix, fps, batch, trackCount))
|
||||||
|
tun.SendData(EncodeVP8Config(fps, batch, trackCount))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
230
transport/call/tunnel/dctunnel.go
Normal file
230
transport/call/tunnel/dctunnel.go
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
package tunnel
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
|
"github.com/pion/datachannel"
|
||||||
|
"github.com/pion/webrtc/v4"
|
||||||
|
"github.com/sagernet/sing/common/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
const chunkSize = 994
|
||||||
|
|
||||||
|
type chunkBuf struct {
|
||||||
|
chunks [][]byte
|
||||||
|
count int
|
||||||
|
size int
|
||||||
|
}
|
||||||
|
|
||||||
|
type DCTunnel struct {
|
||||||
|
dc *webrtc.DataChannel
|
||||||
|
raw datachannel.ReadWriteCloser
|
||||||
|
writeRaw datachannel.ReadWriteCloser
|
||||||
|
logger logger.ContextLogger
|
||||||
|
onData func([]byte)
|
||||||
|
onClose func()
|
||||||
|
obf *TunnelObfuscator
|
||||||
|
chunked bool
|
||||||
|
readBuf int
|
||||||
|
|
||||||
|
recvBufs sync.Map
|
||||||
|
sendMsgID uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDCTunnel(dc *webrtc.DataChannel, obf *TunnelObfuscator, readBuf int, logger logger.ContextLogger) *DCTunnel {
|
||||||
|
t := &DCTunnel{dc: dc, obf: obf, readBuf: readBuf, logger: logger}
|
||||||
|
raw, err := dc.Detach()
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn(fmt.Sprintf("dctunnel: detach failed, using callback mode: %v", err))
|
||||||
|
dc.OnMessage(func(msg webrtc.DataChannelMessage) {
|
||||||
|
t.deliverMessage(msg.Data)
|
||||||
|
})
|
||||||
|
dc.OnClose(func() {
|
||||||
|
if t.onClose != nil {
|
||||||
|
t.onClose()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
t.raw = raw
|
||||||
|
go t.readLoop()
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDCTunnelFromRaw(dc *webrtc.DataChannel, raw datachannel.ReadWriteCloser, obf *TunnelObfuscator, readBuf int, logger logger.ContextLogger) *DCTunnel {
|
||||||
|
t := &DCTunnel{dc: dc, raw: raw, obf: obf, readBuf: readBuf, logger: logger}
|
||||||
|
go t.readLoop()
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewChunkedDCTunnel(readRaw datachannel.ReadWriteCloser, writeDC *webrtc.DataChannel, obf *TunnelObfuscator, readBuf int, logger logger.ContextLogger) *DCTunnel {
|
||||||
|
writeRaw, err := writeDC.Detach()
|
||||||
|
if err != nil {
|
||||||
|
logger.Error(fmt.Sprintf("dctunnel: write DC detach failed: %v", err))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
t := &DCTunnel{raw: readRaw, writeRaw: writeRaw, obf: obf, readBuf: readBuf, logger: logger, chunked: true}
|
||||||
|
go t.readLoop()
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewChunkedDCTunnelFromRaw(readRaw, writeRaw datachannel.ReadWriteCloser, obf *TunnelObfuscator, readBuf int, logger logger.ContextLogger) *DCTunnel {
|
||||||
|
t := &DCTunnel{raw: readRaw, writeRaw: writeRaw, obf: obf, readBuf: readBuf, logger: logger, chunked: true}
|
||||||
|
go t.readLoop()
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *DCTunnel) SendData(data []byte) {
|
||||||
|
for len(data) >= 4 {
|
||||||
|
frameLen := int(binary.BigEndian.Uint32(data[0:4]))
|
||||||
|
if frameLen < 5 || 4+frameLen > len(data) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body := data[4 : 4+frameLen]
|
||||||
|
wire := body
|
||||||
|
if t.obf != nil {
|
||||||
|
wire = t.obf.EncryptPayload(body)
|
||||||
|
if wire == nil {
|
||||||
|
data = data[4+frameLen:]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if t.chunked {
|
||||||
|
t.sendChunked(wire)
|
||||||
|
} else {
|
||||||
|
t.sendRaw(wire)
|
||||||
|
}
|
||||||
|
data = data[4+frameLen:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *DCTunnel) SetOnData(fn func([]byte)) { t.onData = fn }
|
||||||
|
func (t *DCTunnel) OnData() func([]byte) { return t.onData }
|
||||||
|
func (t *DCTunnel) SetOnClose(fn func()) { t.onClose = fn }
|
||||||
|
func (t *DCTunnel) Reconfigure(fps, batch int) {}
|
||||||
|
|
||||||
|
func (t *DCTunnel) readLoop() {
|
||||||
|
buf := make([]byte, t.readBuf)
|
||||||
|
for {
|
||||||
|
n, isString, err := t.raw.ReadDataChannel(buf)
|
||||||
|
if err != nil {
|
||||||
|
if err != io.EOF {
|
||||||
|
t.logger.Warn(fmt.Sprintf("dctunnel: read error: %v", err))
|
||||||
|
}
|
||||||
|
if t.onClose != nil {
|
||||||
|
t.onClose()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if isString {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if t.chunked && n >= 6 {
|
||||||
|
t.handleChunk(buf[:n])
|
||||||
|
} else if n > 0 {
|
||||||
|
t.deliverMessage(buf[:n])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *DCTunnel) handleChunk(data []byte) {
|
||||||
|
id := uint16(data[0])<<8 | uint16(data[1])
|
||||||
|
idx := int(uint16(data[2])<<8 | uint16(data[3]))
|
||||||
|
total := int(uint16(data[4])<<8 | uint16(data[5]))
|
||||||
|
payload := data[6:]
|
||||||
|
if total == 1 {
|
||||||
|
cp := make([]byte, len(payload))
|
||||||
|
copy(cp, payload)
|
||||||
|
t.deliverMessage(cp)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val, _ := t.recvBufs.LoadOrStore(id, &chunkBuf{chunks: make([][]byte, total)})
|
||||||
|
cb := val.(*chunkBuf)
|
||||||
|
if idx < len(cb.chunks) && cb.chunks[idx] == nil {
|
||||||
|
cp := make([]byte, len(payload))
|
||||||
|
copy(cp, payload)
|
||||||
|
cb.chunks[idx] = cp
|
||||||
|
cb.count++
|
||||||
|
cb.size += len(cp)
|
||||||
|
}
|
||||||
|
if cb.count == total {
|
||||||
|
t.recvBufs.Delete(id)
|
||||||
|
out := make([]byte, 0, cb.size)
|
||||||
|
for _, c := range cb.chunks {
|
||||||
|
out = append(out, c...)
|
||||||
|
}
|
||||||
|
t.deliverMessage(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *DCTunnel) deliverMessage(data []byte) {
|
||||||
|
if len(data) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if t.obf != nil {
|
||||||
|
pt, ok := t.obf.DecryptPayload(data)
|
||||||
|
if !ok {
|
||||||
|
t.logger.Debug(fmt.Sprintf("dctunnel: decrypt failed, dropping %d bytes", len(data)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data = pt
|
||||||
|
}
|
||||||
|
if t.onData != nil && len(data) > 0 {
|
||||||
|
frame := make([]byte, 4+len(data))
|
||||||
|
binary.BigEndian.PutUint32(frame[0:4], uint32(len(data)))
|
||||||
|
copy(frame[4:], data)
|
||||||
|
t.onData(frame)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *DCTunnel) sendChunked(data []byte) {
|
||||||
|
w := t.writeRaw
|
||||||
|
if w == nil {
|
||||||
|
w = t.raw
|
||||||
|
}
|
||||||
|
if w == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
total := int(math.Ceil(float64(len(data)) / float64(chunkSize)))
|
||||||
|
if total == 0 {
|
||||||
|
total = 1
|
||||||
|
}
|
||||||
|
id := uint16(atomic.AddUint32(&t.sendMsgID, 1)) & 0xFFFF
|
||||||
|
for i := 0; i < total; i++ {
|
||||||
|
start := i * chunkSize
|
||||||
|
end := start + chunkSize
|
||||||
|
if end > len(data) {
|
||||||
|
end = len(data)
|
||||||
|
}
|
||||||
|
p := data[start:end]
|
||||||
|
f := make([]byte, 6+len(p))
|
||||||
|
f[0] = byte(id >> 8)
|
||||||
|
f[1] = byte(id & 0xFF)
|
||||||
|
f[2] = byte(i >> 8)
|
||||||
|
f[3] = byte(i & 0xFF)
|
||||||
|
f[4] = byte(total >> 8)
|
||||||
|
f[5] = byte(total & 0xFF)
|
||||||
|
copy(f[6:], p)
|
||||||
|
w.Write(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *DCTunnel) sendRaw(data []byte) {
|
||||||
|
w := t.writeRaw
|
||||||
|
if w == nil {
|
||||||
|
w = t.raw
|
||||||
|
}
|
||||||
|
if w != nil {
|
||||||
|
w.Write(data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if t.dc == nil || t.dc.ReadyState() != webrtc.DataChannelStateOpen {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.dc.Send(data)
|
||||||
|
}
|
||||||
69
transport/call/tunnel/dctunnel_bench_test.go
Normal file
69
transport/call/tunnel/dctunnel_bench_test.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
package tunnel
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type discardRawConn struct{}
|
||||||
|
|
||||||
|
func (discardRawConn) Read(p []byte) (int, error) { return 0, io.EOF }
|
||||||
|
func (discardRawConn) ReadDataChannel(p []byte) (int, bool, error) { return 0, false, io.EOF }
|
||||||
|
func (discardRawConn) Write(p []byte) (int, error) { return len(p), nil }
|
||||||
|
func (discardRawConn) WriteDataChannel(p []byte, isString bool) (int, error) {
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
func (discardRawConn) Close() error { return nil }
|
||||||
|
|
||||||
|
type benchLogger struct{}
|
||||||
|
|
||||||
|
func (benchLogger) Trace(args ...any) {}
|
||||||
|
func (benchLogger) Debug(args ...any) {}
|
||||||
|
func (benchLogger) Info(args ...any) {}
|
||||||
|
func (benchLogger) Notice(args ...any) {}
|
||||||
|
func (benchLogger) Warn(args ...any) {}
|
||||||
|
func (benchLogger) Error(args ...any) {}
|
||||||
|
func (benchLogger) Fatal(args ...any) {}
|
||||||
|
func (benchLogger) Panic(args ...any) {}
|
||||||
|
func (benchLogger) TraceContext(ctx context.Context, args ...any) {}
|
||||||
|
func (benchLogger) DebugContext(ctx context.Context, args ...any) {}
|
||||||
|
func (benchLogger) InfoContext(ctx context.Context, args ...any) {}
|
||||||
|
func (benchLogger) NoticeContext(ctx context.Context, args ...any) {}
|
||||||
|
func (benchLogger) WarnContext(ctx context.Context, args ...any) {}
|
||||||
|
func (benchLogger) ErrorContext(ctx context.Context, args ...any) {}
|
||||||
|
func (benchLogger) FatalContext(ctx context.Context, args ...any) {}
|
||||||
|
func (benchLogger) PanicContext(ctx context.Context, args ...any) {}
|
||||||
|
|
||||||
|
func newBenchDCTunnel() *DCTunnel {
|
||||||
|
return &DCTunnel{raw: discardRawConn{}, logger: benchLogger{}, readBuf: 4096}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkDCTunnelSendData(b *testing.B) {
|
||||||
|
sizes := []int{64, 512, 4096}
|
||||||
|
for _, size := range sizes {
|
||||||
|
payload := make([]byte, size)
|
||||||
|
frame := EncodeFrame(42, MsgData, payload)
|
||||||
|
b.Run(sizeLabel(size), func(b *testing.B) {
|
||||||
|
t := newBenchDCTunnel()
|
||||||
|
b.ReportAllocs()
|
||||||
|
b.SetBytes(int64(len(frame)))
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
t.SendData(frame)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sizeLabel(n int) string {
|
||||||
|
switch n {
|
||||||
|
case 64:
|
||||||
|
return "64B"
|
||||||
|
case 512:
|
||||||
|
return "512B"
|
||||||
|
case 4096:
|
||||||
|
return "4KB"
|
||||||
|
default:
|
||||||
|
return "custom"
|
||||||
|
}
|
||||||
|
}
|
||||||
400
transport/call/tunnel/multi_track_kcp_tunnel.go
Normal file
400
transport/call/tunnel/multi_track_kcp_tunnel.go
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
package tunnel
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
kcp "github.com/xtaci/kcp-go/v5"
|
||||||
|
|
||||||
|
"github.com/sagernet/sing/common/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
kcpConvBase = 0x77627374
|
||||||
|
kcpUpdateInterval = 10 * time.Millisecond
|
||||||
|
// One KCP segment must ride in a single RTP packet so a dropped packet
|
||||||
|
// loses only its own frame, not a two-packet frame that readVP8Track
|
||||||
|
// would discard whole. 1200 RTP budget - 1 VP8 descriptor - interframe
|
||||||
|
// header - 24 XChaCha20 nonce - 16 Poly1305 tag - 1 channel tag.
|
||||||
|
kcpSegmentMTU = 1200 - 1 - interframeHdrLen - 24 - 16 - 1
|
||||||
|
kcpReceiveBufSize = 128 * 1024
|
||||||
|
kcpStatsEvery = 500
|
||||||
|
|
||||||
|
kcpWindowFloor = 64
|
||||||
|
kcpWindowCeiling = 512
|
||||||
|
kcpCarrierRTT = 250 * time.Millisecond
|
||||||
|
kcpWaitSndFactor = 2
|
||||||
|
kcpBackpressurePoll = 2 * time.Millisecond
|
||||||
|
|
||||||
|
kcpChannelReliable byte = 0x00
|
||||||
|
kcpChannelRaw byte = 0x01
|
||||||
|
|
||||||
|
KCPCarrierQueueDepth = kcpWaitSndFactor * kcpWindowCeiling
|
||||||
|
)
|
||||||
|
|
||||||
|
func computeKCPWindow(fps, batch int) int {
|
||||||
|
rate := fps * batch
|
||||||
|
if rate < 1 {
|
||||||
|
rate = defaultVP8FPS * defaultVP8Batch
|
||||||
|
}
|
||||||
|
window := int(float64(rate) * kcpCarrierRTT.Seconds())
|
||||||
|
if window < kcpWindowFloor {
|
||||||
|
return kcpWindowFloor
|
||||||
|
}
|
||||||
|
if window > kcpWindowCeiling {
|
||||||
|
return kcpWindowCeiling
|
||||||
|
}
|
||||||
|
return window
|
||||||
|
}
|
||||||
|
|
||||||
|
type trackKCPSession struct {
|
||||||
|
conv uint32
|
||||||
|
vp8 *VP8DataTunnel
|
||||||
|
parent *MultiTrackKCPTunnel
|
||||||
|
kcpMu sync.Mutex
|
||||||
|
kcp *kcp.KCP
|
||||||
|
recvBuf []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTrackKCPSession(parent *MultiTrackKCPTunnel, vp8 *VP8DataTunnel, conv uint32, window int) *trackKCPSession {
|
||||||
|
session := &trackKCPSession{
|
||||||
|
conv: conv,
|
||||||
|
vp8: vp8,
|
||||||
|
parent: parent,
|
||||||
|
recvBuf: make([]byte, kcpReceiveBufSize),
|
||||||
|
}
|
||||||
|
session.kcp = kcp.NewKCP(conv, func(buf []byte, size int) {
|
||||||
|
if size <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
segment := make([]byte, size+1)
|
||||||
|
segment[0] = kcpChannelReliable
|
||||||
|
copy(segment[1:], buf[:size])
|
||||||
|
parent.outputSegments.Add(1)
|
||||||
|
if !session.vp8.TrySendData(segment) {
|
||||||
|
parent.droppedSegments.Add(1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
session.kcp.NoDelay(1, 10, 2, 1)
|
||||||
|
session.kcp.WndSize(window, window)
|
||||||
|
session.kcp.SetMtu(kcpSegmentMTU)
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *trackKCPSession) setWindow(window int) {
|
||||||
|
s.kcpMu.Lock()
|
||||||
|
s.kcp.WndSize(window, window)
|
||||||
|
s.kcpMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *trackKCPSession) send(frame []byte) {
|
||||||
|
s.kcpMu.Lock()
|
||||||
|
s.kcp.Send(frame)
|
||||||
|
s.kcp.Update()
|
||||||
|
s.kcpMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *trackKCPSession) input(segment []byte) [][]byte {
|
||||||
|
s.kcpMu.Lock()
|
||||||
|
s.kcp.Input(segment, kcp.IKCP_PACKET_REGULAR, true)
|
||||||
|
var messages [][]byte
|
||||||
|
for {
|
||||||
|
size := s.kcp.PeekSize()
|
||||||
|
if size <= 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if size > len(s.recvBuf) {
|
||||||
|
s.recvBuf = make([]byte, size)
|
||||||
|
}
|
||||||
|
n := s.kcp.Recv(s.recvBuf)
|
||||||
|
if n <= 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
message := make([]byte, n)
|
||||||
|
copy(message, s.recvBuf[:n])
|
||||||
|
messages = append(messages, message)
|
||||||
|
}
|
||||||
|
s.kcpMu.Unlock()
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *trackKCPSession) update() {
|
||||||
|
s.kcpMu.Lock()
|
||||||
|
s.kcp.Update()
|
||||||
|
s.kcpMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *trackKCPSession) waitSnd() int {
|
||||||
|
s.kcpMu.Lock()
|
||||||
|
pending := s.kcp.WaitSnd()
|
||||||
|
s.kcpMu.Unlock()
|
||||||
|
return pending
|
||||||
|
}
|
||||||
|
|
||||||
|
type MultiTrackKCPTunnel struct {
|
||||||
|
mt *MultiTrackTunnel
|
||||||
|
logger logger.ContextLogger
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
sessions []*trackKCPSession
|
||||||
|
convMap map[uint32]*trackKCPSession
|
||||||
|
connPin map[uint32]int
|
||||||
|
onData func([]byte)
|
||||||
|
onClose func()
|
||||||
|
|
||||||
|
stopCh chan struct{}
|
||||||
|
stopOnce sync.Once
|
||||||
|
|
||||||
|
currentWindow atomic.Int32
|
||||||
|
|
||||||
|
sentMessages atomic.Uint64
|
||||||
|
deliveredMessages atomic.Uint64
|
||||||
|
outputSegments atomic.Uint64
|
||||||
|
inputSegments atomic.Uint64
|
||||||
|
rawSent atomic.Uint64
|
||||||
|
rawReceived atomic.Uint64
|
||||||
|
droppedSegments atomic.Uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMultiTrackKCPTunnel(mt *MultiTrackTunnel, logger logger.ContextLogger) *MultiTrackKCPTunnel {
|
||||||
|
t := &MultiTrackKCPTunnel{
|
||||||
|
mt: mt,
|
||||||
|
logger: logger,
|
||||||
|
convMap: make(map[uint32]*trackKCPSession),
|
||||||
|
connPin: make(map[uint32]int),
|
||||||
|
stopCh: make(chan struct{}),
|
||||||
|
}
|
||||||
|
subs := mt.SubTunnels()
|
||||||
|
window := kcpWindowFloor
|
||||||
|
if len(subs) > 0 {
|
||||||
|
window = computeKCPWindow(subs[0].FPS(), subs[0].Batch())
|
||||||
|
}
|
||||||
|
t.currentWindow.Store(int32(window))
|
||||||
|
for i, sub := range subs {
|
||||||
|
conv := uint32(kcpConvBase + i)
|
||||||
|
session := newTrackKCPSession(t, sub, conv, window)
|
||||||
|
t.sessions = append(t.sessions, session)
|
||||||
|
t.convMap[conv] = session
|
||||||
|
}
|
||||||
|
if logger != nil {
|
||||||
|
logger.Debug(fmt.Sprintf("kcptunnel: init tracks=%d window=%d queue=%d", len(subs), window, KCPCarrierQueueDepth))
|
||||||
|
}
|
||||||
|
mt.SetOnData(t.handleDecodedSegment)
|
||||||
|
mt.SetOnClose(t.handleInnerClose)
|
||||||
|
go t.updateLoop()
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *MultiTrackKCPTunnel) SendData(frame []byte) {
|
||||||
|
if len(frame) < 9 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
connID := binary.BigEndian.Uint32(frame[4:8])
|
||||||
|
msgType := frame[8]
|
||||||
|
|
||||||
|
if msgType == MsgUDP || msgType == MsgUDPReply {
|
||||||
|
t.sendRaw(connID, frame)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t.mu.Lock()
|
||||||
|
if len(t.sessions) == 0 {
|
||||||
|
t.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
index, pinned := t.connPin[connID]
|
||||||
|
if !pinned || index >= len(t.sessions) {
|
||||||
|
index = int(connID % uint32(len(t.sessions)))
|
||||||
|
t.connPin[connID] = index
|
||||||
|
}
|
||||||
|
session := t.sessions[index]
|
||||||
|
t.mu.Unlock()
|
||||||
|
|
||||||
|
if msgType == MsgData {
|
||||||
|
sndCap := int(t.currentWindow.Load()) * kcpWaitSndFactor
|
||||||
|
for session.waitSnd() >= sndCap {
|
||||||
|
select {
|
||||||
|
case <-t.stopCh:
|
||||||
|
return
|
||||||
|
case <-time.After(kcpBackpressurePoll):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.sentMessages.Add(1)
|
||||||
|
session.send(frame)
|
||||||
|
|
||||||
|
if msgType == MsgClose {
|
||||||
|
t.mu.Lock()
|
||||||
|
delete(t.connPin, connID)
|
||||||
|
t.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *MultiTrackKCPTunnel) sendRaw(connID uint32, frame []byte) {
|
||||||
|
t.mu.Lock()
|
||||||
|
if len(t.sessions) == 0 {
|
||||||
|
t.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
index := int(connID % uint32(len(t.sessions)))
|
||||||
|
session := t.sessions[index]
|
||||||
|
t.mu.Unlock()
|
||||||
|
|
||||||
|
segment := make([]byte, len(frame)+1)
|
||||||
|
segment[0] = kcpChannelRaw
|
||||||
|
copy(segment[1:], frame)
|
||||||
|
t.rawSent.Add(1)
|
||||||
|
session.vp8.TrySendData(segment)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *MultiTrackKCPTunnel) InjectSegment(payload []byte) {
|
||||||
|
t.handleDecodedSegment(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *MultiTrackKCPTunnel) handleDecodedSegment(payload []byte) {
|
||||||
|
if len(payload) < 1 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
channel := payload[0]
|
||||||
|
body := payload[1:]
|
||||||
|
|
||||||
|
if channel == kcpChannelRaw {
|
||||||
|
t.mu.Lock()
|
||||||
|
callback := t.onData
|
||||||
|
t.mu.Unlock()
|
||||||
|
if callback == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.rawReceived.Add(1)
|
||||||
|
callback(body)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(body) < 4 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conv := binary.LittleEndian.Uint32(body[0:4])
|
||||||
|
t.mu.Lock()
|
||||||
|
session := t.convMap[conv]
|
||||||
|
callback := t.onData
|
||||||
|
t.mu.Unlock()
|
||||||
|
if session == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.inputSegments.Add(1)
|
||||||
|
messages := session.input(body)
|
||||||
|
if callback == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, message := range messages {
|
||||||
|
t.deliveredMessages.Add(1)
|
||||||
|
callback(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *MultiTrackKCPTunnel) SetOnData(fn func([]byte)) {
|
||||||
|
t.mu.Lock()
|
||||||
|
t.onData = fn
|
||||||
|
t.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *MultiTrackKCPTunnel) SetOnClose(fn func()) {
|
||||||
|
t.mu.Lock()
|
||||||
|
t.onClose = fn
|
||||||
|
t.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *MultiTrackKCPTunnel) Reconfigure(fps, batch int) {
|
||||||
|
t.mt.Reconfigure(fps, batch)
|
||||||
|
window := computeKCPWindow(fps, batch)
|
||||||
|
t.applyWindow(window)
|
||||||
|
if t.logger != nil {
|
||||||
|
t.logger.Debug(fmt.Sprintf("kcptunnel: reconfigure fps=%d batch=%d -> window=%d", fps, batch, window))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *MultiTrackKCPTunnel) applyWindow(window int) {
|
||||||
|
t.currentWindow.Store(int32(window))
|
||||||
|
t.mu.Lock()
|
||||||
|
sessions := make([]*trackKCPSession, len(t.sessions))
|
||||||
|
copy(sessions, t.sessions)
|
||||||
|
t.mu.Unlock()
|
||||||
|
for _, session := range sessions {
|
||||||
|
session.setWindow(window)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *MultiTrackKCPTunnel) AddSession(sub *VP8DataTunnel) {
|
||||||
|
window := int(t.currentWindow.Load())
|
||||||
|
t.mu.Lock()
|
||||||
|
conv := uint32(kcpConvBase + len(t.sessions))
|
||||||
|
session := newTrackKCPSession(t, sub, conv, window)
|
||||||
|
t.sessions = append(t.sessions, session)
|
||||||
|
t.convMap[conv] = session
|
||||||
|
t.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *MultiTrackKCPTunnel) RemoveLastSession() {
|
||||||
|
t.mu.Lock()
|
||||||
|
if len(t.sessions) <= 1 {
|
||||||
|
t.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
last := t.sessions[len(t.sessions)-1]
|
||||||
|
t.sessions = t.sessions[:len(t.sessions)-1]
|
||||||
|
delete(t.convMap, last.conv)
|
||||||
|
t.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *MultiTrackKCPTunnel) Stop() {
|
||||||
|
t.stopOnce.Do(func() { close(t.stopCh) })
|
||||||
|
t.mt.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *MultiTrackKCPTunnel) StopLayer() {
|
||||||
|
t.stopOnce.Do(func() { close(t.stopCh) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *MultiTrackKCPTunnel) handleInnerClose() {
|
||||||
|
t.stopOnce.Do(func() { close(t.stopCh) })
|
||||||
|
t.mu.Lock()
|
||||||
|
callback := t.onClose
|
||||||
|
t.mu.Unlock()
|
||||||
|
if callback != nil {
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *MultiTrackKCPTunnel) updateLoop() {
|
||||||
|
ticker := time.NewTicker(kcpUpdateInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
ticks := 0
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-t.stopCh:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
t.mu.Lock()
|
||||||
|
sessions := make([]*trackKCPSession, len(t.sessions))
|
||||||
|
copy(sessions, t.sessions)
|
||||||
|
t.mu.Unlock()
|
||||||
|
for _, session := range sessions {
|
||||||
|
session.update()
|
||||||
|
}
|
||||||
|
ticks++
|
||||||
|
if ticks%kcpStatsEvery == 0 && t.logger != nil {
|
||||||
|
snmp := kcp.DefaultSnmp.Copy()
|
||||||
|
t.logger.Debug(fmt.Sprintf("kcptunnel: sessions=%d window=%d sent=%d delivered=%d out_segs=%d in_segs=%d raw_out=%d raw_in=%d dropped=%d",
|
||||||
|
len(sessions), t.currentWindow.Load(), t.sentMessages.Load(), t.deliveredMessages.Load(),
|
||||||
|
t.outputSegments.Load(), t.inputSegments.Load(),
|
||||||
|
t.rawSent.Load(), t.rawReceived.Load(), t.droppedSegments.Load()))
|
||||||
|
t.logger.Debug(fmt.Sprintf("kcptunnel: kcp_out=%d kcp_in=%d retrans=%d fastretrans=%d lost=%d repeat=%d",
|
||||||
|
snmp.OutSegs, snmp.InSegs, snmp.RetransSegs, snmp.FastRetransSegs, snmp.LostSegs, snmp.RepeatSegs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
199
transport/call/tunnel/multi_track_tunnel.go
Normal file
199
transport/call/tunnel/multi_track_tunnel.go
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
package tunnel
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MultiTrackTunnel struct {
|
||||||
|
tunnels []*VP8DataTunnel
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
onData func([]byte)
|
||||||
|
onClose func()
|
||||||
|
onPeerRestart func()
|
||||||
|
isClosed bool
|
||||||
|
fps int
|
||||||
|
batch int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMultiTrackTunnel(tunnels []*VP8DataTunnel) *MultiTrackTunnel {
|
||||||
|
m := &MultiTrackTunnel{tunnels: tunnels}
|
||||||
|
for i, tun := range tunnels {
|
||||||
|
m.wireSubTunnel(tun, i == 0)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MultiTrackTunnel) AddSubTunnel(tun *VP8DataTunnel) {
|
||||||
|
m.mu.Lock()
|
||||||
|
if m.isClosed {
|
||||||
|
m.mu.Unlock()
|
||||||
|
tun.Stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.tunnels = append(m.tunnels, tun)
|
||||||
|
fps := m.fps
|
||||||
|
batch := m.batch
|
||||||
|
m.mu.Unlock()
|
||||||
|
m.wireSubTunnel(tun, false)
|
||||||
|
if fps > 0 && batch > 0 {
|
||||||
|
tun.Start(fps, batch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MultiTrackTunnel) RemoveLastSubTunnel() *VP8DataTunnel {
|
||||||
|
m.mu.Lock()
|
||||||
|
if len(m.tunnels) <= 1 {
|
||||||
|
m.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
last := m.tunnels[len(m.tunnels)-1]
|
||||||
|
m.tunnels = m.tunnels[:len(m.tunnels)-1]
|
||||||
|
m.mu.Unlock()
|
||||||
|
last.Stop()
|
||||||
|
return last
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MultiTrackTunnel) SubTunnelCount() int {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
return len(m.tunnels)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MultiTrackTunnel) SendData(data []byte) {
|
||||||
|
m.mu.Lock()
|
||||||
|
tunnels := m.tunnels
|
||||||
|
m.mu.Unlock()
|
||||||
|
if len(tunnels) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var connID uint32
|
||||||
|
if len(data) >= 8 {
|
||||||
|
connID = binary.BigEndian.Uint32(data[4:8])
|
||||||
|
}
|
||||||
|
idx := connID % uint32(len(tunnels))
|
||||||
|
tunnels[idx].SendData(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MultiTrackTunnel) DeliverData(data []byte) {
|
||||||
|
m.mu.Lock()
|
||||||
|
handler := m.onData
|
||||||
|
m.mu.Unlock()
|
||||||
|
if handler != nil {
|
||||||
|
handler(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MultiTrackTunnel) SubTunnels() []*VP8DataTunnel {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
subs := make([]*VP8DataTunnel, len(m.tunnels))
|
||||||
|
copy(subs, m.tunnels)
|
||||||
|
return subs
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MultiTrackTunnel) SetOnData(fn func([]byte)) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.onData = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MultiTrackTunnel) SetOnClose(fn func()) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.onClose = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MultiTrackTunnel) SetOnPeerRestart(fn func()) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.onPeerRestart = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MultiTrackTunnel) Reconfigure(fps, batch int) {
|
||||||
|
m.mu.Lock()
|
||||||
|
m.fps = fps
|
||||||
|
m.batch = batch
|
||||||
|
tunnels := m.tunnels
|
||||||
|
m.mu.Unlock()
|
||||||
|
for _, tun := range tunnels {
|
||||||
|
tun.Reconfigure(fps, batch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MultiTrackTunnel) Start(fps, batch int) {
|
||||||
|
m.mu.Lock()
|
||||||
|
m.fps = fps
|
||||||
|
m.batch = batch
|
||||||
|
tunnels := m.tunnels
|
||||||
|
m.mu.Unlock()
|
||||||
|
for _, tun := range tunnels {
|
||||||
|
tun.Start(fps, batch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MultiTrackTunnel) Stop() {
|
||||||
|
m.mu.Lock()
|
||||||
|
if m.isClosed {
|
||||||
|
m.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.isClosed = true
|
||||||
|
tunnels := m.tunnels
|
||||||
|
m.mu.Unlock()
|
||||||
|
for _, tun := range tunnels {
|
||||||
|
tun.Stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MultiTrackTunnel) HandleFrame(frame []byte) {
|
||||||
|
m.mu.Lock()
|
||||||
|
var first *VP8DataTunnel
|
||||||
|
if len(m.tunnels) > 0 {
|
||||||
|
first = m.tunnels[0]
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
if first != nil {
|
||||||
|
first.HandleFrame(frame)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MultiTrackTunnel) wireSubTunnel(tun *VP8DataTunnel, isCamera bool) {
|
||||||
|
tun.SetOnData(func(data []byte) {
|
||||||
|
m.mu.Lock()
|
||||||
|
handler := m.onData
|
||||||
|
m.mu.Unlock()
|
||||||
|
if handler != nil {
|
||||||
|
handler(data)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if !isCamera {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tun.SetOnPeerRestart(func() {
|
||||||
|
m.mu.Lock()
|
||||||
|
handler := m.onPeerRestart
|
||||||
|
m.mu.Unlock()
|
||||||
|
if handler != nil {
|
||||||
|
handler()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
tun.SetOnClose(func() {
|
||||||
|
m.mu.Lock()
|
||||||
|
if m.isClosed {
|
||||||
|
m.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.isClosed = true
|
||||||
|
closeHandler := m.onClose
|
||||||
|
subTunnels := m.tunnels
|
||||||
|
m.mu.Unlock()
|
||||||
|
for _, t := range subTunnels {
|
||||||
|
t.Stop()
|
||||||
|
}
|
||||||
|
if closeHandler != nil {
|
||||||
|
closeHandler()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
221
transport/call/tunnel/obfuscator.go
Normal file
221
transport/call/tunnel/obfuscator.go
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
package tunnel
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/chacha20poly1305"
|
||||||
|
)
|
||||||
|
|
||||||
|
var vp8Keepalive = []byte{
|
||||||
|
0x30, 0x01, 0x00, 0x9d, 0x01, 0x2a, 0x10, 0x00,
|
||||||
|
0x10, 0x00, 0x00, 0x47, 0x08, 0x85, 0x85, 0x88,
|
||||||
|
0x99, 0x84, 0x88, 0xfc,
|
||||||
|
}
|
||||||
|
|
||||||
|
var vp8Interframe = []byte{
|
||||||
|
0xb1, 0x01, 0x00, 0x08, 0x11, 0x18, 0x00, 0x18,
|
||||||
|
0x00, 0x18, 0x58, 0x2f, 0xf4, 0x00, 0x08, 0x00,
|
||||||
|
0x00,
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
vp8KeepaliveLen = 20
|
||||||
|
vp8InterframeLen = 17
|
||||||
|
epochFieldLen = 4
|
||||||
|
keepaliveHdrLen = vp8KeepaliveLen + epochFieldLen
|
||||||
|
interframeHdrLen = vp8InterframeLen + epochFieldLen
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrEmptySecret = errors.New("tunnel: obfuscator requires a non-empty secret")
|
||||||
|
|
||||||
|
type DecodeResult struct {
|
||||||
|
HasFrame bool
|
||||||
|
Keepalive bool
|
||||||
|
SelfEcho bool
|
||||||
|
PeerRestart bool
|
||||||
|
Payload []byte
|
||||||
|
PeerEpoch uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
type TunnelObfuscator struct {
|
||||||
|
aead cipher.AEAD
|
||||||
|
localEpoch uint32
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
peerEpoch uint32
|
||||||
|
hasPeer bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeriveSecretFromJoinLink(joinLink string) []byte {
|
||||||
|
token := extractJoinToken(joinLink)
|
||||||
|
if token == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []byte(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTunnelObfuscator(secret []byte) (*TunnelObfuscator, error) {
|
||||||
|
if len(secret) == 0 {
|
||||||
|
return nil, ErrEmptySecret
|
||||||
|
}
|
||||||
|
keyHash := sha256.Sum256(secret)
|
||||||
|
aead, err := chacha20poly1305.NewX(keyHash[:])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var epochBytes [4]byte
|
||||||
|
if _, err := rand.Read(epochBytes[:]); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
epoch := binary.BigEndian.Uint32(epochBytes[:])
|
||||||
|
if epoch == 0 {
|
||||||
|
epoch = 1
|
||||||
|
}
|
||||||
|
return &TunnelObfuscator{aead: aead, localEpoch: epoch}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *TunnelObfuscator) LocalEpoch() uint32 { return o.localEpoch }
|
||||||
|
|
||||||
|
func (o *TunnelObfuscator) EncodeKeepalive(padLen int) []byte {
|
||||||
|
hdr := o.keepaliveHeader()
|
||||||
|
if padLen <= 0 {
|
||||||
|
return hdr
|
||||||
|
}
|
||||||
|
out := make([]byte, keepaliveHdrLen+padLen)
|
||||||
|
copy(out, hdr)
|
||||||
|
if _, err := rand.Read(out[keepaliveHdrLen:]); err != nil {
|
||||||
|
return hdr
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *TunnelObfuscator) EncodeData(payload []byte) []byte {
|
||||||
|
hdr := o.dataHeader()
|
||||||
|
nonce := make([]byte, o.aead.NonceSize())
|
||||||
|
if _, err := rand.Read(nonce); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]byte, 0, len(hdr)+len(nonce)+len(payload)+o.aead.Overhead())
|
||||||
|
out = append(out, hdr...)
|
||||||
|
out = append(out, nonce...)
|
||||||
|
out = o.aead.Seal(out, nonce, payload, nil)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *TunnelObfuscator) EncryptPayload(plaintext []byte) []byte {
|
||||||
|
if o == nil {
|
||||||
|
return plaintext
|
||||||
|
}
|
||||||
|
nonce := make([]byte, o.aead.NonceSize())
|
||||||
|
if _, err := rand.Read(nonce); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]byte, 0, len(nonce)+len(plaintext)+o.aead.Overhead())
|
||||||
|
out = append(out, nonce...)
|
||||||
|
return o.aead.Seal(out, nonce, plaintext, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *TunnelObfuscator) DecryptPayload(data []byte) ([]byte, bool) {
|
||||||
|
if o == nil {
|
||||||
|
return data, true
|
||||||
|
}
|
||||||
|
nonceSize := o.aead.NonceSize()
|
||||||
|
if len(data) < nonceSize+o.aead.Overhead() {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
nonce := data[:nonceSize]
|
||||||
|
ciphertext := data[nonceSize:]
|
||||||
|
plaintext, err := o.aead.Open(nil, nonce, ciphertext, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return plaintext, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *TunnelObfuscator) Decode(frame []byte) DecodeResult {
|
||||||
|
if len(frame) < 1 {
|
||||||
|
return DecodeResult{}
|
||||||
|
}
|
||||||
|
var hdrLen, epochOff int
|
||||||
|
isKeepaliveFrame := false
|
||||||
|
switch frame[0] {
|
||||||
|
case vp8Keepalive[0]:
|
||||||
|
hdrLen = keepaliveHdrLen
|
||||||
|
epochOff = vp8KeepaliveLen
|
||||||
|
isKeepaliveFrame = true
|
||||||
|
case vp8Interframe[0]:
|
||||||
|
hdrLen = interframeHdrLen
|
||||||
|
epochOff = vp8InterframeLen
|
||||||
|
default:
|
||||||
|
return DecodeResult{}
|
||||||
|
}
|
||||||
|
if len(frame) < hdrLen {
|
||||||
|
return DecodeResult{}
|
||||||
|
}
|
||||||
|
peerEpoch := binary.BigEndian.Uint32(frame[epochOff : epochOff+epochFieldLen])
|
||||||
|
if peerEpoch == o.localEpoch {
|
||||||
|
return DecodeResult{HasFrame: true, SelfEcho: true, PeerEpoch: peerEpoch}
|
||||||
|
}
|
||||||
|
res := DecodeResult{HasFrame: true, PeerEpoch: peerEpoch}
|
||||||
|
o.mu.Lock()
|
||||||
|
if !o.hasPeer {
|
||||||
|
o.peerEpoch = peerEpoch
|
||||||
|
o.hasPeer = true
|
||||||
|
} else if o.peerEpoch != peerEpoch {
|
||||||
|
o.peerEpoch = peerEpoch
|
||||||
|
res.PeerRestart = true
|
||||||
|
}
|
||||||
|
o.mu.Unlock()
|
||||||
|
if isKeepaliveFrame || len(frame) == hdrLen {
|
||||||
|
res.Keepalive = true
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
body := frame[hdrLen:]
|
||||||
|
nonceSize := o.aead.NonceSize()
|
||||||
|
if len(body) < nonceSize+o.aead.Overhead() {
|
||||||
|
return DecodeResult{}
|
||||||
|
}
|
||||||
|
nonce := body[:nonceSize]
|
||||||
|
ciphertext := body[nonceSize:]
|
||||||
|
plaintext, err := o.aead.Open(nil, nonce, ciphertext, nil)
|
||||||
|
if err != nil {
|
||||||
|
return DecodeResult{}
|
||||||
|
}
|
||||||
|
res.Payload = plaintext
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractJoinToken(joinLink string) string {
|
||||||
|
s := strings.TrimSpace(joinLink)
|
||||||
|
s = strings.TrimRight(s, "/")
|
||||||
|
if i := strings.IndexByte(s, '?'); i >= 0 {
|
||||||
|
s = s[:i]
|
||||||
|
}
|
||||||
|
if i := strings.IndexByte(s, '#'); i >= 0 {
|
||||||
|
s = s[:i]
|
||||||
|
}
|
||||||
|
if i := strings.LastIndexByte(s, '/'); i >= 0 {
|
||||||
|
s = s[i+1:]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *TunnelObfuscator) keepaliveHeader() []byte {
|
||||||
|
hdr := make([]byte, keepaliveHdrLen)
|
||||||
|
copy(hdr, vp8Keepalive)
|
||||||
|
binary.BigEndian.PutUint32(hdr[vp8KeepaliveLen:], o.localEpoch)
|
||||||
|
return hdr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *TunnelObfuscator) dataHeader() []byte {
|
||||||
|
hdr := make([]byte, interframeHdrLen)
|
||||||
|
copy(hdr, vp8Interframe)
|
||||||
|
binary.BigEndian.PutUint32(hdr[vp8InterframeLen:], o.localEpoch)
|
||||||
|
return hdr
|
||||||
|
}
|
||||||
94
transport/call/tunnel/protocol.go
Normal file
94
transport/call/tunnel/protocol.go
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
package tunnel
|
||||||
|
|
||||||
|
import "encoding/binary"
|
||||||
|
|
||||||
|
const (
|
||||||
|
MsgConnect byte = 0x01
|
||||||
|
MsgConnectOK byte = 0x02
|
||||||
|
MsgConnectErr byte = 0x03
|
||||||
|
MsgData byte = 0x04
|
||||||
|
MsgClose byte = 0x05
|
||||||
|
MsgUDP byte = 0x06
|
||||||
|
MsgUDPReply byte = 0x07
|
||||||
|
MsgConfig byte = 0x08
|
||||||
|
MsgConfigAck byte = 0x09
|
||||||
|
)
|
||||||
|
|
||||||
|
const ControlConnID uint32 = 0
|
||||||
|
|
||||||
|
type DataTunnel interface {
|
||||||
|
SendData(data []byte)
|
||||||
|
SetOnData(fn func([]byte))
|
||||||
|
SetOnClose(fn func())
|
||||||
|
Reconfigure(fps, batch int)
|
||||||
|
}
|
||||||
|
|
||||||
|
func EncodeVP8Config(fps, batch, trackCount int) []byte {
|
||||||
|
if fps < 1 {
|
||||||
|
fps = 1
|
||||||
|
}
|
||||||
|
if batch < 1 {
|
||||||
|
batch = 1
|
||||||
|
}
|
||||||
|
if trackCount < 1 {
|
||||||
|
trackCount = 1
|
||||||
|
}
|
||||||
|
if fps > 0xFFFF {
|
||||||
|
fps = 0xFFFF
|
||||||
|
}
|
||||||
|
if batch > 0xFFFF {
|
||||||
|
batch = 0xFFFF
|
||||||
|
}
|
||||||
|
if trackCount > 0xFFFF {
|
||||||
|
trackCount = 0xFFFF
|
||||||
|
}
|
||||||
|
var payload [6]byte
|
||||||
|
binary.BigEndian.PutUint16(payload[0:2], uint16(fps))
|
||||||
|
binary.BigEndian.PutUint16(payload[2:4], uint16(batch))
|
||||||
|
binary.BigEndian.PutUint16(payload[4:6], uint16(trackCount))
|
||||||
|
return EncodeFrame(ControlConnID, MsgConfig, payload[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func DecodeVP8Config(payload []byte) (fps, batch, trackCount int, ok bool) {
|
||||||
|
if len(payload) < 4 {
|
||||||
|
return 0, 0, 0, false
|
||||||
|
}
|
||||||
|
fps = int(binary.BigEndian.Uint16(payload[0:2]))
|
||||||
|
batch = int(binary.BigEndian.Uint16(payload[2:4]))
|
||||||
|
trackCount = 1
|
||||||
|
if len(payload) >= 6 {
|
||||||
|
trackCount = int(binary.BigEndian.Uint16(payload[4:6]))
|
||||||
|
}
|
||||||
|
return fps, batch, trackCount, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func EncodeFrame(connID uint32, msgType byte, payload []byte) []byte {
|
||||||
|
buf := make([]byte, 4+5+len(payload))
|
||||||
|
binary.BigEndian.PutUint32(buf[0:4], uint32(5+len(payload)))
|
||||||
|
binary.BigEndian.PutUint32(buf[4:8], connID)
|
||||||
|
buf[8] = msgType
|
||||||
|
copy(buf[9:], payload)
|
||||||
|
return buf
|
||||||
|
}
|
||||||
|
|
||||||
|
func LooksLikeRelayFrame(payload []byte) bool {
|
||||||
|
if len(payload) < 9 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
frameLen := binary.BigEndian.Uint32(payload[0:4])
|
||||||
|
return frameLen >= 5 && int(frameLen)+4 <= len(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DecodeFrames(data []byte, cb func(connID uint32, msgType byte, payload []byte)) {
|
||||||
|
for len(data) >= 4 {
|
||||||
|
frameLen := int(binary.BigEndian.Uint32(data[0:4]))
|
||||||
|
if frameLen < 5 || 4+frameLen > len(data) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
connID := binary.BigEndian.Uint32(data[4:8])
|
||||||
|
msgType := data[8]
|
||||||
|
payload := data[9 : 4+frameLen]
|
||||||
|
cb(connID, msgType, payload)
|
||||||
|
data = data[4+frameLen:]
|
||||||
|
}
|
||||||
|
}
|
||||||
662
transport/call/tunnel/relay_bridge.go
Normal file
662
transport/call/tunnel/relay_bridge.go
Normal file
@@ -0,0 +1,662 @@
|
|||||||
|
package tunnel
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sagernet/sing-box/transport/call/common"
|
||||||
|
"github.com/sagernet/sing/common/logger"
|
||||||
|
M "github.com/sagernet/sing/common/metadata"
|
||||||
|
N "github.com/sagernet/sing/common/network"
|
||||||
|
)
|
||||||
|
|
||||||
|
type udpClient struct {
|
||||||
|
pending chan []byte
|
||||||
|
closed atomic.Bool
|
||||||
|
addr string
|
||||||
|
}
|
||||||
|
|
||||||
|
type RelayBridge struct {
|
||||||
|
tunnelMu sync.RWMutex
|
||||||
|
tunnel DataTunnel
|
||||||
|
conns sync.Map
|
||||||
|
udpClients sync.Map
|
||||||
|
nextID atomic.Uint32
|
||||||
|
logger logger.ContextLogger
|
||||||
|
mode string
|
||||||
|
readBuf int
|
||||||
|
ready chan struct{}
|
||||||
|
once sync.Once
|
||||||
|
closed atomic.Bool
|
||||||
|
dialer N.Dialer
|
||||||
|
|
||||||
|
acceptHandlerMu sync.Mutex
|
||||||
|
acceptHandler func(conn net.Conn, destination string)
|
||||||
|
|
||||||
|
udpAcceptHandlerMu sync.Mutex
|
||||||
|
udpAcceptHandler func(conn net.Conn, destination string)
|
||||||
|
|
||||||
|
onPeerConfigMu sync.Mutex
|
||||||
|
onPeerConfig func(fps, batch, trackCount int)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRelayBridge(tunnel DataTunnel, mode string, readBuf int, dialer N.Dialer, logger logger.ContextLogger) *RelayBridge {
|
||||||
|
rb := &RelayBridge{
|
||||||
|
tunnel: tunnel,
|
||||||
|
logger: logger,
|
||||||
|
mode: mode,
|
||||||
|
readBuf: readBuf,
|
||||||
|
dialer: dialer,
|
||||||
|
ready: make(chan struct{}),
|
||||||
|
}
|
||||||
|
tunnel.SetOnData(rb.handleTunnelData)
|
||||||
|
tunnel.SetOnClose(rb.handleTunnelClose)
|
||||||
|
return rb
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rb *RelayBridge) SetAcceptHandler(fn func(conn net.Conn, destination string)) {
|
||||||
|
rb.acceptHandlerMu.Lock()
|
||||||
|
rb.acceptHandler = fn
|
||||||
|
rb.acceptHandlerMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rb *RelayBridge) SetUDPAcceptHandler(fn func(conn net.Conn, destination string)) {
|
||||||
|
rb.udpAcceptHandlerMu.Lock()
|
||||||
|
rb.udpAcceptHandler = fn
|
||||||
|
rb.udpAcceptHandlerMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rb *RelayBridge) SetOnPeerConfig(fn func(fps, batch, trackCount int)) {
|
||||||
|
rb.onPeerConfigMu.Lock()
|
||||||
|
rb.onPeerConfig = fn
|
||||||
|
rb.onPeerConfigMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rb *RelayBridge) DialContext(ctx context.Context, destination string) (net.Conn, error) {
|
||||||
|
if rb.closed.Load() {
|
||||||
|
return nil, fmt.Errorf("relay: bridge already closed")
|
||||||
|
}
|
||||||
|
if M.ParseSocksaddr(destination).IsIPv6() {
|
||||||
|
return nil, fmt.Errorf("relay: network unreachable (ipv6): %s", common.MaskAddr(destination))
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-rb.ready:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
id := rb.nextID.Add(1)
|
||||||
|
tc := newTunnelConn(id, rb)
|
||||||
|
rb.conns.Store(id, tc)
|
||||||
|
rb.logger.Debug(fmt.Sprintf("relay: DIAL %d -> %s", id, common.MaskAddr(destination)))
|
||||||
|
rb.send(id, MsgConnect, []byte(destination))
|
||||||
|
select {
|
||||||
|
case err := <-tc.rdy:
|
||||||
|
if err != nil {
|
||||||
|
rb.conns.Delete(id)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return tc, nil
|
||||||
|
case <-ctx.Done():
|
||||||
|
rb.conns.Delete(id)
|
||||||
|
rb.send(id, MsgClose, nil)
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rb *RelayBridge) ListenPacket(ctx context.Context, destination string) (net.Conn, error) {
|
||||||
|
if rb.closed.Load() {
|
||||||
|
return nil, fmt.Errorf("relay: bridge already closed")
|
||||||
|
}
|
||||||
|
if M.ParseSocksaddr(destination).IsIPv6() {
|
||||||
|
return nil, fmt.Errorf("relay: network unreachable (ipv6): %s", common.MaskAddr(destination))
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-rb.ready:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
id := rb.nextID.Add(1)
|
||||||
|
uc := &udpClient{pending: make(chan []byte, 64), addr: destination}
|
||||||
|
rb.udpClients.Store(id, uc)
|
||||||
|
return &tunnelPacketConn{id: id, rb: rb, uc: uc, destStr: destination}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rb *RelayBridge) Reset() {
|
||||||
|
rb.closeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rb *RelayBridge) Close() {
|
||||||
|
if !rb.closed.CompareAndSwap(false, true) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rb.closeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rb *RelayBridge) MarkReady() {
|
||||||
|
rb.once.Do(func() { close(rb.ready) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rb *RelayBridge) currentTunnel() DataTunnel {
|
||||||
|
rb.tunnelMu.RLock()
|
||||||
|
defer rb.tunnelMu.RUnlock()
|
||||||
|
return rb.tunnel
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rb *RelayBridge) SwapTunnel(newTunnel DataTunnel) {
|
||||||
|
rb.tunnelMu.Lock()
|
||||||
|
rb.tunnel = newTunnel
|
||||||
|
rb.tunnelMu.Unlock()
|
||||||
|
newTunnel.SetOnData(rb.handleTunnelData)
|
||||||
|
newTunnel.SetOnClose(rb.handleTunnelClose)
|
||||||
|
rb.closeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rb *RelayBridge) IsClosed() bool {
|
||||||
|
return rb.closed.Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rb *RelayBridge) handleTunnelClose() {
|
||||||
|
rb.closeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rb *RelayBridge) closeAll() {
|
||||||
|
var ids []uint32
|
||||||
|
rb.conns.Range(func(key, value any) bool {
|
||||||
|
if id, ok := key.(uint32); ok {
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
if c, ok := value.(net.Conn); ok {
|
||||||
|
c.Close()
|
||||||
|
}
|
||||||
|
rb.conns.Delete(key)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
udpCount := 0
|
||||||
|
rb.udpClients.Range(func(key, value any) bool {
|
||||||
|
udpCount++
|
||||||
|
if uc, ok := value.(*udpClient); ok {
|
||||||
|
uc.closed.Store(true)
|
||||||
|
close(uc.pending)
|
||||||
|
}
|
||||||
|
rb.udpClients.Delete(key)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
rb.logger.Debug(fmt.Sprintf("relay: closeAll mode=%s tcp=%d udp=%d ids=%v nextID=%d", rb.mode, len(ids), udpCount, ids, rb.nextID.Load()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rb *RelayBridge) send(connID uint32, msgType byte, payload []byte) {
|
||||||
|
frame := EncodeFrame(connID, msgType, payload)
|
||||||
|
rb.currentTunnel().SendData(frame)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rb *RelayBridge) handleTunnelData(data []byte) {
|
||||||
|
DecodeFrames(data, func(connID uint32, msgType byte, payload []byte) {
|
||||||
|
if connID == ControlConnID && msgType == MsgConfig {
|
||||||
|
fps, batch, trackCount, ok := DecodeVP8Config(payload)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if rb.mode == "creator" {
|
||||||
|
rb.logger.Debug(fmt.Sprintf("relay: peer requested vp8 pacing fps=%d batch=%d trackCount=%d", fps, batch, trackCount))
|
||||||
|
rb.currentTunnel().Reconfigure(fps, batch)
|
||||||
|
rb.send(ControlConnID, MsgConfigAck, nil)
|
||||||
|
rb.onPeerConfigMu.Lock()
|
||||||
|
cb := rb.onPeerConfig
|
||||||
|
rb.onPeerConfigMu.Unlock()
|
||||||
|
if cb != nil {
|
||||||
|
cb(fps, batch, trackCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if connID == ControlConnID && msgType == MsgConfigAck {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch rb.mode {
|
||||||
|
case "joiner":
|
||||||
|
rb.handleJoinerMessage(connID, msgType, payload)
|
||||||
|
case "creator":
|
||||||
|
rb.handleCreatorMessage(connID, msgType, payload)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rb *RelayBridge) handleJoinerMessage(connID uint32, msgType byte, payload []byte) {
|
||||||
|
if msgType == MsgUDPReply {
|
||||||
|
uval, ok := rb.udpClients.Load(connID)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uc := uval.(*udpClient)
|
||||||
|
if uc.closed.Load() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cp := make([]byte, len(payload))
|
||||||
|
copy(cp, payload)
|
||||||
|
select {
|
||||||
|
case uc.pending <- cp:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val, ok := rb.conns.Load(connID)
|
||||||
|
if !ok {
|
||||||
|
if msgType != MsgClose {
|
||||||
|
rb.logger.Debug(fmt.Sprintf("relay[joiner]: drop msgType=%d for unknown conn %d (payload=%dB)", msgType, connID, len(payload)))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tc := val.(*tunnelConn)
|
||||||
|
switch msgType {
|
||||||
|
case MsgConnectOK:
|
||||||
|
select {
|
||||||
|
case tc.rdy <- nil:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
case MsgConnectErr:
|
||||||
|
select {
|
||||||
|
case tc.rdy <- fmt.Errorf("%s", payload):
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
case MsgData:
|
||||||
|
tc.deliver(payload)
|
||||||
|
case MsgClose:
|
||||||
|
tc.remoteClosed()
|
||||||
|
rb.conns.Delete(connID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rb *RelayBridge) handleCreatorMessage(connID uint32, msgType byte, payload []byte) {
|
||||||
|
switch msgType {
|
||||||
|
case MsgConnect:
|
||||||
|
rb.acceptHandlerMu.Lock()
|
||||||
|
handler := rb.acceptHandler
|
||||||
|
rb.acceptHandlerMu.Unlock()
|
||||||
|
if handler != nil {
|
||||||
|
destination := string(payload)
|
||||||
|
tc := newTunnelConn(connID, rb)
|
||||||
|
rb.conns.Store(connID, tc)
|
||||||
|
rb.send(connID, MsgConnectOK, nil)
|
||||||
|
go handler(tc, destination)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go rb.connectTCP(connID, string(payload))
|
||||||
|
case MsgUDP:
|
||||||
|
payloadCopy := make([]byte, len(payload))
|
||||||
|
copy(payloadCopy, payload)
|
||||||
|
go rb.handleUDP(connID, payloadCopy)
|
||||||
|
case MsgData:
|
||||||
|
val, ok := rb.conns.Load(connID)
|
||||||
|
if !ok {
|
||||||
|
rb.logger.Debug(fmt.Sprintf("relay[creator]: drop MsgData for unknown conn %d (payload=%dB)", connID, len(payload)))
|
||||||
|
rb.send(connID, MsgClose, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch c := val.(type) {
|
||||||
|
case *tunnelConn:
|
||||||
|
c.deliver(payload)
|
||||||
|
case net.Conn:
|
||||||
|
if _, err := c.Write(payload); err != nil {
|
||||||
|
rb.logger.Debug(fmt.Sprintf("relay[creator]: write to target %d failed: %s", connID, common.MaskError(err)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case MsgClose:
|
||||||
|
found := false
|
||||||
|
if val, ok := rb.conns.LoadAndDelete(connID); ok {
|
||||||
|
found = true
|
||||||
|
switch c := val.(type) {
|
||||||
|
case *tunnelConn:
|
||||||
|
c.remoteClosed()
|
||||||
|
case net.Conn:
|
||||||
|
c.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if uval, ok := rb.udpClients.LoadAndDelete(connID); ok {
|
||||||
|
found = true
|
||||||
|
switch uc := uval.(type) {
|
||||||
|
case *creatorUDPConn:
|
||||||
|
uc.remoteClosed()
|
||||||
|
case net.Conn:
|
||||||
|
uc.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
rb.logger.Debug(fmt.Sprintf("relay[creator]: drop MsgClose for unknown conn %d", connID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rb *RelayBridge) handleUDP(connID uint32, payload []byte) {
|
||||||
|
if len(payload) < 2 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
addrLen := int(payload[0])
|
||||||
|
if addrLen == 0 || len(payload) < 1+addrLen {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if bytes.IndexByte(payload[1:1+addrLen], 0) != -1 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
addr := string(payload[1 : 1+addrLen])
|
||||||
|
data := payload[1+addrLen:]
|
||||||
|
rb.udpAcceptHandlerMu.Lock()
|
||||||
|
handler := rb.udpAcceptHandler
|
||||||
|
rb.udpAcceptHandlerMu.Unlock()
|
||||||
|
if handler != nil {
|
||||||
|
var cuc *creatorUDPConn
|
||||||
|
if val, ok := rb.udpClients.Load(connID); ok {
|
||||||
|
existing, ok := val.(*creatorUDPConn)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cuc = existing
|
||||||
|
} else {
|
||||||
|
created := newCreatorUDPConn(connID, rb, addr)
|
||||||
|
if actual, loaded := rb.udpClients.LoadOrStore(connID, created); loaded {
|
||||||
|
existing, ok := actual.(*creatorUDPConn)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cuc = existing
|
||||||
|
} else {
|
||||||
|
cuc = created
|
||||||
|
go handler(cuc, addr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cuc.deliver(data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var egress net.Conn
|
||||||
|
if val, ok := rb.udpClients.Load(connID); ok {
|
||||||
|
existing, ok := val.(net.Conn)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
egress = existing
|
||||||
|
} else {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
created, err := rb.dialer.DialContext(ctx, N.NetworkUDP, M.ParseSocksaddr(addr))
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
rb.logger.Warn(fmt.Sprintf("relay[creator]: UDP %d open %s failed: %v", connID, common.MaskAddr(addr), err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if actual, loaded := rb.udpClients.LoadOrStore(connID, created); loaded {
|
||||||
|
created.Close()
|
||||||
|
existing, ok := actual.(net.Conn)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
egress = existing
|
||||||
|
} else {
|
||||||
|
egress = created
|
||||||
|
go func(conn net.Conn, id uint32, target string) {
|
||||||
|
defer conn.Close()
|
||||||
|
defer rb.udpClients.Delete(id)
|
||||||
|
defer rb.send(id, MsgClose, nil)
|
||||||
|
buf := make([]byte, common.UDPBufSize)
|
||||||
|
for {
|
||||||
|
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||||
|
n, err := conn.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rb.send(id, MsgUDPReply, buf[:n])
|
||||||
|
}
|
||||||
|
}(egress, connID, addr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
egress.SetWriteDeadline(time.Now().Add(5 * time.Second))
|
||||||
|
if _, err := egress.Write(data); err != nil {
|
||||||
|
rb.logger.Debug(fmt.Sprintf("relay[creator]: UDP %d write %s failed: %v", connID, common.MaskAddr(addr), err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rb *RelayBridge) connectTCP(connID uint32, addr string) {
|
||||||
|
rb.logger.Debug(fmt.Sprintf("relay: CONNECT %d -> %s", connID, common.MaskAddr(addr)))
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
conn, err := rb.dialer.DialContext(ctx, N.NetworkTCP, M.ParseSocksaddr(addr))
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
rb.logger.Warn(fmt.Sprintf("relay: CONNECT %d failed: %s", connID, common.MaskError(err)))
|
||||||
|
rb.send(connID, MsgConnectErr, []byte(common.MaskError(err)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rb.conns.Store(connID, conn)
|
||||||
|
rb.send(connID, MsgConnectOK, nil)
|
||||||
|
rb.logger.Debug(fmt.Sprintf("relay: CONNECTED %d -> %s", connID, common.MaskAddr(addr)))
|
||||||
|
buf := make([]byte, rb.readBuf)
|
||||||
|
var totalRead int64
|
||||||
|
var reads int
|
||||||
|
for {
|
||||||
|
n, err := conn.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
rb.send(connID, MsgData, buf[:n])
|
||||||
|
totalRead += int64(n)
|
||||||
|
reads++
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if err != io.EOF {
|
||||||
|
rb.logger.Warn(fmt.Sprintf("relay: conn %d read error: %s (read %d times, %dB)", connID, common.MaskError(err), reads, totalRead))
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rb.send(connID, MsgClose, nil)
|
||||||
|
rb.conns.Delete(connID)
|
||||||
|
}
|
||||||
|
|
||||||
|
type tunnelAddr struct{}
|
||||||
|
|
||||||
|
func (tunnelAddr) Network() string { return "call" }
|
||||||
|
func (tunnelAddr) String() string { return "call" }
|
||||||
|
|
||||||
|
type tunnelConn struct {
|
||||||
|
id uint32
|
||||||
|
rb *RelayBridge
|
||||||
|
rdy chan error
|
||||||
|
readBuf bytes.Buffer
|
||||||
|
readMu sync.Mutex
|
||||||
|
readCond chan struct{}
|
||||||
|
closed atomic.Bool
|
||||||
|
closeCh chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTunnelConn(id uint32, rb *RelayBridge) *tunnelConn {
|
||||||
|
return &tunnelConn{
|
||||||
|
id: id,
|
||||||
|
rb: rb,
|
||||||
|
rdy: make(chan error, 1),
|
||||||
|
readCond: make(chan struct{}, 1),
|
||||||
|
closeCh: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *tunnelConn) Read(b []byte) (int, error) {
|
||||||
|
for {
|
||||||
|
tc.readMu.Lock()
|
||||||
|
if tc.readBuf.Len() > 0 {
|
||||||
|
n, _ := tc.readBuf.Read(b)
|
||||||
|
tc.readMu.Unlock()
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
tc.readMu.Unlock()
|
||||||
|
select {
|
||||||
|
case <-tc.closeCh:
|
||||||
|
tc.readMu.Lock()
|
||||||
|
if tc.readBuf.Len() > 0 {
|
||||||
|
n, _ := tc.readBuf.Read(b)
|
||||||
|
tc.readMu.Unlock()
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
tc.readMu.Unlock()
|
||||||
|
return 0, io.EOF
|
||||||
|
case <-tc.readCond:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *tunnelConn) Write(b []byte) (int, error) {
|
||||||
|
if tc.closed.Load() {
|
||||||
|
return 0, io.ErrClosedPipe
|
||||||
|
}
|
||||||
|
tc.rb.send(tc.id, MsgData, b)
|
||||||
|
return len(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *tunnelConn) Close() error {
|
||||||
|
if tc.closed.CompareAndSwap(false, true) {
|
||||||
|
close(tc.closeCh)
|
||||||
|
tc.rb.send(tc.id, MsgClose, nil)
|
||||||
|
tc.rb.conns.Delete(tc.id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *tunnelConn) LocalAddr() net.Addr { return tunnelAddr{} }
|
||||||
|
func (tc *tunnelConn) RemoteAddr() net.Addr { return tunnelAddr{} }
|
||||||
|
func (tc *tunnelConn) SetDeadline(t time.Time) error { return nil }
|
||||||
|
func (tc *tunnelConn) SetReadDeadline(t time.Time) error { return nil }
|
||||||
|
func (tc *tunnelConn) SetWriteDeadline(t time.Time) error { return nil }
|
||||||
|
|
||||||
|
func (tc *tunnelConn) deliver(payload []byte) {
|
||||||
|
tc.readMu.Lock()
|
||||||
|
tc.readBuf.Write(payload)
|
||||||
|
tc.readMu.Unlock()
|
||||||
|
select {
|
||||||
|
case tc.readCond <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *tunnelConn) remoteClosed() {
|
||||||
|
if tc.closed.CompareAndSwap(false, true) {
|
||||||
|
close(tc.closeCh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type tunnelPacketConn struct {
|
||||||
|
id uint32
|
||||||
|
rb *RelayBridge
|
||||||
|
uc *udpClient
|
||||||
|
destStr string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pc *tunnelPacketConn) Read(b []byte) (int, error) {
|
||||||
|
data, ok := <-pc.uc.pending
|
||||||
|
if !ok {
|
||||||
|
return 0, io.EOF
|
||||||
|
}
|
||||||
|
n := copy(b, data)
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pc *tunnelPacketConn) Write(b []byte) (int, error) {
|
||||||
|
if pc.uc.closed.Load() {
|
||||||
|
return 0, io.ErrClosedPipe
|
||||||
|
}
|
||||||
|
payload := make([]byte, 1+len(pc.destStr)+len(b))
|
||||||
|
payload[0] = byte(len(pc.destStr))
|
||||||
|
copy(payload[1:], pc.destStr)
|
||||||
|
copy(payload[1+len(pc.destStr):], b)
|
||||||
|
pc.rb.send(pc.id, MsgUDP, payload)
|
||||||
|
return len(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pc *tunnelPacketConn) Close() error {
|
||||||
|
if pc.uc.closed.CompareAndSwap(false, true) {
|
||||||
|
close(pc.uc.pending)
|
||||||
|
pc.rb.udpClients.Delete(pc.id)
|
||||||
|
pc.rb.send(pc.id, MsgClose, nil)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pc *tunnelPacketConn) LocalAddr() net.Addr { return tunnelAddr{} }
|
||||||
|
func (pc *tunnelPacketConn) RemoteAddr() net.Addr { return tunnelAddr{} }
|
||||||
|
func (pc *tunnelPacketConn) SetDeadline(t time.Time) error { return nil }
|
||||||
|
func (pc *tunnelPacketConn) SetReadDeadline(t time.Time) error { return nil }
|
||||||
|
func (pc *tunnelPacketConn) SetWriteDeadline(t time.Time) error { return nil }
|
||||||
|
|
||||||
|
type creatorUDPConn struct {
|
||||||
|
id uint32
|
||||||
|
rb *RelayBridge
|
||||||
|
addr string
|
||||||
|
readBuf bytes.Buffer
|
||||||
|
readMu sync.Mutex
|
||||||
|
readCond chan struct{}
|
||||||
|
closed atomic.Bool
|
||||||
|
closeCh chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCreatorUDPConn(id uint32, rb *RelayBridge, addr string) *creatorUDPConn {
|
||||||
|
return &creatorUDPConn{
|
||||||
|
id: id,
|
||||||
|
rb: rb,
|
||||||
|
addr: addr,
|
||||||
|
readCond: make(chan struct{}, 1),
|
||||||
|
closeCh: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc *creatorUDPConn) Read(b []byte) (int, error) {
|
||||||
|
for {
|
||||||
|
uc.readMu.Lock()
|
||||||
|
if uc.readBuf.Len() > 0 {
|
||||||
|
n, _ := uc.readBuf.Read(b)
|
||||||
|
uc.readMu.Unlock()
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
uc.readMu.Unlock()
|
||||||
|
select {
|
||||||
|
case <-uc.closeCh:
|
||||||
|
return 0, io.EOF
|
||||||
|
case <-uc.readCond:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc *creatorUDPConn) Write(b []byte) (int, error) {
|
||||||
|
if uc.closed.Load() {
|
||||||
|
return 0, io.ErrClosedPipe
|
||||||
|
}
|
||||||
|
uc.rb.send(uc.id, MsgUDPReply, b)
|
||||||
|
return len(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc *creatorUDPConn) Close() error {
|
||||||
|
if uc.closed.CompareAndSwap(false, true) {
|
||||||
|
close(uc.closeCh)
|
||||||
|
uc.rb.send(uc.id, MsgClose, nil)
|
||||||
|
uc.rb.udpClients.Delete(uc.id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc *creatorUDPConn) LocalAddr() net.Addr { return tunnelAddr{} }
|
||||||
|
func (uc *creatorUDPConn) RemoteAddr() net.Addr { return tunnelAddr{} }
|
||||||
|
func (uc *creatorUDPConn) SetDeadline(t time.Time) error { return nil }
|
||||||
|
func (uc *creatorUDPConn) SetReadDeadline(t time.Time) error { return nil }
|
||||||
|
func (uc *creatorUDPConn) SetWriteDeadline(t time.Time) error { return nil }
|
||||||
|
|
||||||
|
func (uc *creatorUDPConn) deliver(payload []byte) {
|
||||||
|
uc.readMu.Lock()
|
||||||
|
uc.readBuf.Write(payload)
|
||||||
|
uc.readMu.Unlock()
|
||||||
|
select {
|
||||||
|
case uc.readCond <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc *creatorUDPConn) remoteClosed() {
|
||||||
|
if uc.closed.CompareAndSwap(false, true) {
|
||||||
|
close(uc.closeCh)
|
||||||
|
}
|
||||||
|
}
|
||||||
278
transport/call/tunnel/screen_tunnel.go
Normal file
278
transport/call/tunnel/screen_tunnel.go
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
package tunnel
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sagernet/sing-box/transport/call/common"
|
||||||
|
"github.com/sagernet/sing/common/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
screenWriterFPS = 24
|
||||||
|
screenWriterBatch = 30
|
||||||
|
screenWriterMaxBytes = 60000
|
||||||
|
screenWriterQueue = 256
|
||||||
|
screenKeepalivePadMax = 48
|
||||||
|
)
|
||||||
|
|
||||||
|
type ScreenWriter struct {
|
||||||
|
obf *TunnelObfuscator
|
||||||
|
logger logger.ContextLogger
|
||||||
|
label string
|
||||||
|
|
||||||
|
sendMu sync.Mutex
|
||||||
|
send func([]byte) error
|
||||||
|
|
||||||
|
stopCh chan struct{}
|
||||||
|
sendQueue chan []byte
|
||||||
|
cfgChan chan struct{}
|
||||||
|
stopOnce sync.Once
|
||||||
|
running atomic.Bool
|
||||||
|
|
||||||
|
cfgMu sync.Mutex
|
||||||
|
fps int
|
||||||
|
batch int
|
||||||
|
sent atomic.Uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewScreenWriter(obf *TunnelObfuscator, label string, logger logger.ContextLogger) *ScreenWriter {
|
||||||
|
return &ScreenWriter{
|
||||||
|
obf: obf,
|
||||||
|
logger: logger,
|
||||||
|
label: label,
|
||||||
|
stopCh: make(chan struct{}),
|
||||||
|
sendQueue: make(chan []byte, screenWriterQueue),
|
||||||
|
cfgChan: make(chan struct{}, 1),
|
||||||
|
fps: screenWriterFPS,
|
||||||
|
batch: screenWriterBatch,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *ScreenWriter) SetSend(fn func([]byte) error) {
|
||||||
|
w.sendMu.Lock()
|
||||||
|
w.send = fn
|
||||||
|
w.sendMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *ScreenWriter) SendData(data []byte) {
|
||||||
|
if len(data) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case w.sendQueue <- data:
|
||||||
|
case <-w.stopCh:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *ScreenWriter) Reconfigure(fps, batch int) {
|
||||||
|
if fps <= 0 && batch <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.cfgMu.Lock()
|
||||||
|
changed := false
|
||||||
|
if fps > 0 && w.fps != fps {
|
||||||
|
w.fps = fps
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if batch > 0 && w.batch != batch {
|
||||||
|
w.batch = batch
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
w.cfgMu.Unlock()
|
||||||
|
if changed {
|
||||||
|
select {
|
||||||
|
case w.cfgChan <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *ScreenWriter) Start() {
|
||||||
|
if !w.running.CompareAndSwap(false, true) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go w.writerLoop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *ScreenWriter) Stop() {
|
||||||
|
if !w.running.CompareAndSwap(true, false) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.stopOnce.Do(func() { close(w.stopCh) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *ScreenWriter) interval() time.Duration {
|
||||||
|
w.cfgMu.Lock()
|
||||||
|
fps, batch := w.fps, w.batch
|
||||||
|
w.cfgMu.Unlock()
|
||||||
|
frame := time.Second / time.Duration(fps)
|
||||||
|
sample := frame
|
||||||
|
if batch > 1 {
|
||||||
|
sample = frame / time.Duration(batch)
|
||||||
|
}
|
||||||
|
if sample <= 0 {
|
||||||
|
sample = time.Millisecond
|
||||||
|
}
|
||||||
|
return sample
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *ScreenWriter) nextKeepalive(sample time.Duration) (ticks, padLen int) {
|
||||||
|
ticks = int(common.DurationInRange(keepaliveIdleMin, keepaliveIdleMax) / sample)
|
||||||
|
if ticks < 1 {
|
||||||
|
ticks = 1
|
||||||
|
}
|
||||||
|
return ticks, common.IntInRange(0, screenKeepalivePadMax)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *ScreenWriter) emit(msg []byte) {
|
||||||
|
if msg == nil || len(msg) > screenWriterMaxBytes {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.sendMu.Lock()
|
||||||
|
send := w.send
|
||||||
|
w.sendMu.Unlock()
|
||||||
|
if send == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := send(msg); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n := w.sent.Add(1)
|
||||||
|
if n <= 5 || n%500 == 0 {
|
||||||
|
w.logger.Debug(fmt.Sprintf("[%s] sent frame #%d size=%d", w.label, n, len(msg)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *ScreenWriter) writerLoop() {
|
||||||
|
for {
|
||||||
|
sample := w.interval()
|
||||||
|
keepaliveEvery, keepalivePad := w.nextKeepalive(sample)
|
||||||
|
ticker := time.NewTicker(sample)
|
||||||
|
idle := 0
|
||||||
|
reconfigure := false
|
||||||
|
for !reconfigure {
|
||||||
|
select {
|
||||||
|
case <-w.stopCh:
|
||||||
|
ticker.Stop()
|
||||||
|
return
|
||||||
|
case <-w.cfgChan:
|
||||||
|
reconfigure = true
|
||||||
|
case <-ticker.C:
|
||||||
|
select {
|
||||||
|
case data := <-w.sendQueue:
|
||||||
|
w.emit(w.obf.EncodeData(data))
|
||||||
|
idle = 0
|
||||||
|
default:
|
||||||
|
idle++
|
||||||
|
if idle < keepaliveEvery {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
idle = 0
|
||||||
|
w.emit(w.obf.EncodeKeepalive(keepalivePad))
|
||||||
|
keepaliveEvery, keepalivePad = w.nextKeepalive(sample)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ticker.Stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type SymmetricScreenTunnel struct {
|
||||||
|
cam *VP8DataTunnel
|
||||||
|
screen *ScreenWriter
|
||||||
|
obf *TunnelObfuscator
|
||||||
|
logger logger.ContextLogger
|
||||||
|
screenReady func() bool
|
||||||
|
|
||||||
|
onDataMu sync.Mutex
|
||||||
|
onData func([]byte)
|
||||||
|
recv atomic.Uint64
|
||||||
|
trackCount atomic.Int32
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSymmetricScreenTunnel(cam *VP8DataTunnel, screen *ScreenWriter, obf *TunnelObfuscator, screenReady func() bool, logger logger.ContextLogger) *SymmetricScreenTunnel {
|
||||||
|
return &SymmetricScreenTunnel{cam: cam, screen: screen, obf: obf, screenReady: screenReady, logger: logger}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SymmetricScreenTunnel) SetTrackCount(n int) {
|
||||||
|
if n < 1 {
|
||||||
|
n = 1
|
||||||
|
}
|
||||||
|
if n > 2 {
|
||||||
|
n = 2
|
||||||
|
}
|
||||||
|
old := s.trackCount.Swap(int32(n))
|
||||||
|
if int(old) != n {
|
||||||
|
s.logger.Debug(fmt.Sprintf("screen tunnel track count %d -> %d", old, n))
|
||||||
|
}
|
||||||
|
if n >= 2 {
|
||||||
|
s.screen.Start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SymmetricScreenTunnel) SendData(data []byte) {
|
||||||
|
var connID uint32
|
||||||
|
if len(data) >= 8 {
|
||||||
|
connID = binary.BigEndian.Uint32(data[4:8])
|
||||||
|
}
|
||||||
|
if connID == ControlConnID {
|
||||||
|
s.cam.SendData(data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tc := uint32(s.trackCount.Load())
|
||||||
|
if tc < 1 {
|
||||||
|
tc = 1
|
||||||
|
}
|
||||||
|
if connID%tc == 1 && s.screenUp() {
|
||||||
|
s.screen.SendData(data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.cam.SendData(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SymmetricScreenTunnel) SetOnData(fn func([]byte)) {
|
||||||
|
s.onDataMu.Lock()
|
||||||
|
s.onData = fn
|
||||||
|
s.onDataMu.Unlock()
|
||||||
|
s.cam.SetOnData(fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SymmetricScreenTunnel) SetOnClose(fn func()) { s.cam.SetOnClose(fn) }
|
||||||
|
|
||||||
|
func (s *SymmetricScreenTunnel) Reconfigure(fps, batch int) {
|
||||||
|
s.cam.Reconfigure(fps, batch)
|
||||||
|
s.screen.Reconfigure(fps, batch)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SymmetricScreenTunnel) Stop() {
|
||||||
|
s.screen.Stop()
|
||||||
|
s.cam.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SymmetricScreenTunnel) HandleScreenFrame(frame []byte) {
|
||||||
|
res := s.obf.Decode(frame)
|
||||||
|
n := s.recv.Add(1)
|
||||||
|
if n <= 10 || n%500 == 0 {
|
||||||
|
s.logger.Debug(fmt.Sprintf("screen recv frame #%d in=%d hasFrame=%v keepalive=%v payload=%d", n, len(frame), res.HasFrame, res.Keepalive, len(res.Payload)))
|
||||||
|
}
|
||||||
|
if !res.HasFrame || res.SelfEcho || res.Keepalive || len(res.Payload) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.onDataMu.Lock()
|
||||||
|
handler := s.onData
|
||||||
|
s.onDataMu.Unlock()
|
||||||
|
if handler != nil {
|
||||||
|
handler(res.Payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SymmetricScreenTunnel) screenUp() bool {
|
||||||
|
if s.screenReady == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return s.screenReady()
|
||||||
|
}
|
||||||
319
transport/call/tunnel/vp8tunnel.go
Normal file
319
transport/call/tunnel/vp8tunnel.go
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
package tunnel
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pion/webrtc/v4"
|
||||||
|
"github.com/pion/webrtc/v4/pkg/media"
|
||||||
|
"github.com/sagernet/sing-box/transport/call/common"
|
||||||
|
"github.com/sagernet/sing/common/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultVP8FPS = 24
|
||||||
|
defaultVP8Batch = 30
|
||||||
|
keepaliveIdleMin = 60 * time.Millisecond
|
||||||
|
keepaliveIdleMax = 200 * time.Millisecond
|
||||||
|
keepalivePadMax = 176
|
||||||
|
sendQueueDepth = 128
|
||||||
|
|
||||||
|
paceBatchFloorPercent = 80
|
||||||
|
paceDriftMin = 5 * time.Second
|
||||||
|
paceDriftMax = 20 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
type VP8DataTunnel struct {
|
||||||
|
track *webrtc.TrackLocalStaticSample
|
||||||
|
logger logger.ContextLogger
|
||||||
|
obf *TunnelObfuscator
|
||||||
|
stopCh chan struct{}
|
||||||
|
sendQueue chan []byte
|
||||||
|
cfgChan chan struct{}
|
||||||
|
|
||||||
|
stopOnce sync.Once
|
||||||
|
running atomic.Bool
|
||||||
|
|
||||||
|
cfgMu sync.Mutex
|
||||||
|
fps int
|
||||||
|
batch int
|
||||||
|
keepaliveMin time.Duration
|
||||||
|
keepaliveMax time.Duration
|
||||||
|
keepalivePadMax int
|
||||||
|
|
||||||
|
sentFrames atomic.Uint64
|
||||||
|
recvFrames atomic.Uint64
|
||||||
|
keepaliveFrames atomic.Uint64
|
||||||
|
|
||||||
|
OnData func([]byte)
|
||||||
|
OnClose func()
|
||||||
|
OnPeerRestart func()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *VP8DataTunnel) SetOnData(fn func([]byte)) { t.OnData = fn }
|
||||||
|
func (t *VP8DataTunnel) SetOnClose(fn func()) { t.OnClose = fn }
|
||||||
|
func (t *VP8DataTunnel) SetOnPeerRestart(fn func()) { t.OnPeerRestart = fn }
|
||||||
|
|
||||||
|
func NewVP8DataTunnel(track *webrtc.TrackLocalStaticSample, obf *TunnelObfuscator, logger logger.ContextLogger) *VP8DataTunnel {
|
||||||
|
return NewVP8DataTunnelWithQueue(track, obf, logger, sendQueueDepth)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewVP8DataTunnelWithQueue(track *webrtc.TrackLocalStaticSample, obf *TunnelObfuscator, logger logger.ContextLogger, queueDepth int) *VP8DataTunnel {
|
||||||
|
if queueDepth < sendQueueDepth {
|
||||||
|
queueDepth = sendQueueDepth
|
||||||
|
}
|
||||||
|
return &VP8DataTunnel{
|
||||||
|
track: track,
|
||||||
|
obf: obf,
|
||||||
|
logger: logger,
|
||||||
|
stopCh: make(chan struct{}),
|
||||||
|
sendQueue: make(chan []byte, queueDepth),
|
||||||
|
cfgChan: make(chan struct{}, 1),
|
||||||
|
fps: defaultVP8FPS,
|
||||||
|
batch: defaultVP8Batch,
|
||||||
|
keepaliveMin: keepaliveIdleMin,
|
||||||
|
keepaliveMax: keepaliveIdleMax,
|
||||||
|
keepalivePadMax: keepalivePadMax,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *VP8DataTunnel) SetKeepaliveShape(minPeriod, maxPeriod time.Duration, padMax int) {
|
||||||
|
t.cfgMu.Lock()
|
||||||
|
if minPeriod > 0 {
|
||||||
|
t.keepaliveMin = minPeriod
|
||||||
|
}
|
||||||
|
if maxPeriod >= t.keepaliveMin {
|
||||||
|
t.keepaliveMax = maxPeriod
|
||||||
|
}
|
||||||
|
if padMax >= 0 {
|
||||||
|
t.keepalivePadMax = padMax
|
||||||
|
}
|
||||||
|
newMin, newMax, newPad := t.keepaliveMin, t.keepaliveMax, t.keepalivePadMax
|
||||||
|
t.cfgMu.Unlock()
|
||||||
|
t.logger.Debug(fmt.Sprintf("vp8tunnel: keepalive shape min=%s max=%s padMax=%d", newMin, newMax, newPad))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *VP8DataTunnel) nextKeepalive(sampleInterval time.Duration) (ticks, padLen int) {
|
||||||
|
t.cfgMu.Lock()
|
||||||
|
minPeriod, maxPeriod, padMax := t.keepaliveMin, t.keepaliveMax, t.keepalivePadMax
|
||||||
|
t.cfgMu.Unlock()
|
||||||
|
ticks = int(common.DurationInRange(minPeriod, maxPeriod) / sampleInterval)
|
||||||
|
if ticks < 1 {
|
||||||
|
ticks = 1
|
||||||
|
}
|
||||||
|
return ticks, common.IntInRange(0, padMax)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *VP8DataTunnel) Reconfigure(fps, batch int) {
|
||||||
|
if fps <= 0 && batch <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.cfgMu.Lock()
|
||||||
|
changed := false
|
||||||
|
if fps > 0 && t.fps != fps {
|
||||||
|
t.fps = fps
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if batch > 0 && t.batch != batch {
|
||||||
|
t.batch = batch
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
newFPS, newBatch := t.fps, t.batch
|
||||||
|
t.cfgMu.Unlock()
|
||||||
|
if !changed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.logger.Debug(fmt.Sprintf("vp8tunnel: reconfigure fps=%d batch=%d", newFPS, newBatch))
|
||||||
|
select {
|
||||||
|
case t.cfgChan <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *VP8DataTunnel) FPS() int {
|
||||||
|
t.cfgMu.Lock()
|
||||||
|
defer t.cfgMu.Unlock()
|
||||||
|
return t.fps
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *VP8DataTunnel) Batch() int {
|
||||||
|
t.cfgMu.Lock()
|
||||||
|
defer t.cfgMu.Unlock()
|
||||||
|
return t.batch
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *VP8DataTunnel) SendData(data []byte) {
|
||||||
|
if len(data) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case t.sendQueue <- data:
|
||||||
|
case <-t.stopCh:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *VP8DataTunnel) TrySendData(data []byte) bool {
|
||||||
|
if len(data) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case t.sendQueue <- data:
|
||||||
|
return true
|
||||||
|
case <-t.stopCh:
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *VP8DataTunnel) Start(fps, batch int) {
|
||||||
|
t.cfgMu.Lock()
|
||||||
|
if fps > 0 {
|
||||||
|
t.fps = fps
|
||||||
|
}
|
||||||
|
if batch > 0 {
|
||||||
|
t.batch = batch
|
||||||
|
}
|
||||||
|
t.cfgMu.Unlock()
|
||||||
|
if !t.running.CompareAndSwap(false, true) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go t.writerLoop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *VP8DataTunnel) Stop() {
|
||||||
|
if !t.running.CompareAndSwap(true, false) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.stopOnce.Do(func() { close(t.stopCh) })
|
||||||
|
if t.OnClose != nil {
|
||||||
|
t.OnClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *VP8DataTunnel) HandleFrame(frame []byte) {
|
||||||
|
res := t.obf.Decode(frame)
|
||||||
|
if !res.HasFrame {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if res.SelfEcho {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if res.PeerRestart {
|
||||||
|
t.logger.Info(fmt.Sprintf("vp8tunnel: peer restart detected, new epoch=0x%08x", res.PeerEpoch))
|
||||||
|
if t.OnPeerRestart != nil {
|
||||||
|
t.OnPeerRestart()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if res.Keepalive || len(res.Payload) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n := t.recvFrames.Add(1)
|
||||||
|
if n <= 5 || n%500 == 0 {
|
||||||
|
t.logger.Debug(fmt.Sprintf("vp8tunnel: recv frame #%d size=%d", n, len(res.Payload)))
|
||||||
|
}
|
||||||
|
if t.OnData != nil {
|
||||||
|
t.OnData(res.Payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *VP8DataTunnel) currentRate() (fps, batch int) {
|
||||||
|
t.cfgMu.Lock()
|
||||||
|
defer t.cfgMu.Unlock()
|
||||||
|
return t.fps, t.batch
|
||||||
|
}
|
||||||
|
|
||||||
|
func sampleIntervalFor(fps, batch int) time.Duration {
|
||||||
|
if fps < 1 {
|
||||||
|
fps = 1
|
||||||
|
}
|
||||||
|
frameInterval := time.Second / time.Duration(fps)
|
||||||
|
interval := frameInterval
|
||||||
|
if batch > 1 {
|
||||||
|
interval = frameInterval / time.Duration(batch)
|
||||||
|
}
|
||||||
|
if interval <= 0 {
|
||||||
|
interval = time.Millisecond
|
||||||
|
}
|
||||||
|
return interval
|
||||||
|
}
|
||||||
|
|
||||||
|
func pacedBatchFor(batch int) int {
|
||||||
|
if batch <= 1 {
|
||||||
|
return batch
|
||||||
|
}
|
||||||
|
floor := batch * paceBatchFloorPercent / 100
|
||||||
|
if floor < 1 {
|
||||||
|
floor = 1
|
||||||
|
}
|
||||||
|
return common.IntInRange(floor, batch)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *VP8DataTunnel) writerLoop() {
|
||||||
|
for {
|
||||||
|
fps, batch := t.currentRate()
|
||||||
|
pacedBatch := pacedBatchFor(batch)
|
||||||
|
sampleInterval := sampleIntervalFor(fps, pacedBatch)
|
||||||
|
keepaliveEvery, keepalivePad := t.nextKeepalive(sampleInterval)
|
||||||
|
t.logger.Debug(fmt.Sprintf("vp8tunnel: writer (re)started fps=%d batch=%d pacedBatch=%d sampleInterval=%s keepaliveEvery=%d",
|
||||||
|
fps, batch, pacedBatch, sampleInterval, keepaliveEvery))
|
||||||
|
|
||||||
|
ticker := time.NewTicker(sampleInterval)
|
||||||
|
drift := time.NewTimer(common.DurationInRange(paceDriftMin, paceDriftMax))
|
||||||
|
idleTicks := 0
|
||||||
|
reconfigure := false
|
||||||
|
for !reconfigure {
|
||||||
|
select {
|
||||||
|
case <-t.stopCh:
|
||||||
|
ticker.Stop()
|
||||||
|
drift.Stop()
|
||||||
|
return
|
||||||
|
case <-t.cfgChan:
|
||||||
|
reconfigure = true
|
||||||
|
case <-drift.C:
|
||||||
|
pacedBatch = pacedBatchFor(batch)
|
||||||
|
sampleInterval = sampleIntervalFor(fps, pacedBatch)
|
||||||
|
ticker.Reset(sampleInterval)
|
||||||
|
keepaliveEvery, keepalivePad = t.nextKeepalive(sampleInterval)
|
||||||
|
drift.Reset(common.DurationInRange(paceDriftMin, paceDriftMax))
|
||||||
|
t.logger.Debug(fmt.Sprintf("vp8tunnel: pace drift pacedBatch=%d/%d sampleInterval=%s", pacedBatch, batch, sampleInterval))
|
||||||
|
case <-ticker.C:
|
||||||
|
var sample []byte
|
||||||
|
isKeepalive := false
|
||||||
|
select {
|
||||||
|
case data := <-t.sendQueue:
|
||||||
|
sample = t.obf.EncodeData(data)
|
||||||
|
idleTicks = 0
|
||||||
|
default:
|
||||||
|
idleTicks++
|
||||||
|
if idleTicks < keepaliveEvery {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
idleTicks = 0
|
||||||
|
sample = t.obf.EncodeKeepalive(keepalivePad)
|
||||||
|
keepaliveEvery, keepalivePad = t.nextKeepalive(sampleInterval)
|
||||||
|
isKeepalive = true
|
||||||
|
}
|
||||||
|
if sample == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := t.track.WriteSample(media.Sample{Data: sample, Duration: sampleInterval}); err != nil {
|
||||||
|
t.logger.Debug(fmt.Sprintf("vp8tunnel: WriteSample error: %v", err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
n := t.sentFrames.Add(1)
|
||||||
|
if isKeepalive {
|
||||||
|
t.keepaliveFrames.Add(1)
|
||||||
|
}
|
||||||
|
if n <= 5 || n%500 == 0 {
|
||||||
|
keepalives := t.keepaliveFrames.Load()
|
||||||
|
t.logger.Debug(fmt.Sprintf("vp8tunnel: sent frame #%d size=%d data=%d keepalive=%d", n, len(sample), n-keepalives, keepalives))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ticker.Stop()
|
||||||
|
drift.Stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
284
transport/call/vk/api.go
Normal file
284
transport/call/vk/api.go
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
package vk
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sagernet/sing-box/transport/call/common"
|
||||||
|
"github.com/sagernet/sing/common/logger"
|
||||||
|
N "github.com/sagernet/sing/common/network"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TurnServer struct {
|
||||||
|
URLs []string `json:"urls"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Credential string `json:"credential"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type StunServer struct {
|
||||||
|
URLs []string `json:"urls"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CallInfo struct {
|
||||||
|
CallID string
|
||||||
|
JoinLink string
|
||||||
|
ShortLink string
|
||||||
|
OKJoinLink string
|
||||||
|
TurnServer TurnServer
|
||||||
|
StunServer StunServer
|
||||||
|
WSEndpoint string
|
||||||
|
}
|
||||||
|
|
||||||
|
type vkTokenResponse struct {
|
||||||
|
Data struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type callSettingsResponse struct {
|
||||||
|
Response struct {
|
||||||
|
Settings struct {
|
||||||
|
PublicKey string `json:"public_key"`
|
||||||
|
} `json:"settings"`
|
||||||
|
} `json:"response"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type callTokenResponse struct {
|
||||||
|
Response struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
APIBaseURL string `json:"api_base_url"`
|
||||||
|
} `json:"response"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type okAuthResponse struct {
|
||||||
|
SessionKey string `json:"session_key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type joinResponse struct {
|
||||||
|
Endpoint string `json:"endpoint"`
|
||||||
|
TurnServer TurnServer `json:"turn_server"`
|
||||||
|
StunServer StunServer `json:"stun_server"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func JoinExistingCall(dialer N.Dialer, cookieStr, vkLink string, cfg VKConfig, logger logger.ContextLogger) (*CallInfo, error) {
|
||||||
|
if cfg.AppID == "" || cfg.APIVersion == "" {
|
||||||
|
return nil, fmt.Errorf("config incomplete: app_id=%q api=%q", cfg.AppID, cfg.APIVersion)
|
||||||
|
}
|
||||||
|
token := extractJoinToken(vkLink)
|
||||||
|
if token == "" {
|
||||||
|
return nil, fmt.Errorf("could not extract join token from %q", vkLink)
|
||||||
|
}
|
||||||
|
logger.Info(fmt.Sprintf("[auth] Joining existing call token=%s", token))
|
||||||
|
resp, err := authAndJoin(dialer, cookieStr, token, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &CallInfo{
|
||||||
|
JoinLink: vkLink,
|
||||||
|
OKJoinLink: token,
|
||||||
|
TurnServer: resp.TurnServer,
|
||||||
|
StunServer: resp.StunServer,
|
||||||
|
WSEndpoint: resp.Endpoint,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateAndJoinCall(dialer N.Dialer, cookieStr, peerId string, cfg VKConfig, logger logger.ContextLogger) (*CallInfo, error) {
|
||||||
|
if cfg.AppID == "" || cfg.APIVersion == "" {
|
||||||
|
return nil, fmt.Errorf("config incomplete: app_id=%q api=%q", cfg.AppID, cfg.APIVersion)
|
||||||
|
}
|
||||||
|
auth := func(bearer string) map[string]string {
|
||||||
|
return map[string]string{"Authorization": "Bearer " + bearer}
|
||||||
|
}
|
||||||
|
logger.Info("[auth] Getting VK token...")
|
||||||
|
r, err := httpPost(dialer, "https://login.vk.com/?act=web_token",
|
||||||
|
url.Values{"version": {"1"}, "app_id": {cfg.AppID}},
|
||||||
|
map[string]string{"Cookie": cookieStr})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("web_token: %w", err)
|
||||||
|
}
|
||||||
|
var tok vkTokenResponse
|
||||||
|
json.Unmarshal(r, &tok)
|
||||||
|
vkToken := tok.Data.AccessToken
|
||||||
|
if vkToken == "" {
|
||||||
|
return nil, fmt.Errorf("empty VK token, response: %s", string(r))
|
||||||
|
}
|
||||||
|
logger.Info(fmt.Sprintf("[auth] Creating call peer_id=%s...", peerId))
|
||||||
|
r, err = httpPost(dialer, "https://api.vk.com/method/calls.start",
|
||||||
|
url.Values{"v": {cfg.APIVersion}, "peer_id": {peerId}}, auth(vkToken))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("calls.start: %w", err)
|
||||||
|
}
|
||||||
|
var call struct {
|
||||||
|
Response struct {
|
||||||
|
CallID string `json:"call_id"`
|
||||||
|
JoinLink string `json:"join_link"`
|
||||||
|
OKJoinLink string `json:"ok_join_link"`
|
||||||
|
ShortCredentials struct {
|
||||||
|
LinkWithPassword string `json:"link_with_password"`
|
||||||
|
} `json:"short_credentials"`
|
||||||
|
} `json:"response"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(r, &call)
|
||||||
|
c := call.Response
|
||||||
|
if c.CallID == "" {
|
||||||
|
return nil, fmt.Errorf("empty call_id, response: %s", string(r))
|
||||||
|
}
|
||||||
|
if c.OKJoinLink == "" {
|
||||||
|
return nil, fmt.Errorf("empty ok_join_link, response: %s", string(r))
|
||||||
|
}
|
||||||
|
logger.Debug(fmt.Sprintf("[auth] call_id: %s", c.CallID))
|
||||||
|
logger.Debug(fmt.Sprintf("[auth] join_link: %s", c.JoinLink))
|
||||||
|
logger.Info("[auth] Joining conversation...")
|
||||||
|
resp, err := authAndJoin(dialer, cookieStr, c.OKJoinLink, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &CallInfo{
|
||||||
|
CallID: c.CallID, JoinLink: c.JoinLink, ShortLink: c.ShortCredentials.LinkWithPassword,
|
||||||
|
OKJoinLink: c.OKJoinLink, TurnServer: resp.TurnServer, StunServer: resp.StunServer,
|
||||||
|
WSEndpoint: resp.Endpoint,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildICEServers(callInfo *CallInfo) []ICEServerSpec {
|
||||||
|
var servers []ICEServerSpec
|
||||||
|
if len(callInfo.StunServer.URLs) > 0 {
|
||||||
|
servers = append(servers, ICEServerSpec{URLs: callInfo.StunServer.URLs})
|
||||||
|
}
|
||||||
|
if len(callInfo.TurnServer.URLs) > 0 {
|
||||||
|
urls := append([]string{}, callInfo.TurnServer.URLs...)
|
||||||
|
urls = append(urls, urls[len(urls)-1]+"?transport=tcp")
|
||||||
|
servers = append(servers, ICEServerSpec{
|
||||||
|
URLs: urls, Username: callInfo.TurnServer.Username, Credential: callInfo.TurnServer.Credential,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return servers
|
||||||
|
}
|
||||||
|
|
||||||
|
type ICEServerSpec struct {
|
||||||
|
URLs []string
|
||||||
|
Username string
|
||||||
|
Credential string
|
||||||
|
}
|
||||||
|
|
||||||
|
func authAndJoin(dialer N.Dialer, cookieStr, okJoinLink string, cfg VKConfig) (*joinResponse, error) {
|
||||||
|
auth := func(bearer string) map[string]string {
|
||||||
|
return map[string]string{"Authorization": "Bearer " + bearer}
|
||||||
|
}
|
||||||
|
r, err := httpPost(dialer, "https://login.vk.com/?act=web_token",
|
||||||
|
url.Values{"version": {"1"}, "app_id": {cfg.AppID}},
|
||||||
|
map[string]string{"Cookie": cookieStr})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("web_token: %w", err)
|
||||||
|
}
|
||||||
|
var tok vkTokenResponse
|
||||||
|
json.Unmarshal(r, &tok)
|
||||||
|
if tok.Data.AccessToken == "" {
|
||||||
|
return nil, fmt.Errorf("empty VK token, response: %s", string(r))
|
||||||
|
}
|
||||||
|
r, err = httpPost(dialer, "https://api.vk.com/method/calls.getSettings",
|
||||||
|
url.Values{"v": {cfg.APIVersion}}, auth(tok.Data.AccessToken))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("calls.getSettings: %w", err)
|
||||||
|
}
|
||||||
|
var settings callSettingsResponse
|
||||||
|
json.Unmarshal(r, &settings)
|
||||||
|
appKey := settings.Response.Settings.PublicKey
|
||||||
|
if appKey == "" {
|
||||||
|
return nil, fmt.Errorf("empty public_key, response: %s", string(r))
|
||||||
|
}
|
||||||
|
r, err = httpPost(dialer, "https://api.vk.com/method/messages.getCallToken",
|
||||||
|
url.Values{"v": {cfg.APIVersion}, "env": {"production"}}, auth(tok.Data.AccessToken))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("messages.getCallToken: %w", err)
|
||||||
|
}
|
||||||
|
var callToken callTokenResponse
|
||||||
|
json.Unmarshal(r, &callToken)
|
||||||
|
if callToken.Response.Token == "" {
|
||||||
|
return nil, fmt.Errorf("empty call token, response: %s", string(r))
|
||||||
|
}
|
||||||
|
if callToken.Response.APIBaseURL == "" {
|
||||||
|
return nil, fmt.Errorf("empty api_base_url, response: %s", string(r))
|
||||||
|
}
|
||||||
|
apiBaseURL := strings.TrimRight(callToken.Response.APIBaseURL, "/")
|
||||||
|
if !strings.HasSuffix(apiBaseURL, "/fb.do") {
|
||||||
|
apiBaseURL += "/fb.do"
|
||||||
|
}
|
||||||
|
sd, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"device_id": "sing-box-go-1", "client_version": cfg.AppVersion,
|
||||||
|
"client_type": "SDK_JS", "auth_token": callToken.Response.Token, "version": 3,
|
||||||
|
})
|
||||||
|
r, err = httpPost(dialer, apiBaseURL, url.Values{
|
||||||
|
"method": {"auth.anonymLogin"}, "application_key": {appKey},
|
||||||
|
"format": {"json"}, "session_data": {string(sd)},
|
||||||
|
}, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("auth.anonymLogin: %w", err)
|
||||||
|
}
|
||||||
|
var okAuth okAuthResponse
|
||||||
|
json.Unmarshal(r, &okAuth)
|
||||||
|
if okAuth.SessionKey == "" {
|
||||||
|
return nil, fmt.Errorf("empty session_key, response: %s", string(r))
|
||||||
|
}
|
||||||
|
ms, _ := json.Marshal(map[string]bool{
|
||||||
|
"isAudioEnabled": false, "isVideoEnabled": true, "isScreenSharingEnabled": false,
|
||||||
|
})
|
||||||
|
r, err = httpPost(dialer, apiBaseURL, url.Values{
|
||||||
|
"method": {"vchat.joinConversationByLink"}, "session_key": {okAuth.SessionKey},
|
||||||
|
"application_key": {appKey}, "format": {"json"}, "joinLink": {okJoinLink},
|
||||||
|
"isVideo": {"true"}, "isAudio": {"false"}, "mediaSettings": {string(ms)},
|
||||||
|
}, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("vchat.joinConversationByLink: %w", err)
|
||||||
|
}
|
||||||
|
var jr joinResponse
|
||||||
|
json.Unmarshal(r, &jr)
|
||||||
|
if jr.Endpoint == "" {
|
||||||
|
return nil, fmt.Errorf("empty WS endpoint, response: %s", string(r))
|
||||||
|
}
|
||||||
|
return &jr, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractJoinToken(link string) string {
|
||||||
|
link = strings.TrimSpace(link)
|
||||||
|
if link == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if u, err := url.Parse(link); err == nil && u.Scheme != "" {
|
||||||
|
path := strings.Trim(u.Path, "/")
|
||||||
|
if path != "" {
|
||||||
|
parts := strings.Split(path, "/")
|
||||||
|
return parts[len(parts)-1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.ContainsAny(link, "/?&=") {
|
||||||
|
return link
|
||||||
|
}
|
||||||
|
parts := strings.Split(strings.TrimRight(link, "/"), "/")
|
||||||
|
return parts[len(parts)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
func httpPost(dialer N.Dialer, endpoint string, form url.Values, extraHeaders map[string]string) ([]byte, error) {
|
||||||
|
body := form.Encode()
|
||||||
|
req, err := http.NewRequest("POST", endpoint, strings.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("User-Agent", common.UserAgent)
|
||||||
|
req.Header.Set("Origin", "https://vk.com")
|
||||||
|
req.Header.Set("Referer", "https://vk.com/")
|
||||||
|
for k, v := range extraHeaders {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
resp, err := common.HttpClient(dialer).Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
return io.ReadAll(resp.Body)
|
||||||
|
}
|
||||||
375
transport/call/vk/captcha_proxy.go
Normal file
375
transport/call/vk/captcha_proxy.go
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
package vk
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httputil"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
M "github.com/sagernet/sing/common/metadata"
|
||||||
|
N "github.com/sagernet/sing/common/network"
|
||||||
|
)
|
||||||
|
|
||||||
|
var activeCaptchaProxy struct {
|
||||||
|
sync.Mutex
|
||||||
|
listener net.Listener
|
||||||
|
port int
|
||||||
|
keyCh chan string
|
||||||
|
doneCh chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func StartCaptchaProxy(redirectURI string, dialer N.Dialer) int {
|
||||||
|
StopCaptchaProxy()
|
||||||
|
targetURL, err := url.Parse(redirectURI)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
port := listener.Addr().(*net.TCPAddr).Port
|
||||||
|
localOrigin := fmt.Sprintf("http://127.0.0.1:%d", port)
|
||||||
|
upstreamOrigin := targetURL.Scheme + "://" + targetURL.Host
|
||||||
|
keyCh := make(chan string, 1)
|
||||||
|
transport := &http.Transport{
|
||||||
|
MaxIdleConns: 100,
|
||||||
|
MaxIdleConnsPerHost: 100,
|
||||||
|
IdleConnTimeout: 90 * time.Second,
|
||||||
|
TLSHandshakeTimeout: 10 * time.Second,
|
||||||
|
ForceAttemptHTTP2: false,
|
||||||
|
}
|
||||||
|
if dialer != nil {
|
||||||
|
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
|
return dialer.DialContext(ctx, network, M.ParseSocksaddr(addr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
proxy := &httputil.ReverseProxy{
|
||||||
|
Transport: transport,
|
||||||
|
Rewrite: func(req *httputil.ProxyRequest) {
|
||||||
|
req.Out.URL.Scheme = targetURL.Scheme
|
||||||
|
req.Out.URL.Host = targetURL.Host
|
||||||
|
if req.Out.URL.Path == "" {
|
||||||
|
req.Out.URL.Path = targetURL.Path
|
||||||
|
}
|
||||||
|
req.Out.Host = targetURL.Host
|
||||||
|
req.Out.Header.Del("Accept-Encoding")
|
||||||
|
req.Out.Header.Del("TE")
|
||||||
|
for _, headerName := range []string{"Origin", "Referer"} {
|
||||||
|
val := req.Out.Header.Get(headerName)
|
||||||
|
if val != "" {
|
||||||
|
req.Out.Header.Set(headerName, strings.ReplaceAll(val, localOrigin, upstreamOrigin))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ModifyResponse: func(res *http.Response) error {
|
||||||
|
rewriteProxyCookies(res)
|
||||||
|
if res.StatusCode >= 300 && res.StatusCode < 400 {
|
||||||
|
if loc := res.Header.Get("Location"); loc != "" {
|
||||||
|
res.Header.Set("Location", strings.ReplaceAll(loc, upstreamOrigin, localOrigin))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
contentType := res.Header.Get("Content-Type")
|
||||||
|
shouldInspect := isHTMLLike(contentType) || strings.Contains(res.Request.URL.Path, "captchaNotRobot.check")
|
||||||
|
if !shouldInspect {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
reader := res.Body
|
||||||
|
decompressed := false
|
||||||
|
if res.Header.Get("Content-Encoding") == "gzip" {
|
||||||
|
gzReader, err := gzip.NewReader(res.Body)
|
||||||
|
if err == nil {
|
||||||
|
reader = gzReader
|
||||||
|
decompressed = true
|
||||||
|
defer gzReader.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bodyBytes, err := io.ReadAll(reader)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
res.Body.Close()
|
||||||
|
if strings.Contains(res.Request.URL.Path, "captchaNotRobot.check") {
|
||||||
|
token := extractSuccessToken(bodyBytes)
|
||||||
|
if token != "" {
|
||||||
|
select {
|
||||||
|
case keyCh <- token:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if isHTMLLike(contentType) {
|
||||||
|
for _, h := range []string{
|
||||||
|
"Content-Security-Policy", "Content-Security-Policy-Report-Only",
|
||||||
|
"X-Content-Security-Policy", "X-WebKit-CSP",
|
||||||
|
"Cross-Origin-Opener-Policy", "Cross-Origin-Embedder-Policy",
|
||||||
|
"Cross-Origin-Resource-Policy", "X-Frame-Options",
|
||||||
|
"Strict-Transport-Security", "Alt-Svc",
|
||||||
|
} {
|
||||||
|
res.Header.Del(h)
|
||||||
|
}
|
||||||
|
bodyBytes = []byte(rewriteCaptchaHTML(string(bodyBytes), localOrigin, upstreamOrigin))
|
||||||
|
}
|
||||||
|
if decompressed {
|
||||||
|
res.Header.Del("Content-Encoding")
|
||||||
|
}
|
||||||
|
res.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||||
|
res.ContentLength = int64(len(bodyBytes))
|
||||||
|
res.Header.Set("Content-Length", fmt.Sprint(len(bodyBytes)))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/local-captcha-result", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
token := r.FormValue("token")
|
||||||
|
if token != "" {
|
||||||
|
select {
|
||||||
|
case keyCh <- token:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
fmt.Fprint(w, "ok")
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/generic_proxy", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
proxyURL := r.URL.Query().Get("proxy_url")
|
||||||
|
parsed, err := url.Parse(proxyURL)
|
||||||
|
if err != nil || parsed.Host == "" {
|
||||||
|
http.Error(w, "Bad URL", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
genericProxy := &httputil.ReverseProxy{
|
||||||
|
Transport: transport,
|
||||||
|
Rewrite: func(req *httputil.ProxyRequest) {
|
||||||
|
req.Out.URL.Scheme = parsed.Scheme
|
||||||
|
req.Out.URL.Host = parsed.Host
|
||||||
|
req.Out.URL.Path = parsed.Path
|
||||||
|
req.Out.URL.RawQuery = parsed.RawQuery
|
||||||
|
req.Out.Host = parsed.Host
|
||||||
|
req.Out.Header.Del("Accept-Encoding")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
genericProxy.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/" && targetURL.Path != "" && targetURL.Path != "/" && r.URL.RawQuery == "" {
|
||||||
|
localPath := targetURL.Path
|
||||||
|
if targetURL.RawQuery != "" {
|
||||||
|
localPath += "?" + targetURL.RawQuery
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, localPath, http.StatusTemporaryRedirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
proxy.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
activeCaptchaProxy.Lock()
|
||||||
|
activeCaptchaProxy.listener = listener
|
||||||
|
activeCaptchaProxy.port = port
|
||||||
|
activeCaptchaProxy.keyCh = keyCh
|
||||||
|
activeCaptchaProxy.doneCh = make(chan struct{})
|
||||||
|
activeCaptchaProxy.Unlock()
|
||||||
|
go http.Serve(listener, mux)
|
||||||
|
return port
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetCaptchaResult() string {
|
||||||
|
activeCaptchaProxy.Lock()
|
||||||
|
ch := activeCaptchaProxy.keyCh
|
||||||
|
done := activeCaptchaProxy.doneCh
|
||||||
|
activeCaptchaProxy.Unlock()
|
||||||
|
if ch == nil || done == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case token := <-ch:
|
||||||
|
return token
|
||||||
|
case <-done:
|
||||||
|
return ""
|
||||||
|
case <-time.After(300 * time.Second):
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func StopCaptchaProxy() {
|
||||||
|
activeCaptchaProxy.Lock()
|
||||||
|
ln := activeCaptchaProxy.listener
|
||||||
|
done := activeCaptchaProxy.doneCh
|
||||||
|
activeCaptchaProxy.listener = nil
|
||||||
|
activeCaptchaProxy.port = 0
|
||||||
|
activeCaptchaProxy.keyCh = nil
|
||||||
|
activeCaptchaProxy.doneCh = nil
|
||||||
|
activeCaptchaProxy.Unlock()
|
||||||
|
if done != nil {
|
||||||
|
close(done)
|
||||||
|
}
|
||||||
|
if ln != nil {
|
||||||
|
ln.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func rewriteProxyCookies(res *http.Response) {
|
||||||
|
cookies := res.Cookies()
|
||||||
|
if len(cookies) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res.Header.Del("Set-Cookie")
|
||||||
|
for _, cookie := range cookies {
|
||||||
|
cookie.Domain = ""
|
||||||
|
cookie.Secure = false
|
||||||
|
cookie.Partitioned = false
|
||||||
|
if cookie.SameSite == http.SameSiteNoneMode || cookie.SameSite == http.SameSiteStrictMode {
|
||||||
|
cookie.SameSite = http.SameSiteLaxMode
|
||||||
|
}
|
||||||
|
res.Header.Add("Set-Cookie", cookie.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isHTMLLike(contentType string) bool {
|
||||||
|
return strings.Contains(contentType, "text/html") ||
|
||||||
|
strings.Contains(contentType, "application/xhtml+xml")
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractSuccessToken(body []byte) string {
|
||||||
|
var payload struct {
|
||||||
|
Response struct {
|
||||||
|
SuccessToken string `json:"success_token"`
|
||||||
|
} `json:"response"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &payload); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return payload.Response.SuccessToken
|
||||||
|
}
|
||||||
|
|
||||||
|
func rewriteCaptchaHTML(html, localOrigin, upstreamOrigin string) string {
|
||||||
|
html = strings.ReplaceAll(html, upstreamOrigin, localOrigin)
|
||||||
|
script := fmt.Sprintf(`
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var localOrigin = %q;
|
||||||
|
var upstreamOrigin = %q;
|
||||||
|
|
||||||
|
function rewriteUrl(urlStr) {
|
||||||
|
if (!urlStr || typeof urlStr !== 'string') return urlStr;
|
||||||
|
if (urlStr.indexOf(localOrigin) === 0) return urlStr;
|
||||||
|
if (urlStr.indexOf(upstreamOrigin) === 0) return localOrigin + urlStr.slice(upstreamOrigin.length);
|
||||||
|
if (urlStr.indexOf('//') === 0) {
|
||||||
|
return '/generic_proxy?proxy_url=' + encodeURIComponent(window.location.protocol + urlStr);
|
||||||
|
}
|
||||||
|
if (urlStr.indexOf('http://') === 0 || urlStr.indexOf('https://') === 0) {
|
||||||
|
return '/generic_proxy?proxy_url=' + encodeURIComponent(urlStr);
|
||||||
|
}
|
||||||
|
return urlStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rewriteElementAttr(el, attr) {
|
||||||
|
if (!el || !el.getAttribute) return;
|
||||||
|
var value = el.getAttribute(attr);
|
||||||
|
if (!value) return;
|
||||||
|
var rewritten = rewriteUrl(value);
|
||||||
|
if (rewritten !== value) el.setAttribute(attr, rewritten);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rewriteDocument(root) {
|
||||||
|
if (!root || !root.querySelectorAll) return;
|
||||||
|
root.querySelectorAll('[href]').forEach(function(el) { rewriteElementAttr(el, 'href'); });
|
||||||
|
root.querySelectorAll('[src]').forEach(function(el) { rewriteElementAttr(el, 'src'); });
|
||||||
|
root.querySelectorAll('form[action]').forEach(function(el) { rewriteElementAttr(el, 'action'); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSuccessToken(token) {
|
||||||
|
if (!token) return;
|
||||||
|
fetch('/local-captcha-result', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||||
|
body: 'token=' + encodeURIComponent(token)
|
||||||
|
}).catch(function() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
var origOpen = XMLHttpRequest.prototype.open;
|
||||||
|
XMLHttpRequest.prototype.open = function() {
|
||||||
|
if (arguments[1] && typeof arguments[1] === 'string') {
|
||||||
|
this._origUrl = arguments[1];
|
||||||
|
arguments[1] = rewriteUrl(arguments[1]);
|
||||||
|
}
|
||||||
|
return origOpen.apply(this, arguments);
|
||||||
|
};
|
||||||
|
var origSend = XMLHttpRequest.prototype.send;
|
||||||
|
XMLHttpRequest.prototype.send = function() {
|
||||||
|
var xhr = this;
|
||||||
|
if (this._origUrl && this._origUrl.indexOf('captchaNotRobot.check') !== -1) {
|
||||||
|
xhr.addEventListener('load', function() {
|
||||||
|
try {
|
||||||
|
var data = JSON.parse(xhr.responseText);
|
||||||
|
if (data.response && data.response.success_token) handleSuccessToken(data.response.success_token);
|
||||||
|
} catch (e) {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return origSend.apply(this, arguments);
|
||||||
|
};
|
||||||
|
|
||||||
|
var origFetch = window.fetch;
|
||||||
|
if (origFetch) {
|
||||||
|
window.fetch = function() {
|
||||||
|
var url = arguments[0];
|
||||||
|
var urlStr = (typeof url === 'object' && url && url.url) ? url.url : url;
|
||||||
|
var origUrlStr = urlStr;
|
||||||
|
if (typeof urlStr === 'string') {
|
||||||
|
urlStr = rewriteUrl(urlStr);
|
||||||
|
arguments[0] = urlStr;
|
||||||
|
}
|
||||||
|
var p = origFetch.apply(this, arguments);
|
||||||
|
if (typeof origUrlStr === 'string' && origUrlStr.indexOf('captchaNotRobot.check') !== -1) {
|
||||||
|
p.then(function(r) { return r.clone().json(); }).then(function(data) {
|
||||||
|
if (data.response && data.response.success_token) handleSuccessToken(data.response.success_token);
|
||||||
|
}).catch(function() {});
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var origWindowOpen = window.open;
|
||||||
|
if (origWindowOpen) {
|
||||||
|
window.open = function(url) {
|
||||||
|
if (typeof url === 'string') arguments[0] = rewriteUrl(url);
|
||||||
|
return origWindowOpen.apply(this, arguments);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
rewriteDocument(document);
|
||||||
|
if (document.documentElement && window.MutationObserver) {
|
||||||
|
new MutationObserver(function(mutations) {
|
||||||
|
mutations.forEach(function(mutation) {
|
||||||
|
if (mutation.type === 'attributes' && mutation.target) {
|
||||||
|
rewriteElementAttr(mutation.target, mutation.attributeName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mutation.addedNodes.forEach(function(node) {
|
||||||
|
if (node.nodeType === 1) rewriteDocument(node);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}).observe(document.documentElement, {
|
||||||
|
subtree: true, childList: true, attributes: true,
|
||||||
|
attributeFilter: ['href', 'src', 'action']
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
`, localOrigin, upstreamOrigin)
|
||||||
|
if idx := strings.Index(html, "</head>"); idx >= 0 {
|
||||||
|
return html[:idx] + script + html[idx:]
|
||||||
|
}
|
||||||
|
if idx := strings.Index(html, "</body>"); idx >= 0 {
|
||||||
|
return html[:idx] + script + html[idx:]
|
||||||
|
}
|
||||||
|
return html + script
|
||||||
|
}
|
||||||
28
transport/call/vk/config.go
Normal file
28
transport/call/vk/config.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package vk
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/sagernet/sing/common/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
type VKConfig struct {
|
||||||
|
AppID string
|
||||||
|
APIVersion string
|
||||||
|
SDKVersion string
|
||||||
|
AppVersion string
|
||||||
|
ProtocolVersion string
|
||||||
|
}
|
||||||
|
|
||||||
|
func FetchConfig(logger logger.ContextLogger) (VKConfig, error) {
|
||||||
|
cfg := VKConfig{
|
||||||
|
AppID: "6287487",
|
||||||
|
APIVersion: "5.280",
|
||||||
|
SDKVersion: "2.8.6-beta.22",
|
||||||
|
AppVersion: "1.1",
|
||||||
|
ProtocolVersion: "6",
|
||||||
|
}
|
||||||
|
logger.Debug(fmt.Sprintf("[config] app_id=%s api=%s sdk=%s app=%s proto=%s",
|
||||||
|
cfg.AppID, cfg.APIVersion, cfg.SDKVersion, cfg.AppVersion, cfg.ProtocolVersion))
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
125
transport/call/vk/connect.go
Normal file
125
transport/call/vk/connect.go
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
package vk
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sagernet/sing-box/adapter"
|
||||||
|
"github.com/sagernet/sing-box/transport/call/common"
|
||||||
|
"github.com/sagernet/sing-box/transport/call/tunnel"
|
||||||
|
"github.com/sagernet/sing/common/logger"
|
||||||
|
N "github.com/sagernet/sing/common/network"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ConnectCreator(ctx context.Context, cookieStr, joinLink string, readBuf int, dialer N.Dialer, logger logger.ContextLogger) (*tunnel.RelayBridge, string, error) {
|
||||||
|
cfg, err := FetchConfig(logger)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
var callInfo *CallInfo
|
||||||
|
if joinLink != "" {
|
||||||
|
callInfo, err = JoinExistingCall(dialer, cookieStr, joinLink, cfg, logger)
|
||||||
|
} else {
|
||||||
|
callInfo, err = CreateAndJoinCall(dialer, cookieStr, "", cfg, logger)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
if readBuf <= 0 {
|
||||||
|
readBuf = 32768
|
||||||
|
}
|
||||||
|
obf, err := tunnel.NewTunnelObfuscator(tunnel.DeriveSecretFromJoinLink(callInfo.JoinLink))
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("vk: obfuscator init: %w", err)
|
||||||
|
}
|
||||||
|
bridge := &Bridge{
|
||||||
|
dialer: dialer,
|
||||||
|
readBuf: readBuf,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
bridge.newRelay = func() Relay {
|
||||||
|
ur := NewTunnelRelay(dialer, logger)
|
||||||
|
ur.readBufSize = readBuf
|
||||||
|
ur.SetObfuscator(obf)
|
||||||
|
ur.OnConnected = func(tun tunnel.DataTunnel) {
|
||||||
|
bridgeReadBuf := common.VP8BufSize
|
||||||
|
if _, ok := tun.(*tunnel.DCTunnel); ok {
|
||||||
|
bridgeReadBuf = readBuf
|
||||||
|
}
|
||||||
|
rb := tunnel.NewRelayBridge(tun, "creator", bridgeReadBuf, dialer, logger)
|
||||||
|
rb.MarkReady()
|
||||||
|
if st, ok := tun.(*tunnel.SymmetricScreenTunnel); ok {
|
||||||
|
rb.SetOnPeerConfig(func(fps, batch, trackCount int) {
|
||||||
|
st.SetTrackCount(trackCount)
|
||||||
|
bridge.setScreenSharing(trackCount > 1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
bridge.mu.Lock()
|
||||||
|
bridge.activeBridge = rb
|
||||||
|
bridge.mu.Unlock()
|
||||||
|
}
|
||||||
|
return ur
|
||||||
|
}
|
||||||
|
go bridge.Run(callInfo, cookieStr, cfg)
|
||||||
|
deadline := time.Now().Add(60 * time.Second)
|
||||||
|
for {
|
||||||
|
bridge.mu.Lock()
|
||||||
|
rb := bridge.activeBridge
|
||||||
|
bridge.mu.Unlock()
|
||||||
|
if rb != nil {
|
||||||
|
return rb, callInfo.JoinLink, nil
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
return nil, "", fmt.Errorf("vk: creator tunnel timed out")
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, "", ctx.Err()
|
||||||
|
case <-time.After(200 * time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ConnectJoiner(ctx context.Context, joinLink, displayName string, readBuf int, dialer N.Dialer, dnsRouter adapter.DNSRouter, logger logger.ContextLogger) (tunnel.DataTunnel, error) {
|
||||||
|
if displayName == "" {
|
||||||
|
displayName = "Joiner"
|
||||||
|
}
|
||||||
|
authJSON, err := RunVKAuth(dialer, joinLink, displayName, logger)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("vk: auth: %w", err)
|
||||||
|
}
|
||||||
|
var params VKAuthParams
|
||||||
|
if err := json.Unmarshal([]byte(authJSON), ¶ms); err != nil {
|
||||||
|
return nil, fmt.Errorf("vk: decode auth params: %w", err)
|
||||||
|
}
|
||||||
|
params.TunnelMode = "video"
|
||||||
|
paramsJSON, err := json.Marshal(params)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("vk: encode auth params: %w", err)
|
||||||
|
}
|
||||||
|
joiner := NewVKJoiner(
|
||||||
|
logger,
|
||||||
|
nil,
|
||||||
|
common.AddTunnelTracks,
|
||||||
|
common.ReadTrack,
|
||||||
|
dialer,
|
||||||
|
dnsRouter,
|
||||||
|
)
|
||||||
|
tunCh := make(chan tunnel.DataTunnel, 1)
|
||||||
|
joiner.OnConnected = func(tun tunnel.DataTunnel) {
|
||||||
|
select {
|
||||||
|
case tunCh <- tun:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
go joiner.RunWithParams(string(paramsJSON))
|
||||||
|
select {
|
||||||
|
case tun := <-tunCh:
|
||||||
|
return tun, nil
|
||||||
|
case <-ctx.Done():
|
||||||
|
joiner.Close()
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
318
transport/call/vk/creator.go
Normal file
318
transport/call/vk/creator.go
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
package vk
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
"github.com/pion/webrtc/v4"
|
||||||
|
"github.com/sagernet/sing-box/transport/call/common"
|
||||||
|
"github.com/sagernet/sing-box/transport/call/tunnel"
|
||||||
|
"github.com/sagernet/sing/common/logger"
|
||||||
|
M "github.com/sagernet/sing/common/metadata"
|
||||||
|
N "github.com/sagernet/sing/common/network"
|
||||||
|
)
|
||||||
|
|
||||||
|
const topologyDirect = "DIRECT"
|
||||||
|
const maxServerBounces = 5
|
||||||
|
|
||||||
|
type Bridge struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
vkWs *websocket.Conn
|
||||||
|
vkSeq int
|
||||||
|
iceServers []webrtc.ICEServer
|
||||||
|
topology string
|
||||||
|
peers map[int64]struct{}
|
||||||
|
relay Relay
|
||||||
|
newRelay func() Relay
|
||||||
|
p2p *P2PHandler
|
||||||
|
screenSharing bool
|
||||||
|
|
||||||
|
serverBounces int
|
||||||
|
suppressScreenshare bool
|
||||||
|
bouncing bool
|
||||||
|
|
||||||
|
dialer N.Dialer
|
||||||
|
activeBridge *tunnel.RelayBridge
|
||||||
|
readBuf int
|
||||||
|
logger logger.ContextLogger
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) setScreenSharing(enabled bool) {
|
||||||
|
b.mu.Lock()
|
||||||
|
if b.vkWs == nil || b.screenSharing == enabled {
|
||||||
|
b.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if enabled && b.suppressScreenshare {
|
||||||
|
b.mu.Unlock()
|
||||||
|
b.logger.Debug("[vk-ws] screenshare suppressed after SERVER flap, staying single-track DIRECT")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.screenSharing = enabled
|
||||||
|
b.mu.Unlock()
|
||||||
|
b.logger.Debug(fmt.Sprintf("[vk-ws] peer track count change, screenshare=%v", enabled))
|
||||||
|
b.sendMediaSettings(enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) vkSend(command string, extra map[string]interface{}) {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
if b.vkWs == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.vkSeq++
|
||||||
|
seq := b.vkSeq
|
||||||
|
var out []byte
|
||||||
|
if pid, ok := extra["participantId"]; ok {
|
||||||
|
dataJSON, _ := json.Marshal(extra["data"])
|
||||||
|
out = []byte(fmt.Sprintf(`{"command":%q,"sequence":%d,"participantId":%v,"data":%s}`,
|
||||||
|
command, seq, pid, dataJSON))
|
||||||
|
} else {
|
||||||
|
extra["command"] = command
|
||||||
|
extra["sequence"] = seq
|
||||||
|
out, _ = json.Marshal(extra)
|
||||||
|
}
|
||||||
|
b.vkWs.WriteMessage(websocket.TextMessage, out)
|
||||||
|
b.logger.Debug(fmt.Sprintf("[vk-ws] -> %s", command))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) sendMediaSettings(screenSharing bool) {
|
||||||
|
b.vkSend("change-media-settings", map[string]interface{}{
|
||||||
|
"mediaSettings": map[string]interface{}{
|
||||||
|
"isAudioEnabled": false, "isVideoEnabled": true,
|
||||||
|
"isScreenSharingEnabled": screenSharing, "isFastScreenSharingEnabled": false,
|
||||||
|
"isAudioSharingEnabled": false, "isAnimojiEnabled": false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) handleVKMessage(raw []byte) {
|
||||||
|
var msg map[string]interface{}
|
||||||
|
if err := json.Unmarshal(raw, &msg); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msgType, _ := msg["type"].(string)
|
||||||
|
switch msgType {
|
||||||
|
case "notification":
|
||||||
|
notif, _ := msg["notification"].(string)
|
||||||
|
b.logger.Debug(fmt.Sprintf("[vk-ws] <- notification: %s", notif))
|
||||||
|
switch notif {
|
||||||
|
case "connection":
|
||||||
|
b.logger.Debug("[vk-ws] TURN creds received")
|
||||||
|
case "transmitted-data":
|
||||||
|
data, _ := msg["data"].(map[string]interface{})
|
||||||
|
if data != nil && b.topology == topologyDirect && b.p2p != nil {
|
||||||
|
b.p2p.OnTransmittedData(data)
|
||||||
|
}
|
||||||
|
case "registered-peer":
|
||||||
|
pid, _ := msg["participantId"].(float64)
|
||||||
|
if b.topology == topologyDirect && b.p2p != nil {
|
||||||
|
b.p2p.OnRegisteredPeer(int64(pid))
|
||||||
|
}
|
||||||
|
case "topology-changed":
|
||||||
|
topo, _ := msg["topology"].(string)
|
||||||
|
b.logger.Debug(fmt.Sprintf("[vk-ws] Topology changed to %s", topo))
|
||||||
|
b.topology = topo
|
||||||
|
if topo != topologyDirect {
|
||||||
|
b.bounceForServerTopology("SERVER topology")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case "participant-joined", "participant-added":
|
||||||
|
if pid, ok := msg["participantId"].(float64); ok {
|
||||||
|
b.peers[int64(pid)] = struct{}{}
|
||||||
|
b.logger.Debug(fmt.Sprintf("[vk-ws] Participant %d joined (total: %d)", int64(pid), len(b.peers)))
|
||||||
|
if b.topology != topologyDirect {
|
||||||
|
b.bounceForServerTopology("participant joined under SERVER")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "participant-left":
|
||||||
|
if pid, ok := msg["participantId"].(float64); ok {
|
||||||
|
delete(b.peers, int64(pid))
|
||||||
|
b.logger.Debug(fmt.Sprintf("[vk-ws] Participant %d left (total: %d)", int64(pid), len(b.peers)))
|
||||||
|
}
|
||||||
|
case "hungup":
|
||||||
|
if pid, ok := msg["participantId"].(float64); ok {
|
||||||
|
delete(b.peers, int64(pid))
|
||||||
|
b.logger.Debug(fmt.Sprintf("[vk-ws] Participant %d hung up (total: %d)", int64(pid), len(b.peers)))
|
||||||
|
} else {
|
||||||
|
b.logger.Debug("[vk-ws] Participant hung up")
|
||||||
|
}
|
||||||
|
case "closed-conversation":
|
||||||
|
reason, _ := msg["reason"].(string)
|
||||||
|
b.logger.Debug(fmt.Sprintf("[vk-ws] Conversation closed: %s", reason))
|
||||||
|
b.mu.Lock()
|
||||||
|
if b.vkWs != nil {
|
||||||
|
b.vkWs.Close()
|
||||||
|
}
|
||||||
|
b.mu.Unlock()
|
||||||
|
default:
|
||||||
|
snippet, _ := json.Marshal(msg)
|
||||||
|
if len(snippet) > 1000 {
|
||||||
|
snippet = append(snippet[:1000], '.', '.', '.')
|
||||||
|
}
|
||||||
|
b.logger.Debug(fmt.Sprintf("[vk-ws] unhandled: %s", string(snippet)))
|
||||||
|
}
|
||||||
|
case "response":
|
||||||
|
seq, _ := msg["sequence"].(float64)
|
||||||
|
snippet, _ := json.Marshal(msg)
|
||||||
|
if len(snippet) > 1000 {
|
||||||
|
snippet = append(snippet[:1000], '.', '.', '.')
|
||||||
|
}
|
||||||
|
b.logger.Debug(fmt.Sprintf("[vk-ws] <- response seq=%d: %s", int(seq), string(snippet)))
|
||||||
|
case "error":
|
||||||
|
errMsg, _ := msg["message"].(string)
|
||||||
|
errCode, _ := msg["error"].(string)
|
||||||
|
b.logger.Warn(fmt.Sprintf("[vk-ws] <- error: %s %s", errCode, errMsg))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) connectVKWs(wsURL string) error {
|
||||||
|
vkHeader := http.Header{}
|
||||||
|
vkHeader.Set("User-Agent", common.UserAgent)
|
||||||
|
vkHeader.Set("Origin", "https://vk.com")
|
||||||
|
vkDialer := websocket.Dialer{
|
||||||
|
WriteBufferSize: common.RTPBufSize,
|
||||||
|
NetDialContext: b.dialContext,
|
||||||
|
}
|
||||||
|
vkWs, _, err := vkDialer.Dial(wsURL, vkHeader)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.mu.Lock()
|
||||||
|
b.vkWs = vkWs
|
||||||
|
b.vkSeq = 0
|
||||||
|
b.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) dialContext(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
|
return b.dialer.DialContext(ctx, network, M.ParseSocksaddr(addr))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) initRelay() {
|
||||||
|
if b.relay != nil {
|
||||||
|
b.relay.Close()
|
||||||
|
}
|
||||||
|
b.topology = topologyDirect
|
||||||
|
b.peers = make(map[int64]struct{})
|
||||||
|
b.relay = b.newRelay()
|
||||||
|
b.p2p = NewP2PHandler(b)
|
||||||
|
b.p2p.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) bounceForServerTopology(reason string) {
|
||||||
|
b.mu.Lock()
|
||||||
|
if b.bouncing {
|
||||||
|
b.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.bouncing = true
|
||||||
|
b.serverBounces++
|
||||||
|
count := b.serverBounces
|
||||||
|
if count > maxServerBounces {
|
||||||
|
b.suppressScreenshare = true
|
||||||
|
}
|
||||||
|
suppress := b.suppressScreenshare
|
||||||
|
ws := b.vkWs
|
||||||
|
b.mu.Unlock()
|
||||||
|
if suppress {
|
||||||
|
b.logger.Debug(fmt.Sprintf("[vk-ws] %s -> reconnect #%d, suppressing screenshare to settle single-track DIRECT", reason, count))
|
||||||
|
} else {
|
||||||
|
b.logger.Debug(fmt.Sprintf("[vk-ws] %s -> manual reconnect #%d to recover DIRECT", reason, count))
|
||||||
|
}
|
||||||
|
if ws != nil {
|
||||||
|
ws.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) readLoop() error {
|
||||||
|
for {
|
||||||
|
_, msg, err := b.vkWs.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if string(msg) == "ping" {
|
||||||
|
b.mu.Lock()
|
||||||
|
b.vkWs.WriteMessage(websocket.TextMessage, []byte("pong"))
|
||||||
|
b.mu.Unlock()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.handleVKMessage(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) Run(callInfo *CallInfo, cookieStr string, cfg VKConfig) {
|
||||||
|
b.logger.Info(fmt.Sprintf("CALL CREATED join_link=%s turn=%s protocol=v%s sdk=%s",
|
||||||
|
callInfo.JoinLink, strings.Join(callInfo.TurnServer.URLs, ", "), cfg.ProtocolVersion, cfg.SDKVersion))
|
||||||
|
b.iceServers = buildWebRTCICEServers(BuildICEServers(callInfo))
|
||||||
|
wsEndpoint := callInfo.WSEndpoint
|
||||||
|
capabilities := "2F7F"
|
||||||
|
makeWSURL := func(ep string) string {
|
||||||
|
return ep +
|
||||||
|
"&platform=WEB" +
|
||||||
|
"&appVersion=" + cfg.AppVersion +
|
||||||
|
"&version=" + cfg.ProtocolVersion +
|
||||||
|
"&device=browser&capabilities=" + capabilities + "&clientType=VK&tgt=join"
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
time.Sleep(15 * time.Second)
|
||||||
|
b.mu.Lock()
|
||||||
|
ws := b.vkWs
|
||||||
|
b.mu.Unlock()
|
||||||
|
if ws != nil {
|
||||||
|
b.mu.Lock()
|
||||||
|
ws.WriteMessage(websocket.PingMessage, nil)
|
||||||
|
b.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
for {
|
||||||
|
b.initRelay()
|
||||||
|
b.logger.Debug("[vk-ws] Connecting...")
|
||||||
|
if err := b.connectVKWs(makeWSURL(wsEndpoint)); err != nil {
|
||||||
|
b.logger.Warn(fmt.Sprintf("[vk-ws] Connect failed: %s, retrying in 5s...", common.MaskError(err)))
|
||||||
|
time.Sleep(5 * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.logger.Debug("[vk-ws] Connected")
|
||||||
|
b.mu.Lock()
|
||||||
|
b.screenSharing = false
|
||||||
|
b.bouncing = false
|
||||||
|
b.mu.Unlock()
|
||||||
|
b.sendMediaSettings(false)
|
||||||
|
err := b.readLoop()
|
||||||
|
b.logger.Debug(fmt.Sprintf("[vk-ws] Closed: %s", common.MaskError(err)))
|
||||||
|
b.mu.Lock()
|
||||||
|
b.vkWs = nil
|
||||||
|
b.mu.Unlock()
|
||||||
|
b.logger.Debug("[vk-ws] Rejoining in 3s...")
|
||||||
|
time.Sleep(3 * time.Second)
|
||||||
|
joinResp, rerr := authAndJoin(b.dialer, cookieStr, callInfo.OKJoinLink, cfg)
|
||||||
|
if rerr != nil {
|
||||||
|
b.logger.Warn(fmt.Sprintf("[rejoin] Failed: %v, retrying in 5s...", rerr))
|
||||||
|
time.Sleep(5 * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
wsEndpoint = joinResp.Endpoint
|
||||||
|
callInfo.TurnServer = joinResp.TurnServer
|
||||||
|
callInfo.StunServer = joinResp.StunServer
|
||||||
|
b.iceServers = buildWebRTCICEServers(BuildICEServers(callInfo))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildWebRTCICEServers(specs []ICEServerSpec) []webrtc.ICEServer {
|
||||||
|
out := make([]webrtc.ICEServer, len(specs))
|
||||||
|
for i, s := range specs {
|
||||||
|
out[i] = webrtc.ICEServer{URLs: s.URLs, Username: s.Username, Credential: s.Credential}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
757
transport/call/vk/joiner.go
Normal file
757
transport/call/vk/joiner.go
Normal file
@@ -0,0 +1,757 @@
|
|||||||
|
package vk
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pion/webrtc/v4"
|
||||||
|
"github.com/sagernet/sing-box/adapter"
|
||||||
|
"github.com/sagernet/sing-box/common/dialer"
|
||||||
|
"github.com/sagernet/sing-box/transport/call/common"
|
||||||
|
"github.com/sagernet/sing-box/transport/call/tunnel"
|
||||||
|
"github.com/sagernet/sing-box/transport/call/wtsignal"
|
||||||
|
"github.com/sagernet/sing/common/logger"
|
||||||
|
M "github.com/sagernet/sing/common/metadata"
|
||||||
|
N "github.com/sagernet/sing/common/network"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
vkReconnectInitialDelay = time.Second
|
||||||
|
vkReconnectMaxDelay = 16 * time.Second
|
||||||
|
vkMaxReconnectAttempts = 10
|
||||||
|
)
|
||||||
|
|
||||||
|
const vkTopologyDirect = "DIRECT"
|
||||||
|
|
||||||
|
type vkAuthRottenError struct {
|
||||||
|
Code string
|
||||||
|
Msg string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *vkAuthRottenError) Error() string {
|
||||||
|
if e.Msg != "" {
|
||||||
|
return fmt.Sprintf("auth rotten: %s %s", e.Code, e.Msg)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("auth rotten: %s", e.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
type VKAuthParams struct {
|
||||||
|
SessionKey string `json:"sessionKey"`
|
||||||
|
ApplicationKey string `json:"applicationKey"`
|
||||||
|
APIBaseURL string `json:"apiBaseURL"`
|
||||||
|
JoinLink string `json:"joinLink"`
|
||||||
|
AnonymToken string `json:"anonymToken"`
|
||||||
|
AppVersion string `json:"appVersion"`
|
||||||
|
ProtocolVersion string `json:"protocolVersion"`
|
||||||
|
TunnelMode string `json:"tunnelMode"`
|
||||||
|
VP8FPS int `json:"vp8Fps"`
|
||||||
|
VP8Batch int `json:"vp8Batch"`
|
||||||
|
DualTrack bool `json:"dualTrack"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type VKJoinResponse struct {
|
||||||
|
Endpoint string `json:"endpoint"`
|
||||||
|
WtEndpoint string `json:"wt_endpoint"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
TurnServer struct {
|
||||||
|
URLs []string `json:"urls"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Credential string `json:"credential"`
|
||||||
|
} `json:"turn_server"`
|
||||||
|
StunServer struct {
|
||||||
|
URLs []string `json:"urls"`
|
||||||
|
} `json:"stun_server"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type VKJoiner struct {
|
||||||
|
logger logger.ContextLogger
|
||||||
|
OnConnected func(tunnel.DataTunnel)
|
||||||
|
OnRemoteCandidate func(target int, candidateOrSDP string)
|
||||||
|
PCConfig common.PeerConnectionConfigurer
|
||||||
|
AddTracks common.AddTunnelTracksFunc
|
||||||
|
ReadTrackFn common.ReadTrackFunc
|
||||||
|
Dialer N.Dialer
|
||||||
|
DNSRouter adapter.DNSRouter
|
||||||
|
|
||||||
|
authParams *VKAuthParams
|
||||||
|
joinResp *VKJoinResponse
|
||||||
|
sfu *wtsignal.Conn
|
||||||
|
vkMu sync.Mutex
|
||||||
|
vkSeq int
|
||||||
|
remotePeerID *int64
|
||||||
|
|
||||||
|
pc *webrtc.PeerConnection
|
||||||
|
sampleTrack *webrtc.TrackLocalStaticSample
|
||||||
|
dc *webrtc.DataChannel
|
||||||
|
vp8tunnel *tunnel.VP8DataTunnel
|
||||||
|
sym *tunnel.SymmetricScreenTunnel
|
||||||
|
producerScreen screenUplink
|
||||||
|
obf *tunnel.TunnelObfuscator
|
||||||
|
vp8FPS int
|
||||||
|
vp8Batch int
|
||||||
|
dualTrack bool
|
||||||
|
remoteSet bool
|
||||||
|
pendingICE []webrtc.ICECandidateInit
|
||||||
|
|
||||||
|
configAck tunnel.ConfigAckTracker
|
||||||
|
reconnectAttempt atomic.Int32
|
||||||
|
stopCh chan struct{}
|
||||||
|
stopOnce sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewVKJoiner(logger logger.ContextLogger, pcConfig common.PeerConnectionConfigurer, addTracks common.AddTunnelTracksFunc, readTrackFn common.ReadTrackFunc, dialer N.Dialer, dnsRouter adapter.DNSRouter) *VKJoiner {
|
||||||
|
return &VKJoiner{
|
||||||
|
logger: logger,
|
||||||
|
PCConfig: pcConfig,
|
||||||
|
AddTracks: addTracks,
|
||||||
|
ReadTrackFn: readTrackFn,
|
||||||
|
Dialer: dialer,
|
||||||
|
DNSRouter: dnsRouter,
|
||||||
|
stopCh: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *VKJoiner) RunWithParams(jsonParams string) {
|
||||||
|
var params VKAuthParams
|
||||||
|
if err := json.Unmarshal([]byte(jsonParams), ¶ms); err != nil {
|
||||||
|
h.logger.Error(fmt.Sprintf("vk-joiner: failed to parse auth params: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.authParams = ¶ms
|
||||||
|
obf, err := tunnel.NewTunnelObfuscator(tunnel.DeriveSecretFromJoinLink(params.JoinLink))
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error(fmt.Sprintf("vk-joiner: obfuscator init failed: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.obf = obf
|
||||||
|
h.vp8FPS = params.VP8FPS
|
||||||
|
h.vp8Batch = params.VP8Batch
|
||||||
|
// h.dualTrack = params.DualTrack // temporarily disabled for VK joiners
|
||||||
|
h.logger.Debug("vk-joiner: auth params received")
|
||||||
|
h.logger.Debug(fmt.Sprintf("vk-joiner: obf key-source=%q localEpoch=0x%08x", params.JoinLink, obf.LocalEpoch()))
|
||||||
|
h.logger.Debug(fmt.Sprintf("vk-joiner: appVersion=%s protocolVersion=%s vp8Fps=%d vp8Batch=%d",
|
||||||
|
params.AppVersion, params.ProtocolVersion, params.VP8FPS, params.VP8Batch))
|
||||||
|
h.logger.Info("vk-joiner: connecting")
|
||||||
|
if err := h.runOnce(); err != nil {
|
||||||
|
h.logger.Error(fmt.Sprintf("vk-joiner: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
if h.isClosed() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logger.Info("vk-joiner: tunnel lost")
|
||||||
|
if !h.waitBeforeRetry(int(h.reconnectAttempt.Load())) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
attempt := h.reconnectAttempt.Add(1)
|
||||||
|
if h.isClosed() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if int(attempt) > vkMaxReconnectAttempts {
|
||||||
|
h.logger.Warn(fmt.Sprintf("vk-joiner: gave up after %d consecutive reconnect attempts", vkMaxReconnectAttempts))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logger.Info(fmt.Sprintf("vk-joiner: reconnect attempt #%d", attempt))
|
||||||
|
if err := h.runOnce(); err != nil {
|
||||||
|
var authRotten *vkAuthRottenError
|
||||||
|
if errors.As(err, &authRotten) {
|
||||||
|
h.logger.Error(fmt.Sprintf("vk-joiner: %v, surrendering", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logger.Warn(fmt.Sprintf("vk-joiner: %v, will retry", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *VKJoiner) Close() {
|
||||||
|
h.stopOnce.Do(func() { close(h.stopCh) })
|
||||||
|
StopCaptchaProxy()
|
||||||
|
h.vkMu.Lock()
|
||||||
|
sfu := h.sfu
|
||||||
|
h.sfu = nil
|
||||||
|
h.vkMu.Unlock()
|
||||||
|
if sfu != nil {
|
||||||
|
sfu.Close()
|
||||||
|
}
|
||||||
|
if h.vp8tunnel != nil {
|
||||||
|
h.vp8tunnel.Stop()
|
||||||
|
}
|
||||||
|
if h.pc != nil {
|
||||||
|
h.pc.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *VKJoiner) closeTransport() {
|
||||||
|
h.vkMu.Lock()
|
||||||
|
sfu := h.sfu
|
||||||
|
h.vkMu.Unlock()
|
||||||
|
if sfu != nil {
|
||||||
|
sfu.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *VKJoiner) runOnce() error {
|
||||||
|
h.resetSessionState()
|
||||||
|
if err := h.joinCall(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
h.connectSFU()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *VKJoiner) MarkConfigAcked() { h.configAck.Mark() }
|
||||||
|
|
||||||
|
func (h *VKJoiner) waitBeforeRetry(attempt int) bool {
|
||||||
|
delay := common.BackoffWithJitter(attempt, vkReconnectInitialDelay, vkReconnectMaxDelay)
|
||||||
|
h.logger.Debug(fmt.Sprintf("vk-joiner: waiting %s before reconnect", delay))
|
||||||
|
timer := time.NewTimer(delay)
|
||||||
|
defer timer.Stop()
|
||||||
|
select {
|
||||||
|
case <-timer.C:
|
||||||
|
return !h.isClosed()
|
||||||
|
case <-h.stopCh:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *VKJoiner) isClosed() bool {
|
||||||
|
select {
|
||||||
|
case <-h.stopCh:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *VKJoiner) resetSessionState() {
|
||||||
|
h.vkMu.Lock()
|
||||||
|
sfu := h.sfu
|
||||||
|
h.sfu = nil
|
||||||
|
h.vkSeq = 0
|
||||||
|
h.vkMu.Unlock()
|
||||||
|
if sfu != nil {
|
||||||
|
sfu.Close()
|
||||||
|
}
|
||||||
|
if h.sym != nil {
|
||||||
|
h.sym.Stop()
|
||||||
|
h.sym = nil
|
||||||
|
}
|
||||||
|
if h.vp8tunnel != nil {
|
||||||
|
h.vp8tunnel.Stop()
|
||||||
|
h.vp8tunnel = nil
|
||||||
|
}
|
||||||
|
h.producerScreen.reset()
|
||||||
|
if h.dc != nil {
|
||||||
|
h.dc.Close()
|
||||||
|
h.dc = nil
|
||||||
|
}
|
||||||
|
if h.pc != nil {
|
||||||
|
h.pc.Close()
|
||||||
|
h.pc = nil
|
||||||
|
}
|
||||||
|
h.sampleTrack = nil
|
||||||
|
h.remoteSet = false
|
||||||
|
h.pendingICE = nil
|
||||||
|
h.remotePeerID = nil
|
||||||
|
h.joinResp = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *VKJoiner) dialContext(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
|
return h.Dialer.DialContext(ctx, network, M.ParseSocksaddr(addr))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *VKJoiner) resolveHost(host string) (string, error) {
|
||||||
|
if ip := net.ParseIP(host); ip != nil {
|
||||||
|
return host, nil
|
||||||
|
}
|
||||||
|
rd, hasRD := h.Dialer.(dialer.ResolveDialer)
|
||||||
|
if h.DNSRouter == nil || !hasRD {
|
||||||
|
return "", fmt.Errorf("no DNS router available to resolve %s", host)
|
||||||
|
}
|
||||||
|
addrs, err := h.DNSRouter.Lookup(context.Background(), host, rd.QueryOptions())
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if len(addrs) == 0 {
|
||||||
|
return "", fmt.Errorf("no addresses for %s", host)
|
||||||
|
}
|
||||||
|
return addrs[0].String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *VKJoiner) joinCall() error {
|
||||||
|
apiURL := h.authParams.APIBaseURL
|
||||||
|
parsed, err := url.Parse(apiURL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("bad apiBaseURL: %w", err)
|
||||||
|
}
|
||||||
|
screenFlag := "false"
|
||||||
|
if h.dualTrack {
|
||||||
|
screenFlag = "true"
|
||||||
|
}
|
||||||
|
body := url.Values{
|
||||||
|
"method": {"vchat.joinConversationByLink"},
|
||||||
|
"session_key": {h.authParams.SessionKey},
|
||||||
|
"application_key": {h.authParams.ApplicationKey},
|
||||||
|
"joinLink": {h.authParams.JoinLink},
|
||||||
|
"anonymToken": {h.authParams.AnonymToken},
|
||||||
|
"isVideo": {"true"},
|
||||||
|
"isAudio": {"false"},
|
||||||
|
"mediaSettings": {`{"isAudioEnabled":false,"isVideoEnabled":true,"isScreenSharingEnabled":` + screenFlag + `}`},
|
||||||
|
"format": {"json"},
|
||||||
|
}
|
||||||
|
client := &http.Client{
|
||||||
|
Timeout: 15 * time.Second,
|
||||||
|
Transport: &http.Transport{
|
||||||
|
TLSClientConfig: &tls.Config{ServerName: parsed.Hostname()},
|
||||||
|
DialContext: h.dialContext,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest("POST", apiURL, strings.NewReader(body.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("new request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("User-Agent", common.UserAgent)
|
||||||
|
h.logger.Debug("vk-joiner: calling joinConversationByLink...")
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("joinConversationByLink: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
raw, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read join response: %w", err)
|
||||||
|
}
|
||||||
|
var joinResp VKJoinResponse
|
||||||
|
if jsonErr := json.Unmarshal(raw, &joinResp); jsonErr != nil {
|
||||||
|
return fmt.Errorf("decode join response: %w (body: %s)", jsonErr, truncateBody(raw))
|
||||||
|
}
|
||||||
|
if joinResp.Endpoint == "" {
|
||||||
|
if rotten := detectVKAuthRotten(raw); rotten != nil {
|
||||||
|
return rotten
|
||||||
|
}
|
||||||
|
return fmt.Errorf("empty endpoint in join response: %s", truncateBody(raw))
|
||||||
|
}
|
||||||
|
h.joinResp = &joinResp
|
||||||
|
h.logger.Debug(fmt.Sprintf("vk-joiner: joined, turn=%v", joinResp.TurnServer.URLs))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *VKJoiner) connectSFU() {
|
||||||
|
endpoint := h.joinResp.WtEndpoint
|
||||||
|
if endpoint == "" {
|
||||||
|
h.logger.Error("vk-joiner: no wt_endpoint in join response, cannot connect")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(endpoint)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error(fmt.Sprintf("vk-joiner: bad endpoint URL: %s", common.MaskError(err)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hostname := parsed.Hostname()
|
||||||
|
resolvedIP, err := h.resolveHost(hostname)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error(fmt.Sprintf("vk-joiner: DNS resolve failed: %s", common.MaskError(err)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logger.Debug(fmt.Sprintf("vk-joiner: resolved %s -> %s", common.MaskAddr(hostname), common.MaskAddr(resolvedIP)))
|
||||||
|
capabilities := "2F7F"
|
||||||
|
wtURL := endpoint +
|
||||||
|
"&platform=WEB" +
|
||||||
|
"&appVersion=" + h.authParams.AppVersion +
|
||||||
|
"&version=" + h.authParams.ProtocolVersion +
|
||||||
|
"&device=browser&capabilities=" + capabilities + "&clientType=VK&tgt=join&compression=deflate-raw"
|
||||||
|
sfu, err := wtsignal.Dial(wtURL, hostname, resolvedIP)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error(fmt.Sprintf("vk-joiner: WebTransport connect failed: %s", common.MaskError(err)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.vkMu.Lock()
|
||||||
|
h.sfu = sfu
|
||||||
|
h.vkSeq = 0
|
||||||
|
h.vkMu.Unlock()
|
||||||
|
h.logger.Debug("vk-joiner: WebTransport connected")
|
||||||
|
h.vkSend("update-media-modifiers", map[string]interface{}{
|
||||||
|
"mediaModifiers": map[string]interface{}{"denoise": true, "denoiseAnn": true},
|
||||||
|
})
|
||||||
|
h.vkSend("change-media-settings", map[string]interface{}{
|
||||||
|
"mediaSettings": map[string]interface{}{
|
||||||
|
"isAudioEnabled": false, "isVideoEnabled": true,
|
||||||
|
"isScreenSharingEnabled": h.dualTrack, "isFastScreenSharingEnabled": false,
|
||||||
|
"isAudioSharingEnabled": false, "isAnimojiEnabled": false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
h.readLoop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *VKJoiner) vkSend(command string, extra map[string]interface{}) {
|
||||||
|
h.vkMu.Lock()
|
||||||
|
defer h.vkMu.Unlock()
|
||||||
|
if h.sfu == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.vkSeq++
|
||||||
|
extra["command"] = command
|
||||||
|
extra["sequence"] = h.vkSeq
|
||||||
|
out, _ := json.Marshal(extra)
|
||||||
|
h.sfu.Send(out)
|
||||||
|
h.logger.Debug(fmt.Sprintf("vk-joiner: -> %s", command))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *VKJoiner) vkSendTransmitData(participantId int64, payload map[string]interface{}) {
|
||||||
|
h.vkMu.Lock()
|
||||||
|
defer h.vkMu.Unlock()
|
||||||
|
if h.sfu == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.vkSeq++
|
||||||
|
payloadJSON, _ := json.Marshal(payload)
|
||||||
|
out := fmt.Sprintf(`{"command":"transmit-data","sequence":%d,"participantId":%d,"data":%s}`,
|
||||||
|
h.vkSeq, participantId, payloadJSON)
|
||||||
|
h.sfu.Send([]byte(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *VKJoiner) readLoop() {
|
||||||
|
h.vkMu.Lock()
|
||||||
|
sfu := h.sfu
|
||||||
|
h.vkMu.Unlock()
|
||||||
|
if sfu == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
msg, err := sfu.Recv()
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Debug(fmt.Sprintf("vk-joiner: WebTransport closed: %s", common.MaskError(err)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if string(msg) == "ping" {
|
||||||
|
sfu.Send([]byte("pong"))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
h.handleVKMessage(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *VKJoiner) handleVKMessage(raw []byte) {
|
||||||
|
var msg map[string]interface{}
|
||||||
|
if err := json.Unmarshal(raw, &msg); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msgType, _ := msg["type"].(string)
|
||||||
|
switch msgType {
|
||||||
|
case "notification":
|
||||||
|
notif, _ := msg["notification"].(string)
|
||||||
|
switch notif {
|
||||||
|
case "connection":
|
||||||
|
h.handleConnection(msg)
|
||||||
|
case "transmitted-data":
|
||||||
|
data, _ := msg["data"].(map[string]interface{})
|
||||||
|
if data != nil {
|
||||||
|
if pid, ok := msg["participantId"].(float64); ok && h.remotePeerID == nil {
|
||||||
|
h.onRegisteredPeer(int64(pid))
|
||||||
|
}
|
||||||
|
h.onTransmittedData(data)
|
||||||
|
}
|
||||||
|
case "registered-peer":
|
||||||
|
if pid, ok := msg["participantId"].(float64); ok {
|
||||||
|
h.onRegisteredPeer(int64(pid))
|
||||||
|
}
|
||||||
|
case "topology-changed":
|
||||||
|
topo, _ := msg["topology"].(string)
|
||||||
|
h.logger.Debug(fmt.Sprintf("vk-joiner: topology: %s", topo))
|
||||||
|
if topo != "" && topo != vkTopologyDirect {
|
||||||
|
h.logger.Debug(fmt.Sprintf("vk-joiner: %s topology -> closing transport to reconnect and recover DIRECT", topo))
|
||||||
|
h.closeTransport()
|
||||||
|
}
|
||||||
|
case "participant-joined", "participant-added":
|
||||||
|
h.logger.Debug(fmt.Sprintf("vk-joiner: <- %s", notif))
|
||||||
|
case "participant-left":
|
||||||
|
h.logger.Debug(fmt.Sprintf("vk-joiner: <- %s", notif))
|
||||||
|
case "hungup":
|
||||||
|
h.logger.Debug("vk-joiner: peer hungup -> closing transport to reconnect")
|
||||||
|
h.closeTransport()
|
||||||
|
}
|
||||||
|
case "response":
|
||||||
|
seq, _ := msg["sequence"].(float64)
|
||||||
|
h.logger.Debug(fmt.Sprintf("vk-joiner: <- response seq=%d", int(seq)))
|
||||||
|
case "error":
|
||||||
|
errMsg, _ := msg["message"].(string)
|
||||||
|
errCode, _ := msg["error"].(string)
|
||||||
|
h.logger.Warn(fmt.Sprintf("vk-joiner: ERROR: %s %s", errCode, errMsg))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *VKJoiner) handleConnection(msg map[string]interface{}) {
|
||||||
|
if conv, ok := msg["conversation"].(map[string]interface{}); ok {
|
||||||
|
topo, _ := conv["topology"].(string)
|
||||||
|
h.logger.Debug(fmt.Sprintf("vk-joiner: connection topology=%q", topo))
|
||||||
|
}
|
||||||
|
convParams, ok := msg["conversationParams"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
turn, ok := convParams["turn"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
urlsRaw, _ := turn["urls"].([]interface{})
|
||||||
|
var urls []string
|
||||||
|
for _, u := range urlsRaw {
|
||||||
|
if s, ok := u.(string); ok {
|
||||||
|
urls = append(urls, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
username, _ := turn["username"].(string)
|
||||||
|
credential, _ := turn["credential"].(string)
|
||||||
|
h.joinResp.TurnServer.URLs = urls
|
||||||
|
h.joinResp.TurnServer.Username = username
|
||||||
|
h.joinResp.TurnServer.Credential = credential
|
||||||
|
h.logger.Debug(fmt.Sprintf("vk-joiner: TURN from connection: %v", urls))
|
||||||
|
if h.pc == nil {
|
||||||
|
h.initPC()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *VKJoiner) initPC() {
|
||||||
|
var iceServers []webrtc.ICEServer
|
||||||
|
if len(h.joinResp.StunServer.URLs) > 0 {
|
||||||
|
iceServers = append(iceServers, webrtc.ICEServer{URLs: h.joinResp.StunServer.URLs})
|
||||||
|
}
|
||||||
|
if len(h.joinResp.TurnServer.URLs) > 0 {
|
||||||
|
iceServers = append(iceServers, webrtc.ICEServer{
|
||||||
|
URLs: h.joinResp.TurnServer.URLs,
|
||||||
|
Username: h.joinResp.TurnServer.Username,
|
||||||
|
Credential: h.joinResp.TurnServer.Credential,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
mode := h.authParams.TunnelMode
|
||||||
|
settingEngine := webrtc.SettingEngine{}
|
||||||
|
settingEngine.DisableCloseByDTLS(true)
|
||||||
|
settingEngine.DetachDataChannels()
|
||||||
|
if h.PCConfig != nil {
|
||||||
|
h.PCConfig.ConfigureSettingEngine(&settingEngine)
|
||||||
|
}
|
||||||
|
pc, err := webrtc.NewAPI(webrtc.WithSettingEngine(settingEngine)).NewPeerConnection(webrtc.Configuration{
|
||||||
|
ICEServers: iceServers,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error(fmt.Sprintf("vk-joiner: failed to create PC: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.pc = pc
|
||||||
|
h.logger.Debug(fmt.Sprintf("vk-joiner: tunnel mode: %s", mode))
|
||||||
|
if mode == "video" {
|
||||||
|
h.sampleTrack = h.AddTracks(pc, h.logger, "vk-joiner")
|
||||||
|
}
|
||||||
|
negotiated := true
|
||||||
|
dcID := uint16(2)
|
||||||
|
dc, err := pc.CreateDataChannel("tunnel", &webrtc.DataChannelInit{
|
||||||
|
Negotiated: &negotiated,
|
||||||
|
ID: &dcID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Warn(fmt.Sprintf("vk-joiner: could not create tunnel DC: %v", err))
|
||||||
|
} else {
|
||||||
|
h.dc = dc
|
||||||
|
dc.OnOpen(func() {
|
||||||
|
h.logger.Debug("vk-joiner: tunnel DC open")
|
||||||
|
if mode == "dc" {
|
||||||
|
h.reconnectAttempt.Store(0)
|
||||||
|
h.logger.Info("vk-joiner: === DC TUNNEL CONNECTED ===")
|
||||||
|
if h.OnConnected != nil {
|
||||||
|
h.OnConnected(tunnel.NewDCTunnel(dc, h.obf, common.RTPBufSize, h.logger))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
dc.OnClose(func() {
|
||||||
|
h.logger.Debug("vk-joiner: tunnel DC closed")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
pc.OnICECandidate(func(candidate *webrtc.ICECandidate) {
|
||||||
|
if candidate == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.onLocalICECandidate(candidate)
|
||||||
|
})
|
||||||
|
pc.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
|
||||||
|
h.logger.Debug(fmt.Sprintf("vk-joiner: PC state: %s", state.String()))
|
||||||
|
if state == webrtc.PeerConnectionStateFailed || state == webrtc.PeerConnectionStateDisconnected {
|
||||||
|
h.logger.Debug(fmt.Sprintf("vk-joiner: PC %s, closing transport to trigger reconnect", state.String()))
|
||||||
|
h.closeTransport()
|
||||||
|
}
|
||||||
|
if mode == "video" && state == webrtc.PeerConnectionStateConnected && h.vp8tunnel == nil {
|
||||||
|
h.reconnectAttempt.Store(0)
|
||||||
|
h.logger.Info("vk-joiner: === TUNNEL CONNECTED ===")
|
||||||
|
h.vp8tunnel = tunnel.NewVP8DataTunnel(h.sampleTrack, h.obf, h.logger)
|
||||||
|
h.vp8tunnel.Start(h.vp8FPS, h.vp8Batch)
|
||||||
|
var downlink tunnel.DataTunnel = h.vp8tunnel
|
||||||
|
trackCount := 1
|
||||||
|
if h.dualTrack {
|
||||||
|
writer := tunnel.NewScreenWriter(h.obf, "screen-up", h.logger)
|
||||||
|
writer.Reconfigure(h.vp8tunnel.FPS(), h.vp8tunnel.Batch())
|
||||||
|
writer.SetSend(h.producerScreen.send)
|
||||||
|
h.sym = tunnel.NewSymmetricScreenTunnel(h.vp8tunnel, writer, h.obf, h.producerScreen.ready, h.logger)
|
||||||
|
h.sym.SetTrackCount(2)
|
||||||
|
downlink = h.sym
|
||||||
|
trackCount = 2
|
||||||
|
h.logger.Info("vk-joiner: === SYMMETRIC DUAL-TRACK: camera VP8 + screen DCs ===")
|
||||||
|
}
|
||||||
|
vp8tun := h.vp8tunnel
|
||||||
|
if !h.configAck.Acknowledged() {
|
||||||
|
acked, cancel := h.configAck.Arm()
|
||||||
|
go tunnel.SendVP8ConfigUntilAcked(acked, cancel, h.stopCh, vp8tun,
|
||||||
|
vp8tun.FPS(), vp8tun.Batch(), trackCount, h.logger, "vk-joiner")
|
||||||
|
h.logger.Debug(fmt.Sprintf("vk-joiner: pushed vp8 config to creator fps=%d batch=%d trackCount=%d", vp8tun.FPS(), vp8tun.Batch(), trackCount))
|
||||||
|
}
|
||||||
|
if h.OnConnected != nil {
|
||||||
|
h.OnConnected(downlink)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if mode == "video" {
|
||||||
|
pc.OnDataChannel(func(dc *webrtc.DataChannel) {
|
||||||
|
h.logger.Debug(fmt.Sprintf("vk-joiner: remote DataChannel: label=%q id=%v", dc.Label(), dc.ID()))
|
||||||
|
if !h.dualTrack {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch dc.Label() {
|
||||||
|
case "consumerScreenShare":
|
||||||
|
readScreenDataChannel(dc, func(frame []byte) {
|
||||||
|
if h.sym != nil {
|
||||||
|
h.sym.HandleScreenFrame(frame)
|
||||||
|
}
|
||||||
|
}, h.logger)
|
||||||
|
case "producerScreenShare":
|
||||||
|
attachScreenWriterDC(dc, h.producerScreen.attach, h.logger)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
pc.OnTrack(func(track *webrtc.TrackRemote, receiver *webrtc.RTPReceiver) {
|
||||||
|
h.logger.Debug(fmt.Sprintf("vk-joiner: remote track: codec=%s ssrc=%d", track.Codec().MimeType, track.SSRC()))
|
||||||
|
go h.ReadTrackFn(track, func(frame []byte) {
|
||||||
|
if h.vp8tunnel != nil {
|
||||||
|
h.vp8tunnel.HandleFrame(frame)
|
||||||
|
}
|
||||||
|
}, h.logger, "vk-joiner")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
h.logger.Debug("vk-joiner: PC ready, waiting for remote offer")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *VKJoiner) onRegisteredPeer(pid int64) {
|
||||||
|
h.remotePeerID = &pid
|
||||||
|
h.logger.Debug(fmt.Sprintf("vk-joiner: peer registered: %d", pid))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *VKJoiner) onLocalICECandidate(candidate *webrtc.ICECandidate) {
|
||||||
|
if h.remotePeerID == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
candidateJSON := candidate.ToJSON()
|
||||||
|
raw, _ := json.Marshal(candidateJSON)
|
||||||
|
var parsed interface{}
|
||||||
|
json.Unmarshal(raw, &parsed)
|
||||||
|
h.vkSendTransmitData(*h.remotePeerID, map[string]interface{}{"candidate": parsed})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *VKJoiner) onTransmittedData(data map[string]interface{}) {
|
||||||
|
if h.pc == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if candidate, ok := data["candidate"]; ok {
|
||||||
|
candidateJSON, _ := json.Marshal(candidate)
|
||||||
|
var candidateInit webrtc.ICECandidateInit
|
||||||
|
json.Unmarshal(candidateJSON, &candidateInit)
|
||||||
|
if h.OnRemoteCandidate != nil {
|
||||||
|
h.OnRemoteCandidate(0, candidateInit.Candidate)
|
||||||
|
}
|
||||||
|
if h.remoteSet {
|
||||||
|
h.pc.AddICECandidate(candidateInit)
|
||||||
|
} else {
|
||||||
|
h.pendingICE = append(h.pendingICE, candidateInit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sdp, ok := data["sdp"].(map[string]interface{}); ok {
|
||||||
|
sdpType, _ := sdp["type"].(string)
|
||||||
|
sdpStr, _ := sdp["sdp"].(string)
|
||||||
|
if h.OnRemoteCandidate != nil {
|
||||||
|
h.OnRemoteCandidate(-1, sdpStr)
|
||||||
|
}
|
||||||
|
h.logger.Debug(fmt.Sprintf("vk-joiner: remote SDP: %s", sdpType))
|
||||||
|
if sdpType == "answer" {
|
||||||
|
h.pc.SetRemoteDescription(webrtc.SessionDescription{Type: webrtc.SDPTypeAnswer, SDP: sdpStr})
|
||||||
|
h.remoteSet = true
|
||||||
|
for _, candidate := range h.pendingICE {
|
||||||
|
h.pc.AddICECandidate(candidate)
|
||||||
|
}
|
||||||
|
h.pendingICE = nil
|
||||||
|
} else if sdpType == "offer" {
|
||||||
|
h.pc.SetRemoteDescription(webrtc.SessionDescription{Type: webrtc.SDPTypeOffer, SDP: sdpStr})
|
||||||
|
h.remoteSet = true
|
||||||
|
for _, candidate := range h.pendingICE {
|
||||||
|
h.pc.AddICECandidate(candidate)
|
||||||
|
}
|
||||||
|
h.pendingICE = nil
|
||||||
|
answer, err := h.pc.CreateAnswer(nil)
|
||||||
|
if err != nil || h.remotePeerID == nil {
|
||||||
|
h.logger.Warn(fmt.Sprintf("vk-joiner: create answer failed: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.pc.SetLocalDescription(answer)
|
||||||
|
sdpJSON, _ := json.Marshal(answer.SDP)
|
||||||
|
h.vkMu.Lock()
|
||||||
|
if h.sfu != nil {
|
||||||
|
h.vkSeq++
|
||||||
|
raw := fmt.Sprintf(`{"command":"transmit-data","sequence":%d,"participantId":%d,"data":{"sdp":{"sdp":%s,"type":%q},"animojiVersion":2},"participantType":"USER"}`,
|
||||||
|
h.vkSeq, *h.remotePeerID, sdpJSON, answer.Type.String())
|
||||||
|
h.sfu.Send([]byte(raw))
|
||||||
|
h.logger.Debug(fmt.Sprintf("vk-joiner: -> answer (seq=%d)", h.vkSeq))
|
||||||
|
}
|
||||||
|
h.vkMu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectVKAuthRotten(raw []byte) *vkAuthRottenError {
|
||||||
|
var generic map[string]interface{}
|
||||||
|
if err := json.Unmarshal(raw, &generic); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
errCode, _ := generic["error_code"].(string)
|
||||||
|
if errCode == "" {
|
||||||
|
if codeNum, ok := generic["error_code"].(float64); ok {
|
||||||
|
errCode = fmt.Sprintf("%.0f", codeNum)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if errCode == "" {
|
||||||
|
errCode, _ = generic["errorCode"].(string)
|
||||||
|
}
|
||||||
|
if errCode == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch errCode {
|
||||||
|
case "SESSION_EXPIRED", "AUTH_LOGIN", "SESSION_NOT_FOUND", "INVALID_SESSION_KEY":
|
||||||
|
errMsg, _ := generic["error_msg"].(string)
|
||||||
|
return &vkAuthRottenError{Code: errCode, Msg: errMsg}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateBody(raw []byte) string {
|
||||||
|
const maxLen = 200
|
||||||
|
if len(raw) > maxLen {
|
||||||
|
return string(raw[:maxLen]) + "..."
|
||||||
|
}
|
||||||
|
return string(raw)
|
||||||
|
}
|
||||||
194
transport/call/vk/p2p.go
Normal file
194
transport/call/vk/p2p.go
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
package vk
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
"github.com/pion/webrtc/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
type P2PHandler struct {
|
||||||
|
bridge *Bridge
|
||||||
|
remotePeerId *int64
|
||||||
|
pendingOffer *webrtc.SessionDescription
|
||||||
|
pendingCandidates []webrtc.ICECandidateInit
|
||||||
|
connected bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewP2PHandler(bridge *Bridge) *P2PHandler {
|
||||||
|
return &P2PHandler{bridge: bridge}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *P2PHandler) Init() {
|
||||||
|
relay := p.bridge.relay
|
||||||
|
if err := relay.Init(p.bridge.iceServers); err != nil {
|
||||||
|
p.bridge.logger.Error(fmt.Sprintf("[p2p] Init relay failed: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.setupCallbacks()
|
||||||
|
offer, err := relay.CreateOffer()
|
||||||
|
if err != nil {
|
||||||
|
p.bridge.logger.Error(fmt.Sprintf("[p2p] Create offer failed: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.pendingOffer = &offer
|
||||||
|
p.bridge.logger.Debug(fmt.Sprintf("[p2p] Offer ready, SDP length: %d", len(offer.SDP)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *P2PHandler) Reset() {
|
||||||
|
p.bridge.logger.Debug("[p2p] Resetting Pion PC...")
|
||||||
|
p.connected = false
|
||||||
|
p.bridge.relay.Close()
|
||||||
|
p.bridge.relay = p.bridge.newRelay()
|
||||||
|
relay := p.bridge.relay
|
||||||
|
if err := relay.Init(p.bridge.iceServers); err != nil {
|
||||||
|
p.bridge.logger.Warn(fmt.Sprintf("[p2p] Reset init failed: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.setupCallbacks()
|
||||||
|
offer, err := relay.CreateOffer()
|
||||||
|
if err != nil {
|
||||||
|
p.bridge.logger.Warn(fmt.Sprintf("[p2p] Reset create-offer failed: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.pendingOffer = &offer
|
||||||
|
p.pendingCandidates = nil
|
||||||
|
p.bridge.logger.Debug(fmt.Sprintf("[p2p] New offer ready after reset, SDP length: %d", len(offer.SDP)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *P2PHandler) OnRegisteredPeer(participantId int64) {
|
||||||
|
oldPeer := p.remotePeerId
|
||||||
|
p.remotePeerId = &participantId
|
||||||
|
p.bridge.logger.Debug(fmt.Sprintf("[p2p] Peer registered: %d (connected=%v)", participantId, p.connected))
|
||||||
|
if oldPeer != nil && (p.pendingOffer == nil) {
|
||||||
|
if *oldPeer != participantId {
|
||||||
|
p.bridge.logger.Debug(fmt.Sprintf("[p2p] New peer %d replacing old peer %d, resetting", participantId, *oldPeer))
|
||||||
|
} else {
|
||||||
|
p.bridge.logger.Debug(fmt.Sprintf("[p2p] Same peer %d re-registered, no pending offer, resetting", participantId))
|
||||||
|
}
|
||||||
|
p.Reset()
|
||||||
|
}
|
||||||
|
p.sendOfferToPeer(participantId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *P2PHandler) OnTransmittedData(data map[string]interface{}) {
|
||||||
|
if cand, ok := data["candidate"]; ok {
|
||||||
|
p.bridge.logger.Debug("[p2p] Remote ICE candidate")
|
||||||
|
candJSON, _ := json.Marshal(cand)
|
||||||
|
var candInit webrtc.ICECandidateInit
|
||||||
|
json.Unmarshal(candJSON, &candInit)
|
||||||
|
p.bridge.relay.AddICECandidate(candInit)
|
||||||
|
}
|
||||||
|
if sdp, ok := data["sdp"].(map[string]interface{}); ok {
|
||||||
|
sdpType, _ := sdp["type"].(string)
|
||||||
|
sdpStr, _ := sdp["sdp"].(string)
|
||||||
|
p.bridge.logger.Debug(fmt.Sprintf("[p2p] Remote SDP: %s", sdpType))
|
||||||
|
if sdpType == "answer" {
|
||||||
|
p.bridge.relay.SetRemoteDescription(webrtc.SDPTypeAnswer, sdpStr)
|
||||||
|
} else if sdpType == "offer" {
|
||||||
|
p.bridge.relay.SetRemoteDescription(webrtc.SDPTypeOffer, sdpStr)
|
||||||
|
answer, err := p.bridge.relay.CreateAnswer()
|
||||||
|
if err == nil && p.remotePeerId != nil {
|
||||||
|
p.bridge.vkSend("transmit-data", map[string]interface{}{
|
||||||
|
"participantId": *p.remotePeerId,
|
||||||
|
"data": map[string]interface{}{"sdp": map[string]interface{}{
|
||||||
|
"type": answer.Type.String(), "sdp": answer.SDP,
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *P2PHandler) OnPionICECandidate(data json.RawMessage) {
|
||||||
|
if p.remotePeerId != nil {
|
||||||
|
var cand interface{}
|
||||||
|
json.Unmarshal(data, &cand)
|
||||||
|
p.bridge.vkSend("transmit-data", map[string]interface{}{
|
||||||
|
"participantId": *p.remotePeerId,
|
||||||
|
"data": map[string]interface{}{"candidate": cand},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
var candInit webrtc.ICECandidateInit
|
||||||
|
json.Unmarshal(data, &candInit)
|
||||||
|
p.pendingCandidates = append(p.pendingCandidates, candInit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *P2PHandler) OnConnectionState(state string) {
|
||||||
|
switch state {
|
||||||
|
case "connected":
|
||||||
|
p.connected = true
|
||||||
|
p.bridge.logger.Info("TUNNEL CONNECTED")
|
||||||
|
case "disconnected":
|
||||||
|
p.connected = false
|
||||||
|
p.bridge.logger.Debug("[p2p] Connection disconnected, kicking peer")
|
||||||
|
p.kickRemotePeer()
|
||||||
|
case "failed":
|
||||||
|
p.connected = false
|
||||||
|
p.bridge.logger.Debug("[p2p] Connection failed, removing stale peer")
|
||||||
|
p.kickRemotePeer()
|
||||||
|
case "closed":
|
||||||
|
p.connected = false
|
||||||
|
p.bridge.logger.Debug("[p2p] Connection closed, kicking peer")
|
||||||
|
p.kickRemotePeer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *P2PHandler) setupCallbacks() {
|
||||||
|
p.bridge.relay.OnICECandidate(func(cand *webrtc.ICECandidate) {
|
||||||
|
if cand == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.bridge.logger.Debug(fmt.Sprintf("[p2p] ICE candidate: type=%s proto=%s", cand.Typ.String(), cand.Protocol.String()))
|
||||||
|
candJSON := cand.ToJSON()
|
||||||
|
raw, _ := json.Marshal(candJSON)
|
||||||
|
p.OnPionICECandidate(raw)
|
||||||
|
})
|
||||||
|
p.bridge.relay.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
|
||||||
|
p.OnConnectionState(state.String())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *P2PHandler) kickRemotePeer() {
|
||||||
|
if p.remotePeerId != nil {
|
||||||
|
p.bridge.vkSend("remove-participant", map[string]interface{}{
|
||||||
|
"participantId": *p.remotePeerId,
|
||||||
|
"ban": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *P2PHandler) sendOfferToPeer(participantId int64) {
|
||||||
|
offer := p.pendingOffer
|
||||||
|
candidates := p.pendingCandidates
|
||||||
|
p.pendingOffer = nil
|
||||||
|
p.pendingCandidates = nil
|
||||||
|
if offer != nil {
|
||||||
|
p.bridge.logger.Debug(fmt.Sprintf("[p2p] Sending offer to peer %d", participantId))
|
||||||
|
sdpStr, _ := json.Marshal(offer.SDP)
|
||||||
|
p.bridge.mu.Lock()
|
||||||
|
p.bridge.vkSeq++
|
||||||
|
seq := p.bridge.vkSeq
|
||||||
|
raw := fmt.Sprintf(`{"command":"transmit-data","sequence":%d,"participantId":%d,"data":{"sdp":{"type":%q,"sdp":%s}}}`,
|
||||||
|
seq, participantId, offer.Type.String(), sdpStr)
|
||||||
|
if p.bridge.vkWs != nil {
|
||||||
|
p.bridge.vkWs.WriteMessage(websocket.TextMessage, []byte(raw))
|
||||||
|
}
|
||||||
|
p.bridge.mu.Unlock()
|
||||||
|
p.bridge.logger.Debug("[vk-ws] -> transmit-data (offer)")
|
||||||
|
}
|
||||||
|
for _, cand := range candidates {
|
||||||
|
candJSON, _ := json.Marshal(cand)
|
||||||
|
var c interface{}
|
||||||
|
json.Unmarshal(candJSON, &c)
|
||||||
|
p.bridge.vkSend("transmit-data", map[string]interface{}{
|
||||||
|
"participantId": participantId,
|
||||||
|
"data": map[string]interface{}{"candidate": c},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(candidates) > 0 {
|
||||||
|
p.bridge.logger.Debug(fmt.Sprintf("[p2p] Flushed %d ICE candidates", len(candidates)))
|
||||||
|
}
|
||||||
|
}
|
||||||
468
transport/call/vk/relay.go
Normal file
468
transport/call/vk/relay.go
Normal file
@@ -0,0 +1,468 @@
|
|||||||
|
package vk
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pion/rtp"
|
||||||
|
"github.com/pion/rtp/codecs"
|
||||||
|
"github.com/pion/webrtc/v4"
|
||||||
|
"github.com/sagernet/sing-box/transport/call/common"
|
||||||
|
"github.com/sagernet/sing-box/transport/call/tunnel"
|
||||||
|
"github.com/sagernet/sing/common/logger"
|
||||||
|
M "github.com/sagernet/sing/common/metadata"
|
||||||
|
N "github.com/sagernet/sing/common/network"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Relay interface {
|
||||||
|
Init(iceServers []webrtc.ICEServer) error
|
||||||
|
CreateOffer() (webrtc.SessionDescription, error)
|
||||||
|
CreateAnswer() (webrtc.SessionDescription, error)
|
||||||
|
SetRemoteDescription(sdpType webrtc.SDPType, sdp string) error
|
||||||
|
AddICECandidate(candidate webrtc.ICECandidateInit) error
|
||||||
|
OnICECandidate(fn func(*webrtc.ICECandidate))
|
||||||
|
OnConnectionStateChange(fn func(webrtc.PeerConnectionState))
|
||||||
|
Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
type dcConn struct {
|
||||||
|
conn net.Conn
|
||||||
|
ch chan []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
type TunnelRelay struct {
|
||||||
|
pc *webrtc.PeerConnection
|
||||||
|
remoteSet bool
|
||||||
|
pending []webrtc.ICECandidateInit
|
||||||
|
externalICE func(*webrtc.ICECandidate)
|
||||||
|
externalCSC func(webrtc.PeerConnectionState)
|
||||||
|
|
||||||
|
dc *webrtc.DataChannel
|
||||||
|
dcMu sync.Mutex
|
||||||
|
conns sync.Map
|
||||||
|
|
||||||
|
sampleTrack *webrtc.TrackLocalStaticSample
|
||||||
|
tun *tunnel.VP8DataTunnel
|
||||||
|
obf *tunnel.TunnelObfuscator
|
||||||
|
OnConnected func(tunnel.DataTunnel)
|
||||||
|
|
||||||
|
screenDC *webrtc.DataChannel
|
||||||
|
producerScreen *webrtc.DataChannel
|
||||||
|
sym *tunnel.SymmetricScreenTunnel
|
||||||
|
|
||||||
|
dialer N.Dialer
|
||||||
|
readBufSize int
|
||||||
|
logger logger.ContextLogger
|
||||||
|
|
||||||
|
mode string
|
||||||
|
modeOnce sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTunnelRelay(dialer N.Dialer, logger logger.ContextLogger) *TunnelRelay {
|
||||||
|
return &TunnelRelay{mode: "unknown", dialer: dialer, logger: logger}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *TunnelRelay) SetObfuscator(o *tunnel.TunnelObfuscator) { u.obf = o }
|
||||||
|
|
||||||
|
func (u *TunnelRelay) Init(iceServers []webrtc.ICEServer) error {
|
||||||
|
pc, err := webrtc.NewPeerConnection(webrtc.Configuration{ICEServers: iceServers})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u.pc = pc
|
||||||
|
negotiated := true
|
||||||
|
dcID := uint16(2)
|
||||||
|
dc, err := pc.CreateDataChannel("tunnel", &webrtc.DataChannelInit{
|
||||||
|
Negotiated: &negotiated,
|
||||||
|
ID: &dcID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
u.logger.Warn(fmt.Sprintf("[relay] could not create tunnel DC: %v", err))
|
||||||
|
} else {
|
||||||
|
u.dc = dc
|
||||||
|
dc.OnOpen(func() {
|
||||||
|
u.logger.Debug(fmt.Sprintf("[relay] tunnel DC open (readyState=%v)", dc.ReadyState()))
|
||||||
|
})
|
||||||
|
dc.OnClose(func() {
|
||||||
|
u.logger.Debug("[relay] tunnel DC closed")
|
||||||
|
if u.mode == "dc" {
|
||||||
|
u.closeAllConns()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
dc.OnMessage(func(msg webrtc.DataChannelMessage) {
|
||||||
|
u.modeOnce.Do(func() {
|
||||||
|
u.mode = "dc"
|
||||||
|
u.logger.Info("[relay] === MODE: DC ===")
|
||||||
|
})
|
||||||
|
u.handleDCMessage(msg.Data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sampleTrack, _ := webrtc.NewTrackLocalStaticSample(
|
||||||
|
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeVP8},
|
||||||
|
"video", "tunnel-video",
|
||||||
|
)
|
||||||
|
u.sampleTrack = sampleTrack
|
||||||
|
audioTrack, _ := webrtc.NewTrackLocalStaticRTP(
|
||||||
|
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeOpus},
|
||||||
|
"audio", "tunnel-audio",
|
||||||
|
)
|
||||||
|
pc.AddTrack(audioTrack)
|
||||||
|
pc.AddTrack(sampleTrack)
|
||||||
|
ordered := true
|
||||||
|
dcNotif, err := pc.CreateDataChannel("producerNotification", &webrtc.DataChannelInit{Ordered: &ordered})
|
||||||
|
if err == nil {
|
||||||
|
dcNotif.OnOpen(func() { u.logger.Debug("[relay] producerNotification DC opened") })
|
||||||
|
dcNotif.OnMessage(func(msg webrtc.DataChannelMessage) {
|
||||||
|
u.logger.Debug(fmt.Sprintf("[relay] producerNotification msg len=%d", len(msg.Data)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
dcCmd, err := pc.CreateDataChannel("producerCommand", &webrtc.DataChannelInit{Ordered: &ordered})
|
||||||
|
if err == nil {
|
||||||
|
dcCmd.OnOpen(func() { u.logger.Debug("[relay] producerCommand DC opened") })
|
||||||
|
dcCmd.OnMessage(func(msg webrtc.DataChannelMessage) {
|
||||||
|
u.logger.Debug(fmt.Sprintf("[relay] producerCommand msg len=%d", len(msg.Data)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
producerScreen, psErr := pc.CreateDataChannel("producerScreenShare", &webrtc.DataChannelInit{Ordered: &ordered})
|
||||||
|
if psErr == nil {
|
||||||
|
u.producerScreen = producerScreen
|
||||||
|
producerScreen.OnOpen(func() { u.logger.Debug("[relay] producerScreenShare DC open, reading uplink screen") })
|
||||||
|
producerScreen.OnMessage(func(msg webrtc.DataChannelMessage) {
|
||||||
|
if u.sym != nil {
|
||||||
|
u.sym.HandleScreenFrame(msg.Data)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
screenDC, scErr := pc.CreateDataChannel("consumerScreenShare", &webrtc.DataChannelInit{Ordered: &ordered})
|
||||||
|
if scErr == nil {
|
||||||
|
u.screenDC = screenDC
|
||||||
|
screenDC.OnOpen(func() { u.logger.Debug("[relay] consumerScreenShare DC open, writing downlink screen") })
|
||||||
|
}
|
||||||
|
pc.OnICECandidate(func(cand *webrtc.ICECandidate) {
|
||||||
|
if cand == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if u.externalICE != nil {
|
||||||
|
u.externalICE(cand)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
pc.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
|
||||||
|
u.logger.Debug(fmt.Sprintf("[relay] connection state: %s (mode=%s)", state.String(), u.mode))
|
||||||
|
if u.externalCSC != nil {
|
||||||
|
u.externalCSC(state)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
pc.OnTrack(func(track *webrtc.TrackRemote, receiver *webrtc.RTPReceiver) {
|
||||||
|
u.logger.Debug(fmt.Sprintf("[relay] remote track: %s", track.Codec().MimeType))
|
||||||
|
u.modeOnce.Do(func() {
|
||||||
|
u.mode = "video"
|
||||||
|
u.logger.Info("[relay] === MODE: VIDEO ===")
|
||||||
|
u.tun = tunnel.NewVP8DataTunnel(sampleTrack, u.obf, u.logger)
|
||||||
|
u.tun.Start(0, 0)
|
||||||
|
var downlink tunnel.DataTunnel = u.tun
|
||||||
|
if u.screenDC != nil {
|
||||||
|
writer := tunnel.NewScreenWriter(u.obf, "screen-down", u.logger)
|
||||||
|
dc := u.screenDC
|
||||||
|
writer.SetSend(dc.Send)
|
||||||
|
u.sym = tunnel.NewSymmetricScreenTunnel(u.tun, writer, u.obf, func() bool {
|
||||||
|
return dc.ReadyState() == webrtc.DataChannelStateOpen
|
||||||
|
}, u.logger)
|
||||||
|
downlink = u.sym
|
||||||
|
u.logger.Info("[relay] === MODE: VIDEO (with screenshare) ===")
|
||||||
|
}
|
||||||
|
if u.OnConnected != nil {
|
||||||
|
u.OnConnected(downlink)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
go u.readTrack(track)
|
||||||
|
})
|
||||||
|
u.logger.Debug(fmt.Sprintf("[relay] PC created (%d ICE servers)", len(iceServers)))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *TunnelRelay) CreateOffer() (webrtc.SessionDescription, error) {
|
||||||
|
offer, err := u.pc.CreateOffer(nil)
|
||||||
|
if err != nil {
|
||||||
|
return offer, err
|
||||||
|
}
|
||||||
|
u.pc.SetLocalDescription(offer)
|
||||||
|
return offer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *TunnelRelay) CreateAnswer() (webrtc.SessionDescription, error) {
|
||||||
|
answer, err := u.pc.CreateAnswer(nil)
|
||||||
|
if err != nil {
|
||||||
|
return answer, err
|
||||||
|
}
|
||||||
|
u.pc.SetLocalDescription(answer)
|
||||||
|
return answer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *TunnelRelay) SetRemoteDescription(sdpType webrtc.SDPType, sdp string) error {
|
||||||
|
err := u.pc.SetRemoteDescription(webrtc.SessionDescription{Type: sdpType, SDP: sdp})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u.remoteSet = true
|
||||||
|
for _, cand := range u.pending {
|
||||||
|
u.pc.AddICECandidate(cand)
|
||||||
|
}
|
||||||
|
u.pending = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *TunnelRelay) AddICECandidate(candidate webrtc.ICECandidateInit) error {
|
||||||
|
if !u.remoteSet {
|
||||||
|
u.pending = append(u.pending, candidate)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return u.pc.AddICECandidate(candidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *TunnelRelay) OnICECandidate(fn func(*webrtc.ICECandidate)) {
|
||||||
|
u.externalICE = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *TunnelRelay) OnConnectionStateChange(fn func(webrtc.PeerConnectionState)) {
|
||||||
|
u.externalCSC = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *TunnelRelay) Close() {
|
||||||
|
u.closeAllConns()
|
||||||
|
if u.sym != nil {
|
||||||
|
u.sym.Stop()
|
||||||
|
u.sym = nil
|
||||||
|
}
|
||||||
|
if u.tun != nil {
|
||||||
|
u.tun.Stop()
|
||||||
|
u.tun = nil
|
||||||
|
}
|
||||||
|
u.dcMu.Lock()
|
||||||
|
u.dc = nil
|
||||||
|
u.dcMu.Unlock()
|
||||||
|
if u.pc != nil {
|
||||||
|
u.pc.OnConnectionStateChange(nil)
|
||||||
|
u.pc.OnICECandidate(nil)
|
||||||
|
u.pc.OnTrack(nil)
|
||||||
|
oldPC := u.pc
|
||||||
|
u.pc = nil
|
||||||
|
go oldPC.Close()
|
||||||
|
}
|
||||||
|
u.remoteSet = false
|
||||||
|
u.pending = nil
|
||||||
|
u.sampleTrack = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *TunnelRelay) handleDCMessage(data []byte) {
|
||||||
|
if u.obf != nil {
|
||||||
|
pt, ok := u.obf.DecryptPayload(data)
|
||||||
|
if !ok {
|
||||||
|
u.logger.Debug(fmt.Sprintf("[dc] decrypt failed, dropping %d bytes", len(data)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data = pt
|
||||||
|
}
|
||||||
|
if len(data) < 5 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
connID := binary.BigEndian.Uint32(data[0:4])
|
||||||
|
mt := data[4]
|
||||||
|
payload := data[5:]
|
||||||
|
switch mt {
|
||||||
|
case tunnel.MsgConnect:
|
||||||
|
go u.connectTCP(connID, string(payload))
|
||||||
|
case tunnel.MsgUDP:
|
||||||
|
go u.handleUDP(connID, payload)
|
||||||
|
case tunnel.MsgData:
|
||||||
|
val, ok := u.conns.Load(connID)
|
||||||
|
if ok {
|
||||||
|
dc := val.(*dcConn)
|
||||||
|
cp := make([]byte, len(payload))
|
||||||
|
copy(cp, payload)
|
||||||
|
select {
|
||||||
|
case dc.ch <- cp:
|
||||||
|
default:
|
||||||
|
u.logger.Debug(fmt.Sprintf("[dc] conn %d write queue full, dropping %d bytes", connID, len(payload)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case tunnel.MsgClose:
|
||||||
|
val, ok := u.conns.LoadAndDelete(connID)
|
||||||
|
if ok {
|
||||||
|
dc := val.(*dcConn)
|
||||||
|
close(dc.ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *TunnelRelay) sendDCFrame(connID uint32, mt byte, payload []byte) {
|
||||||
|
u.dcMu.Lock()
|
||||||
|
defer u.dcMu.Unlock()
|
||||||
|
if u.dc == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
buf := make([]byte, 5+len(payload))
|
||||||
|
binary.BigEndian.PutUint32(buf[0:4], connID)
|
||||||
|
buf[4] = mt
|
||||||
|
copy(buf[5:], payload)
|
||||||
|
wire := buf
|
||||||
|
if u.obf != nil {
|
||||||
|
wire = u.obf.EncryptPayload(buf)
|
||||||
|
if wire == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
u.dc.Send(wire)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *TunnelRelay) connectTCP(connID uint32, addr string) {
|
||||||
|
u.logger.Debug(fmt.Sprintf("[dc] CONNECT %d -> %s", connID, common.MaskAddr(addr)))
|
||||||
|
conn, err := u.dialTCP(addr)
|
||||||
|
if err != nil {
|
||||||
|
u.logger.Warn(fmt.Sprintf("[dc] CONNECT %d failed: %s", connID, common.MaskError(err)))
|
||||||
|
u.sendDCFrame(connID, tunnel.MsgConnectErr, []byte(common.MaskError(err)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dc := &dcConn{conn: conn, ch: make(chan []byte, 256)}
|
||||||
|
u.conns.Store(connID, dc)
|
||||||
|
u.sendDCFrame(connID, tunnel.MsgConnectOK, nil)
|
||||||
|
u.logger.Debug(fmt.Sprintf("[dc] CONNECTED %d -> %s", connID, common.MaskAddr(addr)))
|
||||||
|
go func() {
|
||||||
|
for data := range dc.ch {
|
||||||
|
conn.Write(data)
|
||||||
|
}
|
||||||
|
conn.Close()
|
||||||
|
}()
|
||||||
|
bufSz := u.readBufSize
|
||||||
|
if bufSz <= 0 {
|
||||||
|
bufSz = common.RTPBufSize
|
||||||
|
}
|
||||||
|
buf := make([]byte, bufSz)
|
||||||
|
sent := 0
|
||||||
|
for {
|
||||||
|
n, err := conn.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
u.sendDCFrame(connID, tunnel.MsgData, buf[:n])
|
||||||
|
sent += n
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if err != io.EOF {
|
||||||
|
u.logger.Warn(fmt.Sprintf("[dc] conn %d read error: %s", connID, common.MaskError(err)))
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
u.logger.Debug(fmt.Sprintf("[dc] conn %d closed, sent %d bytes", connID, sent))
|
||||||
|
u.sendDCFrame(connID, tunnel.MsgClose, nil)
|
||||||
|
u.conns.Delete(connID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *TunnelRelay) handleUDP(connID uint32, payload []byte) {
|
||||||
|
if len(payload) < 2 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
addrLen := int(payload[0])
|
||||||
|
if len(payload) < 1+addrLen {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
addr := string(payload[1 : 1+addrLen])
|
||||||
|
data := payload[1+addrLen:]
|
||||||
|
conn, err := u.dialUDP(addr)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
conn.SetDeadline(time.Now().Add(5 * time.Second))
|
||||||
|
conn.Write(data)
|
||||||
|
resp := make([]byte, common.UDPBufSize)
|
||||||
|
n, err := conn.Read(resp)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u.sendDCFrame(connID, tunnel.MsgUDPReply, resp[:n])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *TunnelRelay) dialTCP(addr string) (net.Conn, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return u.dialer.DialContext(ctx, N.NetworkTCP, M.ParseSocksaddr(addr))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *TunnelRelay) dialUDP(addr string) (net.Conn, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return u.dialer.DialContext(ctx, N.NetworkUDP, M.ParseSocksaddr(addr))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *TunnelRelay) closeAllConns() {
|
||||||
|
u.conns.Range(func(key, val any) bool {
|
||||||
|
dc := val.(*dcConn)
|
||||||
|
dc.conn.Close()
|
||||||
|
u.conns.Delete(key)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *TunnelRelay) readTrack(track *webrtc.TrackRemote) {
|
||||||
|
if track.Codec().MimeType != webrtc.MimeTypeVP8 {
|
||||||
|
buf := make([]byte, common.UDPBufSize)
|
||||||
|
for {
|
||||||
|
if _, _, err := track.Read(buf); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var vp8Pkt codecs.VP8Packet
|
||||||
|
var pkt rtp.Packet
|
||||||
|
var frameBuf []byte
|
||||||
|
var lastSeq uint16
|
||||||
|
var haveLastSeq bool
|
||||||
|
frameValid := false
|
||||||
|
var recvCount int
|
||||||
|
buf := make([]byte, common.RTPBufSize)
|
||||||
|
for {
|
||||||
|
n, _, err := track.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if pkt.Unmarshal(buf[:n]) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if haveLastSeq && pkt.SequenceNumber != lastSeq+1 {
|
||||||
|
frameValid = false
|
||||||
|
frameBuf = frameBuf[:0]
|
||||||
|
}
|
||||||
|
lastSeq = pkt.SequenceNumber
|
||||||
|
haveLastSeq = true
|
||||||
|
vp8Payload, err := vp8Pkt.Unmarshal(pkt.Payload)
|
||||||
|
if err != nil {
|
||||||
|
frameValid = false
|
||||||
|
frameBuf = frameBuf[:0]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if vp8Pkt.S == 1 {
|
||||||
|
frameBuf = frameBuf[:0]
|
||||||
|
frameValid = true
|
||||||
|
}
|
||||||
|
if !frameValid {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
frameBuf = append(frameBuf, vp8Payload...)
|
||||||
|
if !pkt.Marker {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
recvCount++
|
||||||
|
if recvCount <= 3 || recvCount%200 == 0 {
|
||||||
|
u.logger.Debug(fmt.Sprintf("[video] recv vp8 frame #%d %d bytes", recvCount, len(frameBuf)))
|
||||||
|
}
|
||||||
|
if u.tun != nil {
|
||||||
|
u.tun.HandleFrame(frameBuf)
|
||||||
|
}
|
||||||
|
frameBuf = frameBuf[:0]
|
||||||
|
frameValid = false
|
||||||
|
}
|
||||||
|
}
|
||||||
101
transport/call/vk/screen_datachannels.go
Normal file
101
transport/call/vk/screen_datachannels.go
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
package vk
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/pion/datachannel"
|
||||||
|
"github.com/pion/webrtc/v4"
|
||||||
|
"github.com/sagernet/sing/common/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
var errScreenNotReady = errors.New("screen DC not ready")
|
||||||
|
|
||||||
|
type screenUplink struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
raw io.WriteCloser
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *screenUplink) attach(raw io.WriteCloser) {
|
||||||
|
u.mu.Lock()
|
||||||
|
u.raw = raw
|
||||||
|
u.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *screenUplink) ready() bool {
|
||||||
|
u.mu.Lock()
|
||||||
|
defer u.mu.Unlock()
|
||||||
|
return u.raw != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *screenUplink) send(b []byte) error {
|
||||||
|
u.mu.Lock()
|
||||||
|
raw := u.raw
|
||||||
|
u.mu.Unlock()
|
||||||
|
if raw == nil {
|
||||||
|
return errScreenNotReady
|
||||||
|
}
|
||||||
|
_, err := raw.Write(b)
|
||||||
|
if err != nil {
|
||||||
|
u.mu.Lock()
|
||||||
|
if u.raw == raw {
|
||||||
|
u.raw = nil
|
||||||
|
}
|
||||||
|
u.mu.Unlock()
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *screenUplink) reset() {
|
||||||
|
u.mu.Lock()
|
||||||
|
if u.raw != nil {
|
||||||
|
u.raw.Close()
|
||||||
|
u.raw = nil
|
||||||
|
}
|
||||||
|
u.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func readScreenDataChannel(dc *webrtc.DataChannel, handler func([]byte), logger logger.ContextLogger) {
|
||||||
|
dc.OnOpen(func() {
|
||||||
|
var raw datachannel.ReadWriteCloser
|
||||||
|
raw, err := dc.Detach()
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("[vk-joiner] screen DC detach failed, using OnMessage")
|
||||||
|
dc.OnMessage(func(m webrtc.DataChannelMessage) {
|
||||||
|
if !m.IsString && len(m.Data) > 0 {
|
||||||
|
frame := make([]byte, len(m.Data))
|
||||||
|
copy(frame, m.Data)
|
||||||
|
handler(frame)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.Debug("[vk-joiner] screen DC attached for reading")
|
||||||
|
buf := make([]byte, 65536)
|
||||||
|
for {
|
||||||
|
n, isString, rerr := raw.ReadDataChannel(buf)
|
||||||
|
if rerr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if isString || n == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
frame := make([]byte, n)
|
||||||
|
copy(frame, buf[:n])
|
||||||
|
handler(frame)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func attachScreenWriterDC(dc *webrtc.DataChannel, onRaw func(io.WriteCloser), logger logger.ContextLogger) {
|
||||||
|
dc.OnOpen(func() {
|
||||||
|
raw, err := dc.Detach()
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("[vk-joiner] screen writer DC detach failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.Debug("[vk-joiner] screen DC attached for writing")
|
||||||
|
onRaw(raw)
|
||||||
|
})
|
||||||
|
}
|
||||||
244
transport/call/vk/vk_auth.go
Normal file
244
transport/call/vk/vk_auth.go
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
package vk
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math/rand"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sagernet/sing-box/transport/call/common"
|
||||||
|
"github.com/sagernet/sing/common/logger"
|
||||||
|
N "github.com/sagernet/sing/common/network"
|
||||||
|
)
|
||||||
|
|
||||||
|
type vkAuthConfig struct {
|
||||||
|
AppID string `json:"appID"`
|
||||||
|
ApiVersion string `json:"apiVersion"`
|
||||||
|
AppVersion string `json:"appVersion"`
|
||||||
|
ProtocolVersion string `json:"protocolVersion"`
|
||||||
|
PublicKey string `json:"publicKey,omitempty"`
|
||||||
|
OkJoinLink string `json:"okJoinLink,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type vkCaptchaError struct {
|
||||||
|
captchaSid string
|
||||||
|
redirectURI string
|
||||||
|
captchaTs string
|
||||||
|
captchaAttempt string
|
||||||
|
}
|
||||||
|
|
||||||
|
func RunVKAuth(dialer N.Dialer, joinLink, displayName string, logger logger.ContextLogger) (string, error) {
|
||||||
|
client := common.HttpClient(dialer)
|
||||||
|
httpPost := func(targetURL string, form url.Values, extraHeaders map[string]string) (map[string]interface{}, error) {
|
||||||
|
req, _ := http.NewRequest("POST", targetURL, strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("User-Agent", common.UserAgent)
|
||||||
|
req.Header.Set("Origin", "https://vk.ru")
|
||||||
|
req.Header.Set("Referer", "https://vk.ru/")
|
||||||
|
for k, v := range extraHeaders {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var result map[string]interface{}
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("json: %w (body: %s)", err, string(body[:minInt(len(body), 200)]))
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
cfg := &vkAuthConfig{
|
||||||
|
AppID: "6287487",
|
||||||
|
ApiVersion: "5.282",
|
||||||
|
AppVersion: "1.1",
|
||||||
|
ProtocolVersion: "5",
|
||||||
|
}
|
||||||
|
logger.Debug(fmt.Sprintf("vk-auth: appID=%s api=%s appVersion=%s proto=%s", cfg.AppID, cfg.ApiVersion, cfg.AppVersion, cfg.ProtocolVersion))
|
||||||
|
logger.Info("vk-auth: getting anonymous token")
|
||||||
|
anonResp, err := httpPost("https://login.vk.ru/?act=get_anonym_token", url.Values{
|
||||||
|
"client_id": {cfg.AppID},
|
||||||
|
}, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("get_anonym_token: %w", err)
|
||||||
|
}
|
||||||
|
dataMap, _ := anonResp["data"].(map[string]interface{})
|
||||||
|
accessToken, _ := dataMap["access_token"].(string)
|
||||||
|
if accessToken == "" {
|
||||||
|
return "", fmt.Errorf("empty access_token: %v", anonResp)
|
||||||
|
}
|
||||||
|
logger.Debug("vk-auth: anon token OK")
|
||||||
|
auth := map[string]string{"Authorization": "Bearer " + accessToken}
|
||||||
|
logger.Info("vk-auth: getting call settings")
|
||||||
|
settingsResp, err := httpPost("https://api.vk.ru/method/calls.getSettings", url.Values{
|
||||||
|
"v": {cfg.ApiVersion},
|
||||||
|
}, auth)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("calls.getSettings: %w", err)
|
||||||
|
}
|
||||||
|
if respObj, ok := settingsResp["response"].(map[string]interface{}); ok {
|
||||||
|
if settings, ok := respObj["settings"].(map[string]interface{}); ok {
|
||||||
|
if pk, ok := settings["public_key"].(string); ok {
|
||||||
|
cfg.PublicKey = pk
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.Debug(fmt.Sprintf("vk-auth: publicKey=%s", cfg.PublicKey))
|
||||||
|
logger.Info("vk-auth: getting call preview")
|
||||||
|
previewResp, err := httpPost("https://api.vk.ru/method/calls.getCallPreview", url.Values{
|
||||||
|
"v": {cfg.ApiVersion},
|
||||||
|
"vk_join_link": {joinLink},
|
||||||
|
}, auth)
|
||||||
|
if err == nil {
|
||||||
|
if respObj, ok := previewResp["response"].(map[string]interface{}); ok {
|
||||||
|
if okLink, ok := respObj["ok_join_link"].(string); ok {
|
||||||
|
cfg.OkJoinLink = okLink
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.Info("vk-auth: getting call token")
|
||||||
|
callParams := url.Values{
|
||||||
|
"v": {cfg.ApiVersion},
|
||||||
|
"vk_join_link": {joinLink},
|
||||||
|
"name": {displayName},
|
||||||
|
}
|
||||||
|
var callToken string
|
||||||
|
var apiBaseURL string
|
||||||
|
var okJoinLink string
|
||||||
|
for attempt := 0; attempt < 5; attempt++ {
|
||||||
|
callResp, err := httpPost("https://api.vk.ru/method/calls.getAnonymousToken", callParams, auth)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("getAnonymousToken: %w", err)
|
||||||
|
}
|
||||||
|
if errObj, hasErr := callResp["error"].(map[string]interface{}); hasErr {
|
||||||
|
errCode, _ := errObj["error_code"].(float64)
|
||||||
|
if int(errCode) == 14 {
|
||||||
|
captchaErr := parseVKCaptchaError(errObj)
|
||||||
|
if captchaErr == nil {
|
||||||
|
return "", fmt.Errorf("captcha error missing fields: %v", errObj)
|
||||||
|
}
|
||||||
|
logger.Info("vk-auth: captcha required")
|
||||||
|
proxyPort := StartCaptchaProxy(captchaErr.redirectURI, dialer)
|
||||||
|
if proxyPort == 0 {
|
||||||
|
return "", fmt.Errorf("failed to start captcha proxy")
|
||||||
|
}
|
||||||
|
logger.Notice(fmt.Sprintf("vk-auth: solve the captcha to continue: http://127.0.0.1:%d/", proxyPort))
|
||||||
|
successToken := GetCaptchaResult()
|
||||||
|
StopCaptchaProxy()
|
||||||
|
if successToken == "" {
|
||||||
|
return "", fmt.Errorf("captcha timed out")
|
||||||
|
}
|
||||||
|
logger.Info("vk-auth: captcha solved, retrying")
|
||||||
|
captchaAttempt := captchaErr.captchaAttempt
|
||||||
|
if captchaAttempt == "" || captchaAttempt == "0" {
|
||||||
|
captchaAttempt = "1"
|
||||||
|
}
|
||||||
|
callParams = url.Values{
|
||||||
|
"v": {cfg.ApiVersion},
|
||||||
|
"vk_join_link": {joinLink},
|
||||||
|
"name": {displayName},
|
||||||
|
"captcha_key": {""},
|
||||||
|
"captcha_sid": {captchaErr.captchaSid},
|
||||||
|
"is_sound_captcha": {"0"},
|
||||||
|
"success_token": {successToken},
|
||||||
|
"captcha_ts": {captchaErr.captchaTs},
|
||||||
|
"captcha_attempt": {captchaAttempt},
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("VK API error: %v", errObj)
|
||||||
|
}
|
||||||
|
respMap, ok := callResp["response"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("unexpected response: %v", callResp)
|
||||||
|
}
|
||||||
|
callToken, _ = respMap["token"].(string)
|
||||||
|
apiBaseURL, _ = respMap["api_base_url"].(string)
|
||||||
|
okJoinLink, _ = respMap["ok_join_link"].(string)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if callToken == "" {
|
||||||
|
return "", fmt.Errorf("failed to get call token")
|
||||||
|
}
|
||||||
|
logger.Info("vk-auth: authenticating with OK.ru")
|
||||||
|
baseURL := strings.TrimRight(apiBaseURL, "/")
|
||||||
|
if !strings.HasSuffix(baseURL, "/fb.do") {
|
||||||
|
baseURL += "/fb.do"
|
||||||
|
}
|
||||||
|
deviceID := fmt.Sprintf("%d", rand.Int63n(9e18))
|
||||||
|
sessionData, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"version": 2,
|
||||||
|
"device_id": deviceID,
|
||||||
|
"client_version": cfg.AppVersion,
|
||||||
|
"client_type": "SDK_JS",
|
||||||
|
})
|
||||||
|
okResp, err := httpPost(baseURL, url.Values{
|
||||||
|
"method": {"auth.anonymLogin"},
|
||||||
|
"session_data": {string(sessionData)},
|
||||||
|
"application_key": {cfg.PublicKey},
|
||||||
|
"format": {"json"},
|
||||||
|
}, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("anonymLogin: %w", err)
|
||||||
|
}
|
||||||
|
sessionKey, _ := okResp["session_key"].(string)
|
||||||
|
if sessionKey == "" {
|
||||||
|
return "", fmt.Errorf("missing session_key: %v", okResp)
|
||||||
|
}
|
||||||
|
logger.Debug("vk-auth: OK.ru session OK")
|
||||||
|
finalJoinLink := okJoinLink
|
||||||
|
if finalJoinLink == "" {
|
||||||
|
finalJoinLink = cfg.OkJoinLink
|
||||||
|
}
|
||||||
|
if finalJoinLink == "" {
|
||||||
|
finalJoinLink = joinLink
|
||||||
|
}
|
||||||
|
result := map[string]string{
|
||||||
|
"sessionKey": sessionKey,
|
||||||
|
"applicationKey": cfg.PublicKey,
|
||||||
|
"apiBaseURL": baseURL,
|
||||||
|
"joinLink": finalJoinLink,
|
||||||
|
"anonymToken": callToken,
|
||||||
|
"appVersion": cfg.AppVersion,
|
||||||
|
"protocolVersion": cfg.ProtocolVersion,
|
||||||
|
}
|
||||||
|
jsonBytes, _ := json.Marshal(result)
|
||||||
|
logger.Debug("vk-auth: done")
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseVKCaptchaError(errObj map[string]interface{}) *vkCaptchaError {
|
||||||
|
redirectURI, _ := errObj["redirect_uri"].(string)
|
||||||
|
if redirectURI == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
captchaSid := ""
|
||||||
|
if sid, ok := errObj["captcha_sid"].(string); ok {
|
||||||
|
captchaSid = sid
|
||||||
|
} else if sidNum, ok := errObj["captcha_sid"].(float64); ok {
|
||||||
|
captchaSid = fmt.Sprintf("%.0f", sidNum)
|
||||||
|
}
|
||||||
|
captchaTs, _ := errObj["captcha_ts"].(string)
|
||||||
|
captchaAttempt, _ := errObj["captcha_attempt"].(string)
|
||||||
|
return &vkCaptchaError{
|
||||||
|
captchaSid: captchaSid,
|
||||||
|
redirectURI: redirectURI,
|
||||||
|
captchaTs: captchaTs,
|
||||||
|
captchaAttempt: captchaAttempt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func minInt(a, b int) int {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
363
transport/call/wbstream/api.go
Normal file
363
transport/call/wbstream/api.go
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
package wbstream
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sagernet/sing-box/transport/call/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
APIBase = "https://stream.wb.ru"
|
||||||
|
Origin = "https://stream.wb.ru"
|
||||||
|
)
|
||||||
|
|
||||||
|
var WBStreamCookieAllowlist = []string{
|
||||||
|
"wbx-refresh",
|
||||||
|
"x_wbaas_token",
|
||||||
|
"_wbauid",
|
||||||
|
"wbx-validation-key",
|
||||||
|
}
|
||||||
|
|
||||||
|
var ModeratorPermissions = []string{
|
||||||
|
"ROOM_PERMISSION_SEND_CHAT",
|
||||||
|
"ROOM_PERMISSION_SHARE_AUDIO",
|
||||||
|
"ROOM_PERMISSION_SHARE_SCREEN",
|
||||||
|
"ROOM_PERMISSION_SHARE_VIDEO",
|
||||||
|
"ROOM_PERMISSION_MODIFY_PERMISSIONS",
|
||||||
|
"ROOM_PERMISSION_MODERATE_ROOM",
|
||||||
|
"ROOM_PERMISSION_CALL_DATA_ACCESS",
|
||||||
|
"ROOM_PERMISSION_LOCAL_RECORD",
|
||||||
|
}
|
||||||
|
|
||||||
|
type guestRegisterRequest struct {
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
Device guestDeviceCfg `json:"device"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type guestDeviceCfg struct {
|
||||||
|
DeviceName string `json:"deviceName"`
|
||||||
|
DeviceType string `json:"deviceType"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type guestRegisterResponse struct {
|
||||||
|
AccessToken string `json:"accessToken"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type createRoomRequest struct {
|
||||||
|
RoomType string `json:"roomType"`
|
||||||
|
RoomPrivacy string `json:"roomPrivacy"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type createRoomResponse struct {
|
||||||
|
RoomID string `json:"roomId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type connectionDetailsResponse struct {
|
||||||
|
RoomToken string `json:"roomToken"`
|
||||||
|
ServerURL string `json:"serverUrl"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type cookieTransport struct {
|
||||||
|
base http.RoundTripper
|
||||||
|
cookie string
|
||||||
|
}
|
||||||
|
|
||||||
|
type slideV3Response struct {
|
||||||
|
Payload struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
} `json:"payload"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseRoomID(input string) string {
|
||||||
|
trimmed := strings.TrimSpace(input)
|
||||||
|
if trimmed == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if rest, ok := strings.CutPrefix(trimmed, "wbstream://"); ok {
|
||||||
|
return strings.Trim(rest, "/")
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(trimmed, "http://") || strings.HasPrefix(trimmed, "https://") {
|
||||||
|
u, err := url.Parse(trimmed)
|
||||||
|
if err == nil {
|
||||||
|
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
|
||||||
|
for i := 0; i < len(parts)-1; i++ {
|
||||||
|
if parts[i] == "room" && parts[i+1] != "" {
|
||||||
|
return parts[i+1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Trim(trimmed, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func RegisterGuest(client *http.Client, displayName string) (string, error) {
|
||||||
|
body, _ := json.Marshal(guestRegisterRequest{
|
||||||
|
DisplayName: displayName,
|
||||||
|
Device: guestDeviceCfg{
|
||||||
|
DeviceName: "Linux",
|
||||||
|
DeviceType: "PARTICIPANT_DEVICE_TYPE_WEB_DESKTOP",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
req, err := http.NewRequest(http.MethodPost, APIBase+"/auth/api/v1/auth/user/guest-register", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := httpDo(client, req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
raw, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("guest-register: status %d: %s", resp.StatusCode, string(raw))
|
||||||
|
}
|
||||||
|
var r guestRegisterResponse
|
||||||
|
if err := json.Unmarshal(raw, &r); err != nil {
|
||||||
|
return "", fmt.Errorf("guest-register decode: %w", err)
|
||||||
|
}
|
||||||
|
return r.AccessToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateRoom(client *http.Client, accessToken string) (string, error) {
|
||||||
|
body, _ := json.Marshal(createRoomRequest{
|
||||||
|
RoomType: "ROOM_TYPE_ALL_ON_SCREEN",
|
||||||
|
RoomPrivacy: "ROOM_PRIVACY_FREE",
|
||||||
|
})
|
||||||
|
req, err := http.NewRequest(http.MethodPost, APIBase+"/api-room/api/v2/room", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
setBearer(req, accessToken)
|
||||||
|
resp, err := httpDo(client, req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
raw, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||||
|
return "", fmt.Errorf("create-room: status %d: %s", resp.StatusCode, string(raw))
|
||||||
|
}
|
||||||
|
var r createRoomResponse
|
||||||
|
if err := json.Unmarshal(raw, &r); err != nil {
|
||||||
|
return "", fmt.Errorf("create-room decode: %w", err)
|
||||||
|
}
|
||||||
|
return r.RoomID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func JoinRoom(client *http.Client, accessToken, roomID string) error {
|
||||||
|
url := fmt.Sprintf("%s/api-room/api/v1/room/%s/join", APIBase, roomID)
|
||||||
|
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader([]byte("{}")))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
setBearer(req, accessToken)
|
||||||
|
resp, err := httpDo(client, req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
raw, _ := io.ReadAll(resp.Body)
|
||||||
|
return fmt.Errorf("join-room: status %d: %s", resp.StatusCode, string(raw))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetConnectionDetails(client *http.Client, accessToken, roomID, displayName string) (string, string, error) {
|
||||||
|
detailsURL := fmt.Sprintf("%s/api-room-manager/v2/room/%s/connection-details?deviceType=PARTICIPANT_DEVICE_TYPE_WEB_DESKTOP&displayName=%s",
|
||||||
|
APIBase, roomID, url.QueryEscape(displayName))
|
||||||
|
req, err := http.NewRequest(http.MethodGet, detailsURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
setBearer(req, accessToken)
|
||||||
|
resp, err := httpDo(client, req)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
raw, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", "", fmt.Errorf("connection-details: status %d: %s", resp.StatusCode, string(raw))
|
||||||
|
}
|
||||||
|
var r connectionDetailsResponse
|
||||||
|
if err := json.Unmarshal(raw, &r); err != nil {
|
||||||
|
return "", "", fmt.Errorf("connection-details decode: %w", err)
|
||||||
|
}
|
||||||
|
return r.RoomToken, r.ServerURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func AuthAndGetToken(client *http.Client, roomID, displayName string) (string, string, string, string, error) {
|
||||||
|
accessToken, err := RegisterGuest(client, displayName)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", "", fmt.Errorf("register guest: %w", err)
|
||||||
|
}
|
||||||
|
return joinAndGetDetails(client, accessToken, roomID, displayName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func AuthAsLoggedIn(client *http.Client, cookieHeader, accessToken, roomID, displayName string) (string, string, string, string, error) {
|
||||||
|
if cookieHeader == "" && accessToken == "" {
|
||||||
|
return "", "", "", "", fmt.Errorf("cookies or access token required for logged-in auth")
|
||||||
|
}
|
||||||
|
client = clientWithCookies(client, cookieHeader)
|
||||||
|
return joinAndGetDetails(client, accessToken, roomID, displayName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func RefreshAccessToken(client *http.Client, cookieHeader, deviceID string) (string, error) {
|
||||||
|
req, err := http.NewRequest(http.MethodPost, "https://auth-stream.wb.ru/v2/auth/slide-v3", bytes.NewReader(nil))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if deviceID == "" {
|
||||||
|
deviceID = newRequestID()
|
||||||
|
}
|
||||||
|
req.Header.Set("wb-apptype", "web")
|
||||||
|
req.Header.Set("X-Real-IP", "")
|
||||||
|
req.Header.Set("deviceId", deviceID)
|
||||||
|
req.Header.Set("X-Request-ID", newRequestID())
|
||||||
|
req.Header.Set("Origin", Origin)
|
||||||
|
req.Header.Set("Referer", Origin+"/")
|
||||||
|
req.Header.Set("Cookie", cookieHeader)
|
||||||
|
req.Header.Set("User-Agent", common.UserAgent)
|
||||||
|
if client == nil {
|
||||||
|
client = http.DefaultClient
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
raw, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("slide-v3: status %d: %s", resp.StatusCode, string(raw))
|
||||||
|
}
|
||||||
|
var r slideV3Response
|
||||||
|
if err := json.Unmarshal(raw, &r); err != nil {
|
||||||
|
return "", fmt.Errorf("slide-v3 decode: %w", err)
|
||||||
|
}
|
||||||
|
if r.Payload.AccessToken == "" {
|
||||||
|
return "", fmt.Errorf("slide-v3: empty access_token in response: %s", string(raw))
|
||||||
|
}
|
||||||
|
return r.Payload.AccessToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetParticipantPermissions(client *http.Client, accessToken, roomID, participantID string, permissions []string) error {
|
||||||
|
setURL := fmt.Sprintf("%s/api-room-manager/api/v1/room/%s/participant/%s/set-permissions", APIBase, roomID, participantID)
|
||||||
|
body, err := json.Marshal(map[string]any{"permissions": permissions})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest(http.MethodPut, setURL, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
setBearer(req, accessToken)
|
||||||
|
resp, err := httpDo(client, req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return fmt.Errorf("set-permissions %s -> %d %s", participantID, resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func KickParticipant(client *http.Client, accessToken, roomID, participantID string) error {
|
||||||
|
if client == nil {
|
||||||
|
client = http.DefaultClient
|
||||||
|
}
|
||||||
|
kickURL := fmt.Sprintf("%s/api-room-manager/api/v1/room/%s/participant/%s/kick", APIBase, roomID, participantID)
|
||||||
|
req, err := http.NewRequest("DELETE", kickURL, strings.NewReader("{}"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||||
|
req.Header.Set("User-Agent", common.UserAgent)
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return fmt.Errorf("kick %s -> %d %s", participantID, resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *cookieTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
req.Header.Set("Cookie", t.cookie)
|
||||||
|
base := t.base
|
||||||
|
if base == nil {
|
||||||
|
base = http.DefaultTransport
|
||||||
|
}
|
||||||
|
return base.RoundTrip(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func httpDo(client *http.Client, req *http.Request) (*http.Response, error) {
|
||||||
|
req.Header.Set("User-Agent", common.UserAgent)
|
||||||
|
if client == nil {
|
||||||
|
client = http.DefaultClient
|
||||||
|
}
|
||||||
|
return client.Do(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clientWithCookies(client *http.Client, cookieHeader string) *http.Client {
|
||||||
|
if cookieHeader == "" {
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
if client == nil {
|
||||||
|
client = &http.Client{}
|
||||||
|
}
|
||||||
|
wrapped := *client
|
||||||
|
wrapped.Transport = &cookieTransport{base: client.Transport, cookie: cookieHeader}
|
||||||
|
return &wrapped
|
||||||
|
}
|
||||||
|
|
||||||
|
func setBearer(req *http.Request, accessToken string) {
|
||||||
|
if accessToken != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRequestID() string {
|
||||||
|
var b [16]byte
|
||||||
|
if _, err := rand.Read(b[:]); err != nil {
|
||||||
|
return "00000000-0000-0000-0000-000000000000"
|
||||||
|
}
|
||||||
|
b[6] = (b[6] & 0x0f) | 0x40
|
||||||
|
b[8] = (b[8] & 0x3f) | 0x80
|
||||||
|
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinAndGetDetails(client *http.Client, accessToken, roomID, displayName string) (string, string, string, string, error) {
|
||||||
|
var err error
|
||||||
|
if roomID == "" {
|
||||||
|
roomID, err = CreateRoom(client, accessToken)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", "", fmt.Errorf("create room: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := JoinRoom(client, accessToken, roomID); err != nil {
|
||||||
|
return "", "", "", "", fmt.Errorf("join room: %w", err)
|
||||||
|
}
|
||||||
|
roomToken, serverURL, err := GetConnectionDetails(client, accessToken, roomID, displayName)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", "", fmt.Errorf("get connection details: %w", err)
|
||||||
|
}
|
||||||
|
return roomID, roomToken, accessToken, serverURL, nil
|
||||||
|
}
|
||||||
185
transport/call/wbstream/connect.go
Normal file
185
transport/call/wbstream/connect.go
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
package wbstream
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sagernet/sing-box/adapter"
|
||||||
|
"github.com/sagernet/sing-box/transport/call/common"
|
||||||
|
"github.com/sagernet/sing-box/transport/call/tunnel"
|
||||||
|
"github.com/sagernet/sing/common/logger"
|
||||||
|
N "github.com/sagernet/sing/common/network"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ConnectCreator(ctx context.Context, cookieStr, roomID, mode string, readBuf int, dialer N.Dialer, logger logger.ContextLogger) (*tunnel.RelayBridge, string, error) {
|
||||||
|
deviceID := common.CookieValue(cookieStr, "__wb_device_id")
|
||||||
|
if deviceID == "" {
|
||||||
|
return nil, "", fmt.Errorf("wbstream: cookies missing __wb_device_id")
|
||||||
|
}
|
||||||
|
cookieHeader := common.FilterCookies(cookieStr, WBStreamCookieAllowlist)
|
||||||
|
httpClient := common.HttpClient(dialer)
|
||||||
|
bearer, err := RefreshAccessToken(httpClient, cookieHeader, deviceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("wbstream: slide-v3 refresh: %w", err)
|
||||||
|
}
|
||||||
|
requestedRoom := ParseRoomID(roomID)
|
||||||
|
resolvedRoomID, roomToken, accessToken, serverURL, err := AuthAsLoggedIn(httpClient, cookieHeader, bearer, requestedRoom, "Creator")
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("wbstream: auth: %w", err)
|
||||||
|
}
|
||||||
|
if readBuf <= 0 {
|
||||||
|
readBuf = 32768
|
||||||
|
}
|
||||||
|
if mode == "" {
|
||||||
|
mode = TunnelModeDC
|
||||||
|
}
|
||||||
|
obf, err := tunnel.NewTunnelObfuscator(tunnel.DeriveSecretFromJoinLink(resolvedRoomID))
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("wbstream: obfuscator init: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
joinSession := func(token, access, server string) (*Session, <-chan tunnel.DataTunnel) {
|
||||||
|
tunCh := make(chan tunnel.DataTunnel, 1)
|
||||||
|
sess := NewSession(SessionConfig{
|
||||||
|
RoomToken: token,
|
||||||
|
ServerURL: server,
|
||||||
|
DisplayName: "Creator",
|
||||||
|
TunnelMode: mode,
|
||||||
|
Obfuscator: obf,
|
||||||
|
Logger: logger,
|
||||||
|
Dialer: dialer,
|
||||||
|
RoomID: resolvedRoomID,
|
||||||
|
AccessToken: access,
|
||||||
|
ReadBuf: readBuf,
|
||||||
|
})
|
||||||
|
sess.OnConnected = func(tun tunnel.DataTunnel) {
|
||||||
|
select {
|
||||||
|
case tunCh <- tun:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sess, tunCh
|
||||||
|
}
|
||||||
|
|
||||||
|
sess, tunCh := joinSession(roomToken, accessToken, serverURL)
|
||||||
|
if err := sess.Start(); err != nil {
|
||||||
|
return nil, "", fmt.Errorf("wbstream: session start: %w", err)
|
||||||
|
}
|
||||||
|
var firstTun tunnel.DataTunnel
|
||||||
|
select {
|
||||||
|
case firstTun = <-tunCh:
|
||||||
|
case <-ctx.Done():
|
||||||
|
sess.Close()
|
||||||
|
return nil, "", ctx.Err()
|
||||||
|
case <-time.After(60 * time.Second):
|
||||||
|
sess.Close()
|
||||||
|
return nil, "", fmt.Errorf("wbstream: creator tunnel timed out")
|
||||||
|
}
|
||||||
|
|
||||||
|
relay := tunnel.NewRelayBridge(firstTun, "creator", bridgeReadBufFor(firstTun, readBuf), dialer, logger)
|
||||||
|
go creatorReconnectLoop(ctx, relay, sess, joinSession, httpClient, cookieHeader, deviceID, resolvedRoomID, readBuf, logger)
|
||||||
|
return relay, APIBase + "/room/" + resolvedRoomID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ConnectJoiner(ctx context.Context, roomID, displayName, mode string, readBuf int, dialer N.Dialer, dnsRouter adapter.DNSRouter, logger logger.ContextLogger) (tunnel.DataTunnel, error) {
|
||||||
|
roomID = ParseRoomID(roomID)
|
||||||
|
if displayName == "" {
|
||||||
|
displayName = "Joiner"
|
||||||
|
}
|
||||||
|
if mode == "" {
|
||||||
|
mode = TunnelModeDC
|
||||||
|
}
|
||||||
|
joiner := NewWBStreamJoiner(logger, dialer, dnsRouter, nil)
|
||||||
|
tunCh := make(chan tunnel.DataTunnel, 1)
|
||||||
|
joiner.OnConnected = func(tun tunnel.DataTunnel) {
|
||||||
|
select {
|
||||||
|
case tunCh <- tun:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
params := fmt.Sprintf(`{"roomId":%q,"displayName":%q,"tunnelMode":%q}`, roomID, displayName, mode)
|
||||||
|
go joiner.RunWithParams(params)
|
||||||
|
select {
|
||||||
|
case tun := <-tunCh:
|
||||||
|
return tun, nil
|
||||||
|
case <-ctx.Done():
|
||||||
|
joiner.Close()
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func creatorReconnectLoop(
|
||||||
|
ctx context.Context,
|
||||||
|
relay *tunnel.RelayBridge,
|
||||||
|
sess *Session,
|
||||||
|
joinSession func(token, access, server string) (*Session, <-chan tunnel.DataTunnel),
|
||||||
|
httpClient *http.Client,
|
||||||
|
cookieHeader, deviceID, roomID string,
|
||||||
|
readBuf int,
|
||||||
|
logger logger.ContextLogger,
|
||||||
|
) {
|
||||||
|
current := sess
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-current.Done():
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
current.Close()
|
||||||
|
if relay.IsClosed() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.Debug("wbstream: creator session ended, rejoining")
|
||||||
|
|
||||||
|
var newTun tunnel.DataTunnel
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
}
|
||||||
|
if relay.IsClosed() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bearer, err := RefreshAccessToken(httpClient, cookieHeader, deviceID)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn(fmt.Sprintf("wbstream: rejoin token refresh failed: %v, retrying", err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, roomToken, accessToken, serverURL, err := AuthAsLoggedIn(httpClient, cookieHeader, bearer, roomID, "Creator")
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn(fmt.Sprintf("wbstream: rejoin auth failed: %v, retrying", err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
newSess, tunCh := joinSession(roomToken, accessToken, serverURL)
|
||||||
|
if err := newSess.Start(); err != nil {
|
||||||
|
logger.Warn(fmt.Sprintf("wbstream: rejoin session start failed: %v, retrying", err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case newTun = <-tunCh:
|
||||||
|
case <-ctx.Done():
|
||||||
|
newSess.Close()
|
||||||
|
return
|
||||||
|
case <-time.After(60 * time.Second):
|
||||||
|
logger.Warn("wbstream: rejoin tunnel timed out, retrying")
|
||||||
|
newSess.Close()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
current = newSess
|
||||||
|
break
|
||||||
|
}
|
||||||
|
relay.SwapTunnel(newTun)
|
||||||
|
logger.Info(fmt.Sprintf("wbstream: creator tunnel reconnected, buf=%d", bridgeReadBufFor(newTun, readBuf)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func bridgeReadBufFor(tun tunnel.DataTunnel, readBuf int) int {
|
||||||
|
switch tun.(type) {
|
||||||
|
case *tunnel.DCTunnel, *tunnel.MultiTrackKCPTunnel:
|
||||||
|
return readBuf
|
||||||
|
}
|
||||||
|
return common.VP8BufSize
|
||||||
|
}
|
||||||
53
transport/call/wbstream/dc_packet.go
Normal file
53
transport/call/wbstream/dc_packet.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package wbstream
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/pion/datachannel"
|
||||||
|
"github.com/sagernet/sing-box/transport/call/livekit"
|
||||||
|
)
|
||||||
|
|
||||||
|
type dataPacketWrapper struct {
|
||||||
|
inner datachannel.ReadWriteCloser
|
||||||
|
kind int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *dataPacketWrapper) ReadDataChannel(p []byte) (int, bool, error) {
|
||||||
|
buf := make([]byte, len(p))
|
||||||
|
for {
|
||||||
|
n, isString, err := w.inner.ReadDataChannel(buf)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false, err
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
payload, ok := livekit.DecodeDataPacketUser(buf[:n])
|
||||||
|
if !ok || len(payload) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
copied := copy(p, payload)
|
||||||
|
return copied, isString, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *dataPacketWrapper) WriteDataChannel(p []byte, isString bool) (int, error) {
|
||||||
|
wire := livekit.EncodeDataPacketUser(p, w.kind)
|
||||||
|
if _, err := w.inner.WriteDataChannel(wire, isString); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *dataPacketWrapper) Read(p []byte) (int, error) {
|
||||||
|
n, _, err := w.ReadDataChannel(p)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *dataPacketWrapper) Write(p []byte) (int, error) {
|
||||||
|
return w.WriteDataChannel(p, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *dataPacketWrapper) Close() error { return w.inner.Close() }
|
||||||
|
|
||||||
|
func newDataPacketWrapper(inner datachannel.ReadWriteCloser, kind int) *dataPacketWrapper {
|
||||||
|
return &dataPacketWrapper{inner: inner, kind: kind}
|
||||||
|
}
|
||||||
217
transport/call/wbstream/joiner.go
Normal file
217
transport/call/wbstream/joiner.go
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
package wbstream
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pion/webrtc/v4"
|
||||||
|
"github.com/sagernet/sing-box/adapter"
|
||||||
|
"github.com/sagernet/sing-box/transport/call/common"
|
||||||
|
"github.com/sagernet/sing-box/transport/call/tunnel"
|
||||||
|
"github.com/sagernet/sing/common/logger"
|
||||||
|
M "github.com/sagernet/sing/common/metadata"
|
||||||
|
N "github.com/sagernet/sing/common/network"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
reconnectInitialDelay = time.Second
|
||||||
|
reconnectMaxDelay = 16 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
type WBStreamJoiner struct {
|
||||||
|
logger logger.ContextLogger
|
||||||
|
OnConnected func(tunnel.DataTunnel)
|
||||||
|
dialer N.Dialer
|
||||||
|
dnsRouter adapter.DNSRouter
|
||||||
|
PCConfig common.PeerConnectionConfigurer
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
session *Session
|
||||||
|
closed bool
|
||||||
|
stopCh chan struct{}
|
||||||
|
stopOnce sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWBStreamJoiner(logger logger.ContextLogger, dialer N.Dialer, dnsRouter adapter.DNSRouter, pcConfig common.PeerConnectionConfigurer) *WBStreamJoiner {
|
||||||
|
return &WBStreamJoiner{
|
||||||
|
logger: logger,
|
||||||
|
dialer: dialer,
|
||||||
|
dnsRouter: dnsRouter,
|
||||||
|
PCConfig: pcConfig,
|
||||||
|
stopCh: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *WBStreamJoiner) RunWithParams(jsonParams string) {
|
||||||
|
var params struct {
|
||||||
|
RoomID string `json:"roomId"`
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
TunnelMode string `json:"tunnelMode"`
|
||||||
|
VP8FPS int `json:"vp8Fps"`
|
||||||
|
VP8Batch int `json:"vp8Batch"`
|
||||||
|
DualTrack bool `json:"dualTrack"`
|
||||||
|
Reliable *bool `json:"reliable"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(jsonParams), ¶ms); err != nil {
|
||||||
|
j.logger.Error(fmt.Sprintf("wbstream-joiner: failed to parse params: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if params.RoomID == "" {
|
||||||
|
j.logger.Error("wbstream-joiner: missing roomId")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if params.DisplayName == "" {
|
||||||
|
params.DisplayName = "Joiner"
|
||||||
|
}
|
||||||
|
reliable := params.Reliable != nil && *params.Reliable
|
||||||
|
httpClient := j.makeHTTPClient()
|
||||||
|
j.logger.Info(fmt.Sprintf("wbstream-joiner: room=%s name=%s vp8Fps=%d vp8Batch=%d dualTrack=%v", params.RoomID, params.DisplayName, params.VP8FPS, params.VP8Batch, params.DualTrack))
|
||||||
|
obf, err := tunnel.NewTunnelObfuscator(tunnel.DeriveSecretFromJoinLink(params.RoomID))
|
||||||
|
if err != nil {
|
||||||
|
j.logger.Error(fmt.Sprintf("wbstream-joiner: obfuscator init failed: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
j.logger.Debug(fmt.Sprintf("wbstream-joiner: obf key-source=%q localEpoch=0x%08x", params.RoomID, obf.LocalEpoch()))
|
||||||
|
var settingEngine *webrtc.SettingEngine
|
||||||
|
if j.PCConfig != nil {
|
||||||
|
se := webrtc.SettingEngine{}
|
||||||
|
j.PCConfig.ConfigureSettingEngine(&se)
|
||||||
|
settingEngine = &se
|
||||||
|
}
|
||||||
|
var attempt atomic.Int32
|
||||||
|
j.logger.Info("wbstream-joiner: connecting")
|
||||||
|
if err := j.runOnce(httpClient, params.RoomID, params.DisplayName, params.TunnelMode, obf, settingEngine, params.VP8FPS, params.VP8Batch, params.DualTrack, reliable, &attempt); err != nil {
|
||||||
|
j.logger.Error(fmt.Sprintf("wbstream-joiner: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
if j.isClosed() {
|
||||||
|
j.logger.Info("wbstream-joiner: stopped")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
j.logger.Info("wbstream-joiner: tunnel lost")
|
||||||
|
if !j.waitBeforeRetry(int(attempt.Load())) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
attempt.Add(1)
|
||||||
|
if j.isClosed() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
j.logger.Info(fmt.Sprintf("wbstream-joiner: reconnect attempt #%d", attempt.Load()))
|
||||||
|
if err := j.runOnce(httpClient, params.RoomID, params.DisplayName, params.TunnelMode, obf, settingEngine, params.VP8FPS, params.VP8Batch, params.DualTrack, reliable, &attempt); err != nil {
|
||||||
|
j.logger.Warn(fmt.Sprintf("wbstream-joiner: %v, will retry", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *WBStreamJoiner) MarkConfigAcked() {
|
||||||
|
j.mu.Lock()
|
||||||
|
sess := j.session
|
||||||
|
j.mu.Unlock()
|
||||||
|
if sess != nil {
|
||||||
|
sess.MarkConfigAcked()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *WBStreamJoiner) Close() {
|
||||||
|
j.stopOnce.Do(func() { close(j.stopCh) })
|
||||||
|
j.mu.Lock()
|
||||||
|
j.closed = true
|
||||||
|
sess := j.session
|
||||||
|
j.session = nil
|
||||||
|
j.mu.Unlock()
|
||||||
|
if sess != nil {
|
||||||
|
sess.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *WBStreamJoiner) runOnce(httpClient *http.Client, roomID, displayName, tunnelMode string, obf *tunnel.TunnelObfuscator, settingEngine *webrtc.SettingEngine, vp8FPS, vp8Batch int, dualTrack, reliable bool, attempt *atomic.Int32) error {
|
||||||
|
_, roomToken, _, serverURL, authErr := AuthAndGetToken(httpClient, roomID, displayName)
|
||||||
|
if authErr != nil {
|
||||||
|
return fmt.Errorf("auth: %w", authErr)
|
||||||
|
}
|
||||||
|
j.logger.Debug(fmt.Sprintf("wbstream-joiner: server=%s", serverURL))
|
||||||
|
sess := NewSession(SessionConfig{
|
||||||
|
RoomToken: roomToken,
|
||||||
|
ServerURL: serverURL,
|
||||||
|
DisplayName: displayName,
|
||||||
|
TunnelMode: tunnelMode,
|
||||||
|
Obfuscator: obf,
|
||||||
|
Logger: j.logger,
|
||||||
|
SettingEngine: settingEngine,
|
||||||
|
Dialer: j.dialer,
|
||||||
|
DNSRouter: j.dnsRouter,
|
||||||
|
VP8FPS: vp8FPS,
|
||||||
|
VP8Batch: vp8Batch,
|
||||||
|
ScreenShare: dualTrack,
|
||||||
|
IsJoiner: true,
|
||||||
|
Reliable: reliable,
|
||||||
|
})
|
||||||
|
sess.OnConnected = func(tun tunnel.DataTunnel) {
|
||||||
|
attempt.Store(0)
|
||||||
|
j.logger.Info("wbstream-joiner: === TUNNEL CONNECTED ===")
|
||||||
|
if j.OnConnected != nil {
|
||||||
|
j.OnConnected(tun)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
j.mu.Lock()
|
||||||
|
if j.closed {
|
||||||
|
j.mu.Unlock()
|
||||||
|
sess.Close()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
j.session = sess
|
||||||
|
j.mu.Unlock()
|
||||||
|
if err := sess.Start(); err != nil {
|
||||||
|
j.clearSession(sess)
|
||||||
|
return fmt.Errorf("session: %w", err)
|
||||||
|
}
|
||||||
|
<-sess.Done()
|
||||||
|
sess.Close()
|
||||||
|
j.clearSession(sess)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *WBStreamJoiner) waitBeforeRetry(attempt int) bool {
|
||||||
|
delay := common.BackoffWithJitter(attempt, reconnectInitialDelay, reconnectMaxDelay)
|
||||||
|
j.logger.Debug(fmt.Sprintf("wbstream-joiner: waiting %s before reconnect", delay))
|
||||||
|
timer := time.NewTimer(delay)
|
||||||
|
defer timer.Stop()
|
||||||
|
select {
|
||||||
|
case <-timer.C:
|
||||||
|
return !j.isClosed()
|
||||||
|
case <-j.stopCh:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *WBStreamJoiner) clearSession(sess *Session) {
|
||||||
|
j.mu.Lock()
|
||||||
|
if j.session == sess {
|
||||||
|
j.session = nil
|
||||||
|
}
|
||||||
|
j.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *WBStreamJoiner) isClosed() bool {
|
||||||
|
j.mu.Lock()
|
||||||
|
defer j.mu.Unlock()
|
||||||
|
return j.closed
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *WBStreamJoiner) makeDialContext() func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
|
return func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
|
return j.dialer.DialContext(ctx, network, M.ParseSocksaddr(addr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *WBStreamJoiner) makeHTTPClient() *http.Client {
|
||||||
|
transport := &http.Transport{DialContext: j.makeDialContext()}
|
||||||
|
return &http.Client{Timeout: 60 * time.Second, Transport: transport}
|
||||||
|
}
|
||||||
738
transport/call/wbstream/session.go
Normal file
738
transport/call/wbstream/session.go
Normal file
@@ -0,0 +1,738 @@
|
|||||||
|
package wbstream
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/pion/rtp"
|
||||||
|
"github.com/pion/rtp/codecs"
|
||||||
|
"github.com/pion/webrtc/v4"
|
||||||
|
"github.com/sagernet/sing-box/adapter"
|
||||||
|
"github.com/sagernet/sing-box/transport/call/common"
|
||||||
|
"github.com/sagernet/sing-box/transport/call/livekit"
|
||||||
|
"github.com/sagernet/sing-box/transport/call/tunnel"
|
||||||
|
"github.com/sagernet/sing/common/logger"
|
||||||
|
M "github.com/sagernet/sing/common/metadata"
|
||||||
|
N "github.com/sagernet/sing/common/network"
|
||||||
|
)
|
||||||
|
|
||||||
|
type peerEntry struct {
|
||||||
|
sid string
|
||||||
|
identity string
|
||||||
|
firstSeen time.Time
|
||||||
|
state int32
|
||||||
|
promoted bool
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
TunnelModeVideo = "video"
|
||||||
|
TunnelModeDC = "dc"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SessionConfig struct {
|
||||||
|
RoomToken string
|
||||||
|
ServerURL string
|
||||||
|
DisplayName string
|
||||||
|
TunnelMode string
|
||||||
|
Obfuscator *tunnel.TunnelObfuscator
|
||||||
|
Logger logger.ContextLogger
|
||||||
|
SettingEngine *webrtc.SettingEngine
|
||||||
|
Dialer N.Dialer
|
||||||
|
DNSRouter adapter.DNSRouter
|
||||||
|
VP8FPS int
|
||||||
|
VP8Batch int
|
||||||
|
RoomID string
|
||||||
|
AccessToken string
|
||||||
|
ReadBuf int
|
||||||
|
ScreenShare bool
|
||||||
|
IsJoiner bool
|
||||||
|
Reliable bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type Session struct {
|
||||||
|
cfg SessionConfig
|
||||||
|
|
||||||
|
lk *livekit.Client
|
||||||
|
sampleTracks []*webrtc.TrackLocalStaticSample
|
||||||
|
sampleTransceivers []*webrtc.RTPTransceiver
|
||||||
|
|
||||||
|
pubReliableDC *webrtc.DataChannel
|
||||||
|
pubReliableDCReady bool
|
||||||
|
subReliableDC *webrtc.DataChannel
|
||||||
|
|
||||||
|
vp8tun *tunnel.MultiTrackTunnel
|
||||||
|
kcptun *tunnel.MultiTrackKCPTunnel
|
||||||
|
dctun *tunnel.DCTunnel
|
||||||
|
mu sync.Mutex
|
||||||
|
tunFired bool
|
||||||
|
done chan struct{}
|
||||||
|
|
||||||
|
peersBySID map[string]peerEntry
|
||||||
|
kickedSIDs map[string]bool
|
||||||
|
|
||||||
|
configAcked chan struct{}
|
||||||
|
configAckedOnce sync.Once
|
||||||
|
|
||||||
|
OnConnected func(tunnel.DataTunnel)
|
||||||
|
OnPeerRestart func()
|
||||||
|
OnRemoteCandidate func(target int, candidateOrSDP string)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSession(cfg SessionConfig) *Session {
|
||||||
|
return &Session{
|
||||||
|
cfg: cfg,
|
||||||
|
done: make(chan struct{}),
|
||||||
|
configAcked: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) MarkConfigAcked() {
|
||||||
|
s.configAckedOnce.Do(func() {
|
||||||
|
s.cfg.Logger.Debug("[lk] peer acked vp8 config")
|
||||||
|
close(s.configAcked)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) Done() <-chan struct{} { return s.done }
|
||||||
|
|
||||||
|
func (s *Session) Start() error {
|
||||||
|
s.lk = livekit.NewClient(livekit.Config{
|
||||||
|
ServerURL: s.cfg.ServerURL,
|
||||||
|
Token: s.cfg.RoomToken,
|
||||||
|
Origin: Origin,
|
||||||
|
UserAgent: common.UserAgent,
|
||||||
|
Logger: s.cfg.Logger,
|
||||||
|
SettingEngine: s.cfg.SettingEngine,
|
||||||
|
NetDialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
|
return s.cfg.Dialer.DialContext(ctx, network, M.ParseSocksaddr(addr))
|
||||||
|
},
|
||||||
|
DNSRouter: s.cfg.DNSRouter,
|
||||||
|
})
|
||||||
|
s.lk.OnReady = s.onLKReady
|
||||||
|
s.lk.OnTrack = s.onRemoteTrack
|
||||||
|
s.lk.OnDataChannel = s.onRemoteDataChannel
|
||||||
|
s.lk.OnPubConnected = s.startTunnel
|
||||||
|
if s.cfg.AccessToken != "" && s.cfg.RoomID != "" {
|
||||||
|
s.lk.OnParticipantUpdate = s.onParticipantUpdate
|
||||||
|
}
|
||||||
|
s.lk.OnRemoteCandidate = func(target int, ic webrtc.ICECandidateInit) {
|
||||||
|
if s.OnRemoteCandidate != nil {
|
||||||
|
s.OnRemoteCandidate(target, ic.Candidate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.lk.OnRemoteSDP = func(target int, _, sdp string) {
|
||||||
|
if s.OnRemoteCandidate != nil {
|
||||||
|
s.OnRemoteCandidate(-1, sdp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.lk.Connect(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
go s.lk.PingLoop()
|
||||||
|
go func() {
|
||||||
|
if err := s.lk.ReadLoop(); err != nil {
|
||||||
|
s.cfg.Logger.Debug(fmt.Sprintf("[lk] read loop ended: %v", err))
|
||||||
|
}
|
||||||
|
s.stopTunnels()
|
||||||
|
close(s.done)
|
||||||
|
}()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) AdaptTrackCount(peerCount int) {
|
||||||
|
if peerCount < 1 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pubPC := s.lk.PubPC()
|
||||||
|
if pubPC == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
current := len(s.sampleTracks)
|
||||||
|
s.mu.Unlock()
|
||||||
|
if peerCount == current {
|
||||||
|
s.cfg.Logger.Debug(fmt.Sprintf("[lk] adapt-track-count: peer=%d current=%d, no change", peerCount, current))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if peerCount > current {
|
||||||
|
s.cfg.Logger.Debug(fmt.Sprintf("[lk] adapt-track-count: scaling publisher tracks %d -> %d", current, peerCount))
|
||||||
|
for i := current; i < peerCount; i++ {
|
||||||
|
if !s.addPublisherTrack(pubPC, i) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
s.cfg.Logger.Debug(fmt.Sprintf("[lk] adapt-track-count: shrinking publisher tracks %d -> %d", current, peerCount))
|
||||||
|
for i := current; i > peerCount; i-- {
|
||||||
|
if !s.removePublisherTrack() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
offer, err := pubPC.CreateOffer(nil)
|
||||||
|
if err != nil {
|
||||||
|
s.cfg.Logger.Error(fmt.Sprintf("[lk] adapt-track-count: create offer: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := pubPC.SetLocalDescription(offer); err != nil {
|
||||||
|
s.cfg.Logger.Error(fmt.Sprintf("[lk] adapt-track-count: set local offer: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.lk.SendOffer(offer.SDP); err != nil {
|
||||||
|
s.cfg.Logger.Error(fmt.Sprintf("[lk] adapt-track-count: send offer: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.cfg.Logger.Debug(fmt.Sprintf("[lk] adapt-track-count: renegotiation offer sent (%d bytes)", len(offer.SDP)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) Close() {
|
||||||
|
if s.lk != nil {
|
||||||
|
s.lk.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) stopTunnels() {
|
||||||
|
s.mu.Lock()
|
||||||
|
vp8 := s.vp8tun
|
||||||
|
kcptun := s.kcptun
|
||||||
|
s.mu.Unlock()
|
||||||
|
if kcptun != nil {
|
||||||
|
kcptun.Stop()
|
||||||
|
}
|
||||||
|
if vp8 != nil {
|
||||||
|
vp8.Stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) onLKReady() {
|
||||||
|
pubPC := s.lk.PubPC()
|
||||||
|
if pubPC == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
camID := "videochannel-" + uuid.New().String()
|
||||||
|
trackCam, err := webrtc.NewTrackLocalStaticSample(
|
||||||
|
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeVP8, ClockRate: 90000},
|
||||||
|
camID, "tunnel-video-"+uuid.New().String(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
s.cfg.Logger.Error(fmt.Sprintf("[lk] create local cam track: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tracks := []*webrtc.TrackLocalStaticSample{trackCam}
|
||||||
|
if s.cfg.ScreenShare {
|
||||||
|
screenID := "screenchannel-" + uuid.New().String()
|
||||||
|
trackScreen, err := webrtc.NewTrackLocalStaticSample(
|
||||||
|
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeVP8, ClockRate: 90000},
|
||||||
|
screenID, "tunnel-screen-"+uuid.New().String(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
s.cfg.Logger.Error(fmt.Sprintf("[lk] create local screen track: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tracks = append(tracks, trackScreen)
|
||||||
|
}
|
||||||
|
transceivers := make([]*webrtc.RTPTransceiver, 0, len(tracks))
|
||||||
|
for _, t := range tracks {
|
||||||
|
trx, err := pubPC.AddTransceiverFromTrack(t,
|
||||||
|
webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionSendonly})
|
||||||
|
if err != nil {
|
||||||
|
s.cfg.Logger.Error(fmt.Sprintf("[lk] add transceiver: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
transceivers = append(transceivers, trx)
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
s.sampleTracks = tracks
|
||||||
|
s.sampleTransceivers = transceivers
|
||||||
|
s.mu.Unlock()
|
||||||
|
ordered := true
|
||||||
|
dc, err := pubPC.CreateDataChannel("_reliable", &webrtc.DataChannelInit{
|
||||||
|
Ordered: &ordered,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
s.cfg.Logger.Error(fmt.Sprintf("[lk] create reliable DC: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
s.pubReliableDC = dc
|
||||||
|
s.mu.Unlock()
|
||||||
|
dc.OnOpen(func() {
|
||||||
|
s.cfg.Logger.Debug("[lk] reliable DC open")
|
||||||
|
s.mu.Lock()
|
||||||
|
s.pubReliableDCReady = true
|
||||||
|
s.mu.Unlock()
|
||||||
|
s.maybeStartDCTunnel()
|
||||||
|
})
|
||||||
|
for i, t := range tracks {
|
||||||
|
source := livekit.TrackSourceCamera
|
||||||
|
if i > 0 {
|
||||||
|
source = livekit.TrackSourceScreenShare
|
||||||
|
}
|
||||||
|
if err := s.lk.SendAddTrack(t.ID(), "videochannel",
|
||||||
|
livekit.TrackTypeVideo, source, 1280, 720); err != nil {
|
||||||
|
s.cfg.Logger.Error(fmt.Sprintf("[lk] send add-track: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
offer, err := pubPC.CreateOffer(nil)
|
||||||
|
if err != nil {
|
||||||
|
s.cfg.Logger.Error(fmt.Sprintf("[lk] create offer: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := pubPC.SetLocalDescription(offer); err != nil {
|
||||||
|
s.cfg.Logger.Error(fmt.Sprintf("[lk] set local offer: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.lk.SendOffer(offer.SDP); err != nil {
|
||||||
|
s.cfg.Logger.Error(fmt.Sprintf("[lk] send offer: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.cfg.Logger.Debug(fmt.Sprintf("[lk] sent publisher offer (%d bytes)", len(offer.SDP)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) startTunnel() {
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.vp8tun != nil || len(s.sampleTracks) == 0 {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
subs := make([]*tunnel.VP8DataTunnel, 0, len(s.sampleTracks))
|
||||||
|
for _, t := range s.sampleTracks {
|
||||||
|
subs = append(subs, tunnel.NewVP8DataTunnelWithQueue(t, s.cfg.Obfuscator, s.cfg.Logger, tunnel.KCPCarrierQueueDepth))
|
||||||
|
}
|
||||||
|
s.vp8tun = tunnel.NewMultiTrackTunnel(subs)
|
||||||
|
s.vp8tun.SetOnPeerRestart(func() {
|
||||||
|
s.cfg.Logger.Debug("[wb] peer epoch changed, signalling peer-restart")
|
||||||
|
s.rearmAutoDetect()
|
||||||
|
if s.OnPeerRestart != nil {
|
||||||
|
s.OnPeerRestart()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
s.vp8tun.Start(s.cfg.VP8FPS, s.cfg.VP8Batch)
|
||||||
|
tun := s.vp8tun
|
||||||
|
s.mu.Unlock()
|
||||||
|
s.cfg.Logger.Debug(fmt.Sprintf("[lk] vp8 tunnel writer started tracks=%d", len(subs)))
|
||||||
|
var active tunnel.DataTunnel = tun
|
||||||
|
if s.cfg.TunnelMode == TunnelModeVideo && s.cfg.Reliable {
|
||||||
|
active = s.maybeWrapReliable(tun)
|
||||||
|
}
|
||||||
|
if s.cfg.IsJoiner && s.cfg.TunnelMode != TunnelModeDC {
|
||||||
|
go s.configPingPong(active, len(subs))
|
||||||
|
}
|
||||||
|
if s.cfg.TunnelMode == TunnelModeVideo {
|
||||||
|
s.fireOnConnected(active)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.cfg.TunnelMode == "" {
|
||||||
|
tun.SetOnData(func(payload []byte) { s.activate(tun, payload) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) configPingPong(tun tunnel.DataTunnel, trackCount int) {
|
||||||
|
frame := tunnel.EncodeVP8Config(s.cfg.VP8FPS, s.cfg.VP8Batch, trackCount)
|
||||||
|
tun.SendData(frame)
|
||||||
|
ticker := time.NewTicker(3 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-s.configAcked:
|
||||||
|
return
|
||||||
|
case <-s.done:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
s.cfg.Logger.Debug("[lk] resending vp8 config (no ack yet)")
|
||||||
|
tun.SendData(tunnel.EncodeVP8Config(s.cfg.VP8FPS, s.cfg.VP8Batch, trackCount))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) maybeStartDCTunnel() {
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.dctun != nil {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pubDC := s.pubReliableDC
|
||||||
|
subDC := s.subReliableDC
|
||||||
|
pubReady := s.pubReliableDCReady
|
||||||
|
s.mu.Unlock()
|
||||||
|
if pubDC == nil || subDC == nil || !pubReady {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if subDC.ReadyState() != webrtc.DataChannelStateOpen {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
subRaw, err := subDC.Detach()
|
||||||
|
if err != nil {
|
||||||
|
s.cfg.Logger.Error(fmt.Sprintf("[lk] detach sub DC: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pubRaw, err := pubDC.Detach()
|
||||||
|
if err != nil {
|
||||||
|
s.cfg.Logger.Error(fmt.Sprintf("[lk] detach pub DC: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
readWrapped := newDataPacketWrapper(subRaw, livekit.DataPacketKindReliable)
|
||||||
|
writeWrapped := newDataPacketWrapper(pubRaw, livekit.DataPacketKindReliable)
|
||||||
|
readBuf := s.cfg.ReadBuf
|
||||||
|
if readBuf == 0 {
|
||||||
|
readBuf = common.DCBufSize
|
||||||
|
}
|
||||||
|
dctun := tunnel.NewChunkedDCTunnelFromRaw(readWrapped, writeWrapped, s.cfg.Obfuscator, readBuf, s.cfg.Logger)
|
||||||
|
if dctun == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
s.dctun = dctun
|
||||||
|
s.mu.Unlock()
|
||||||
|
s.cfg.Logger.Debug("[lk] dc tunnel ready (pub+sub _reliable)")
|
||||||
|
if s.cfg.TunnelMode == TunnelModeDC {
|
||||||
|
s.fireOnConnected(dctun)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.cfg.TunnelMode == "" {
|
||||||
|
dctun.SetOnData(func(payload []byte) { s.activate(dctun, payload) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) fireOnConnected(tun tunnel.DataTunnel) {
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.tunFired {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.tunFired = true
|
||||||
|
s.mu.Unlock()
|
||||||
|
if s.OnConnected != nil {
|
||||||
|
s.OnConnected(tun)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) activate(tun tunnel.DataTunnel, payload []byte) {
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.tunFired {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.tunFired = true
|
||||||
|
s.mu.Unlock()
|
||||||
|
var delivered tunnel.DataTunnel = tun
|
||||||
|
useKCP := false
|
||||||
|
if _, ok := tun.(*tunnel.MultiTrackTunnel); ok && !tunnel.LooksLikeRelayFrame(payload) {
|
||||||
|
delivered = s.maybeWrapReliable(tun)
|
||||||
|
useKCP = true
|
||||||
|
}
|
||||||
|
s.cfg.Logger.Debug(fmt.Sprintf("[lk] auto-detected active tunnel: %T", delivered))
|
||||||
|
if s.OnConnected != nil {
|
||||||
|
s.OnConnected(delivered)
|
||||||
|
}
|
||||||
|
switch v := tun.(type) {
|
||||||
|
case *tunnel.DCTunnel:
|
||||||
|
if fwd := v.OnData(); fwd != nil {
|
||||||
|
fwd(payload)
|
||||||
|
}
|
||||||
|
case *tunnel.MultiTrackTunnel:
|
||||||
|
if useKCP {
|
||||||
|
if kcptun, ok := delivered.(*tunnel.MultiTrackKCPTunnel); ok {
|
||||||
|
kcptun.InjectSegment(payload)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
v.DeliverData(payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) maybeWrapReliable(tun tunnel.DataTunnel) tunnel.DataTunnel {
|
||||||
|
vp8, ok := tun.(*tunnel.MultiTrackTunnel)
|
||||||
|
if !ok {
|
||||||
|
return tun
|
||||||
|
}
|
||||||
|
wrapped := tunnel.NewMultiTrackKCPTunnel(vp8, s.cfg.Logger)
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.kcptun != nil {
|
||||||
|
s.kcptun.StopLayer()
|
||||||
|
}
|
||||||
|
s.kcptun = wrapped
|
||||||
|
s.mu.Unlock()
|
||||||
|
s.cfg.Logger.Debug("[lk] per-track kcp reliability active over video tunnel")
|
||||||
|
return wrapped
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) currentVP8Tun() *tunnel.MultiTrackTunnel {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.vp8tun
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) removePublisherTrack() bool {
|
||||||
|
s.mu.Lock()
|
||||||
|
if len(s.sampleTransceivers) <= 1 || len(s.sampleTracks) <= 1 {
|
||||||
|
s.mu.Unlock()
|
||||||
|
s.cfg.Logger.Debug("[lk] adapt-track-count: refusing to remove cam slot")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
last := len(s.sampleTransceivers) - 1
|
||||||
|
trx := s.sampleTransceivers[last]
|
||||||
|
s.sampleTransceivers = s.sampleTransceivers[:last]
|
||||||
|
s.sampleTracks = s.sampleTracks[:last]
|
||||||
|
vp8 := s.vp8tun
|
||||||
|
kcptun := s.kcptun
|
||||||
|
s.mu.Unlock()
|
||||||
|
if kcptun != nil {
|
||||||
|
kcptun.RemoveLastSession()
|
||||||
|
}
|
||||||
|
if vp8 != nil {
|
||||||
|
vp8.RemoveLastSubTunnel()
|
||||||
|
}
|
||||||
|
if err := trx.Stop(); err != nil {
|
||||||
|
s.cfg.Logger.Error(fmt.Sprintf("[lk] adapt-track-count: stop transceiver: %v", err))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) addPublisherTrack(pubPC *webrtc.PeerConnection, slot int) bool {
|
||||||
|
labelPrefix := "screenchannel-"
|
||||||
|
streamPrefix := "tunnel-screen-"
|
||||||
|
source := livekit.TrackSourceScreenShare
|
||||||
|
if slot == 0 {
|
||||||
|
labelPrefix = "videochannel-"
|
||||||
|
streamPrefix = "tunnel-video-"
|
||||||
|
source = livekit.TrackSourceCamera
|
||||||
|
}
|
||||||
|
track, err := webrtc.NewTrackLocalStaticSample(
|
||||||
|
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeVP8, ClockRate: 90000},
|
||||||
|
labelPrefix+uuid.New().String(), streamPrefix+uuid.New().String(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
s.cfg.Logger.Error(fmt.Sprintf("[lk] adapt-track-count: new track slot=%d: %v", slot, err))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
trx, err := pubPC.AddTransceiverFromTrack(track,
|
||||||
|
webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionSendonly})
|
||||||
|
if err != nil {
|
||||||
|
s.cfg.Logger.Error(fmt.Sprintf("[lk] adapt-track-count: add transceiver slot=%d: %v", slot, err))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if err := s.lk.SendAddTrack(track.ID(), "videochannel",
|
||||||
|
livekit.TrackTypeVideo, source, 1280, 720); err != nil {
|
||||||
|
s.cfg.Logger.Error(fmt.Sprintf("[lk] adapt-track-count: send add-track slot=%d: %v", slot, err))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
s.sampleTracks = append(s.sampleTracks, track)
|
||||||
|
s.sampleTransceivers = append(s.sampleTransceivers, trx)
|
||||||
|
vp8 := s.vp8tun
|
||||||
|
kcptun := s.kcptun
|
||||||
|
s.mu.Unlock()
|
||||||
|
if vp8 != nil {
|
||||||
|
newSub := tunnel.NewVP8DataTunnelWithQueue(track, s.cfg.Obfuscator, s.cfg.Logger, tunnel.KCPCarrierQueueDepth)
|
||||||
|
vp8.AddSubTunnel(newSub)
|
||||||
|
if kcptun != nil {
|
||||||
|
kcptun.AddSession(newSub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) rearmAutoDetect() {
|
||||||
|
if s.cfg.TunnelMode != "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
s.tunFired = false
|
||||||
|
orphanKCP := s.kcptun
|
||||||
|
s.kcptun = nil
|
||||||
|
vp8 := s.vp8tun
|
||||||
|
dc := s.dctun
|
||||||
|
s.mu.Unlock()
|
||||||
|
if orphanKCP != nil {
|
||||||
|
orphanKCP.StopLayer()
|
||||||
|
}
|
||||||
|
if vp8 != nil {
|
||||||
|
vp8.SetOnData(func(payload []byte) { s.activate(vp8, payload) })
|
||||||
|
}
|
||||||
|
if dc != nil {
|
||||||
|
dc.SetOnData(func(payload []byte) { s.activate(dc, payload) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) onRemoteTrack(track *webrtc.TrackRemote, _ *webrtc.RTPReceiver) {
|
||||||
|
if track.Codec().MimeType != webrtc.MimeTypeVP8 {
|
||||||
|
go func() {
|
||||||
|
buf := make([]byte, common.UDPBufSize)
|
||||||
|
for {
|
||||||
|
if _, _, err := track.Read(buf); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go s.readVP8Track(track)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) readVP8Track(track *webrtc.TrackRemote) {
|
||||||
|
var vp8Pkt codecs.VP8Packet
|
||||||
|
var pkt rtp.Packet
|
||||||
|
var frameBuf []byte
|
||||||
|
var lastSeq uint16
|
||||||
|
var haveLastSeq bool
|
||||||
|
frameValid := false
|
||||||
|
var recvCount int
|
||||||
|
buf := make([]byte, common.RTPBufSize)
|
||||||
|
for {
|
||||||
|
n, _, err := track.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if pkt.Unmarshal(buf[:n]) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if haveLastSeq && pkt.SequenceNumber != lastSeq+1 {
|
||||||
|
frameValid = false
|
||||||
|
frameBuf = frameBuf[:0]
|
||||||
|
}
|
||||||
|
lastSeq = pkt.SequenceNumber
|
||||||
|
haveLastSeq = true
|
||||||
|
vp8Payload, err := vp8Pkt.Unmarshal(pkt.Payload)
|
||||||
|
if err != nil {
|
||||||
|
frameValid = false
|
||||||
|
frameBuf = frameBuf[:0]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if vp8Pkt.S == 1 {
|
||||||
|
frameBuf = frameBuf[:0]
|
||||||
|
frameValid = true
|
||||||
|
}
|
||||||
|
if !frameValid {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
frameBuf = append(frameBuf, vp8Payload...)
|
||||||
|
if !pkt.Marker {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
recvCount++
|
||||||
|
if recvCount <= 3 || recvCount%200 == 0 {
|
||||||
|
s.cfg.Logger.Debug(fmt.Sprintf("[lk-video] recv vp8 frame #%d %d bytes", recvCount, len(frameBuf)))
|
||||||
|
}
|
||||||
|
tun := s.currentVP8Tun()
|
||||||
|
if tun != nil {
|
||||||
|
tun.HandleFrame(frameBuf)
|
||||||
|
}
|
||||||
|
frameBuf = frameBuf[:0]
|
||||||
|
frameValid = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) onRemoteDataChannel(dc *webrtc.DataChannel) {
|
||||||
|
s.cfg.Logger.Debug(fmt.Sprintf("[lk] remote DC label=%s id=%v", dc.Label(), dc.ID()))
|
||||||
|
if dc.Label() != "_reliable" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
s.subReliableDC = dc
|
||||||
|
s.mu.Unlock()
|
||||||
|
dc.OnOpen(func() {
|
||||||
|
s.cfg.Logger.Debug("[lk] remote _reliable DC open")
|
||||||
|
s.maybeStartDCTunnel()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) onParticipantUpdate(updates []livekit.ParticipantInfo) {
|
||||||
|
selfSID := s.lk.Join().ParticipantSID
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.peersBySID == nil {
|
||||||
|
s.peersBySID = make(map[string]peerEntry)
|
||||||
|
}
|
||||||
|
newcomerSIDs := make(map[string]bool)
|
||||||
|
canPromote := s.cfg.AccessToken != "" && s.cfg.RoomID != ""
|
||||||
|
for _, p := range updates {
|
||||||
|
if p.SID == "" || p.SID == selfSID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if p.State == livekit.ParticipantStateDisconnected {
|
||||||
|
delete(s.peersBySID, p.SID)
|
||||||
|
delete(s.kickedSIDs, p.SID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if s.kickedSIDs[p.SID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entry, ok := s.peersBySID[p.SID]
|
||||||
|
if !ok {
|
||||||
|
entry = peerEntry{sid: p.SID, identity: p.Identity, firstSeen: time.Now()}
|
||||||
|
newcomerSIDs[p.SID] = true
|
||||||
|
}
|
||||||
|
if p.Identity != "" {
|
||||||
|
entry.identity = p.Identity
|
||||||
|
}
|
||||||
|
entry.state = p.State
|
||||||
|
s.peersBySID[p.SID] = entry
|
||||||
|
}
|
||||||
|
var stale []peerEntry
|
||||||
|
staleSIDs := make(map[string]bool)
|
||||||
|
if len(newcomerSIDs) > 0 {
|
||||||
|
for _, e := range s.peersBySID {
|
||||||
|
if e.state == livekit.ParticipantStateActive && !newcomerSIDs[e.sid] {
|
||||||
|
stale = append(stale, e)
|
||||||
|
staleSIDs[e.sid] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var toPromote []peerEntry
|
||||||
|
if canPromote {
|
||||||
|
for sid, entry := range s.peersBySID {
|
||||||
|
if staleSIDs[sid] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !entry.promoted && entry.state == livekit.ParticipantStateActive && entry.identity != "" {
|
||||||
|
entry.promoted = true
|
||||||
|
s.peersBySID[sid] = entry
|
||||||
|
toPromote = append(toPromote, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
for _, e := range toPromote {
|
||||||
|
go s.promotePeer(e.sid, e.identity)
|
||||||
|
}
|
||||||
|
if len(stale) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, e := range stale {
|
||||||
|
if e.identity == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := KickParticipant(common.HttpClient(s.cfg.Dialer), s.cfg.AccessToken, s.cfg.RoomID, e.identity); err != nil {
|
||||||
|
s.cfg.Logger.Warn(fmt.Sprintf("[wb] kick failed identity=%s: %v", e.identity, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.cfg.Logger.Info(fmt.Sprintf("[wb] kicked stale peer identity=%s sid=%s", e.identity, e.sid))
|
||||||
|
s.mu.Lock()
|
||||||
|
delete(s.peersBySID, e.sid)
|
||||||
|
if s.kickedSIDs == nil {
|
||||||
|
s.kickedSIDs = make(map[string]bool)
|
||||||
|
}
|
||||||
|
s.kickedSIDs[e.sid] = true
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) promotePeer(sid, identity string) {
|
||||||
|
if err := SetParticipantPermissions(common.HttpClient(s.cfg.Dialer), s.cfg.AccessToken, s.cfg.RoomID, identity, ModeratorPermissions); err != nil {
|
||||||
|
s.cfg.Logger.Warn(fmt.Sprintf("[wb] promote failed identity=%s: %v", identity, err))
|
||||||
|
s.mu.Lock()
|
||||||
|
if entry, ok := s.peersBySID[sid]; ok {
|
||||||
|
entry.promoted = false
|
||||||
|
s.peersBySID[sid] = entry
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.cfg.Logger.Info(fmt.Sprintf("[wb] promoted to moderator identity=%s sid=%s", identity, sid))
|
||||||
|
}
|
||||||
293
transport/call/wtsignal/wtsignal.go
Normal file
293
transport/call/wtsignal/wtsignal.go
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
package wtsignal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"compress/flate"
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/quic-go/quic-go"
|
||||||
|
"github.com/quic-go/quic-go/http3"
|
||||||
|
"github.com/quic-go/quic-go/quicvarint"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
dialTimeout = 15 * time.Second
|
||||||
|
keepAlivePeriod = 15 * time.Second
|
||||||
|
maxIdleTimeout = 30 * time.Second
|
||||||
|
maxMessageSize = 8 << 20
|
||||||
|
|
||||||
|
webTransportFrameType uint64 = 0x41
|
||||||
|
webTransportUniStreamType uint64 = 0x54
|
||||||
|
|
||||||
|
settingsEnableWebtransportDraft06 = 0x2b603742
|
||||||
|
settingsWebTransportEnabled = 0x2c7cf000
|
||||||
|
settingsWebTransportMaxSessions = 0x14e9cd29
|
||||||
|
settingsWebTransportMaxSessionsStd = 0xc671706a
|
||||||
|
|
||||||
|
closeSessionCapsuleType http3.CapsuleType = 0x2843
|
||||||
|
|
||||||
|
protocolHeaderLegacy = "webtransport"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Conn struct {
|
||||||
|
conn *quic.Conn
|
||||||
|
stream *quic.Stream
|
||||||
|
reader *bufio.Reader
|
||||||
|
compress bool
|
||||||
|
writeMu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func Dial(endpoint, serverName, resolvedIP string) (*Conn, error) {
|
||||||
|
target, err := url.Parse(endpoint)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
port := target.Port()
|
||||||
|
if port == "" {
|
||||||
|
port = "443"
|
||||||
|
}
|
||||||
|
compress := target.Query().Get("compression") == "deflate-raw"
|
||||||
|
|
||||||
|
tlsConf := &tls.Config{
|
||||||
|
InsecureSkipVerify: true,
|
||||||
|
ServerName: serverName,
|
||||||
|
NextProtos: []string{"h3"},
|
||||||
|
}
|
||||||
|
quicConf := &quic.Config{
|
||||||
|
EnableDatagrams: true,
|
||||||
|
EnableStreamResetPartialDelivery: true,
|
||||||
|
KeepAlivePeriod: keepAlivePeriod,
|
||||||
|
MaxIdleTimeout: maxIdleTimeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
dialCtx, cancel := context.WithTimeout(context.Background(), dialTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
qconn, err := quic.DialAddrEarly(dialCtx, net.JoinHostPort(resolvedIP, port), tlsConf, quicConf)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("wt dial: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The VK/OK SFU advertises the HTTP/3 datagram setting but does not
|
||||||
|
// negotiate QUIC transport-level datagrams, which makes quic-go's http3
|
||||||
|
// layer close the connection. Signaling only uses WebTransport streams, so
|
||||||
|
// disable HTTP/3 datagrams on our side, and send the draft-06
|
||||||
|
// ENABLE_WEBTRANSPORT codepoint the SFU expects.
|
||||||
|
tr := &http3.Transport{
|
||||||
|
EnableDatagrams: false,
|
||||||
|
AdditionalSettings: map[uint64]uint64{settingsEnableWebtransportDraft06: 1},
|
||||||
|
}
|
||||||
|
control := tr.NewRawClientConn(qconn)
|
||||||
|
context.AfterFunc(qconn.Context(), func() { tr.Close() })
|
||||||
|
|
||||||
|
go acceptStreams(qconn, control)
|
||||||
|
go acceptUniStreams(qconn, control)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-control.ReceivedSettings():
|
||||||
|
case <-dialCtx.Done():
|
||||||
|
qconn.CloseWithError(0, "")
|
||||||
|
return nil, fmt.Errorf("wt settings: %w", dialCtx.Err())
|
||||||
|
}
|
||||||
|
settings := control.Settings()
|
||||||
|
if !settings.EnableExtendedConnect {
|
||||||
|
qconn.CloseWithError(0, "")
|
||||||
|
return nil, fmt.Errorf("wt: server did not enable extended connect")
|
||||||
|
}
|
||||||
|
|
||||||
|
if settings.Other[settingsWebTransportEnabled] == 0 &&
|
||||||
|
settings.Other[settingsEnableWebtransportDraft06] == 0 &&
|
||||||
|
settings.Other[settingsWebTransportMaxSessions] == 0 &&
|
||||||
|
settings.Other[settingsWebTransportMaxSessionsStd] == 0 {
|
||||||
|
qconn.CloseWithError(0, "")
|
||||||
|
return nil, fmt.Errorf("wt: server did not enable webtransport")
|
||||||
|
}
|
||||||
|
|
||||||
|
requestStr, err := control.OpenRequestStream(dialCtx)
|
||||||
|
if err != nil {
|
||||||
|
qconn.CloseWithError(0, "")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req := (&http.Request{
|
||||||
|
Method: http.MethodConnect,
|
||||||
|
Header: http.Header{},
|
||||||
|
Proto: protocolHeaderLegacy,
|
||||||
|
Host: target.Host,
|
||||||
|
URL: target,
|
||||||
|
}).WithContext(dialCtx)
|
||||||
|
if err := requestStr.SendRequestHeader(req); err != nil {
|
||||||
|
qconn.CloseWithError(0, "")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rsp, err := requestStr.ReadResponse()
|
||||||
|
if err != nil {
|
||||||
|
qconn.CloseWithError(0, "")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if rsp.StatusCode < 200 || rsp.StatusCode >= 300 {
|
||||||
|
qconn.CloseWithError(0, "")
|
||||||
|
return nil, fmt.Errorf("wt: connect status %d", rsp.StatusCode)
|
||||||
|
}
|
||||||
|
sessionID := uint64(requestStr.StreamID())
|
||||||
|
|
||||||
|
go watchSessionClose(requestStr, qconn)
|
||||||
|
|
||||||
|
stream, err := qconn.OpenStreamSync(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
qconn.CloseWithError(0, "")
|
||||||
|
return nil, fmt.Errorf("wt open stream: %w", err)
|
||||||
|
}
|
||||||
|
streamHdr := quicvarint.Append(nil, webTransportFrameType)
|
||||||
|
streamHdr = quicvarint.Append(streamHdr, sessionID)
|
||||||
|
if _, err := stream.Write(streamHdr); err != nil {
|
||||||
|
qconn.CloseWithError(0, "")
|
||||||
|
return nil, fmt.Errorf("wt stream header: %w", err)
|
||||||
|
}
|
||||||
|
stream.SetReliableBoundary()
|
||||||
|
|
||||||
|
return &Conn{
|
||||||
|
conn: qconn,
|
||||||
|
stream: stream,
|
||||||
|
reader: bufio.NewReader(stream),
|
||||||
|
compress: compress,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func acceptStreams(qconn *quic.Conn, control *http3.RawClientConn) {
|
||||||
|
for {
|
||||||
|
stream, err := qconn.AcceptStream(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
typ, err := quicvarint.Peek(stream)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if typ != webTransportFrameType {
|
||||||
|
control.HandleBidirectionalStream(stream)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := quicvarint.Read(quicvarint.NewReader(stream)); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := quicvarint.Read(quicvarint.NewReader(stream)); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
io.Copy(io.Discard, stream)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func acceptUniStreams(qconn *quic.Conn, control *http3.RawClientConn) {
|
||||||
|
for {
|
||||||
|
stream, err := qconn.AcceptUniStream(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
typ, err := quicvarint.Peek(stream)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if typ != webTransportUniStreamType {
|
||||||
|
control.HandleUnidirectionalStream(stream)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := quicvarint.Read(quicvarint.NewReader(stream)); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := quicvarint.Read(quicvarint.NewReader(stream)); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
io.Copy(io.Discard, stream)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func watchSessionClose(requestStr *http3.RequestStream, qconn *quic.Conn) {
|
||||||
|
for {
|
||||||
|
typ, r, err := http3.ParseCapsule(quicvarint.NewReader(requestStr))
|
||||||
|
if err != nil {
|
||||||
|
qconn.CloseWithError(0, "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if typ == closeSessionCapsuleType {
|
||||||
|
qconn.CloseWithError(0, "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
io.Copy(io.Discard, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Conn) Send(payload []byte) error {
|
||||||
|
c.writeMu.Lock()
|
||||||
|
defer c.writeMu.Unlock()
|
||||||
|
if c.compress {
|
||||||
|
compressed, err := deflateRaw(payload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
payload = compressed
|
||||||
|
}
|
||||||
|
buf := quicvarint.Append(make([]byte, 0, len(payload)+8), uint64(len(payload)))
|
||||||
|
buf = append(buf, payload...)
|
||||||
|
_, err := c.stream.Write(buf)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Conn) Recv() ([]byte, error) {
|
||||||
|
length, err := quicvarint.Read(c.reader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if length > maxMessageSize {
|
||||||
|
return nil, fmt.Errorf("wt message too large: %d", length)
|
||||||
|
}
|
||||||
|
payload := make([]byte, length)
|
||||||
|
if _, err := io.ReadFull(c.reader, payload); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if c.compress {
|
||||||
|
return inflateRaw(payload)
|
||||||
|
}
|
||||||
|
return payload, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func deflateRaw(payload []byte) ([]byte, error) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
writer, err := flate.NewWriter(&buf, flate.DefaultCompression)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := writer.Write(payload); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := writer.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func inflateRaw(payload []byte) ([]byte, error) {
|
||||||
|
reader := flate.NewReader(bytes.NewReader(payload))
|
||||||
|
defer reader.Close()
|
||||||
|
return io.ReadAll(reader)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Conn) Close() error {
|
||||||
|
if c.conn != nil {
|
||||||
|
return c.conn.CloseWithError(0, "")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user