mirror of
https://github.com/hackdiaz-dev/free-turn-proxy.git
synced 2026-10-12 09:41:53 +02:00
311 lines
8.7 KiB
Go
311 lines
8.7 KiB
Go
package tcprelay
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/samosvalishe/free-turn-proxy/internal/logx"
|
|
"github.com/samosvalishe/free-turn-proxy/internal/provider"
|
|
"github.com/samosvalishe/free-turn-proxy/internal/stats"
|
|
"github.com/samosvalishe/free-turn-proxy/internal/transport/turndial"
|
|
)
|
|
|
|
type fakeAuth struct {
|
|
authErr error
|
|
backoffUntil int64
|
|
handled atomic.Int32
|
|
reset atomic.Int32
|
|
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)
|
|
}
|
|
|
|
func (a *fakeAuth) HandleAuthError(int) bool {
|
|
a.handled.Add(1)
|
|
return true
|
|
}
|
|
|
|
func (a *fakeAuth) ResetErrors(int) { a.reset.Add(1) }
|
|
func (a *fakeAuth) DropCredentials(int) { a.dropped.Add(1) }
|
|
|
|
func (a *fakeAuth) BackoffUntilUnix() int64 { return a.backoffUntil }
|
|
|
|
// Фатальная ошибка провайдера обязана останавливать клиента, а не крутиться в ретраях.
|
|
func TestMaintainSessionReportsFatal(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
auth := &fakeAuth{}
|
|
deps := &Deps{Log: logx.Nop(), Auth: auth}
|
|
params := &Params{
|
|
Dial: func(context.Context, int) (*turndial.Stream, error) {
|
|
return nil, provider.ErrFatalNoStreams
|
|
},
|
|
}
|
|
|
|
fatalCh := make(chan error, 1)
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
maintainSession(ctx, deps, params, &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 1},
|
|
1, newSessionPool(nil), func(err error) { fatalCh <- err })
|
|
}()
|
|
|
|
select {
|
|
case err := <-fatalCh:
|
|
if !errors.Is(err, provider.ErrFatalNoStreams) {
|
|
t.Fatalf("fatal err = %v, want ErrFatalNoStreams", err)
|
|
}
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatal("fatal not reported")
|
|
}
|
|
select {
|
|
case <-done:
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatal("maintainSession did not return after fatal")
|
|
}
|
|
if auth.reset.Load() != 0 {
|
|
t.Errorf("ResetErrors called %d times on failure", auth.reset.Load())
|
|
}
|
|
}
|
|
|
|
// Ошибка авторизации обязана инвалидировать реквизиты, иначе ретраи идут мёртвыми кредами.
|
|
func TestMaintainSessionHandlesAuthError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
authErr := errors.New("401 Unauthorized")
|
|
auth := &fakeAuth{authErr: authErr}
|
|
deps := &Deps{Log: logx.Nop(), Auth: auth}
|
|
params := &Params{
|
|
Dial: func(context.Context, int) (*turndial.Stream, error) {
|
|
return nil, authErr
|
|
},
|
|
}
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
maintainSession(ctx, deps, params, &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 1},
|
|
1, newSessionPool(nil), func(error) {})
|
|
}()
|
|
|
|
deadline := time.Now().Add(15 * time.Second)
|
|
for auth.handled.Load() == 0 && time.Now().Before(deadline) {
|
|
time.Sleep(20 * time.Millisecond)
|
|
}
|
|
if auth.handled.Load() == 0 {
|
|
t.Fatal("HandleAuthError never called")
|
|
}
|
|
cancel()
|
|
select {
|
|
case <-done:
|
|
case <-time.After(15 * time.Second):
|
|
t.Fatal("maintainSession did not return after cancel")
|
|
}
|
|
}
|
|
|
|
func TestRetryDelayHonorsProviderBackoff(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
auth := &fakeAuth{backoffUntil: time.Now().Add(42 * time.Second).Unix()}
|
|
got := retryDelay(auth, provider.ErrBackoffActive)
|
|
if got < 30*time.Second || got > 45*time.Second {
|
|
t.Errorf("retryDelay() = %v, want ~42s from provider lockout", got)
|
|
}
|
|
|
|
stale := &fakeAuth{backoffUntil: time.Now().Add(-time.Minute).Unix()}
|
|
if got := retryDelay(stale, provider.ErrBackoffActive); got != 5*time.Second {
|
|
t.Errorf("retryDelay(stale lockout) = %v, want 5s", got)
|
|
}
|
|
|
|
if got := retryDelay(&fakeAuth{}, provider.ErrBackoffActive); got != providerBackoffDelay {
|
|
t.Errorf("retryDelay(no lockout) = %v, want %v", got, providerBackoffDelay)
|
|
}
|
|
}
|
|
|
|
// Джиттер разводит одновременный отказ всех сессий пула.
|
|
func TestRetryDelayJittersOrdinaryErrors(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
seen := map[time.Duration]bool{}
|
|
for range 64 {
|
|
d := retryDelay(&fakeAuth{}, errors.New("boom"))
|
|
if d < setupRetryDelay || d >= setupRetryDelay+setupRetryJitter {
|
|
t.Fatalf("retryDelay() = %v, want [%v, %v)", d, setupRetryDelay, setupRetryDelay+setupRetryJitter)
|
|
}
|
|
seen[d] = true
|
|
}
|
|
if len(seen) < 2 {
|
|
t.Error("retryDelay() constant, jitter missing")
|
|
}
|
|
}
|
|
|
|
func TestAwaitDeadOnCancel(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
s := &session{smux: fakeSession(t), permDead: make(chan struct{})}
|
|
go func() { time.Sleep(50 * time.Millisecond); cancel() }()
|
|
|
|
if awaitDead(ctx, logx.Nop(), s, 1) {
|
|
t.Error("awaitDead() = true on ctx cancel, want false")
|
|
}
|
|
}
|
|
|
|
func TestAwaitDeadOnPermDead(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
permDead := make(chan struct{})
|
|
s := &session{smux: fakeSession(t), permDead: permDead}
|
|
close(permDead)
|
|
|
|
if !awaitDead(context.Background(), logx.Nop(), s, 1) {
|
|
t.Error("awaitDead() = false on permDead, want true")
|
|
}
|
|
}
|
|
|
|
func TestAwaitDeadOnClosedSession(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
sess := fakeSession(t)
|
|
s := &session{smux: sess, permDead: make(chan struct{})}
|
|
_ = sess.Close()
|
|
|
|
start := time.Now()
|
|
if !awaitDead(context.Background(), logx.Nop(), s, 1) {
|
|
t.Error("awaitDead() = false on closed smux, want true")
|
|
}
|
|
if d := time.Since(start); d > 100*time.Millisecond {
|
|
t.Errorf("awaitDead() noticed close after %s, want immediately", d)
|
|
}
|
|
}
|
|
|
|
// Рецикл рвёт сессии, но состав пула не трогает: их переподнимает maintainSession.
|
|
func TestWatchRecycleClosesSessions(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
pool := newSessionPool(nil)
|
|
ps := pool.Add(1, fakeSession(t), nil)
|
|
recycle := make(chan struct{}, 1)
|
|
go watchRecycle(ctx, logx.Nop(), pool, recycle)
|
|
|
|
recycle <- struct{}{}
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for !ps.sess.IsClosed() && time.Now().Before(deadline) {
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
if !ps.sess.IsClosed() {
|
|
t.Fatal("watchRecycle did not close session")
|
|
}
|
|
if pool.Count() != 1 {
|
|
t.Errorf("Count() = %d, want 1", pool.Count())
|
|
}
|
|
}
|
|
|
|
func TestNextBackoffGrowsAndCaps(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
d := nextBackoff(0)
|
|
if d != minAcceptBackoff {
|
|
t.Fatalf("nextBackoff(0) = %v, want %v", d, minAcceptBackoff)
|
|
}
|
|
prev := d
|
|
for range 20 {
|
|
d = nextBackoff(d)
|
|
if d < prev {
|
|
t.Fatalf("nextBackoff shrank: %v -> %v", prev, d)
|
|
}
|
|
prev = d
|
|
}
|
|
if d != maxAcceptBackoff {
|
|
t.Errorf("nextBackoff capped at %v, want %v", d, maxAcceptBackoff)
|
|
}
|
|
}
|
|
|
|
// Без Auth в Deps пакет обязан работать: тесты и вызовы без провайдера не должны падать.
|
|
func TestDepsAuthDefaultsToNop(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
d := &Deps{}
|
|
if d.auth().IsAuthError(errors.New("x")) {
|
|
t.Error("nopAuth.IsAuthError() = true")
|
|
}
|
|
if d.auth().HandleAuthError(1) {
|
|
t.Error("nopAuth.HandleAuthError() = true")
|
|
}
|
|
if d.auth().BackoffUntilUnix() != 0 {
|
|
t.Error("nopAuth.BackoffUntilUnix() != 0")
|
|
}
|
|
d.auth().ResetErrors(1)
|
|
d.auth().DropCredentials(1)
|
|
}
|
|
|
|
// Прикладные счётчики не должны включать оверхед ARQ - его считает WireConn.
|
|
func TestProxyConnCountsApplicationBytes(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
backendAddr := echoBackend(t)
|
|
traffic := stats.New(true)
|
|
pool := newSessionPool(nil)
|
|
ps := pool.Add(1, pairedSession(t, ctx, backendAddr), traffic)
|
|
|
|
local, remote := net.Pipe()
|
|
defer func() { _ = local.Close() }()
|
|
go proxyConn(ctx, logx.Nop(), remote, ps, 1)
|
|
|
|
payload := []byte("hello over smux")
|
|
if _, err := local.Write(payload); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := make([]byte, len(payload))
|
|
if err := local.SetReadDeadline(time.Now().Add(30 * time.Second)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := local.Read(got); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var tx, rx uint64
|
|
for deadline := time.Now().Add(5 * time.Second); ; {
|
|
if tx, rx = traffic.Counters(); tx == uint64(len(payload)) || time.Now().After(deadline) {
|
|
break
|
|
}
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
if tx != uint64(len(payload)) {
|
|
t.Errorf("tx = %d, want %d", tx, len(payload))
|
|
}
|
|
if rx != uint64(len(payload)) {
|
|
t.Errorf("rx = %d, want %d", rx, len(payload))
|
|
}
|
|
if traffic.LivenessRx() != rx {
|
|
t.Errorf("LivenessRx() = %d, want %d without WireConn", traffic.LivenessRx(), rx)
|
|
}
|
|
}
|