From 6570cd9dd760ae00093778cd5a4a095fd6701118 Mon Sep 17 00:00:00 2001 From: samosvalishe Date: Sat, 12 Sep 2026 17:29:09 +0300 Subject: [PATCH] =?UTF-8?q?fix(relay):=20=D0=B2=D0=BE=D1=81=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF?= =?UTF-8?q?=D0=BE=D1=82=D0=BE=D0=BA=D0=BE=D0=B2=20=D0=BF=D1=80=D0=B8=20?= =?UTF-8?q?=D0=BE=D1=82=D0=BA=D0=B0=D0=B7=D0=B0=D1=85=20turn=20=D0=B8=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=BD=D1=8F=D1=82=D0=BE=D0=B9=20=D0=BA=D0=B2=D0=BE?= =?UTF-8?q?=D1=82=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + internal/provider/vk/internal/vkauth/cache.go | 5 ++-- .../vk/internal/vkauth/client_test.go | 3 +- internal/proxy/tcprelay/session_test.go | 10 +++++++ internal/proxy/tcprelay/tcprelay.go | 4 +++ internal/proxy/udprelay/loop.go | 24 +++++++++++++-- internal/proxy/udprelay/pair_test.go | 3 ++ internal/transport/turndial/permwatch.go | 12 +++++++- internal/transport/turndial/permwatch_test.go | 30 ++++++++++++++++++- internal/transport/turndial/turndial.go | 14 +++++++++ internal/transport/turndial/turndial_test.go | 18 +++++++++++ mobile/api.go | 5 ++-- 12 files changed, 117 insertions(+), 12 deletions(-) diff --git a/.gitignore b/.gitignore index b33305e..c1034cf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .idea .claude CLAUDE.md +AGENTS.md *.exe *.so *.tmp diff --git a/internal/provider/vk/internal/vkauth/cache.go b/internal/provider/vk/internal/vkauth/cache.go index a00e29f..6bac239 100644 --- a/internal/provider/vk/internal/vkauth/cache.go +++ b/internal/provider/vk/internal/vkauth/cache.go @@ -80,10 +80,9 @@ func IsAuthError(err error) bool { } // Ответ TURN-сервера приходит типизированным - код берём из него, а не из текста. if turnErr, ok := errors.AsType[*stun.TurnError](err); ok { + // 486 означает занятую квоту, а не невалидные реквизиты. switch turnErr.ErrorCodeAttr.Code { - // 486 - квота аллокаций: креды живы, но новую сессию по ним не поднять. - case stun.CodeUnauthorized, stun.CodeWrongCredentials, - stun.CodeStaleNonce, stun.CodeAllocQuotaReached: + case stun.CodeUnauthorized, stun.CodeWrongCredentials, stun.CodeStaleNonce: return true default: return false diff --git a/internal/provider/vk/internal/vkauth/client_test.go b/internal/provider/vk/internal/vkauth/client_test.go index 87d635c..89d53f1 100644 --- a/internal/provider/vk/internal/vkauth/client_test.go +++ b/internal/provider/vk/internal/vkauth/client_test.go @@ -56,7 +56,8 @@ func TestIsAuthErrorTurnCode(t *testing.T) { t.Parallel() codes := map[stun.ErrorCode]bool{ - stun.CodeAllocQuotaReached: true, + // 486 - занята квота по валидным реквизитам, менять их незачем. + stun.CodeAllocQuotaReached: false, stun.CodeUnauthorized: true, stun.CodeStaleNonce: true, stun.CodeWrongCredentials: true, diff --git a/internal/proxy/tcprelay/session_test.go b/internal/proxy/tcprelay/session_test.go index a51124c..d559c26 100644 --- a/internal/proxy/tcprelay/session_test.go +++ b/internal/proxy/tcprelay/session_test.go @@ -12,6 +12,7 @@ import ( "github.com/samosvalishe/free-turn-proxy/internal/provider" "github.com/samosvalishe/free-turn-proxy/internal/proxy/allocpace" "github.com/samosvalishe/free-turn-proxy/internal/stats" + "github.com/samosvalishe/free-turn-proxy/internal/transport/turndial" ) type fakeAuth struct { @@ -22,6 +23,15 @@ type fakeAuth struct { dropped atomic.Int32 } +func TestRetryDelayAllocationQuota(t *testing.T) { + for range 100 { + delay := retryDelay(&fakeAuth{}, errors.Join(errors.New("allocate"), turndial.ErrAllocQuota)) + if delay < 15*time.Second || delay >= 30*time.Second { + t.Fatalf("quota retry delay = %s", delay) + } + } +} + func (a *fakeAuth) IsAuthError(err error) bool { return a.authErr != nil && errors.Is(err, a.authErr) } diff --git a/internal/proxy/tcprelay/tcprelay.go b/internal/proxy/tcprelay/tcprelay.go index 5bac6f5..56b6e77 100644 --- a/internal/proxy/tcprelay/tcprelay.go +++ b/internal/proxy/tcprelay/tcprelay.go @@ -21,6 +21,7 @@ import ( "github.com/samosvalishe/free-turn-proxy/internal/stats" "github.com/samosvalishe/free-turn-proxy/internal/transport/dtlsdial" "github.com/samosvalishe/free-turn-proxy/internal/transport/kcpmux" + "github.com/samosvalishe/free-turn-proxy/internal/transport/turndial" "github.com/samosvalishe/free-turn-proxy/internal/wire" "github.com/samosvalishe/free-turn-proxy/internal/wire/shape" "github.com/xtaci/smux" @@ -324,6 +325,9 @@ func maintainSession(ctx context.Context, deps *Deps, params *Params, peer *net. // retryDelay: пауза провайдера важнее собственной - ретрай в её середине только продлевает // локаут и жжёт персону. Джиттер разводит одновременный отказ всех сессий пула. func retryDelay(auth AuthHandler, err error) time.Duration { + if errors.Is(err, turndial.ErrAllocQuota) { + return time.Duration(15+randx.Intn(15)) * time.Second + } if errors.Is(err, provider.ErrBackoffActive) { if until := auth.BackoffUntilUnix(); until > 0 { if d := time.Until(time.Unix(until, 0)); d > 0 { diff --git a/internal/proxy/udprelay/loop.go b/internal/proxy/udprelay/loop.go index 7e65895..d98c5d4 100644 --- a/internal/proxy/udprelay/loop.go +++ b/internal/proxy/udprelay/loop.go @@ -13,6 +13,7 @@ import ( "github.com/samosvalishe/free-turn-proxy/internal/clientsdb" "github.com/samosvalishe/free-turn-proxy/internal/provider" "github.com/samosvalishe/free-turn-proxy/internal/randx" + "github.com/samosvalishe/free-turn-proxy/internal/transport/turndial" "github.com/samosvalishe/free-turn-proxy/internal/wire" "github.com/samosvalishe/free-turn-proxy/internal/wire/shape" ) @@ -21,6 +22,11 @@ import ( // backoff тут только удлиняет простой. var errPairRecycled = errors.New("udprelay: stream pair recycled") +const ( + quotaBackoff = 15 * time.Second + quotaBackoffJitter = 15 * time.Second +) + // streamPair связывает DTLS-сессию с аллокацией, поверх которой она поднята. Смерть // аллокации обязана ронять DTLS: у новой аллокации другой relayed-адрес, а миграция // адреса по DTLS Connection ID под obf-профилем на сервере не работает - сервер примет @@ -88,6 +94,17 @@ func TURNLoop(ctx context.Context, deps *Deps, params *Params, peer *net.UDPAddr deps.fatal(err) return } + // Квота может оставаться занятой после deallocate. + if errors.Is(err, turndial.ErrAllocQuota) { + wait := quotaBackoff + time.Duration(randx.Intn(int(quotaBackoffJitter/time.Second)))*time.Second + deps.log().Warnf("[STREAM %d] квота аллокаций занята - пауза %s", streamID, wait) + select { + case <-ctx.Done(): + return + case <-time.After(wait): + } + continue + } if errors.Is(err, provider.ErrBackoffActive) { lockoutEnd := deps.Auth.BackoffUntilUnix() var sleepDuration time.Duration @@ -161,6 +178,9 @@ func dtlsSession(dtlsctx context.Context, dtlscancel context.CancelFunc, deps *D if err := clientsdb.WriteClientID(dtlsConn, params.ClientID, clientsdb.ModeUDP); err != nil { return fmt.Errorf("failed to write client ID: %w", err) } + // Аллокация без DTLS ещё не может передавать пользовательский трафик. + deps.ConnectedStreams.Add(1) + defer deps.ConnectedStreams.Add(-1) if okchan != nil { select { case okchan <- struct{}{}: @@ -232,7 +252,6 @@ func oneTURN(ctx context.Context, deps *Deps, params *Params, peer *net.UDPAddr, deps.log().Debugf("[STREAM %d] obf-timing=%s", streamID, params.ObfTiming) } - deps.ConnectedStreams.Add(1) deps.Auth.ResetErrors(streamID) relayedAddr := relayConn.LocalAddr().String() @@ -240,7 +259,6 @@ func oneTURN(ctx context.Context, deps *Deps, params *Params, peer *net.UDPAddr, streamID, relayedAddr, stream.ServerUDPAddr.IP) defer func() { - deps.ConnectedStreams.Add(-1) // Освобождение аллокации логируем всегда: недошедший deallocate держит квоту VK // до конца её lifetime, и следующий Allocate ловит 486. cerr := stream.Close() @@ -275,7 +293,7 @@ func oneTURN(ctx context.Context, deps *Deps, params *Params, peer *net.UDPAddr, select { case <-turnctx.Done(): case <-stream.PermDead: - deps.log().Warnf("[STREAM %d] TURN channel-bind умер - рецикл allocation", streamID) + deps.log().Warnf("[STREAM %d] TURN refresh failed - recycle allocation", streamID) turncancel() } // conn2 молчит, пока приложение не шлёт: без дедлайна его читатель досидел бы diff --git a/internal/proxy/udprelay/pair_test.go b/internal/proxy/udprelay/pair_test.go index b8ff664..1d088fe 100644 --- a/internal/proxy/udprelay/pair_test.go +++ b/internal/proxy/udprelay/pair_test.go @@ -53,6 +53,9 @@ func TestOneDTLSPairCancelRecycles(t *testing.T) { if pair.pipe == nil || pair.cancel == nil { t.Fatal("incomplete pair") } + if got := deps.ConnectedStreams.Load(); got != 0 { + t.Fatalf("unfinished handshake counted as connected: %d", got) + } pair.cancel() diff --git a/internal/transport/turndial/permwatch.go b/internal/transport/turndial/permwatch.go index 37ecbf3..08f4283 100644 --- a/internal/transport/turndial/permwatch.go +++ b/internal/transport/turndial/permwatch.go @@ -10,7 +10,9 @@ import ( const ( permFailMarker = "Failed to bind channel" permOKMarker = "Channel binding successful" - turncScope = "turnc" + // Refresh сообщает об отказе раньше очередного ChannelBind. + allocFailMarker = "Failed to refresh allocation" + turncScope = "turnc" // permFailThreshold - число последовательных провалов ChannelBind refresh до срабатывания onDead. permFailThreshold = 2 ) @@ -40,6 +42,14 @@ type permWatchLogger struct { func (l *permWatchLogger) note(msg string) { switch { + case strings.Contains(msg, allocFailMarker): + l.mu.Lock() + fire := !l.fired + l.fired = true + l.mu.Unlock() + if fire && l.f.onDead != nil { + l.f.onDead() + } case strings.Contains(msg, permFailMarker): l.mu.Lock() l.fails++ diff --git a/internal/transport/turndial/permwatch_test.go b/internal/transport/turndial/permwatch_test.go index 9f8f89e..e80f54f 100644 --- a/internal/transport/turndial/permwatch_test.go +++ b/internal/transport/turndial/permwatch_test.go @@ -64,8 +64,36 @@ func TestPermWatchIgnoresUnrelatedMessages(t *testing.T) { log := f.NewLogger(turncScope) log.Debug("Started refresh permission timer") log.Debug("No permission to refresh") - log.Warnf("Failed to refresh allocation: %s", "x") + log.Debug("Refresh permissions successful") if fired.Load() != 0 { t.Fatalf("fired on unrelated message: %d", fired.Load()) } } + +// Провал Refresh мимо порога ChannelBind: аллокация уже мертва. +func TestPermWatchFiresOnAllocRefreshFail(t *testing.T) { + f, fired := newTestWatch(2) + log := f.NewLogger(turncScope) + + log.Warnf(allocFailMarker+": %s", "all retransmissions failed") + if fired.Load() != 1 { + t.Fatalf("expected immediate fire, got %d", fired.Load()) + } + log.Warnf(allocFailMarker + ": again") + if fired.Load() != 1 { + t.Fatalf("fired more than once: %d", fired.Load()) + } +} + +// Успешный ChannelBind не отменяет мёртвую аллокацию. +func TestPermWatchAllocFailNotResetByBindOK(t *testing.T) { + f, fired := newTestWatch(2) + log := f.NewLogger(turncScope) + + log.Warnf(allocFailMarker + ": x") + log.Debug(permOKMarker) + log.Warnf(allocFailMarker + ": x") + if fired.Load() != 1 { + t.Fatalf("expected exactly 1 fire, got %d", fired.Load()) + } +} diff --git a/internal/transport/turndial/turndial.go b/internal/transport/turndial/turndial.go index dae74f8..a273104 100644 --- a/internal/transport/turndial/turndial.go +++ b/internal/transport/turndial/turndial.go @@ -3,11 +3,13 @@ package turndial import ( "context" + "errors" "fmt" "net" "sync" "time" + "github.com/pion/stun/v3" "github.com/pion/turn/v5" "github.com/samosvalishe/free-turn-proxy/internal/logx" "github.com/samosvalishe/free-turn-proxy/internal/netconn" @@ -15,6 +17,10 @@ import ( "github.com/samosvalishe/free-turn-proxy/internal/randx" ) +// ErrAllocQuota - TURN-код 486: реквизиты валидны, но по ним уже висит предельное число +// аллокаций. Лечится ожиданием, а не сменой реквизитов (см. IsAuthError в vkauth). +var ErrAllocQuota = errors.New("turndial: allocation quota reached") + // Config задаёт параметры подключения к TURN-серверу. type Config struct { HostOverride string @@ -34,6 +40,11 @@ type Stream struct { close func() error } +func isQuotaError(err error) bool { + turnErr, ok := errors.AsType[*stun.TurnError](err) + return ok && turnErr.ErrorCodeAttr.Code == stun.CodeAllocQuotaReached +} + // Close освобождает аллокацию, TURN-клиент и транспортное соединение. func (s *Stream) Close() error { if s == nil || s.close == nil { @@ -141,6 +152,9 @@ func Open(ctx context.Context, cfg Config, peer *net.UDPAddr, user, pass, rawAdd if cerr := closeConn(); cerr != nil { err = fmt.Errorf("%w (close: %v)", err, cerr) } + if isQuotaError(err) { + return nil, fmt.Errorf("%w: %w", ErrAllocQuota, err) + } return nil, fmt.Errorf("TURN allocate: %w", err) } diff --git a/internal/transport/turndial/turndial_test.go b/internal/transport/turndial/turndial_test.go index 83069d7..facdbed 100644 --- a/internal/transport/turndial/turndial_test.go +++ b/internal/transport/turndial/turndial_test.go @@ -2,12 +2,30 @@ package turndial import ( "context" + "errors" + "fmt" "net" "strings" "testing" "time" + + "github.com/pion/stun/v3" ) +func TestIsQuotaError(t *testing.T) { + quota := &stun.TurnError{ErrorCodeAttr: stun.ErrorCodeAttribute{Code: stun.CodeAllocQuotaReached}} + if !isQuotaError(fmt.Errorf("allocate: %w", quota)) { + t.Fatal("wrapped 486 not recognized") + } + if isQuotaError(errors.New("486 Allocation Quota Reached")) || isQuotaError(nil) { + t.Fatal("untyped error treated as quota") + } + unauthorized := &stun.TurnError{ErrorCodeAttr: stun.ErrorCodeAttribute{Code: stun.CodeUnauthorized}} + if isQuotaError(unauthorized) { + t.Fatal("401 treated as quota") + } +} + func TestOpen_BadAddress(t *testing.T) { peer := &net.UDPAddr{IP: net.ParseIP("1.2.3.4"), Port: 1} _, err := Open(context.Background(), Config{}, peer, "u", "p", "not-a-host-port") diff --git a/mobile/api.go b/mobile/api.go index e91d586..56898ef 100644 --- a/mobile/api.go +++ b/mobile/api.go @@ -33,7 +33,6 @@ const ( ) const ( - connectTimeout = 15 * time.Second stopTimeout = 5 * time.Second tunnelCloseTimeout = 3 * time.Second ) @@ -304,8 +303,8 @@ func startLocked(configJSON string, tunFD int, withTunnel bool) error { Solver: solver, CaptchaActive: captchaActive.Load, Options: session.Options{ - ConnectTimeout: connectTimeout, - Traffic: true, + // Сеть и квота TURN восстанавливаются дольше 15 с; остановку решает пользователь. + Traffic: true, }, } if parts != nil {