diff --git a/.gitignore b/.gitignore index 03b3b69..56306e2 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,9 @@ CLAUDE.md /build/ *.test coverage.out +# Скомпилированные бинари cmd/client и cmd/server +/client +/server .env clients.json client_config.json diff --git a/go.mod b/go.mod index ab24d51..e9aa936 100644 --- a/go.mod +++ b/go.mod @@ -14,9 +14,9 @@ require ( github.com/pion/turn/v5 v5.0.4 github.com/xtaci/kcp-go/v5 v5.6.72 github.com/xtaci/smux v1.5.57 - golang.org/x/crypto v0.52.0 + golang.org/x/crypto v0.53.0 golang.org/x/crypto/x509roots/fallback v0.0.0-20260522023950-e5306b2f3e96 - golang.org/x/net v0.55.0 + golang.org/x/net v0.56.0 ) require ( @@ -37,10 +37,13 @@ require ( github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 // indirect github.com/tjfoc/gmsm v1.4.1 // indirect github.com/wlynxg/anet v0.0.5 // indirect - golang.org/x/mod v0.36.0 // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.37.0 // indirect + golang.org/x/mobile v0.0.0-20260611195102-4dd8f1dbf5d2 // indirect + golang.org/x/mod v0.37.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect golang.org/x/time v0.15.0 // indirect - golang.org/x/tools v0.45.0 // indirect + golang.org/x/tools v0.46.0 // indirect ) + +tool golang.org/x/mobile/cmd/gobind diff --git a/go.sum b/go.sum index 90844ca..6067162 100644 --- a/go.sum +++ b/go.sum @@ -95,14 +95,20 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/crypto/x509roots/fallback v0.0.0-20260522023950-e5306b2f3e96 h1:O1tFPDuiV6lk+c24ew9UqcKgmhgDF6aPXwSvYGxVR2A= golang.org/x/crypto/x509roots/fallback v0.0.0-20260522023950-e5306b2f3e96/go.mod h1:+UoQFNBq2p2wO+Q6ddVtYc25GZ6VNdOMyyrd4nrqrKs= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 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/mobile v0.0.0-20260611195102-4dd8f1dbf5d2 h1:zoM1gIKhVkcQNm43kad8OHLgPNoJ12xIqmxHtKr8Mug= +golang.org/x/mobile v0.0.0-20260611195102-4dd8f1dbf5d2/go.mod h1:QGMqsqLn6orFQ/ksqYMf+Fa33Soa1vPoHEd0Pj7N+lQ= 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.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= 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= @@ -112,12 +118,16 @@ golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.0.0-20211104170005-ce137452f963/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +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/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 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.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= 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/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= @@ -126,12 +136,16 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +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/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.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +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/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -141,6 +155,8 @@ golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3 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/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk= +golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 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= diff --git a/internal/provider/vk/iosmanual.go b/internal/provider/vk/iosmanual.go new file mode 100644 index 0000000..e1e2b9f --- /dev/null +++ b/internal/provider/vk/iosmanual.go @@ -0,0 +1,52 @@ +package vk + +import ( + "context" + "fmt" + "net" + "sync" + + "github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/captcha" + manualcaptcha "github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/captcha/manual" +) + +// iosManualURL — адрес локального прокси manual-captcha, который поднимает +// SolveViaProxy. Должен совпадать с портом из пакета manual (captchaListenPort). +// Приложение наводит на него WebView, пока пользователь решает captcha. +const iosManualURL = "http://127.0.0.1:8765" + +// iosManualMu сериализует ручное решение captcha: пользователю показываем +// одновременно только одно окно, даже если в captcha упёрлись сразу несколько +// стримов/кредов. +var iosManualMu sync.Mutex + +// IOSManualSolver строит ManualSolverFunc для мобильного приложения. Извлечение +// success_token переиспользует manualcaptcha.SolveViaProxy — ту же логику, что и +// десктопный CLI (локальный прокси сам ловит токен из ответов VK). show +// вызывается с URL прокси перед показом captcha (приложение должно открыть +// WebView на этот адрес), hide — после завершения, чтобы закрыть окно. +// +// Функция предназначена только для ios-биндинга и нигде в библиотеке/CLI не +// вызывается; поведение vk-провайдера для прочих вызывающих не меняется. +func IOSManualSolver(show func(url string), hide func()) ManualSolverFunc { + return func(ctx context.Context, e *captcha.Error, dialer net.Dialer) (string, string, error) { + if e.RedirectURI == "" { + return "", "", fmt.Errorf("manual captcha: no redirect_uri") + } + + // Один WebView за раз: SolveViaProxy слушает фиксированный порт, да и + // показывать пользователю несколько captcha разом смысла нет. + iosManualMu.Lock() + defer iosManualMu.Unlock() + + if show != nil { + show(iosManualURL) + } + if hide != nil { + defer hide() + } + + token, err := manualcaptcha.SolveViaProxy(ctx, e.RedirectURI, dialer) + return token, "", err + } +} diff --git a/internal/stats/stats.go b/internal/stats/stats.go index f136041..a12119c 100644 --- a/internal/stats/stats.go +++ b/internal/stats/stats.go @@ -23,9 +23,42 @@ func New(enabled bool) *Stats { return &Stats{enabled: enabled} } +// Сессионные глобальные счётчики суммарного трафика. Инкремент идёт только +// между StartGlobalCount и StopGlobalCount — этим явно управляет вызывающий +// (mobile-биндинг на старте/дисконнекте сессии). Рассчитаны на одну активную +// сессию в процессе; вне сессии учёта нет. +var ( + globalTx atomic.Uint64 + globalRx atomic.Uint64 + trackGlobal atomic.Bool +) + +// StartGlobalCount включает учёт суммарного трафика в глобальных счётчиках. +func StartGlobalCount() { + trackGlobal.Store(true) +} + +// StopGlobalCount выключает учёт и обнуляет глобальные счётчики. +func StopGlobalCount() { + trackGlobal.Store(false) + globalTx.Store(0) + globalRx.Store(0) +} + +// GlobalCounters возвращает накопленные суммарные tx/rx байт текущей сессии. +func GlobalCounters() (tx, rx uint64) { + return globalTx.Load(), globalRx.Load() +} + // AddTx учитывает n переданных байт. func (s *Stats) AddTx(n int) { - if !s.enabled || n <= 0 { + if n <= 0 { + return + } + if trackGlobal.Load() { + globalTx.Add(uint64(n)) + } + if !s.enabled { return } s.tx.Add(uint64(n)) @@ -33,7 +66,13 @@ func (s *Stats) AddTx(n int) { // AddRx учитывает n полученных байт. func (s *Stats) AddRx(n int) { - if !s.enabled || n <= 0 { + if n <= 0 { + return + } + if trackGlobal.Load() { + globalRx.Add(uint64(n)) + } + if !s.enabled { return } s.rx.Add(uint64(n)) diff --git a/ios/captcha.go b/ios/captcha.go new file mode 100644 index 0000000..119d4b7 --- /dev/null +++ b/ios/captcha.go @@ -0,0 +1,48 @@ +package ios + +import ( + "sync" + "sync/atomic" +) + +// CaptchaPresenter — мост в UI приложения для ручного решения captcha. gomobile +// генерирует из этого интерфейса ObjC-протокол IosCaptchaPresenter, который +// реализует Swift. Вызывается из Go, когда авто-решатель captcha не справился и +// нужен ручной fallback. +// +// - Show(url): открыть WebView на локальном прокси-адресе url, где пользователь +// решает captcha. Метод блокирующим быть не обязан. +// - Hide(): captcha решена или отменена — закрыть окно. +type CaptchaPresenter interface { + Show(url string) + Hide() +} + +var ( + captchaMu sync.RWMutex + captchaPresenter CaptchaPresenter +) + +// SetCaptchaPresenter регистрирует UI-презентер ручной captcha. Передайте nil, +// чтобы отключить ручной fallback — тогда при провале авто-captcha поток просто +// упадёт, как раньше. Вызывать один раз при старте приложения, до Start. +func SetCaptchaPresenter(p CaptchaPresenter) { + captchaMu.Lock() + captchaPresenter = p + captchaMu.Unlock() +} + +func currentCaptchaPresenter() CaptchaPresenter { + captchaMu.RLock() + defer captchaMu.RUnlock() + return captchaPresenter +} + +// manualCaptchaOnly форсит ручное решение captcha с первой попытки (минуя +// авто-решатель). Читается при старте сессии. +var manualCaptchaOnly atomic.Bool + +// SetManualCaptcha включает/выключает режим «всегда решать captcha вручную». +// Применяется при следующем Start (или переподключении). Требует +// зарегистрированного презентера, иначе ручной путь недоступен. +func SetManualCaptcha(on bool) { manualCaptchaOnly.Store(on) } diff --git a/ios/logbuf.go b/ios/logbuf.go new file mode 100644 index 0000000..8c1c178 --- /dev/null +++ b/ios/logbuf.go @@ -0,0 +1,65 @@ +package ios + +import ( + "fmt" + "log" + "strings" + "sync" + "time" +) + +const logBufMax = 500 + +type logBuffer struct { + mu sync.Mutex + lines []string +} + +func (b *logBuffer) append(line string) { + b.mu.Lock() + defer b.mu.Unlock() + b.lines = append(b.lines, line) + if len(b.lines) > logBufMax { + b.lines = b.lines[len(b.lines)-logBufMax:] + } +} + +func (b *logBuffer) get() string { + b.mu.Lock() + defer b.mu.Unlock() + return strings.Join(b.lines, "\n") +} + +func (b *logBuffer) clear() { + b.mu.Lock() + defer b.mu.Unlock() + b.lines = b.lines[:0] +} + +var sharedLogBuf = &logBuffer{} + +// GetLogs возвращает последние логи фреймворка (до 500 строк). +func GetLogs() string { return sharedLogBuf.get() } + +// ClearLogs очищает буфер логов. +func ClearLogs() { sharedLogBuf.clear() } + +// bufLogger реализует logx.Logger, пишет в stderr и в sharedLogBuf. +type bufLogger struct{ debug bool } + +func (*bufLogger) write(level, format string, v ...any) { + msg := fmt.Sprintf(format, v...) + line := time.Now().Format("15:04:05") + " [" + level + "] " + msg + log.Print("[" + level + "] " + msg) + sharedLogBuf.append(line) +} + +func (l *bufLogger) Debugf(format string, v ...any) { + if l.debug { + l.write("DBG", format, v...) + } +} +func (l *bufLogger) Infof(format string, v ...any) { l.write("INF", format, v...) } +func (l *bufLogger) Warnf(format string, v ...any) { l.write("WRN", format, v...) } +func (l *bufLogger) Errorf(format string, v ...any) { l.write("ERR", format, v...) } +func (l *bufLogger) DebugEnabled() bool { return l.debug } diff --git a/ios/mobile.go b/ios/mobile.go new file mode 100644 index 0000000..f7438fa --- /dev/null +++ b/ios/mobile.go @@ -0,0 +1,324 @@ +// Package ios экспортирует минимальный API для gomobile bind (iOS). +// Все экспортированные функции используют только примитивные типы — ограничение gomobile. +package ios + +import ( + "bytes" + "context" + "errors" + "fmt" + "math" + "net" + "sync" + "sync/atomic" + "time" + + "github.com/samosvalishe/free-turn-proxy/internal/client/dnsdial" + "github.com/samosvalishe/free-turn-proxy/internal/config" + "github.com/samosvalishe/free-turn-proxy/internal/provider/vk" + "github.com/samosvalishe/free-turn-proxy/internal/proxy/udprelay" + "github.com/samosvalishe/free-turn-proxy/internal/stats" + "github.com/samosvalishe/free-turn-proxy/internal/transport/dtlsdial" +) + +// State — состояние подключения. +// gomobile генерирует константы IosStateXxx. +const ( + StateIdle = "idle" + StateConnecting = "connecting" + StateConnected = "connected" + StateError = "error" + // StateCaptcha — пользователь решает captcha вручную. Отдельный статус, чтобы + // UI не показывал ошибку, а watchdog не считал это зависшим подключением. + StateCaptcha = "captcha" +) + +// Snapshot — единый консистентный снимок состояния сессии для UI: и стадия +// подключения, и статистика трафика. gomobile bind транслирует в ObjC-класс +// IosSnapshot. Один GetState() на тик заменяет прежние GetStatus/GetStats/ +// IsRunning — UI получает согласованный срез без гонок порядка чтения. +type Snapshot struct { + State string // idle | connecting | connected | error + Streams int // подключённых TURN-потоков прямо сейчас + Total int // целевое число потоков + ErrMsg string // непустой при State == error + TxTotal int64 // всего отправлено байт + RxTotal int64 // всего получено байт + TxRate int64 // текущая скорость отправки, байт/с + RxRate int64 // текущая скорость получения, байт/с +} + +// statusInfo — внутренний снимок стадии подключения (без статистики). +type statusInfo struct { + state string + streams int + total int + errMsg string +} + +// connectTimeout — сколько ждём первый успешный стрим. Если за это время ни +// один поток так и не поднялся (connectedStreams не стал > 0), считаем, что +// все стримы упали, и переходим в error вместо вечного connecting. Ошибка +// одного стрима не страшна — пока хоть один живой, таймаут не срабатывает. +const connectTimeout = 15 * time.Second + +var ( + mu sync.Mutex + cancelFn context.CancelFunc + statusVal atomic.Value // *statusInfo + running atomic.Bool + sessionGen atomic.Int64 // номер текущей сессии; растёт на каждый Start/Stop +) + +func setStatus(s *statusInfo) { statusVal.Store(s) } + +// clampToInt64 насыщает uint64 до int64: gomobile экспортирует только int64, +// а счётчики байт — uint64. Реальный трафик до math.MaxInt64 не доходит, но +// насыщение делает конверсию безопасной от переполнения. +func clampToInt64(u uint64) int64 { + if u > math.MaxInt64 { + return math.MaxInt64 + } + return int64(u) +} + +// GetState возвращает текущий снимок состояния сессии: стадию подключения +// плюс статистику трафика, собранные на чтении из внутренних атомиков. +func GetState() *Snapshot { + st, _ := statusVal.Load().(*statusInfo) + if st == nil { + st = &statusInfo{state: StateIdle} + } + tx, rx := stats.GlobalCounters() + return &Snapshot{ + State: st.state, + Streams: st.streams, + Total: st.total, + ErrMsg: st.errMsg, + TxTotal: clampToInt64(tx), + RxTotal: clampToInt64(rx), + TxRate: globalTxRate.Load(), + RxRate: globalRxRate.Load(), + } +} + +// Start запускает прокси-клиент. +// +// - link: ссылка VK https://vk.com/call/join/... или freeturn:// URI +// - peer: адрес freeturn-сервера на VPS, например "1.2.3.4:56000" +// - dns: DNS-серверы через запятую, например "8.8.8.8"; "" — авто +// - listen: локальный bind, например "127.0.0.1:9000"; "" — дефолт +// - transport: "tcp" или "udp"; "" — дефолт tcp +// - obfKey: ключ обфускации rtpopus (64 hex символа); "" — без обфускации. +// Должен совпадать с -obf-key сервера. +func Start(link, peer, dns, listen, transport, obfKey string) error { + mu.Lock() + defer mu.Unlock() + + if running.Load() { + return fmt.Errorf("already running") + } + if listen == "" { + listen = "127.0.0.1:9000" + } + + if transport == "" { + transport = "tcp" + } + args := []string{"-link", link, "-peer", peer, "-listen", listen, "-transport", transport} + if dns != "" { + args = append(args, "-dns-servers", dns) + } + if obfKey != "" { + args = append(args, "-obf-profile", "rtpopus", "-obf-key", obfKey) + } + + cfg, err := config.ParseClient(args, &bytes.Buffer{}) + if err != nil { + return err + } + + ClearLogs() + stats.StartGlobalCount() + + ctx, cancel := context.WithCancel(context.Background()) + cancelFn = cancel + running.Store(true) + gen := sessionGen.Add(1) + setStatus(&statusInfo{state: StateConnecting, total: cfg.TURN.N}) + go startRateMeter(ctx) + + go func() { + // Терминальный статус выставляем ровно один раз — здесь. Сначала + // отменяем контекст и ждём горутину-счётчик, чтобы она не затёрла + // финальный статус своим connecting/connected. Статус ставим ДО сброса + // running: тогда UI, увидев IsRunning()==false, гарантированно прочитает + // уже актуальный error/idle. + // finalErr — ошибка из udprelay.Run; watchdogErr — «не поднялся ни один + // стрим за connectTimeout». Читаются в defer после counters.Wait(), что + // даёт happens-after для записи watchdogErr из горутины-счётчика. + var finalErr error + var watchdogErr error + var counters sync.WaitGroup + + defer func() { + cancel() + counters.Wait() + mu.Lock() + defer mu.Unlock() + // Сессию мог уже сменить Stop() или новый Start() — тогда общий стейт + // принадлежит им, и трогать его нельзя, иначе затрём актуальный статус. + if sessionGen.Load() != gen { + return + } + stats.StopGlobalCount() + err := finalErr + if err == nil { + err = watchdogErr + } + if err != nil { + setStatus(&statusInfo{state: StateError, total: cfg.TURN.N, errMsg: err.Error()}) + } else { + setStatus(&statusInfo{state: StateIdle}) + } + running.Store(false) + cancelFn = nil + }() + + logger := &bufLogger{debug: false} + dnsdial.SetLogger(logger) + + if cfg.DNS.Servers != nil { + dnsdial.SetUDPDNSServers(cfg.DNS.Servers) + } + appDialer := dnsdial.AppDialer(cfg.DNS.Mode) + dnsdial.InstallGlobalResolver(cfg.DNS.Mode) + + var connectedStreams atomic.Int32 + + // Если приложение зарегистрировало презентер — включаем ручной fallback + // captcha (авто-решатель пробуется первым, при провале показываем WebView). + // Без презентера solver == nil: при captcha-блоке поток упадёт, как раньше. + // + // captchaActive (сессионный) поднимается на время ручного решения: пока он + // true, watchdog не считает подключение зависшим и UI показывает StateCaptcha. + var captchaActive atomic.Bool + var solver vk.ManualSolverFunc + if p := currentCaptchaPresenter(); p != nil { + solver = vk.IOSManualSolver( + func(url string) { captchaActive.Store(true); p.Show(url) }, + func() { captchaActive.Store(false); p.Hide() }, + ) + } + + prov, err := vk.New(vk.Config{ + // ParseClient гарантирует непустой Links (иначе вернул бы ошибку выше). + // Мобайл передаёт одну ссылку, провайдер берёт один join-код. + Link: cfg.VK.Links[0], + Dialer: appDialer, + ManualOnly: manualCaptchaOnly.Load(), + StreamsPerCache: cfg.VK.StreamsPerCred, + StreamsAlive: connectedStreams.Load, + Log: logger, + }, solver) + if err != nil { + finalErr = fmt.Errorf("provider: %v", err) + return + } + + peerAddr, err := net.ResolveUDPAddr("udp", cfg.Proxy.Peer) + if err != nil { + finalErr = fmt.Errorf("bad peer addr: %v", err) + return + } + + dtlsDialer := &dtlsdial.Dialer{ + HandshakeTimeout: 20 * time.Second, + HandshakeSem: make(chan struct{}, 3), + } + + udpParams := &udprelay.Params{ + Host: cfg.TURN.Host, + Port: cfg.TURN.Port, + TransportUDP: cfg.TURN.TransportUDP, + Profile: string(cfg.Obf.Profile), + ObfKey: cfg.Obf.Key, + ObfTiming: cfg.Obf.Timing, + GetCreds: udprelay.GetCredsFunc(func(ctx context.Context, streamID int) (string, string, []string, error) { + c, err := prov.GetCredentials(ctx, streamID) + if err != nil { + return "", "", nil, err + } + return c.User, c.Pass, c.ServerAddrs, nil + }), + ClientID: "ios-mobile", + } + + // Обновляем счётчик потоков по мере подключения и сторожим стартовый + // таймаут: если за connectTimeout ни один стрим не поднялся — считаем + // подключение неуспешным, пишем watchdogErr и гасим всё через cancel. + counters.Add(1) + go func() { + defer counters.Done() + deadline := time.Now().Add(connectTimeout) + everConnected := false + for { + select { + case <-ctx.Done(): + return + case <-time.After(500 * time.Millisecond): + n := connectedStreams.Load() + if n > 0 { + everConnected = true + } + + // Пока пользователь решает captcha — таймаут на паузу (двигаем + // дедлайн вперёд, чтобы после решения дать свежее окно) и + // показываем отдельный статус вместо «зависшего» connecting. + if captchaActive.Load() { + deadline = time.Now().Add(connectTimeout) + setStatus(&statusInfo{state: StateCaptcha, streams: int(n), total: cfg.TURN.N}) + continue + } + + state := StateConnecting + if n > 0 { + state = StateConnected + } + setStatus(&statusInfo{state: state, streams: int(n), total: cfg.TURN.N}) + + if !everConnected && time.Now().After(deadline) { + watchdogErr = fmt.Errorf("не удалось подключиться: ни один поток не поднялся за %s — проверьте ссылку на звонок и адрес сервера (подробности в логах)", connectTimeout) + cancel() + return + } + } + } + }() + + if err := udprelay.Run(ctx, dtlsDialer, prov, logger, &connectedStreams, udpParams, peerAddr, cfg.Proxy.Listen, cfg.TURN.N); err != nil { + if !errors.Is(ctx.Err(), context.Canceled) { + finalErr = err + } + } + }() + + return nil +} + +// Stop останавливает прокси-клиент. +func Stop() { + mu.Lock() + defer mu.Unlock() + if cancelFn != nil { + cancelFn() + cancelFn = nil + // Освобождаем сессию сразу, не дожидаясь догасания фоновой горутины. + // Бамп поколения гарантирует, что её defer не затрёт стейт после нас и + // не помешает немедленному переподключению. + running.Store(false) + sessionGen.Add(1) + stats.StopGlobalCount() + } + setStatus(&statusInfo{state: StateIdle}) +} diff --git a/ios/mobilestats.go b/ios/mobilestats.go new file mode 100644 index 0000000..a3499a5 --- /dev/null +++ b/ios/mobilestats.go @@ -0,0 +1,45 @@ +package ios + +import ( + "context" + "sync/atomic" + "time" + + "github.com/samosvalishe/free-turn-proxy/internal/stats" +) + +// Мгновенная скорость (байт/с), пересчитывается startRateMeter раз в секунду. +// Суммарные счётчики живут в stats (stats.GlobalCounters) — там же, где учёт байт. +var ( + globalTxRate atomic.Int64 + globalRxRate atomic.Int64 +) + +// startRateMeter раз в секунду пересчитывает мгновенную скорость (байт/с) по +// дельте суммарных счётчиков stats. Учётом трафика управляет Start/Stop явно +// (StartGlobalCount/StopGlobalCount) — здесь только чтение. Живёт ровно одну +// сессию: завершается по отмене ctx с остановкой тикера, без вечных горутин. +func startRateMeter(ctx context.Context) { + t := time.NewTicker(time.Second) + defer t.Stop() + var prevTx, prevRx uint64 + for { + select { + case <-ctx.Done(): + globalTxRate.Store(0) + globalRxRate.Store(0) + return + case <-t.C: + tx, rx := stats.GlobalCounters() + // Счётчики могли обнулиться (StopGlobalCount) — пропускаем такой тик, + // чтобы не получить отрицательную дельту через переполнение uint64. + if tx < prevTx || rx < prevRx { + prevTx, prevRx = tx, rx + continue + } + globalTxRate.Store(clampToInt64(tx - prevTx)) + globalRxRate.Store(clampToInt64(rx - prevRx)) + prevTx, prevRx = tx, rx + } + } +} diff --git a/scripts/build-ios.sh b/scripts/build-ios.sh new file mode 100755 index 0000000..10ed1c5 --- /dev/null +++ b/scripts/build-ios.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Собирает Ios.xcframework из пакета ios/ для iOS и iOS Simulator. +# Использование: ./scripts/build-ios.sh [путь/к/выходному/каталогу] +# По умолчанию кладёт фреймворк в ./build/ внутри репозитория. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +OUT_DIR="${1:-"${REPO_ROOT}/build"}" +FRAMEWORK="${OUT_DIR}/Ios.xcframework" + +if ! command -v gomobile &>/dev/null; then + echo "gomobile не найден. Установите:" + echo " go install golang.org/x/mobile/cmd/gomobile@latest" + echo " gomobile init" + exit 1 +fi + +mkdir -p "${OUT_DIR}" +rm -rf "${FRAMEWORK}" + +echo "Собираем Ios.xcframework → ${FRAMEWORK}" +cd "${REPO_ROOT}" +gomobile bind \ + -target ios,iossimulator \ + -o "${FRAMEWORK}" \ + github.com/samosvalishe/free-turn-proxy/ios + +echo "Готово: ${FRAMEWORK}"