7 Commits

Author SHA1 Message Date
Anton 859c7ff683 Merge pull request #42 from samosvalishe/release-please--branches--master
chore(master): release 1.7.0
2026-07-02 21:22:33 +03:00
Anton f3d8da6919 chore(master): release 1.7.0 2026-07-02 12:30:15 +03:00
samosvalishe b391d7bf25 fix(vkauth): классификация terminal-ошибок по коду перед текстом
Транзиентные коды (5, 6, 9, 14, 29) короткозамыкаются в nil до матча по
error_msg, поэтому подстрока в сообщении больше не может пометить рабочую
ошибку как терминальную и уронить подключение через ErrFatalNoStreams.
2026-07-02 12:28:31 +03:00
Anton 0a6bc0ae4f Merge pull request #36 from smediainfo/feat/host-socket-protect
feat(mobile): add host socket protector (SetProtect) for tun/VPN hosts
2026-07-01 19:30:28 +03:00
Anton 805228e62a Merge pull request #35 from smediainfo/feat/terminal-link-errors
fix(vkauth): fast-fail on terminal call/link errors (9000/9008, anonymous-only, full)
2026-07-01 19:29:28 +03:00
smediainfo 3481de79a3 feat(mobile): add host socket protector (SetProtect) for tun/VPN hosts
Checked type assertions (errcheck) and Close() error returns to satisfy golangci-lint.
2026-06-28 15:31:04 +03:00
smediainfo 014f964ad9 fix(vkauth): fast-fail on terminal call/link errors (9000/9008, anonymous-only, full) 2026-06-28 15:21:28 +03:00
10 changed files with 216 additions and 14 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "1.6.0"
".": "1.7.0"
}
+13
View File
@@ -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)
+3 -1
View File
@@ -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
View File
@@ -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}
}
+28
View File
@@ -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"))
)
+7 -1
View File
@@ -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 {
+30
View File
@@ -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)) })
})
}