mirror of
https://github.com/hackdiaz-dev/free-turn-proxy.git
synced 2026-10-12 09:41:53 +02:00
296 lines
9.0 KiB
Go
296 lines
9.0 KiB
Go
// Package mobile - фасад ядра для gomobile bind (Android/iOS).
|
||
//
|
||
// Экспортируются только примитивные типы, структуры этого пакета и интерфейсы,
|
||
// реализуемые хостом - ограничение gomobile. Логика сессии живёт в
|
||
// internal/session и общая с cmd/client; конфиг разбирает internal/config тем же
|
||
// путём, что и CLI. Здесь остаются разбор входа, единственный синглтон и
|
||
// конвертация типов.
|
||
//
|
||
// Порядок работы хоста:
|
||
//
|
||
// 1. SetEventSink / SetProtect - до первого Start;
|
||
// 2. Start(configJSON) - валидация синхронна, ошибка возвращается сразу;
|
||
// 3. состояние приходит в EventSink, метрики читаются GetState;
|
||
// 4. Stop() - блокирует до фактической остановки сессии.
|
||
package mobile
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"math"
|
||
"strings"
|
||
"sync"
|
||
"sync/atomic"
|
||
"time"
|
||
|
||
"github.com/samosvalishe/free-turn-proxy/internal/config"
|
||
"github.com/samosvalishe/free-turn-proxy/internal/provider/vk"
|
||
"github.com/samosvalishe/free-turn-proxy/internal/session"
|
||
"github.com/samosvalishe/free-turn-proxy/internal/sub"
|
||
"github.com/samosvalishe/free-turn-proxy/internal/tunnel"
|
||
)
|
||
|
||
// Состояния подключения (дублируют session.Phase*).
|
||
const (
|
||
StateIdle = "idle"
|
||
StateConnecting = "connecting"
|
||
StateConnected = "connected"
|
||
StateError = "error"
|
||
// Пользователь решает капчу вручную (watchdog не считает это зависанием).
|
||
StateCaptcha = "captcha"
|
||
)
|
||
|
||
const (
|
||
// Лимит ожидания первого стрима для UI.
|
||
connectTimeout = 15 * time.Second
|
||
// Лимит ожидания во избежание конфликта портов при перезапуске.
|
||
stopTimeout = 5 * time.Second
|
||
)
|
||
|
||
// ErrTunnelRequiresStartTunnel - попытка вызвать Start с tunnel.mode wg/awg.
|
||
// Хост должен вызвать StartTunnel, передав tun-дескриптор платформы.
|
||
var ErrTunnelRequiresStartTunnel = errors.New("tunnel mode requires StartTunnel with a platform tun fd")
|
||
|
||
// Подставляется при сборке.
|
||
var version = "dev"
|
||
|
||
func Version() string { return version }
|
||
|
||
// live - запущенная сессия с отменой и туннелем.
|
||
type live struct {
|
||
sess *session.Session
|
||
cancel context.CancelFunc
|
||
done chan struct{}
|
||
total int
|
||
tunnel *tunnelParts
|
||
}
|
||
|
||
// final - статус после завершения сессии.
|
||
type final struct {
|
||
state string
|
||
errMsg string
|
||
total int
|
||
}
|
||
|
||
var (
|
||
// mu сериализует Start/Stop; чтение идет мимо.
|
||
mu sync.Mutex
|
||
current atomic.Pointer[live]
|
||
lastStop atomic.Pointer[final]
|
||
)
|
||
|
||
// Snapshot - консистентный срез состояния сессии для тика UI.
|
||
type Snapshot struct {
|
||
State string // idle | connecting | connected | captcha | error
|
||
Streams int // подключённых TURN-потоков прямо сейчас
|
||
Total int // целевое число потоков
|
||
ErrMsg string // непустой при State == error
|
||
TxTotal int64 // всего отправлено байт
|
||
RxTotal int64 // всего получено байт
|
||
TxRate int64 // текущая скорость отправки, байт/с
|
||
RxRate int64 // текущая скорость получения, байт/с
|
||
}
|
||
|
||
func GetState() *Snapshot {
|
||
if l := current.Load(); l != nil {
|
||
s := l.sess.Snapshot()
|
||
return &Snapshot{
|
||
State: string(s.Phase),
|
||
Streams: s.Streams,
|
||
Total: s.Total,
|
||
ErrMsg: s.Err,
|
||
TxTotal: clampToInt64(s.TxTotal),
|
||
RxTotal: clampToInt64(s.RxTotal),
|
||
TxRate: s.TxRate,
|
||
RxRate: s.RxRate,
|
||
}
|
||
}
|
||
if f := lastStop.Load(); f != nil {
|
||
return &Snapshot{State: f.state, ErrMsg: f.errMsg, Total: f.total}
|
||
}
|
||
return &Snapshot{State: StateIdle}
|
||
}
|
||
|
||
// clampToInt64 насыщает uint64 до int64: gomobile экспортирует только int64,
|
||
// а счётчики байт - uint64.
|
||
func clampToInt64(u uint64) int64 {
|
||
if u > math.MaxInt64 {
|
||
return math.MaxInt64
|
||
}
|
||
return int64(u)
|
||
}
|
||
|
||
// Хост строит из него стартовое состояние формы.
|
||
func DefaultConfigJSON() string { return config.DefaultClientJSON() }
|
||
|
||
// Для live-валидации формы без запуска.
|
||
func ValidateConfig(configJSON string) string {
|
||
if _, err := config.ParseClientJSON([]byte(configJSON), ""); err != nil {
|
||
return err.Error()
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// Для экрана "какая команда работает" (гарантирует совпадение с ядром).
|
||
func ConfigToArgs(configJSON string) (string, error) {
|
||
c, err := config.ParseClientJSON([]byte(configJSON), "")
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return strings.Join(config.ClientArgs(c), " "), nil
|
||
}
|
||
|
||
// Запуск в режиме прокси (для tunnel.mode wg/awg нужен StartTunnel).
|
||
func Start(configJSON string) error {
|
||
mu.Lock()
|
||
defer mu.Unlock()
|
||
if current.Load() != nil {
|
||
return errors.New("already running")
|
||
}
|
||
return startLocked(configJSON, 0, false)
|
||
}
|
||
|
||
// Пересоздание сессии с новым конфигом (при смене сети).
|
||
func Restart(configJSON string, tunFD int) error {
|
||
mu.Lock()
|
||
defer mu.Unlock()
|
||
stopLocked()
|
||
return startLocked(configJSON, tunFD, true)
|
||
}
|
||
|
||
func Stop() {
|
||
mu.Lock()
|
||
defer mu.Unlock()
|
||
stopLocked()
|
||
}
|
||
|
||
func stopLocked() {
|
||
l := current.Swap(nil)
|
||
if l == nil {
|
||
return
|
||
}
|
||
l.cancel()
|
||
select {
|
||
case <-l.done:
|
||
case <-time.After(stopTimeout):
|
||
}
|
||
// backend.Down() может зависнуть (upstream amneziawg-go): запускаем в горутине,
|
||
// чтобы не блокировать Restart после истечения stopTimeout.
|
||
go l.tunnel.close()
|
||
lastStop.CompareAndSwap(nil, &final{state: StateIdle})
|
||
}
|
||
|
||
func startLocked(configJSON string, tunFD int, withTunnel bool) error {
|
||
raw := []byte(configJSON)
|
||
|
||
// Подписка отдаёт peer, без которого конфиг не проходит валидацию, поэтому
|
||
// тянем её до разбора и накладываем URI ноды поверх.
|
||
overlayURI := ""
|
||
if subURL := config.PeekSubURLJSON(raw); subURL != "" {
|
||
s, err := sub.Fetch(context.Background(), subURL)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to fetch subscription: %w", err)
|
||
}
|
||
if len(s.Nodes) == 0 || s.Nodes[0].URI == nil {
|
||
return errors.New("no nodes found in subscription")
|
||
}
|
||
overlayURI = s.Nodes[0].URI.String()
|
||
}
|
||
|
||
cfg, err := config.ParseClientJSON(raw, overlayURI)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
// ID клиента постоянный и живет в приложении.
|
||
if cfg.ClientID == "" {
|
||
return errors.New("clientId is required")
|
||
}
|
||
if cfg.VK.ManualCaptcha && currentSink() == nil {
|
||
return errors.New("manual captcha requires event sink")
|
||
}
|
||
if cfg.Tunnel.Enabled() && !withTunnel {
|
||
return fmt.Errorf("%w (mode=%s)", ErrTunnelRequiresStartTunnel, cfg.Tunnel.Mode)
|
||
}
|
||
|
||
logger := &sinkLogger{debug: cfg.Log.Debug, buf: sharedLogBuf}
|
||
|
||
// Туннель требует in-memory PacketConn до старта сессии.
|
||
var parts *tunnelParts
|
||
var tunCfg *tunnel.Config
|
||
if cfg.Tunnel.Enabled() {
|
||
parts, tunCfg, err = buildTunnel(cfg, logger)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
// Держит watchdog, пока пользователь решает капчу.
|
||
var captchaActive atomic.Bool
|
||
var solver vk.ManualSolverFunc
|
||
if currentSink() != nil {
|
||
solver = vk.ProxyManualSolver(
|
||
func(url string) { captchaActive.Store(true); emitCaptcha(url) },
|
||
func() { captchaActive.Store(false); emitCaptcha("") },
|
||
)
|
||
}
|
||
|
||
deps := session.Deps{
|
||
Logger: logger,
|
||
Observer: observer{},
|
||
Solver: solver,
|
||
CaptchaActive: captchaActive.Load,
|
||
Options: session.Options{
|
||
ConnectTimeout: connectTimeout,
|
||
Traffic: true,
|
||
},
|
||
}
|
||
if parts != nil {
|
||
deps.LocalPipe = parts.relaySide
|
||
}
|
||
|
||
sess, err := session.New(cfg, deps)
|
||
if err != nil {
|
||
parts.close()
|
||
return err
|
||
}
|
||
|
||
// Handshake повторяется в фоне, ждать готовности не нужно.
|
||
if parts != nil {
|
||
if err := parts.backend.Up(tunCfg, tunFD); err != nil {
|
||
parts.close()
|
||
return err
|
||
}
|
||
}
|
||
|
||
ClearLogs()
|
||
ctx, cancel := context.WithCancel(context.Background())
|
||
l := &live{
|
||
sess: sess,
|
||
cancel: cancel,
|
||
done: make(chan struct{}),
|
||
total: sess.Snapshot().Total,
|
||
tunnel: parts,
|
||
}
|
||
current.Store(l)
|
||
lastStop.Store(nil)
|
||
|
||
go func() {
|
||
defer close(l.done)
|
||
runErr := sess.Run(ctx)
|
||
cancel()
|
||
|
||
// Если Stop перехватил сессию, финальный статус его.
|
||
if !current.CompareAndSwap(l, nil) {
|
||
return
|
||
}
|
||
if runErr != nil {
|
||
lastStop.Store(&final{state: StateError, errMsg: runErr.Error(), total: l.total})
|
||
return
|
||
}
|
||
lastStop.Store(&final{state: StateIdle})
|
||
}()
|
||
|
||
return nil
|
||
}
|