Add call protocol, Rmux. Update AmneziaWG. Fixes and improvements

This commit is contained in:
Shtorm
2026-08-06 14:19:34 +03:00
parent 051e928b01
commit d6b9f693c4
89 changed files with 15671 additions and 132 deletions

View File

@@ -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

View File

@@ -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 (v1v5) with TLS / HTTP obfuscation - **Snell** — Lightweight encrypted proxy (v1v5) 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

View File

@@ -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")

View File

@@ -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:

View File

@@ -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.

View File

@@ -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。

View 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
}
}

View 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
}
}

View 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
}
}

View 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
}
}

View 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
}
}

View 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
}
}

View 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
}
}

View 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
View 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
View 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
View File

@@ -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
View File

@@ -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
View 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
View 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`)
})
}

View File

@@ -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
View 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"`
}

View File

@@ -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
View 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
View 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
}

View File

@@ -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 {

View File

@@ -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)
} }

View File

@@ -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
} }

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 {

View File

@@ -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

View File

@@ -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=

View File

@@ -17,6 +17,7 @@ var muxProtocols = []string{
"h2mux", "h2mux",
"smux", "smux",
"yamux", "yamux",
"rmux",
} }
func TestVMessSMux(t *testing.T) { func TestVMessSMux(t *testing.T) {

View File

@@ -0,0 +1,8 @@
package common
const (
UDPBufSize = 4096
RTPBufSize = 65536
VP8BufSize = 1126
DCBufSize = 32768
)

View 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)

View 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, "; ")
}

View 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
}

View 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)
}

View 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]) + "***"
}

View 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
}
}

View 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
View 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
View 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
View 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
}
}
}

View 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()
}
}

View 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
}
}

View 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"
}

View 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
}

View 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)
}
}
}

View 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
}

View 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)
}
}
}

View 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
}

View 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)
}

View 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{}{},
},
}
}

View 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
}

View 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
}

View 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 ""
}

View 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), &params); 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")
}

View 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)
}
}
}

View 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))
}
}
}

View 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)
}

View 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"
}
}

View 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))
}
}
}
}

View 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()
}
})
}

View 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
}

View 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:]
}
}

View 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)
}
}

View 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()
}

View 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
View 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)
}

View 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
}

View 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
}

View 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), &params); 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()
}
}

View 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
View 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), &params); err != nil {
h.logger.Error(fmt.Sprintf("vk-joiner: failed to parse auth params: %v", err))
return
}
h.authParams = &params
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
View 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
View 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
}
}

View 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)
})
}

View 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
}

View 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
}

View 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
}

View 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}
}

View 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), &params); 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}
}

View 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))
}

View 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
}