mirror of
https://github.com/hackdiaz-dev/free-turn-proxy.git
synced 2026-10-12 09:41:53 +02:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 859c7ff683 | |||
| f3d8da6919 | |||
| b391d7bf25 | |||
| 0a6bc0ae4f | |||
| 805228e62a | |||
| 3481de79a3 | |||
| 014f964ad9 |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "1.6.0"
|
||||
".": "1.7.0"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
# Changelog
|
||||
|
||||
## [1.7.0](https://github.com/samosvalishe/free-turn-proxy/compare/v1.6.0...v1.7.0) (2026-07-02)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **mobile:** add host socket protector (SetProtect) for tun/VPN hosts ([0a6bc0a](https://github.com/samosvalishe/free-turn-proxy/commit/0a6bc0ae4f38f03722855f99461cc91f4181e057))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **vkauth:** fast-fail on terminal call/link errors (9000/9008, anonymous-only, full) ([805228e](https://github.com/samosvalishe/free-turn-proxy/commit/805228e62a42f71ab6c0d345e2a510af5cae32c3))
|
||||
* **vkauth:** классификация terminal-ошибок по коду перед текстом ([b391d7b](https://github.com/samosvalishe/free-turn-proxy/commit/b391d7bf255e6f027d655c7e1a20a21ce04ab22a))
|
||||
|
||||
## [1.6.0](https://github.com/samosvalishe/free-turn-proxy/compare/v1.5.2...v1.6.0) (2026-06-30)
|
||||
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/miekg/dns"
|
||||
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/logx"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/netctl"
|
||||
|
||||
// встроенные Mozilla CA roots для CGO_ENABLED=0 сборок (Android).
|
||||
_ "golang.org/x/crypto/x509roots/fallback"
|
||||
@@ -98,7 +99,7 @@ func newBootstrapTransport(endpoints []DohEndpoint) *http.Transport {
|
||||
for _, ep := range endpoints {
|
||||
bootstrap[ep.Hostname] = ep.BootstrapIPs
|
||||
}
|
||||
dialer := &net.Dialer{Timeout: dohDialerTimeout, KeepAlive: dohDialerKeepAlive}
|
||||
dialer := &net.Dialer{Timeout: dohDialerTimeout, KeepAlive: dohDialerKeepAlive, Control: netctl.Apply}
|
||||
|
||||
return &http.Transport{
|
||||
MaxIdleConns: 8,
|
||||
@@ -440,6 +441,7 @@ func newAppDialer(dial dialFunc) net.Dialer {
|
||||
Timeout: appDialerTimeout,
|
||||
KeepAlive: appDialerKeepAlive,
|
||||
Resolver: &net.Resolver{PreferGo: true, Dial: dial},
|
||||
Control: netctl.Apply,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+69
-10
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/pion/transport/v4"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/netctl"
|
||||
)
|
||||
|
||||
// DirectNet реализует transport.Net через стандартный net.
|
||||
@@ -34,32 +35,84 @@ type directTCPListener struct {
|
||||
*net.TCPListener
|
||||
}
|
||||
|
||||
// All socket-creating methods route through a net.Dialer / net.ListenConfig
|
||||
// carrying netctl.Apply as Control so the host can protect each fd from the
|
||||
// VPN tunnel. (net.DialUDP / net.ListenUDP take no Control, so we go via
|
||||
// Dialer/ListenConfig and type-assert back to the concrete UDP/TCP conn.)
|
||||
func (DirectNet) ListenPacket(network string, address string) (net.PacketConn, error) {
|
||||
return net.ListenPacket(network, address) //nolint:noctx
|
||||
return (&net.ListenConfig{Control: netctl.Apply}).ListenPacket(context.Background(), network, address)
|
||||
}
|
||||
|
||||
func (DirectNet) ListenUDP(network string, locAddr *net.UDPAddr) (transport.UDPConn, error) {
|
||||
return net.ListenUDP(network, locAddr)
|
||||
}
|
||||
|
||||
func (DirectNet) ListenTCP(network string, laddr *net.TCPAddr) (transport.TCPListener, error) {
|
||||
listener, err := net.ListenTCP(network, laddr)
|
||||
addr := ""
|
||||
if locAddr != nil {
|
||||
addr = locAddr.String()
|
||||
}
|
||||
pc, err := (&net.ListenConfig{Control: netctl.Apply}).ListenPacket(context.Background(), network, addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return directTCPListener{listener}, nil
|
||||
udp, ok := pc.(*net.UDPConn)
|
||||
if !ok {
|
||||
_ = pc.Close()
|
||||
return nil, fmt.Errorf("netconn: expected *net.UDPConn, got %T", pc)
|
||||
}
|
||||
return udp, nil
|
||||
}
|
||||
|
||||
func (DirectNet) ListenTCP(network string, laddr *net.TCPAddr) (transport.TCPListener, error) {
|
||||
addr := ""
|
||||
if laddr != nil {
|
||||
addr = laddr.String()
|
||||
}
|
||||
l, err := (&net.ListenConfig{Control: netctl.Apply}).Listen(context.Background(), network, addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tl, ok := l.(*net.TCPListener)
|
||||
if !ok {
|
||||
_ = l.Close()
|
||||
return nil, fmt.Errorf("netconn: expected *net.TCPListener, got %T", l)
|
||||
}
|
||||
return directTCPListener{tl}, nil
|
||||
}
|
||||
|
||||
func (DirectNet) Dial(network, address string) (net.Conn, error) {
|
||||
return net.Dial(network, address) //nolint:noctx
|
||||
return (&net.Dialer{Control: netctl.Apply}).Dial(network, address)
|
||||
}
|
||||
|
||||
func (DirectNet) DialUDP(network string, laddr, raddr *net.UDPAddr) (transport.UDPConn, error) {
|
||||
return net.DialUDP(network, laddr, raddr)
|
||||
d := &net.Dialer{Control: netctl.Apply}
|
||||
if laddr != nil {
|
||||
d.LocalAddr = laddr
|
||||
}
|
||||
c, err := d.Dial(network, raddr.String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
udp, ok := c.(*net.UDPConn)
|
||||
if !ok {
|
||||
_ = c.Close()
|
||||
return nil, fmt.Errorf("netconn: expected *net.UDPConn, got %T", c)
|
||||
}
|
||||
return udp, nil
|
||||
}
|
||||
|
||||
func (DirectNet) DialTCP(network string, laddr, raddr *net.TCPAddr) (transport.TCPConn, error) {
|
||||
return net.DialTCP(network, laddr, raddr)
|
||||
d := &net.Dialer{Control: netctl.Apply}
|
||||
if laddr != nil {
|
||||
d.LocalAddr = laddr
|
||||
}
|
||||
c, err := d.Dial(network, raddr.String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tcp, ok := c.(*net.TCPConn)
|
||||
if !ok {
|
||||
_ = c.Close()
|
||||
return nil, fmt.Errorf("netconn: expected *net.TCPConn, got %T", c)
|
||||
}
|
||||
return tcp, nil
|
||||
}
|
||||
|
||||
func (DirectNet) ResolveIPAddr(network, address string) (*net.IPAddr, error) {
|
||||
@@ -87,10 +140,16 @@ func (DirectNet) InterfaceByName(name string) (*transport.Interface, error) {
|
||||
}
|
||||
|
||||
func (DirectNet) CreateDialer(dialer *net.Dialer) transport.Dialer {
|
||||
if dialer.Control == nil {
|
||||
dialer.Control = netctl.Apply
|
||||
}
|
||||
return directDialer{Dialer: dialer}
|
||||
}
|
||||
|
||||
func (DirectNet) CreateListenConfig(listenerConfig *net.ListenConfig) transport.ListenConfig {
|
||||
if listenerConfig.Control == nil {
|
||||
listenerConfig.Control = netctl.Apply
|
||||
}
|
||||
return directListenConfig{ListenConfig: listenerConfig}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// Package netctl carries a process-global socket Control hook applied to every
|
||||
// OUTBOUND socket the vendored free-turn client opens. The host installs it so
|
||||
// those sockets are excluded from the VPN tunnel (Android VpnService.protect on
|
||||
// the raw fd) — without it the client's own TURN/VK/DNS traffic loops back into
|
||||
// the tunnel and hangs. This patch is local to our vendored copy: upstream has
|
||||
// no such hook, and the dialers it creates (netconn.DirectNet, dnsdial,
|
||||
// turndial) are routed through Apply below.
|
||||
package netctl
|
||||
|
||||
import "syscall"
|
||||
|
||||
var control func(network, address string, c syscall.RawConn) error
|
||||
|
||||
// SetControl installs the per-socket protector. Call before the client starts.
|
||||
// nil (the default) makes Apply a no-op, so desktop builds are unaffected.
|
||||
func SetControl(fn func(network, address string, c syscall.RawConn) error) {
|
||||
control = fn
|
||||
}
|
||||
|
||||
// Apply is assigned as the Control func of every outbound net.Dialer /
|
||||
// net.ListenConfig in the vendored client. It reads `control` at dial time so
|
||||
// the host may register the protector before or after the client starts.
|
||||
func Apply(network, address string, c syscall.RawConn) error {
|
||||
if control != nil {
|
||||
return control(network, address, c)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -257,7 +257,9 @@ func (c *Client) fetch(ctx context.Context, link string, streamID int) (string,
|
||||
lastErr = err
|
||||
c.log.Warnf("[STREAM %d] [VK Auth] Failed with client_id=%s: %v", streamID, creds.ClientID, err)
|
||||
|
||||
if errors.Is(err, ErrCaptchaWaitRequired) || errors.Is(err, ErrFatalCaptchaNoStreams) {
|
||||
if errors.Is(err, ErrCaptchaWaitRequired) || errors.Is(err, ErrFatalCaptchaNoStreams) ||
|
||||
errors.Is(err, ErrInvalidJoinLink) || errors.Is(err, ErrAnonymousBlocked) ||
|
||||
errors.Is(err, ErrCallFull) {
|
||||
return "", "", nil, err
|
||||
}
|
||||
es := err.Error()
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
neturl "net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/browserprofile"
|
||||
@@ -43,6 +44,10 @@ func (c *Client) fetchCallToken(
|
||||
data = retryData
|
||||
continue
|
||||
}
|
||||
if termErr := classifyLinkError(errObj); termErr != nil {
|
||||
c.log.Errorf("[STREAM %d] [VK Auth] terminal link error: %v", streamID, termErr)
|
||||
return "", termErr
|
||||
}
|
||||
return "", fmt.Errorf("VK API error: %v", errObj)
|
||||
}
|
||||
|
||||
@@ -58,6 +63,48 @@ func (c *Client) fetchCallToken(
|
||||
}
|
||||
}
|
||||
|
||||
// classifyLinkError распознаёт ТЕРМИНАЛЬНЫЕ ответы VK в join-флоу и возвращает
|
||||
// соответствующий sentinel (или nil, если ошибку можно ретраить дальше по
|
||||
// client_id). Терминал = ни client_id, ни captcha не помогут, поэтому fast-fail
|
||||
// вместо бесконечного цикла "решить captcha -> ошибка -> следующий client_id".
|
||||
//
|
||||
// Порядок важен: сначала явные коды (терминальные и транзиентные), и только для
|
||||
// неизвестного кода - матч по тексту error_msg. Так текстовый матч не может
|
||||
// перекрыть транзиентный код (5 auth-failed, 6/9/29 rate/flood, 14 captcha) и
|
||||
// убить рабочее подключение из-за подстроки в сообщении.
|
||||
func classifyLinkError(errObj map[string]any) error {
|
||||
code := 0
|
||||
if f, ok := errObj["error_code"].(float64); ok {
|
||||
code = int(f)
|
||||
}
|
||||
|
||||
// Явные терминальные коды: 9008 Join link is not valid, 9000 Call not found.
|
||||
if code == 9000 || code == 9008 {
|
||||
return ErrInvalidJoinLink
|
||||
}
|
||||
// Явные транзиентные коды: гасить нельзя, текст ниже к ним не применяем.
|
||||
switch code {
|
||||
case 5, 6, 9, 14, 29:
|
||||
return nil
|
||||
}
|
||||
|
||||
// Код неизвестен/плавает - осторожный матч по тексту.
|
||||
msg := ""
|
||||
if s, ok := errObj["error_msg"].(string); ok {
|
||||
msg = strings.ToLower(s)
|
||||
}
|
||||
switch {
|
||||
case strings.Contains(msg, "not valid") || strings.Contains(msg, "not found"):
|
||||
return ErrInvalidJoinLink
|
||||
// Анонимный вход запрещён. Матчим по "anonym" (не "authoriz" - коллизия с auth-failed).
|
||||
case strings.Contains(msg, "anonym"):
|
||||
return ErrAnonymousBlocked
|
||||
case strings.Contains(msg, "full"):
|
||||
return ErrCallFull
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// solveCaptcha выполняет одну попытку решения captcha и возвращает тело POST
|
||||
// для следующего retry или ошибку при исчерпании всех режимов.
|
||||
func (c *Client) solveCaptcha(
|
||||
|
||||
@@ -59,4 +59,19 @@ var (
|
||||
ErrCaptchaWaitRequired = errors.Join(provider.ErrBackoffActive, errors.New("CAPTCHA_WAIT_REQUIRED"))
|
||||
ErrFatalCaptchaNoStreams = errors.Join(provider.ErrFatalNoStreams, errors.New("FATAL_CAPTCHA_FAILED_NO_STREAMS"))
|
||||
ErrLockoutActive = errors.New("global lockout active")
|
||||
|
||||
// Terminal call/link errors: retrying other client_ids or solving the captcha
|
||||
// won't help — only the user changing the link / call settings will. We fast-
|
||||
// fail on these instead of looping "solve captcha -> error -> next credential"
|
||||
// forever, and the joined provider.ErrFatalNoStreams lets the host surface a
|
||||
// specific, actionable message.
|
||||
//
|
||||
// ErrInvalidJoinLink: VK 9008 "Join link is not valid" (broken link) or 9000
|
||||
// "Call not found" (the call ended / no longer exists).
|
||||
ErrInvalidJoinLink = errors.Join(provider.ErrFatalNoStreams, errors.New("INVALID_JOIN_LINK"))
|
||||
// ErrAnonymousBlocked: the call is live but anonymous join is disabled
|
||||
// ("authorized users only") — the user must allow joining by link in VK.
|
||||
ErrAnonymousBlocked = errors.Join(provider.ErrFatalNoStreams, errors.New("ANON_BLOCKED"))
|
||||
// ErrCallFull: the call has reached its participant limit.
|
||||
ErrCallFull = errors.Join(provider.ErrFatalNoStreams, errors.New("CALL_FULL"))
|
||||
)
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/pion/logging"
|
||||
"github.com/pion/turn/v5"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/netconn"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/netctl"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/randx"
|
||||
)
|
||||
|
||||
@@ -83,10 +84,15 @@ func Open(ctx context.Context, cfg Config, peer *net.UDPAddr, user, pass, rawAdd
|
||||
closeConn func() error
|
||||
)
|
||||
if cfg.TransportUDP {
|
||||
c, derr := net.DialUDP("udp", nil, turnServerUDPAddr) //nolint:noctx
|
||||
raw, derr := (&net.Dialer{Control: netctl.Apply}).Dial("udp", turnServerUDPAddr.String())
|
||||
if derr != nil {
|
||||
return nil, fmt.Errorf("dial TURN (udp): %w", derr)
|
||||
}
|
||||
c, ok := raw.(*net.UDPConn)
|
||||
if !ok {
|
||||
_ = raw.Close()
|
||||
return nil, fmt.Errorf("turndial: expected *net.UDPConn, got %T", raw)
|
||||
}
|
||||
turnConn = &netconn.ConnectedUDPConn{UDPConn: c}
|
||||
closeConn = c.Close
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package mobile
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/netctl"
|
||||
)
|
||||
|
||||
// Protector is implemented by the host to keep the client's own sockets out of
|
||||
// a VPN tunnel it is feeding. On Android that is VpnService.protect(fd); on
|
||||
// other tun-based hosts the equivalent. Without it the client's TURN / VK API /
|
||||
// DNS traffic is routed back into the tunnel and the connection deadlocks.
|
||||
type Protector interface {
|
||||
// Protect is invoked once for every outbound socket the client opens, with
|
||||
// the socket's raw file descriptor, before it connects.
|
||||
Protect(fd int) bool
|
||||
}
|
||||
|
||||
// SetProtect installs the host socket protector. Pass nil to clear it (the
|
||||
// default is a no-op, so desktop builds are unaffected). May be called before
|
||||
// or after Start; it is read at dial time.
|
||||
func SetProtect(p Protector) {
|
||||
if p == nil {
|
||||
netctl.SetControl(nil)
|
||||
return
|
||||
}
|
||||
netctl.SetControl(func(_, _ string, c syscall.RawConn) error {
|
||||
return c.Control(func(fd uintptr) { p.Protect(int(fd)) })
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user