mirror of
https://github.com/p1neappleXpress/OpenFlux.git
synced 2026-10-02 05:04:39 +08:00
iOS UI: свои домены, журнал на главном, пинг профилей
Свои домены напрямую: список в настройках, ввод нормализуется (можно вставить целый URL). Едут в тот же GeoSite-путь, что и встроенный RU-список, поэтому работают и при выключенном RU-сплите — раньше это было привязано к нему, и ручной список молча не действовал. Журнал вынесен на главный экран с очисткой и копированием. Строки из расширения (отдельный процесс) приходят через sendProviderMessage: OpenFluxReadLog опустошает кольцевой буфер, поэтому читатель должен быть один — им оставлено расширение. Пинг профилей прямо из выпадашки. ICMP приложению недоступен, поэтому это HTTP-round-trip до хоста документа: честный ответ на «жив ли конфиг», но не проверка выходного узла. Шиты переведены на .sheet(item:): с isPresented содержимое строится по состоянию на момент показа, и значение, выставленное в том же действии, до него не доезжает — редактор открывался пустым, а капча белым экраном. Версия сборки 44 -> 64.
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"universal-bypass-tool/transport"
|
||||
)
|
||||
|
||||
// pipeEnd — половина двунаправленного канала в памяти.
|
||||
type pipeEnd struct {
|
||||
peer *pipeEnd
|
||||
cb func([]byte)
|
||||
}
|
||||
|
||||
func (p *pipeEnd) Start() error { return nil }
|
||||
func (p *pipeEnd) Stop() error { return nil }
|
||||
func (p *pipeEnd) Send(data []byte) error {
|
||||
if cb := p.peer.cb; cb != nil {
|
||||
cb(append([]byte(nil), data...))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (p *pipeEnd) Receive(cb func([]byte)) { p.cb = cb }
|
||||
func (p *pipeEnd) IsConnected() bool { return true }
|
||||
func (p *pipeEnd) Stats() transport.TransportStats { return transport.TransportStats{Connected: true} }
|
||||
|
||||
// Нода просит куки → клиент отвечает своими. Это весь смысл контрольного канала,
|
||||
// и ломается он молча: просьба уходит в пустоту, а нода продолжает сидеть на
|
||||
// капче, ничем не отличаясь от обычного обрыва.
|
||||
//
|
||||
// Роль клиента играет wrapControl (он занимает глобальный слот ctrlConn — в
|
||||
// одном процессе живёт ровно одна сторона), роль ноды — обычный
|
||||
// ControlTransport на другом конце трубы.
|
||||
func TestCookieExchangeNodeAsksClientAnswers(t *testing.T) {
|
||||
a, b := &pipeEnd{}, &pipeEnd{}
|
||||
a.peer, b.peer = b, a
|
||||
|
||||
resetYandexRegistry()
|
||||
setInitialCookies("spravka=solved; yandexuid=777")
|
||||
defer setInitialCookies("")
|
||||
|
||||
clientCore := newYandexDocs("https://disk.yandex.ru/i/client", transport.DefaultConfig())
|
||||
defer clientCore.Stop()
|
||||
|
||||
clientCtrl := wrapControl(a, false)
|
||||
clientCtrl.Receive(func([]byte) {})
|
||||
|
||||
nodeCtrl := transport.NewControlTransport(b)
|
||||
nodeCtrl.Receive(func([]byte) {})
|
||||
got := make(chan map[string]string, 1)
|
||||
nodeCtrl.SetControlHandler(func(typ byte, body []byte) {
|
||||
if typ != transport.CtrlCookiesOffer {
|
||||
return
|
||||
}
|
||||
var p transport.CookiesPayload
|
||||
if err := json.Unmarshal(body, &p); err != nil {
|
||||
t.Errorf("bad offer: %v", err)
|
||||
return
|
||||
}
|
||||
got <- p.Cookies
|
||||
})
|
||||
|
||||
if err := nodeCtrl.SendControl(transport.CtrlCookiesRequest,
|
||||
transport.CookiesPayload{Reason: "smartcaptcha"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
select {
|
||||
case cookies := <-got:
|
||||
if cookies["spravka"] != "solved" || cookies["yandexuid"] != "777" {
|
||||
t.Fatalf("client offered the wrong cookies: %v", cookies)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("client never answered the cookie request")
|
||||
}
|
||||
}
|
||||
|
||||
// Если у клиента кук нет, он не должен молча промолчать: UI обязан узнать, что
|
||||
// пользователю надо пройти капчу, иначе связь просто не поднимется и никто не
|
||||
// поймёт почему.
|
||||
func TestCookieRequestWithoutCookiesFlagsCaptchaForUI(t *testing.T) {
|
||||
a, b := &pipeEnd{}, &pipeEnd{}
|
||||
a.peer, b.peer = b, a
|
||||
|
||||
resetYandexRegistry()
|
||||
setInitialCookies("")
|
||||
docURL := "https://disk.yandex.ru/i/needscaptcha"
|
||||
core := newYandexDocs(docURL, transport.DefaultConfig())
|
||||
defer core.Stop()
|
||||
|
||||
clientCtrl := wrapControl(a, false)
|
||||
clientCtrl.Receive(func([]byte) {})
|
||||
|
||||
nodeCtrl := transport.NewControlTransport(b)
|
||||
nodeCtrl.Receive(func([]byte) {})
|
||||
if err := nodeCtrl.SendControl(transport.CtrlCookiesRequest,
|
||||
transport.CookiesPayload{Reason: "smartcaptcha"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if pendingCaptchaURL() == docURL {
|
||||
return
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("UI was never told a captcha is needed, got %q", pendingCaptchaURL())
|
||||
}
|
||||
+163
-2
@@ -11,6 +11,7 @@ import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -209,8 +210,78 @@ const (
|
||||
startTransportError = 3
|
||||
startAddrInUse = 4 // SOCKS5 port could not be bound (e.g. already in use)
|
||||
startPanic = 5
|
||||
startBadEncryption = 6 // secret set but unusable (too short / key derivation failed)
|
||||
)
|
||||
|
||||
// ---- end-to-end encryption ----
|
||||
//
|
||||
// The secret is handed in separately from the start call (like the DoT resolver
|
||||
// and the UDP switch) so the existing C exports keep their signatures and an
|
||||
// older caller keeps working unchanged.
|
||||
|
||||
var (
|
||||
encMu sync.Mutex
|
||||
encSecret string
|
||||
)
|
||||
|
||||
// OpenFluxSetEncryption sets (or clears, with "") the shared secret for
|
||||
// end-to-end AES-256-GCM. Call before starting; it applies to the next start.
|
||||
//
|
||||
//export OpenFluxSetEncryption
|
||||
func OpenFluxSetEncryption(secret *C.char) {
|
||||
s := ""
|
||||
if secret != nil {
|
||||
s = strings.TrimSpace(C.GoString(secret))
|
||||
}
|
||||
encMu.Lock()
|
||||
encSecret = s
|
||||
encMu.Unlock()
|
||||
if s == "" {
|
||||
utils.Debugf("[BRIDGE] encryption: off")
|
||||
} else {
|
||||
utils.Debugf("[BRIDGE] encryption: secret set (%d chars)", len(s))
|
||||
}
|
||||
}
|
||||
|
||||
// encSecretSet — задан ли секрет шифрования. Нужен для транспорта direct:
|
||||
// открытый TCP до узла без шифрования запускать нельзя.
|
||||
func encSecretSet() bool {
|
||||
encMu.Lock()
|
||||
defer encMu.Unlock()
|
||||
return encSecret != ""
|
||||
}
|
||||
|
||||
// wrapEncryption puts the AES-256-GCM layer outermost when a secret is set,
|
||||
// which is exactly where the CLI puts it — so this client interoperates with an
|
||||
// exit node run as `--encryption-key-file=...`.
|
||||
//
|
||||
// The KDF context is derived HERE rather than passed in from Swift, because it
|
||||
// must match the peer bit for bit and the CLI's rule is fixed: the document URL,
|
||||
// falling back to the transport name when there is none. Letting the UI supply
|
||||
// it would add a silent-failure mode where a stray character yields a different
|
||||
// key and every packet is dropped as unauthenticated.
|
||||
func wrapEncryption(inner transport.Transport, transportType, docURL string) (transport.Transport, error) {
|
||||
encMu.Lock()
|
||||
secret := encSecret
|
||||
encMu.Unlock()
|
||||
if secret == "" {
|
||||
return inner, nil
|
||||
}
|
||||
// Та же развилка, что в CLI: у direct контекстом служит имя транспорта,
|
||||
// потому что адрес прослушивания на узле и адрес набора у клиента — разные
|
||||
// строки, и ключи бы разъехались.
|
||||
context := transportType
|
||||
if docURL != "" && transportType != "direct" {
|
||||
context = docURL
|
||||
}
|
||||
enc, err := transport.NewEncryptedTransport(inner, secret, context, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
log.Printf("Encryption: AES-256-GCM enabled (KDF context=%q)", context)
|
||||
return enc, nil
|
||||
}
|
||||
|
||||
// buildDocTransport turns a document-URL spec into a transport. A single URL
|
||||
// yields one channel; a comma-separated list yields a MultiplexTransport that
|
||||
// stripes flows across the documents. Each inner document is wrapped in its own
|
||||
@@ -246,7 +317,9 @@ func buildDocTransport(spec string, config transport.TransportConfig, factory fu
|
||||
//
|
||||
// transportType: "yandex" or "oneme".
|
||||
// url: Yandex.Docs document URL (yandex transport). A comma-separated
|
||||
// list multiplexes across those documents.
|
||||
//
|
||||
// list multiplexes across those documents.
|
||||
//
|
||||
// socksAddr: e.g. "127.0.0.1:1080".
|
||||
// maxToken/maxUid: credentials for the "oneme" (MAX) transport; pass "" for yandex.
|
||||
//
|
||||
@@ -284,12 +357,13 @@ func OpenFluxStartClient(transportType, url, socksAddr, maxToken, maxUid *C.char
|
||||
}
|
||||
probe.Close()
|
||||
|
||||
resetYandexRegistry()
|
||||
config := transport.DefaultConfig()
|
||||
var t transport.Transport
|
||||
switch tt {
|
||||
case "yandex", "":
|
||||
t = buildDocTransport(docURL, config, func(u string) transport.Transport {
|
||||
return yandex.NewYandexDocsTransport(u, config)
|
||||
return newYandexDocs(u, config)
|
||||
})
|
||||
case "volga", "vyandex":
|
||||
t = buildDocTransport(docURL, config, func(u string) transport.Transport {
|
||||
@@ -299,6 +373,17 @@ func OpenFluxStartClient(transportType, url, socksAddr, maxToken, maxUid *C.char
|
||||
t = buildDocTransport(docURL, config, func(u string) transport.Transport {
|
||||
return yandex.NewBoardsTransport(u, config)
|
||||
})
|
||||
case "direct":
|
||||
// Обычный TCP до узла: адрес узла виден, зато канал доступен, когда
|
||||
// носитель с документом упёрся в капчу. Шифрование обязательно.
|
||||
if encSecretSet() {
|
||||
dcfg := transport.DefaultDirectConfig()
|
||||
dcfg.DialAddr = docURL
|
||||
t = transport.NewAdaptiveTransport(transport.NewDirectTransport(config, dcfg))
|
||||
} else {
|
||||
utils.Debugf("[BRIDGE] direct requires an encryption key")
|
||||
return C.int(startBadEncryption)
|
||||
}
|
||||
case "mailru", "mail":
|
||||
t = buildDocTransport(docURL, config, func(u string) transport.Transport {
|
||||
return mailru.NewMailruDocsTransport(u, config)
|
||||
@@ -311,6 +396,17 @@ func OpenFluxStartClient(transportType, url, socksAddr, maxToken, maxUid *C.char
|
||||
return C.int(startBadTransport)
|
||||
}
|
||||
|
||||
t, encErr := wrapEncryption(t, tt, docURL)
|
||||
if encErr != nil {
|
||||
utils.Debugf("[BRIDGE] encryption: %v", encErr)
|
||||
return C.int(startBadEncryption)
|
||||
}
|
||||
|
||||
// Контрольный канал: отдаём ноде куки, чтобы она не упиралась в капчу.
|
||||
// Телефон здесь всегда клиент.
|
||||
t = wrapControl(t, false)
|
||||
startCookieOffering()
|
||||
|
||||
if err := t.Start(); err != nil {
|
||||
utils.Debugf("[BRIDGE] Failed to start transport: %v", err)
|
||||
return C.int(startTransportError)
|
||||
@@ -415,6 +511,71 @@ func OpenFluxReadLog() *C.char {
|
||||
return C.CString(logbuf.drain())
|
||||
}
|
||||
|
||||
// ---- интерактивная капча (SmartCaptcha) ----
|
||||
//
|
||||
// PoW-солвер её не проходит, поэтому её решает человек в WebView, а приложение
|
||||
// возвращает сюда свежие куки. Оба экспорта работают в том процессе, где живёт
|
||||
// ядро: в приложении — для локального SOCKS, в расширении — через IPC от
|
||||
// приложения (см. handleAppMessage в PacketTunnelProvider).
|
||||
|
||||
// OpenFluxCaptchaPending возвращает URL документа, упёршегося в интерактивную
|
||||
// капчу, или пустую строку. Результат C-аллоцирован; освобождать
|
||||
// OpenFluxFreeString.
|
||||
//
|
||||
//export OpenFluxCaptchaPending
|
||||
func OpenFluxCaptchaPending() *C.char {
|
||||
return C.CString(pendingCaptchaURL())
|
||||
}
|
||||
|
||||
// OpenFluxApplyCaptchaCookies принимает куки в формате заголовка Cookie
|
||||
// ("a=1; b=2"), полученные после того как пользователь прошёл капчу, и
|
||||
// раздаёт их живым Yandex.Docs транспортам. Возвращает число транспортов,
|
||||
// которым куки применились.
|
||||
//
|
||||
//export OpenFluxApplyCaptchaCookies
|
||||
func OpenFluxApplyCaptchaCookies(cookies *C.char) C.int {
|
||||
if cookies == nil {
|
||||
return C.int(0)
|
||||
}
|
||||
return C.int(applyCookiesToYandex(C.GoString(cookies)))
|
||||
}
|
||||
|
||||
// OpenFluxRemoteCaptchaPending возвращает адрес, проверку для которого должен
|
||||
// пройти ЭТОТ клиент в интересах узла, или пустую строку.
|
||||
//
|
||||
// Отличается от OpenFluxCaptchaPending принципиально: эту проверку надо
|
||||
// проходить, НЕ отключая туннель, потому что куки должны быть выданы на адрес
|
||||
// узла. Результат C-аллоцирован; освобождать OpenFluxFreeString.
|
||||
//
|
||||
//export OpenFluxRemoteCaptchaPending
|
||||
func OpenFluxRemoteCaptchaPending() *C.char {
|
||||
return C.CString(pendingRemoteCaptchaURL())
|
||||
}
|
||||
|
||||
// OpenFluxOfferCaptchaCookies отдаёт узлу куки, добытые через туннель.
|
||||
// Возвращает число переданных кук.
|
||||
//
|
||||
//export OpenFluxOfferCaptchaCookies
|
||||
func OpenFluxOfferCaptchaCookies(cookies *C.char) C.int {
|
||||
if cookies == nil {
|
||||
return C.int(0)
|
||||
}
|
||||
return C.int(offerCookiesToPeer(C.GoString(cookies)))
|
||||
}
|
||||
|
||||
// OpenFluxSetInitialCookies передаёт куки, добытые до старта туннеля (капча
|
||||
// пройдена при выключенном VPN). Вызывать ПЕРЕД стартом: они попадут в банку
|
||||
// транспорта при создании, до первого запроса к документу.
|
||||
//
|
||||
//export OpenFluxSetInitialCookies
|
||||
func OpenFluxSetInitialCookies(cookies *C.char) {
|
||||
s := ""
|
||||
if cookies != nil {
|
||||
s = C.GoString(cookies)
|
||||
}
|
||||
setInitialCookies(s)
|
||||
}
|
||||
|
||||
// OpenFluxFreeString frees a string returned by this library.
|
||||
//
|
||||
//export OpenFluxFreeString
|
||||
|
||||
+33
-6
@@ -12,6 +12,7 @@ import (
|
||||
"crypto/tls"
|
||||
"encoding/binary"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
@@ -90,12 +91,13 @@ func OpenFluxStartPacketTunnel(transportType, url, maxToken, maxUid *C.char) (rc
|
||||
// multiplexed this is a real slice of the 50 MB budget, and 512 still absorbs
|
||||
// a normal burst. Peak memory, not raw throughput, is the binding constraint
|
||||
// inside the extension.
|
||||
resetYandexRegistry()
|
||||
config.MaxQueueSize = 512
|
||||
var t transport.Transport
|
||||
switch tt {
|
||||
case "yandex", "":
|
||||
t = buildDocTransport(docURL, config, func(u string) transport.Transport {
|
||||
return yandex.NewYandexDocsTransport(u, config)
|
||||
return newYandexDocs(u, config)
|
||||
})
|
||||
case "volga", "vyandex":
|
||||
// Slim VOLGA profile so the relay worker pool + queues stay under the
|
||||
@@ -108,6 +110,17 @@ func OpenFluxStartPacketTunnel(transportType, url, maxToken, maxUid *C.char) (rc
|
||||
t = buildDocTransport(docURL, config, func(u string) transport.Transport {
|
||||
return yandex.NewBoardsTransport(u, config)
|
||||
})
|
||||
case "direct":
|
||||
// Обычный TCP до узла: адрес узла виден, зато канал доступен, когда
|
||||
// носитель с документом упёрся в капчу. Шифрование обязательно.
|
||||
if encSecretSet() {
|
||||
dcfg := transport.DefaultDirectConfig()
|
||||
dcfg.DialAddr = docURL
|
||||
t = transport.NewAdaptiveTransport(transport.NewDirectTransport(config, dcfg))
|
||||
} else {
|
||||
utils.Debugf("[PKT] direct requires an encryption key")
|
||||
return C.int(startBadEncryption)
|
||||
}
|
||||
case "mailru", "mail":
|
||||
t = buildDocTransport(docURL, config, func(u string) transport.Transport {
|
||||
return mailru.NewMailruDocsTransport(u, config)
|
||||
@@ -119,6 +132,20 @@ func OpenFluxStartPacketTunnel(transportType, url, maxToken, maxUid *C.char) (rc
|
||||
return C.int(startBadTransport)
|
||||
}
|
||||
|
||||
// Outermost AES-256-GCM, same position as the CLI's.
|
||||
t, encErr := wrapEncryption(t, tt, docURL)
|
||||
if encErr != nil {
|
||||
utils.Debugf("[PKT] encryption: %v", encErr)
|
||||
return C.int(startBadEncryption)
|
||||
}
|
||||
|
||||
// Контрольный канал: отдаём ноде куки, чтобы она не упиралась в капчу.
|
||||
// Телефон здесь всегда клиент. Обязательно ДО t.Receive: иначе колбэк
|
||||
// повесится на внутренний транспорт и контрольные кадры уедут в очередь
|
||||
// пакетов как мусор.
|
||||
t = wrapControl(t, false)
|
||||
startCookieOffering()
|
||||
|
||||
// Device-bound packet queue. 1024 was too shallow: a download burst fills it
|
||||
// faster than the device drains, packets get dropped, and the tunneled TCP
|
||||
// treats that as loss and backs off — throttling throughput. A deeper queue
|
||||
@@ -143,7 +170,7 @@ func OpenFluxStartPacketTunnel(transportType, url, maxToken, maxUid *C.char) (rc
|
||||
ptOutQ = outQ
|
||||
ptCtx, ptCancel = context.WithCancel(context.Background())
|
||||
ptOn = true
|
||||
utils.Debugf("[PKT] L3 packet tunnel started (transport %s)", tt)
|
||||
log.Printf("Packet tunnel started, transport=%s", tt)
|
||||
return C.int(startOK)
|
||||
}
|
||||
|
||||
@@ -231,8 +258,8 @@ func sendICMPPortUnreachable(orig []byte, outQ chan []byte) {
|
||||
ip := make([]byte, total)
|
||||
ip[0] = 0x45
|
||||
binary.BigEndian.PutUint16(ip[2:4], uint16(total))
|
||||
ip[8] = 64 // TTL
|
||||
ip[9] = 1 // ICMP
|
||||
ip[8] = 64 // TTL
|
||||
ip[9] = 1 // ICMP
|
||||
copy(ip[12:16], orig[16:20]) // src = original destination
|
||||
copy(ip[16:20], orig[12:16]) // dst = original source (the device)
|
||||
ck2 := network.IPChecksum(ip[:20])
|
||||
@@ -369,8 +396,8 @@ func handleDNSPacket(req []byte, outQ chan []byte) {
|
||||
resp[0] = req[0]
|
||||
resp[1] = req[1]
|
||||
binary.BigEndian.PutUint16(resp[2:4], uint16(total))
|
||||
resp[8] = 64 // TTL
|
||||
resp[9] = 17 // UDP
|
||||
resp[8] = 64 // TTL
|
||||
resp[9] = 17 // UDP
|
||||
copy(resp[12:16], dstIP) // src = original destination (the resolver)
|
||||
copy(resp[16:20], srcIP) // dst = the device
|
||||
resp[10], resp[11] = 0, 0 // checksum field
|
||||
|
||||
@@ -14,15 +14,17 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/klauspost/compress v1.20.0
|
||||
github.com/pierrec/lz4/v4 v4.1.27
|
||||
github.com/pion/webrtc/v3 v3.3.6
|
||||
github.com/wlynxg/anet v0.0.5
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/net v0.52.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/klauspost/compress v1.20.0 // indirect
|
||||
github.com/pion/datachannel v1.5.8 // indirect
|
||||
github.com/pion/dtls/v2 v2.2.12 // indirect
|
||||
github.com/pion/ice/v2 v2.3.38 // indirect
|
||||
@@ -40,7 +42,5 @@ require (
|
||||
github.com/pion/turn/v2 v2.1.6 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/stretchr/testify v1.9.0 // indirect
|
||||
golang.org/x/crypto v0.49.0 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import SwiftUI
|
||||
import WebKit
|
||||
|
||||
/// Interactive-captcha solver.
|
||||
///
|
||||
/// Yandex serves two kinds of captcha. The PoW one (`showcaptchafast`) the Go
|
||||
/// core solves by itself. The other — SmartCaptcha (`showcaptcha?cc=1`) — needs
|
||||
/// a real browser and a human, so the core stops, reports it, and waits for
|
||||
/// fresh cookies. This screen is that step: load the document in a WKWebView,
|
||||
/// let the user click through, then hand the resulting cookies back.
|
||||
///
|
||||
/// Detecting "solved" is deliberately loose: Yandex redirects around several
|
||||
/// hosts, so instead of pattern-matching the flow we treat "we are no longer on
|
||||
/// a captcha page" as success and also offer a manual button. Handing over
|
||||
/// cookies is idempotent on the Go side, so an early or repeated hand-over is
|
||||
/// harmless.
|
||||
struct CaptchaView: View {
|
||||
let url: URL
|
||||
/// Called with cookies in Cookie-header form ("a=1; b=2").
|
||||
let onCookies: (String) -> Void
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var status = "Пройдите проверку — куки подхватятся автоматически"
|
||||
@State private var busy = false
|
||||
@State private var looksSolved = false
|
||||
@StateObject private var model = CaptchaWebModel()
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
VStack(spacing: 0) {
|
||||
CaptchaWebView(url: url, model: model)
|
||||
|
||||
VStack(spacing: 8) {
|
||||
if let e = model.loadError {
|
||||
VStack(spacing: 6) {
|
||||
Label("Страница не загрузилась", systemImage: "wifi.exclamationmark")
|
||||
.font(.subheadline.bold()).foregroundColor(.orange)
|
||||
Text(e).font(.caption2).foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
Text("Капча появляется как раз когда туннель не работает, а трафик идёт через него. Маршруты для страницы капчи добавляются по DNS-ответу — первая попытка может не успеть, обновите.")
|
||||
.font(.caption2).foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
Button {
|
||||
model.reload()
|
||||
} label: {
|
||||
Label("Обновить", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
Text(status)
|
||||
.font(.caption2).foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
Button {
|
||||
handOver()
|
||||
} label: {
|
||||
Label(busy ? "Передаю…" : "Готово — передать куки",
|
||||
systemImage: "checkmark.shield")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(busy)
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.secondarySystemBackground))
|
||||
}
|
||||
.navigationTitle("Проверка Яндекса")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Закрыть") { dismiss() }
|
||||
}
|
||||
}
|
||||
.onChange(of: model.lastURL) { newURL in
|
||||
// Left the captcha page: very likely solved. Hand cookies over
|
||||
// once, automatically, so the user does not have to guess.
|
||||
guard let s = newURL?.absoluteString else { return }
|
||||
let onCaptcha = s.contains("showcaptcha") || s.contains("captcha")
|
||||
if !onCaptcha && !looksSolved {
|
||||
looksSolved = true
|
||||
status = "Проверка пройдена — передаю куки…"
|
||||
handOver()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handOver() {
|
||||
busy = true
|
||||
model.collectCookies(for: url) { header in
|
||||
busy = false
|
||||
guard !header.isEmpty else {
|
||||
status = "Куки не найдены — пройдите проверку и нажмите ещё раз"
|
||||
return
|
||||
}
|
||||
onCookies(header)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Observable state shared with the UIKit web view.
|
||||
final class CaptchaWebModel: ObservableObject {
|
||||
@Published var lastURL: URL?
|
||||
/// Текст ошибки загрузки. Без него любая неудача выглядела как пустой белый
|
||||
/// экран: WKWebView просто ничего не рисует и молчит.
|
||||
@Published var loadError: String?
|
||||
@Published var loading = true
|
||||
fileprivate weak var webView: WKWebView?
|
||||
|
||||
func reload() {
|
||||
loadError = nil
|
||||
loading = true
|
||||
webView?.reload()
|
||||
}
|
||||
|
||||
/// Collects cookies for the document's host from the web view's data store
|
||||
/// and formats them as a Cookie header.
|
||||
///
|
||||
/// Reads from WKWebsiteDataStore rather than the response headers because the
|
||||
/// captcha sets its cookie via JavaScript on some flows, where it never
|
||||
/// appears in a navigation response.
|
||||
func collectCookies(for url: URL, completion: @escaping (String) -> Void) {
|
||||
guard let store = webView?.configuration.websiteDataStore.httpCookieStore else {
|
||||
completion("")
|
||||
return
|
||||
}
|
||||
let host = url.host ?? ""
|
||||
store.getAllCookies { cookies in
|
||||
// Keep cookies for the document host and its parent domain: the
|
||||
// captcha is issued on yandex.ru while the document may live on
|
||||
// disk./docs.yandex.ru.
|
||||
let wanted = cookies.filter { c in
|
||||
let d = c.domain.hasPrefix(".") ? String(c.domain.dropFirst()) : c.domain
|
||||
return host == d || host.hasSuffix("." + d) || d.hasSuffix("yandex.ru") || d.hasSuffix("yandex.com")
|
||||
}
|
||||
let header = wanted.map { "\($0.name)=\($0.value)" }.joined(separator: "; ")
|
||||
DispatchQueue.main.async { completion(header) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct CaptchaWebView: UIViewRepresentable {
|
||||
let url: URL
|
||||
let model: CaptchaWebModel
|
||||
|
||||
func makeUIView(context: Context) -> WKWebView {
|
||||
let cfg = WKWebViewConfiguration()
|
||||
// Default (persistent) store on purpose: a non-persistent one would keep
|
||||
// the solved cookies out of reach after the sheet closes.
|
||||
cfg.websiteDataStore = .default()
|
||||
let web = WKWebView(frame: .zero, configuration: cfg)
|
||||
web.navigationDelegate = context.coordinator
|
||||
model.webView = web
|
||||
web.load(URLRequest(url: url))
|
||||
return web
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: WKWebView, context: Context) {}
|
||||
|
||||
func makeCoordinator() -> Coordinator { Coordinator(model: model) }
|
||||
|
||||
final class Coordinator: NSObject, WKNavigationDelegate {
|
||||
let model: CaptchaWebModel
|
||||
init(model: CaptchaWebModel) { self.model = model }
|
||||
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
let u = webView.url
|
||||
DispatchQueue.main.async {
|
||||
self.model.lastURL = u
|
||||
self.model.loading = false
|
||||
self.model.loadError = nil
|
||||
}
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
|
||||
DispatchQueue.main.async { self.model.loading = true }
|
||||
}
|
||||
|
||||
// Обе ветки обязательны: провальная НАВИГАЦИЯ (не достучались до хоста)
|
||||
// и провал уже начатой загрузки приходят разными колбэками.
|
||||
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!,
|
||||
withError error: Error) {
|
||||
report(error)
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||||
report(error)
|
||||
}
|
||||
|
||||
private func report(_ error: Error) {
|
||||
let ns = error as NSError
|
||||
// -999 = навигацию отменили (обычное дело при редиректах), это не сбой.
|
||||
guard ns.code != NSURLErrorCancelled else { return }
|
||||
DispatchQueue.main.async {
|
||||
self.model.loading = false
|
||||
self.model.loadError = "\(ns.localizedDescription) (код \(ns.code))"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,8 @@ struct ContentView: View {
|
||||
@StateObject private var tunnel = TunnelController()
|
||||
@StateObject private var vpn = VPNController()
|
||||
@StateObject private var store = ProfileStore()
|
||||
@StateObject private var directDomains = DirectDomainStore()
|
||||
@StateObject private var ping = PingService()
|
||||
|
||||
// Legacy single-config fields, kept only to migrate old installs into a Profile.
|
||||
@AppStorage("transportKind") private var transportRaw: String = TransportKind.yandex.rawValue
|
||||
@@ -37,13 +39,95 @@ struct ContentView: View {
|
||||
|
||||
@State private var showInfo = false
|
||||
@State private var showSettings = false
|
||||
@State private var editing: Profile? // profile being edited (or a fresh one)
|
||||
@State private var showEditor = false
|
||||
/// Обёртка для .sheet(item:). С .sheet(isPresented:) редактор получал nil
|
||||
/// вместо профиля: SwiftUI строит содержимое шита по состоянию на момент
|
||||
/// показа, а `editing = sel`, выставленный в том же действии, до него не
|
||||
/// доезжает — форма открывалась пустой, и профиль приходилось вводить
|
||||
/// заново. nil внутри = создание нового.
|
||||
private struct EditorTask: Identifiable {
|
||||
let id = UUID()
|
||||
let profile: Profile?
|
||||
}
|
||||
@State private var editorTask: EditorTask?
|
||||
@State private var sharing: Profile? // profile shown as a QR
|
||||
@State private var testHint: String?
|
||||
/// Обёртка для .sheet(item:). С .sheet(isPresented:) и `if let` внутри тело
|
||||
/// шита становилось пустым (белый экран), как только опрос обнулял URL —
|
||||
/// item-вариант захватывает значение на момент показа.
|
||||
private struct CaptchaTask: Identifiable {
|
||||
let id = UUID()
|
||||
let url: URL
|
||||
/// true — проверка за узел: туннель не гасим, куки отдаём ему.
|
||||
var forPeer = false
|
||||
}
|
||||
@State private var captchaTask: CaptchaTask?
|
||||
/// VPN был включён и его выключили ради капчи — значит после передачи кук
|
||||
/// его надо поднять обратно.
|
||||
@State private var resumeVPNAfterCaptcha = false
|
||||
|
||||
/// Сценарий «решить капчу доков через прямой канал».
|
||||
///
|
||||
/// Смысл: сидя на доках, капчу решить НЕЛЬЗЯ — это и есть заблокированный
|
||||
/// носитель. Нужен рабочий канал, выходящий с адреса узла. Поэтому
|
||||
/// переключаемся на direct-профиль, проходим проверку, отдаём куки узлу и
|
||||
/// возвращаемся обратно на доки — всё одной кнопкой.
|
||||
private enum DirectCaptchaStage { case idle, connecting, solving }
|
||||
@State private var dcStage: DirectCaptchaStage = .idle
|
||||
@State private var dcRestoreProfile: UUID?
|
||||
@State private var dcDoc: URL?
|
||||
|
||||
/// Отдельный профиль Прямой TCP — запасной путь, если в доковом профиле
|
||||
/// адрес узла не заполнен.
|
||||
private var directProfile: Profile? {
|
||||
store.profiles.first { $0.transportKind == .direct && $0.isValid }
|
||||
}
|
||||
|
||||
/// Прямой канал, настроенный ВНУТРИ выбранного докового профиля.
|
||||
private var inlineDirect: (addr: String, id: UUID)? {
|
||||
guard let p = store.selected,
|
||||
p.transportKind == .yandex || p.transportKind == .volga,
|
||||
let addr = p.nodeAddr?.trimmingCharacters(in: .whitespaces), !addr.isEmpty,
|
||||
Secrets.directKey(for: p.id) != nil
|
||||
else { return nil }
|
||||
return (addr, p.id)
|
||||
}
|
||||
|
||||
private var dnsSpec: String { dotSpec(dnsPreset, dnsCustom) }
|
||||
|
||||
/// Документ, упёршийся в интерактивную капчу. Ядро живёт либо в расширении
|
||||
/// (системный VPN), либо в самом приложении (локальный прокси) — берём
|
||||
/// оттуда, где оно сейчас работает.
|
||||
/// Проверка, которую надо пройти В ИНТЕРЕСАХ УЗЛА. Принципиально другой
|
||||
/// случай: туннель гасить нельзя, потому что проверка привязывается к адресу,
|
||||
/// с которого её прошли, а куки нужны годные для адреса узла. Значит выходить
|
||||
/// надо через туннель.
|
||||
private var remoteCaptchaTarget: URL? {
|
||||
let raw = vpn.active ? vpn.remoteCaptchaURL : tunnel.remoteCaptchaURL
|
||||
guard let raw = raw, let u = URL(string: raw) else { return nil }
|
||||
return u
|
||||
}
|
||||
|
||||
/// Документ Яндекса из любого профиля — цель для ручного прохождения капчи
|
||||
/// в интересах узла. Нужен потому, что узел может быть вообще не в состоянии
|
||||
/// попросить: если его носитель заблокирован, канала нет. А подключившись
|
||||
/// другим носителем (direct/boards), выход всё равно идёт с адреса узла —
|
||||
/// и капчу можно пройти за него заранее.
|
||||
private var yandexDocForPeer: URL? {
|
||||
for p in store.profiles where p.transportKind == .yandex || p.transportKind == .volga {
|
||||
let first = p.url.split(separator: ",").first.map(String.init) ?? ""
|
||||
if let u = URL(string: first.trimmingCharacters(in: .whitespaces)), u.host != nil {
|
||||
return u
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private var captchaTarget: URL? {
|
||||
let raw = vpn.active ? vpn.captchaURL : tunnel.captchaURL
|
||||
guard let raw = raw, let u = URL(string: raw) else { return nil }
|
||||
return u
|
||||
}
|
||||
|
||||
// MARK: connection state
|
||||
|
||||
private var statusColor: Color {
|
||||
@@ -70,7 +154,9 @@ struct ContentView: View {
|
||||
vpn.start(transport: p.transport, url: p.url,
|
||||
maxToken: p.maxToken, maxUid: p.maxUid,
|
||||
dns: dnsSpec, tunnelUDP: tunnelUDP,
|
||||
split: splitRU ? "ru-direct" : "")
|
||||
split: splitRU ? "ru-direct" : "",
|
||||
directDomains: directDomains.joined,
|
||||
profileID: p.id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +170,8 @@ struct ContentView: View {
|
||||
} else if let p = store.selected, p.isValid {
|
||||
tunnel.start(transport: p.transportKind, url: p.url,
|
||||
maxToken: p.maxToken, maxUid: p.maxUid,
|
||||
port: Int(socksPort) ?? 10808)
|
||||
port: Int(socksPort) ?? 10808,
|
||||
encryptionKey: Secrets.encryptionKey(for: p.id) ?? "")
|
||||
testHint = "Локальный прокси запущен — нажмите ещё раз для проверки."
|
||||
}
|
||||
}
|
||||
@@ -93,37 +180,58 @@ struct ContentView: View {
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
VStack(spacing: 28) {
|
||||
Text(store.profiles.isEmpty
|
||||
? "Добавьте профиль: вставьте ссылку на документ или отсканируйте QR"
|
||||
: "Выберите профиль и нажмите кнопку подключения")
|
||||
.font(.footnote).foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
connectButton
|
||||
|
||||
Text(statusText).font(.headline)
|
||||
|
||||
profilePicker
|
||||
|
||||
Button {
|
||||
checkAvailability()
|
||||
} label: {
|
||||
Label("Проверить доступность", systemImage: "waveform.path.ecg")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(store.selected == nil || !(store.selected?.isValid ?? false))
|
||||
|
||||
if let hint = testHint {
|
||||
Text(hint).font(.caption2).foregroundColor(.secondary)
|
||||
ScrollView {
|
||||
VStack(spacing: 22) {
|
||||
Text(store.profiles.isEmpty
|
||||
? "Добавьте профиль: вставьте ссылку на документ или отсканируйте QR"
|
||||
: "Выберите профиль и нажмите кнопку подключения")
|
||||
.font(.footnote).foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
Spacer()
|
||||
connectButton
|
||||
|
||||
Text(statusText).font(.headline)
|
||||
|
||||
profilePicker
|
||||
|
||||
HStack(spacing: 10) {
|
||||
Button {
|
||||
checkAvailability()
|
||||
} label: {
|
||||
Label("Доступность", systemImage: "waveform.path.ecg")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(store.selected == nil || !(store.selected?.isValid ?? false))
|
||||
|
||||
Button {
|
||||
ping.pingAll(store.profiles)
|
||||
} label: {
|
||||
Label("Пинг всех", systemImage: "dot.radiowaves.left.and.right")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(store.profiles.isEmpty)
|
||||
}
|
||||
|
||||
if let hint = testHint {
|
||||
Text(hint).font(.caption2).foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
solveViaDirectButton
|
||||
|
||||
manualPeerCaptcha
|
||||
|
||||
remoteCaptchaBanner
|
||||
|
||||
captchaBanner
|
||||
|
||||
logPanel
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.padding()
|
||||
.navigationTitle("OpenFlux")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
@@ -134,10 +242,28 @@ struct ContentView: View {
|
||||
Button { showInfo = true } label: { Image(systemName: "info.circle") }
|
||||
}
|
||||
}
|
||||
.onChange(of: vpn.status) { st in
|
||||
if dcStage == .connecting, st.contains("Connected"), let doc = dcDoc {
|
||||
dcStage = .solving
|
||||
captchaTask = CaptchaTask(url: doc, forPeer: true)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
OpenFluxSetDebug(debugLog ? 1 : 0)
|
||||
dnsSpec.withCString { OpenFluxSetDoTResolver(UnsafeMutablePointer(mutating: $0)) }
|
||||
migrateLegacyIfNeeded()
|
||||
// Pipe the extension's log (separate process) into the same panel
|
||||
// as the in-app core's.
|
||||
vpn.logSink = { [weak tunnel] line in tunnel?.appendExternal(line) }
|
||||
}
|
||||
.sheet(item: $captchaTask) { task in
|
||||
CaptchaView(url: task.url) { header in
|
||||
if task.forPeer {
|
||||
handlePeerCaptchaCookies(header)
|
||||
} else {
|
||||
handleCaptchaCookies(header)
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showInfo) { InfoView() }
|
||||
.sheet(isPresented: $showSettings) {
|
||||
@@ -145,11 +271,12 @@ struct ContentView: View {
|
||||
splitRU: $splitRU, dnsPreset: $dnsPreset,
|
||||
dnsCustom: $dnsCustom, tunnelUDP: $tunnelUDP,
|
||||
tunnel: tunnel, vpn: vpn,
|
||||
directDomains: directDomains,
|
||||
selectedProfile: store.selected,
|
||||
port: Int(socksPort) ?? 10808)
|
||||
}
|
||||
.sheet(isPresented: $showEditor) {
|
||||
ProfileEditorView(profile: editing) { saved in
|
||||
.sheet(item: $editorTask) { task in
|
||||
ProfileEditorView(profile: task.profile) { saved in
|
||||
store.upsert(saved)
|
||||
}
|
||||
}
|
||||
@@ -160,6 +287,240 @@ struct ContentView: View {
|
||||
.navigationViewStyle(.stack)
|
||||
}
|
||||
|
||||
// MARK: interactive captcha
|
||||
|
||||
/// Одна кнопка на весь сценарий: доки → прямой канал → капча → куки узлу →
|
||||
/// обратно на доки. Видна, когда выбран доковый профиль и есть куда сходить.
|
||||
@ViewBuilder
|
||||
private var solveViaDirectButton: some View {
|
||||
if let sel = store.selected,
|
||||
sel.transportKind == .yandex || sel.transportKind == .volga,
|
||||
(inlineDirect != nil || directProfile != nil),
|
||||
yandexDocForPeer != nil, dcStage == .idle {
|
||||
Button {
|
||||
startCaptchaViaDirect()
|
||||
} label: {
|
||||
Label("Решить капчу через прямой канал", systemImage: "arrow.triangle.swap")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
} else if dcStage == .connecting {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
Text("Переключаюсь на прямой канал…").font(.caption2).foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startCaptchaViaDirect() {
|
||||
guard let doc = yandexDocForPeer else { return }
|
||||
dcDoc = doc
|
||||
dcRestoreProfile = store.selectedID
|
||||
dcStage = .connecting
|
||||
|
||||
if let inline = inlineDirect {
|
||||
// Адрес и ключ лежат в самом доковом профиле — отдельный профиль не
|
||||
// нужен. Ключ берётся из своего слота Keychain (keySlot: "direct"),
|
||||
// чтобы не спутать его с ключом самих документов.
|
||||
if vpn.active { vpn.stop() }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
|
||||
vpn.start(transport: "direct", url: inline.addr,
|
||||
maxToken: "", maxUid: "",
|
||||
dns: dnsSpec, tunnelUDP: tunnelUDP,
|
||||
split: splitRU ? "ru-direct" : "",
|
||||
directDomains: directDomains.joined,
|
||||
profileID: inline.id, keySlot: "direct")
|
||||
}
|
||||
} else if let d = directProfile {
|
||||
store.select(d.id)
|
||||
if vpn.active { vpn.stop() }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { toggleConnect() }
|
||||
} else {
|
||||
dcStage = .idle
|
||||
return
|
||||
}
|
||||
// Страховка от зависания: если за 40 с не поднялось — выходим из сценария.
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 40) {
|
||||
if dcStage == .connecting {
|
||||
dcStage = .idle
|
||||
testHint = "Прямой канал не поднялся — проверьте профиль и ключ."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Возврат на доковый профиль после передачи кук.
|
||||
private func finishCaptchaViaDirect() {
|
||||
guard dcStage == .solving, let back = dcRestoreProfile else { return }
|
||||
dcStage = .idle
|
||||
dcRestoreProfile = nil
|
||||
store.select(back)
|
||||
vpn.stop()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { toggleConnect() }
|
||||
testHint = "Куки переданы узлу — возвращаюсь на документы."
|
||||
}
|
||||
|
||||
/// Ручной запуск проверки за узел. Виден, когда туннель поднят и есть
|
||||
/// yandex-профиль: тогда выход идёт с адреса узла, и выданная spravka (в неё
|
||||
/// зашит IP) будет годна именно ему.
|
||||
@ViewBuilder
|
||||
private var manualPeerCaptcha: some View {
|
||||
if vpn.active, remoteCaptchaTarget == nil, let u = yandexDocForPeer {
|
||||
Button {
|
||||
captchaTask = CaptchaTask(url: u, forPeer: true)
|
||||
} label: {
|
||||
Label("Пройти капчу Яндекса за узел", systemImage: "shield.lefthalf.filled")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
|
||||
/// Баннер для капчи УЗЛА: туннель остаётся поднятым.
|
||||
@ViewBuilder
|
||||
private var remoteCaptchaBanner: some View {
|
||||
if let u = remoteCaptchaTarget {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Label("Узел не может пройти проверку", systemImage: "arrow.triangle.2.circlepath.circle")
|
||||
.font(.subheadline.bold())
|
||||
.foregroundColor(.blue)
|
||||
Text("Пройдите её, НЕ отключая туннель: выход пойдёт с адреса узла, и куки подойдут именно ему. Приложение передаст их узлу само.")
|
||||
.font(.caption2).foregroundColor(.secondary)
|
||||
Button {
|
||||
captchaTask = CaptchaTask(url: u, forPeer: true)
|
||||
} label: {
|
||||
Label("Пройти за узел", systemImage: "hand.tap")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color.blue.opacity(0.12))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var captchaBanner: some View {
|
||||
if captchaTarget != nil {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Label("Яндекс требует проверку", systemImage: "exclamationmark.shield.fill")
|
||||
.font(.subheadline.bold())
|
||||
.foregroundColor(.orange)
|
||||
Text("Эту капчу нельзя пройти автоматически. Откройте её, пройдите проверку — приложение само подхватит куки и переподключится.")
|
||||
.font(.caption2).foregroundColor(.secondary)
|
||||
Button {
|
||||
startCaptchaFlow()
|
||||
} label: {
|
||||
Label("Пройти проверку", systemImage: "hand.tap")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color.orange.opacity(0.12))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
}
|
||||
|
||||
/// Пакеты в мёртвый туннель — чёрная дыра: страница капчи не грузится и не
|
||||
/// падает, просто висит белым листом. Поэтому на время проверки туннель
|
||||
/// гасим (он всё равно нерабочий — из-за этой самой капчи), а потом
|
||||
/// поднимаем. Куки при этом должны пережить перезапуск, за это отвечает
|
||||
/// Keychain.
|
||||
private func startCaptchaFlow() {
|
||||
guard let u = captchaTarget else { return }
|
||||
if vpn.active {
|
||||
resumeVPNAfterCaptcha = true
|
||||
vpn.stop()
|
||||
// Даём iOS снять маршруты, иначе WebView стартует ещё в туннеле.
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
|
||||
captchaTask = CaptchaTask(url: u)
|
||||
}
|
||||
} else {
|
||||
resumeVPNAfterCaptcha = false
|
||||
captchaTask = CaptchaTask(url: u)
|
||||
}
|
||||
}
|
||||
|
||||
/// Куки, добытые ЧЕРЕЗ туннель, — они годны для адреса узла, а не нашего,
|
||||
/// поэтому локально их не применяем и в Keychain не кладём.
|
||||
private func handlePeerCaptchaCookies(_ header: String) {
|
||||
if vpn.active {
|
||||
vpn.offerCaptchaCookies(header)
|
||||
} else {
|
||||
tunnel.offerCaptchaCookies(header)
|
||||
}
|
||||
testHint = "Куки отправлены узлу."
|
||||
finishCaptchaViaDirect()
|
||||
}
|
||||
|
||||
private func handleCaptchaCookies(_ header: String) {
|
||||
// Сохраняем всегда: если ядра сейчас нет (туннель погашен ради капчи),
|
||||
// это единственный способ донести куки до следующего старта.
|
||||
Secrets.setCaptchaCookies(header)
|
||||
|
||||
if vpn.active {
|
||||
vpn.applyCaptchaCookies(header)
|
||||
} else if tunnel.running {
|
||||
tunnel.applyCaptchaCookies(header)
|
||||
}
|
||||
|
||||
if resumeVPNAfterCaptcha {
|
||||
resumeVPNAfterCaptcha = false
|
||||
testHint = "Проверка пройдена — поднимаю туннель заново."
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { toggleConnect() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: log panel
|
||||
|
||||
private var logPanel: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Label("Журнал", systemImage: "text.alignleft")
|
||||
.font(.subheadline.bold())
|
||||
Spacer()
|
||||
Button {
|
||||
UIPasteboard.general.string = tunnel.log
|
||||
} label: {
|
||||
Image(systemName: "doc.on.doc")
|
||||
}
|
||||
.disabled(tunnel.log.isEmpty)
|
||||
Button {
|
||||
tunnel.clearLog()
|
||||
} label: {
|
||||
Label("Очистить", systemImage: "trash")
|
||||
.labelStyle(.titleAndIcon)
|
||||
.font(.caption)
|
||||
}
|
||||
.disabled(tunnel.log.isEmpty)
|
||||
}
|
||||
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
Text(tunnel.log.isEmpty ? "Пусто" : tunnel.log)
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundColor(tunnel.log.isEmpty ? .secondary : .primary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.textSelection(.enabled)
|
||||
.id("logtail-main")
|
||||
}
|
||||
.onChange(of: tunnel.log) { _ in
|
||||
withAnimation { proxy.scrollTo("logtail-main", anchor: .bottom) }
|
||||
}
|
||||
}
|
||||
.frame(height: 220)
|
||||
.padding(8)
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
|
||||
if vpn.active {
|
||||
Text("Строки туннеля приходят из расширения раз в секунду.")
|
||||
.font(.caption2).foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: central connect button
|
||||
|
||||
private var connectButton: some View {
|
||||
@@ -190,7 +551,7 @@ struct ContentView: View {
|
||||
private var profilePicker: some View {
|
||||
if store.profiles.isEmpty {
|
||||
Button {
|
||||
editing = nil; showEditor = true
|
||||
editorTask = EditorTask(profile: nil)
|
||||
} label: {
|
||||
Label("Добавить профиль", systemImage: "plus.circle.fill")
|
||||
.frame(maxWidth: .infinity).padding(.vertical, 6)
|
||||
@@ -206,14 +567,19 @@ struct ContentView: View {
|
||||
ForEach(store.profiles) { p in
|
||||
Button {
|
||||
store.select(p.id)
|
||||
ping.ping(p) // выбрал — сразу меряем
|
||||
} label: {
|
||||
Label(p.name, systemImage: p.id == store.selectedID ? "checkmark" : "")
|
||||
Label(pingLabel(for: p),
|
||||
systemImage: p.id == store.selectedID ? "checkmark" : "")
|
||||
}
|
||||
}
|
||||
if !store.profiles.isEmpty { Divider() }
|
||||
Button {
|
||||
ping.pingAll(store.profiles)
|
||||
} label: { Label("Пинговать все", systemImage: "dot.radiowaves.left.and.right") }
|
||||
if let sel = store.selected {
|
||||
Button {
|
||||
editing = sel; showEditor = true
|
||||
editorTask = EditorTask(profile: sel)
|
||||
} label: { Label("Изменить «\(sel.name)»", systemImage: "pencil") }
|
||||
Button {
|
||||
sharing = sel
|
||||
@@ -223,7 +589,7 @@ struct ContentView: View {
|
||||
} label: { Label("Удалить «\(sel.name)»", systemImage: "trash") }
|
||||
}
|
||||
Button {
|
||||
editing = nil; showEditor = true
|
||||
editorTask = EditorTask(profile: nil)
|
||||
} label: { Label("Добавить профиль…", systemImage: "plus") }
|
||||
} label: {
|
||||
HStack(spacing: 10) {
|
||||
@@ -239,6 +605,11 @@ struct ContentView: View {
|
||||
.lineLimit(1).truncationMode(.middle)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
if let sel = store.selected, let o = ping.outcome(for: sel) {
|
||||
Text(o.short)
|
||||
.font(.caption2).monospacedDigit()
|
||||
.foregroundColor(pingColor(o))
|
||||
}
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
.font(.caption).foregroundColor(.secondary)
|
||||
}
|
||||
@@ -251,11 +622,27 @@ struct ContentView: View {
|
||||
.disabled(vpn.active)
|
||||
}
|
||||
|
||||
/// Row title in the dropdown: name plus its last ping, so a config can be
|
||||
/// compared without selecting it first.
|
||||
private func pingLabel(for p: Profile) -> String {
|
||||
guard let o = ping.outcome(for: p) else { return p.name }
|
||||
return "\(p.name) — \(o.short)"
|
||||
}
|
||||
|
||||
private func pingColor(_ o: PingService.Outcome) -> Color {
|
||||
switch o {
|
||||
case .running: return .secondary
|
||||
case .failed: return .red
|
||||
case .ms(let v): return v < 400 ? .green : (v < 1200 ? .orange : .red)
|
||||
}
|
||||
}
|
||||
|
||||
private func transportIcon(_ t: String?) -> String {
|
||||
switch t {
|
||||
case "mailru": return "envelope"
|
||||
case "volga": return "waveform"
|
||||
case "boards": return "rectangle.3.group"
|
||||
case "direct": return "arrow.left.arrow.right"
|
||||
case "oneme": return "m.square"
|
||||
default: return "doc.text"
|
||||
}
|
||||
@@ -272,7 +659,7 @@ struct ContentView: View {
|
||||
url = b.isEmpty ? a : "\(a),\(b)"
|
||||
case .volga: url = volgaURL.trimmingCharacters(in: .whitespaces)
|
||||
// boards появился уже после профилей — легаси-конфига для него не бывает
|
||||
case .boards: break
|
||||
case .boards, .direct: break
|
||||
case .mail: url = mailURL.trimmingCharacters(in: .whitespaces)
|
||||
case .max: break
|
||||
}
|
||||
@@ -298,6 +685,9 @@ struct ProfileEditorView: View {
|
||||
@State private var single = ""
|
||||
@State private var maxToken = ""
|
||||
@State private var maxUid = ""
|
||||
@State private var encryptionKey = ""
|
||||
@State private var nodeAddr = ""
|
||||
@State private var directKey = ""
|
||||
@State private var importMsg: String?
|
||||
@State private var showScanner = false
|
||||
|
||||
@@ -311,6 +701,10 @@ struct ProfileEditorView: View {
|
||||
Picker("Транспорт", selection: $transportRaw) {
|
||||
ForEach(TransportKind.allCases) { t in Text(t.title).tag(t.rawValue) }
|
||||
}
|
||||
if transport == .direct {
|
||||
Text("Прямой TCP до узла: в поле ниже — его host:port, ключ шифрования обязателен. Канал не скрытый: адрес узла виден.")
|
||||
.font(.caption2).foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Подключение") {
|
||||
@@ -322,6 +716,8 @@ struct ProfileEditorView: View {
|
||||
TextField("https://disk.yandex.ru/i/…", text: $single).autocapitalization(.none).disableAutocorrection(true)
|
||||
case .boards:
|
||||
TextField("https://boards.yandex.ru/whiteboard/?hash=…", text: $single).autocapitalization(.none).disableAutocorrection(true)
|
||||
case .direct:
|
||||
TextField("адрес узла, например 1.2.3.4:9443", text: $single).autocapitalization(.none).disableAutocorrection(true)
|
||||
case .mail:
|
||||
TextField("https://cloud.mail.ru/public/…", text: $single).autocapitalization(.none).disableAutocorrection(true)
|
||||
case .max:
|
||||
@@ -330,6 +726,39 @@ struct ProfileEditorView: View {
|
||||
}
|
||||
}
|
||||
|
||||
Section("Шифрование (AES-256-GCM)") {
|
||||
SecureField("Общий секрет (от 16 символов)", text: $encryptionKey)
|
||||
.autocapitalization(.none)
|
||||
.disableAutocorrection(true)
|
||||
if encryptionKey.isEmpty {
|
||||
Text(transport == .direct
|
||||
? "Для прямого TCP ключ ОБЯЗАТЕЛЕН — без него подключение не запустится."
|
||||
: "Пусто — трафик идёт через документ без шифрования.")
|
||||
.font(.caption2)
|
||||
.foregroundColor(transport == .direct ? .red : .secondary)
|
||||
} else if encryptionKey.trimmingCharacters(in: .whitespaces).count < 16 {
|
||||
Text("Слишком короткий: нужно минимум 16 символов.")
|
||||
.font(.caption2).foregroundColor(.red)
|
||||
} else {
|
||||
Label("Ключ сохранится в Keychain, не в настройках VPN.",
|
||||
systemImage: "lock.fill")
|
||||
.font(.caption2).foregroundColor(.secondary)
|
||||
}
|
||||
Text("На узле — тот же секрет в файле: --encryption-key-file. Ссылка на документ должна совпадать с --url узла до символа: она входит в вывод ключа.")
|
||||
.font(.caption2).foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
if transport == .yandex || transport == .volga {
|
||||
Section("Прямой канал для капчи") {
|
||||
TextField("адрес узла, например 1.2.3.4:9443", text: $nodeAddr)
|
||||
.autocapitalization(.none).disableAutocorrection(true)
|
||||
SecureField("ключ прямого канала", text: $directKey)
|
||||
.autocapitalization(.none).disableAutocorrection(true)
|
||||
Text("Капчу нельзя пройти, сидя на документах — это и есть заблокированный носитель. Приложение сходит через прямой канал до узла, чтобы выход шёл с ЕГО адреса, и вернётся обратно. Ключ отдельный от поля выше: там ключ самих документов.")
|
||||
.font(.caption2).foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button {
|
||||
importFromClipboard()
|
||||
@@ -376,7 +805,7 @@ struct ProfileEditorView: View {
|
||||
guard !name.trimmingCharacters(in: .whitespaces).isEmpty else { return false }
|
||||
switch transport {
|
||||
case .yandex: return !url1.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
case .volga, .boards, .mail: return !single.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
case .volga, .boards, .mail, .direct: return !single.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
case .max: return !maxToken.isEmpty && !maxUid.isEmpty
|
||||
}
|
||||
}
|
||||
@@ -385,12 +814,15 @@ struct ProfileEditorView: View {
|
||||
guard let p = profile else { return }
|
||||
id = p.id; name = p.name; transportRaw = p.transport
|
||||
maxToken = p.maxToken; maxUid = p.maxUid
|
||||
encryptionKey = Secrets.encryptionKey(for: p.id) ?? ""
|
||||
nodeAddr = p.nodeAddr ?? ""
|
||||
directKey = Secrets.directKey(for: p.id) ?? ""
|
||||
let parts = p.url.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }
|
||||
switch p.transportKind {
|
||||
case .yandex:
|
||||
url1 = parts.first ?? ""
|
||||
if parts.count > 1 { url2 = parts[1] }
|
||||
case .volga, .boards, .mail:
|
||||
case .volga, .boards, .mail, .direct:
|
||||
single = parts.first ?? p.url
|
||||
case .max: break
|
||||
}
|
||||
@@ -419,7 +851,7 @@ struct ProfileEditorView: View {
|
||||
}
|
||||
switch transport {
|
||||
case .yandex: url1 = s
|
||||
case .volga, .boards, .mail: single = s
|
||||
case .volga, .boards, .mail, .direct: single = s
|
||||
case .max: return false
|
||||
}
|
||||
if name.trimmingCharacters(in: .whitespaces).isEmpty { name = transport.title }
|
||||
@@ -435,7 +867,7 @@ struct ProfileEditorView: View {
|
||||
case .yandex:
|
||||
url1 = parsed.urls.first ?? ""
|
||||
url2 = parsed.urls.count > 1 ? parsed.urls[1] : ""
|
||||
case .volga, .boards, .mail:
|
||||
case .volga, .boards, .mail, .direct:
|
||||
single = parsed.urls.first ?? ""
|
||||
case .max: break
|
||||
}
|
||||
@@ -452,14 +884,19 @@ struct ProfileEditorView: View {
|
||||
let a = url1.trimmingCharacters(in: .whitespaces)
|
||||
let b = url2.trimmingCharacters(in: .whitespaces)
|
||||
url = b.isEmpty ? a : "\(a),\(b)"
|
||||
case .volga, .boards, .mail:
|
||||
case .volga, .boards, .mail, .direct:
|
||||
url = single.trimmingCharacters(in: .whitespaces)
|
||||
case .max:
|
||||
url = ""
|
||||
}
|
||||
// The secret goes to the shared Keychain, never into the profile record
|
||||
// (which lives in plain UserDefaults). An emptied field removes it.
|
||||
Secrets.setEncryptionKey(encryptionKey, for: id)
|
||||
Secrets.setDirectKey(directKey, for: id)
|
||||
onSave(Profile(id: id, name: name.trimmingCharacters(in: .whitespaces),
|
||||
transport: transportRaw, url: url,
|
||||
maxToken: maxToken, maxUid: maxUid))
|
||||
maxToken: maxToken, maxUid: maxUid,
|
||||
nodeAddr: nodeAddr.trimmingCharacters(in: .whitespaces)))
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
@@ -475,10 +912,14 @@ struct SettingsSheet: View {
|
||||
@Binding var tunnelUDP: Bool
|
||||
@ObservedObject var tunnel: TunnelController
|
||||
@ObservedObject var vpn: VPNController
|
||||
@ObservedObject var directDomains: DirectDomainStore
|
||||
let selectedProfile: Profile?
|
||||
let port: Int
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var newDomain = ""
|
||||
@State private var domainError: String?
|
||||
|
||||
private var dnsPresetLabel: String {
|
||||
switch dnsPreset {
|
||||
case "cloudflare": return "Cloudflare"
|
||||
@@ -490,6 +931,20 @@ struct SettingsSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func addDomain() {
|
||||
let raw = newDomain
|
||||
guard !raw.trimmingCharacters(in: .whitespaces).isEmpty else { return }
|
||||
if directDomains.add(raw) {
|
||||
newDomain = ""
|
||||
domainError = nil
|
||||
} else if DirectDomainStore.normalize(raw) == nil {
|
||||
domainError = "Не похоже на домен"
|
||||
} else {
|
||||
domainError = "Уже в списке"
|
||||
newDomain = ""
|
||||
}
|
||||
}
|
||||
|
||||
private func applyDNS() {
|
||||
dotSpec(dnsPreset, dnsCustom).withCString {
|
||||
OpenFluxSetDoTResolver(UnsafeMutablePointer(mutating: $0))
|
||||
@@ -510,6 +965,48 @@ struct SettingsSheet: View {
|
||||
.font(.caption2).foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Section("Свои домены напрямую") {
|
||||
HStack {
|
||||
TextField("example.ru", text: $newDomain)
|
||||
.autocapitalization(.none)
|
||||
.disableAutocorrection(true)
|
||||
.keyboardType(.URL)
|
||||
.onSubmit(addDomain)
|
||||
Button(action: addDomain) {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
}
|
||||
.disabled(newDomain.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
}
|
||||
if let e = domainError {
|
||||
Text(e).font(.caption2).foregroundColor(.red)
|
||||
}
|
||||
|
||||
if directDomains.domains.isEmpty {
|
||||
Text("Список пуст — идут только встроенные RU-домены.")
|
||||
.font(.caption2).foregroundColor(.secondary)
|
||||
} else {
|
||||
ForEach(directDomains.domains, id: \.self) { d in
|
||||
HStack {
|
||||
Image(systemName: "arrow.uturn.right")
|
||||
.font(.caption).foregroundColor(.secondary)
|
||||
Text(d).font(.system(.callout, design: .monospaced))
|
||||
Spacer()
|
||||
Button(role: .destructive) {
|
||||
directDomains.remove(d)
|
||||
} label: {
|
||||
Image(systemName: "minus.circle")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
.onDelete { directDomains.remove(at: $0) }
|
||||
}
|
||||
|
||||
Text("Эти домены и их поддомены пойдут мимо туннеля, напрямую. Работают и при выключенном RU-сплите. Применяются при следующем подключении.")
|
||||
.font(.caption2).foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Section("DNS (DNS-over-TLS)") {
|
||||
Picker("DNS", selection: $dnsPreset) {
|
||||
Text("Default (Yandex/Google/CF)").tag("default")
|
||||
@@ -545,7 +1042,8 @@ struct SettingsSheet: View {
|
||||
} else if let p = selectedProfile, p.isValid {
|
||||
Button {
|
||||
tunnel.start(transport: p.transportKind, url: p.url,
|
||||
maxToken: p.maxToken, maxUid: p.maxUid, port: port)
|
||||
maxToken: p.maxToken, maxUid: p.maxUid, port: port,
|
||||
encryptionKey: Secrets.encryptionKey(for: p.id) ?? "")
|
||||
} label: {
|
||||
Label("Запустить локальный прокси", systemImage: "play.fill")
|
||||
}
|
||||
@@ -556,6 +1054,18 @@ struct SettingsSheet: View {
|
||||
Section("Журнал") {
|
||||
Toggle("Подробный лог", isOn: $debugLog)
|
||||
.onChange(of: debugLog) { on in OpenFluxSetDebug(on ? 1 : 0) }
|
||||
HStack {
|
||||
Button {
|
||||
UIPasteboard.general.string = tunnel.log
|
||||
} label: { Label("Скопировать", systemImage: "doc.on.doc") }
|
||||
.disabled(tunnel.log.isEmpty)
|
||||
Spacer()
|
||||
Button(role: .destructive) {
|
||||
tunnel.clearLog()
|
||||
} label: { Label("Очистить", systemImage: "trash") }
|
||||
.disabled(tunnel.log.isEmpty)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
Text(tunnel.log.isEmpty ? "—" : tunnel.log)
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import Foundation
|
||||
|
||||
/// User-added domain suffixes that must bypass the tunnel (go direct), on top of
|
||||
/// the bundled `geosite-ru.txt` set.
|
||||
///
|
||||
/// The extension feeds this list to the same GeoSite path the bundled file uses:
|
||||
/// the Go DNS proxy tags matching answers, and their IPs are appended to
|
||||
/// `excludedRoutes` as /32 routes. So a custom entry behaves exactly like a
|
||||
/// built-in one — no separate engine.
|
||||
///
|
||||
/// Stored as one suffix per line in UserDefaults and handed to the extension
|
||||
/// through `providerConfiguration`, which is why it is a plain string rather
|
||||
/// than an array: the tunnel protocol dictionary only carries property-list
|
||||
/// values, and the Go side already parses newline-separated lists.
|
||||
@MainActor
|
||||
final class DirectDomainStore: ObservableObject {
|
||||
@Published private(set) var domains: [String] = []
|
||||
|
||||
private let key = "directDomains.v1"
|
||||
|
||||
init() { load() }
|
||||
|
||||
/// Newline-separated form, as handed to the extension and to Go.
|
||||
var joined: String { domains.joined(separator: "\n") }
|
||||
|
||||
/// Normalizes user input into a bare suffix: strips scheme, path, `www.`,
|
||||
/// a leading dot and any `domain:` prefix, so pasting a full URL works.
|
||||
/// Returns nil if nothing usable is left.
|
||||
static func normalize(_ raw: String) -> String? {
|
||||
var s = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
guard !s.isEmpty else { return nil }
|
||||
if s.hasPrefix("domain:") { s = String(s.dropFirst("domain:".count)) }
|
||||
if let r = s.range(of: "://") { s = String(s[r.upperBound...]) }
|
||||
if let slash = s.firstIndex(of: "/") { s = String(s[..<slash]) }
|
||||
if let at = s.lastIndex(of: "@") { s = String(s[s.index(after: at)...]) }
|
||||
if let colon = s.firstIndex(of: ":") { s = String(s[..<colon]) }
|
||||
while s.hasPrefix(".") { s = String(s.dropFirst()) }
|
||||
if s.hasPrefix("www.") { s = String(s.dropFirst(4)) }
|
||||
s = s.trimmingCharacters(in: .whitespaces)
|
||||
// Must look like a domain: at least one dot, no spaces, allowed charset.
|
||||
guard s.contains("."), !s.contains(" "),
|
||||
s.range(of: "^[a-z0-9.-]+$", options: .regularExpression) != nil
|
||||
else { return nil }
|
||||
return s
|
||||
}
|
||||
|
||||
/// Adds a normalized suffix. Returns false if it is unusable or a duplicate.
|
||||
@discardableResult
|
||||
func add(_ raw: String) -> Bool {
|
||||
guard let d = Self.normalize(raw), !domains.contains(d) else { return false }
|
||||
domains.append(d)
|
||||
domains.sort()
|
||||
save()
|
||||
return true
|
||||
}
|
||||
|
||||
func remove(at offsets: IndexSet) {
|
||||
// Done by hand rather than via remove(atOffsets:) so this file stays
|
||||
// Foundation-only (that helper comes in with SwiftUI).
|
||||
for i in offsets.sorted(by: >) where domains.indices.contains(i) {
|
||||
domains.remove(at: i)
|
||||
}
|
||||
save()
|
||||
}
|
||||
|
||||
func remove(_ domain: String) {
|
||||
domains.removeAll { $0 == domain }
|
||||
save()
|
||||
}
|
||||
|
||||
private func load() {
|
||||
domains = (UserDefaults.standard.stringArray(forKey: key) ?? []).sorted()
|
||||
}
|
||||
|
||||
private func save() {
|
||||
UserDefaults.standard.set(domains, forKey: key)
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,13 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.developer.networking.networkextension</key>
|
||||
<array>
|
||||
<string>packet-tunnel-provider</string>
|
||||
</array>
|
||||
<key>com.apple.developer.networking.networkextension</key>
|
||||
<array>
|
||||
<string>packet-tunnel-provider</string>
|
||||
</array>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)com.p1neapplexpress-saharev.openflux</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import Foundation
|
||||
|
||||
/// Latency probe for a profile, runnable straight from the profile dropdown
|
||||
/// without connecting anything.
|
||||
///
|
||||
/// It times a request to the profile's own document host — the service the
|
||||
/// transport actually rides on (disk/docs.yandex, boards.yandex, cloud.mail.ru).
|
||||
/// That is what a user means by "is this config alive": if the backend is
|
||||
/// unreachable or slow from here, the tunnel cannot be better. It deliberately
|
||||
/// does NOT measure the exit node, which is only reachable once connected.
|
||||
///
|
||||
/// ICMP is unavailable to a sandboxed app, so this is an HTTP round trip; the
|
||||
/// number is therefore a touch higher than a raw ping, but comparable between
|
||||
/// profiles, which is what it is for.
|
||||
@MainActor
|
||||
final class PingService: ObservableObject {
|
||||
enum Outcome: Equatable {
|
||||
case running
|
||||
case ms(Int)
|
||||
case failed(String)
|
||||
|
||||
var short: String {
|
||||
switch self {
|
||||
case .running: return "…"
|
||||
case .ms(let v): return "\(v) мс"
|
||||
case .failed: return "нет ответа"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Published private(set) var results: [UUID: Outcome] = [:]
|
||||
|
||||
private var inFlight = Set<UUID>()
|
||||
|
||||
func outcome(for p: Profile) -> Outcome? { results[p.id] }
|
||||
|
||||
/// Probes one profile. Repeat calls while a probe is in flight are ignored.
|
||||
func ping(_ p: Profile) {
|
||||
guard !inFlight.contains(p.id) else { return }
|
||||
guard let target = Self.probeURL(for: p) else {
|
||||
results[p.id] = .failed("нет URL")
|
||||
return
|
||||
}
|
||||
inFlight.insert(p.id)
|
||||
results[p.id] = .running
|
||||
|
||||
let cfg = URLSessionConfiguration.ephemeral
|
||||
cfg.timeoutIntervalForRequest = 10
|
||||
cfg.requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData
|
||||
let session = URLSession(configuration: cfg)
|
||||
|
||||
var req = URLRequest(url: target)
|
||||
req.httpMethod = "HEAD"
|
||||
let started = Date()
|
||||
|
||||
session.dataTask(with: req) { [weak self] _, response, err in
|
||||
let elapsed = Int(Date().timeIntervalSince(started) * 1000)
|
||||
Task { @MainActor in
|
||||
guard let self = self else { return }
|
||||
self.inFlight.remove(p.id)
|
||||
if let err = err {
|
||||
self.results[p.id] = .failed((err as NSError).localizedDescription)
|
||||
} else if response is HTTPURLResponse {
|
||||
self.results[p.id] = .ms(elapsed)
|
||||
} else {
|
||||
self.results[p.id] = .failed("нет ответа")
|
||||
}
|
||||
}
|
||||
}.resume()
|
||||
}
|
||||
|
||||
func pingAll(_ profiles: [Profile]) {
|
||||
for p in profiles where p.isValid { ping(p) }
|
||||
}
|
||||
|
||||
/// The host to probe for a profile. MAX carries no document URL, so it has
|
||||
/// no meaningful target and returns nil.
|
||||
static func probeURL(for p: Profile) -> URL? {
|
||||
guard p.transportKind != .max else { return nil }
|
||||
let first = p.url
|
||||
.split(separator: ",")
|
||||
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||
.first { !$0.isEmpty }
|
||||
guard let s = first, let u = URL(string: s), u.host != nil else { return nil }
|
||||
// Probe the host root rather than the document itself: the document path
|
||||
// may redirect into the captcha/login flow, which says nothing about
|
||||
// reachability and costs an extra round trip.
|
||||
var comps = URLComponents()
|
||||
comps.scheme = u.scheme ?? "https"
|
||||
comps.host = u.host
|
||||
comps.path = "/"
|
||||
return comps.url
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,12 @@ struct Profile: Identifiable, Codable, Equatable {
|
||||
var maxToken: String = ""
|
||||
var maxUid: String = ""
|
||||
|
||||
/// Адрес узла (host:port) для прямого канала, которым проходится капча.
|
||||
/// ОПЦИОНАЛЬНОЕ намеренно: синтезированный Decodable требует все
|
||||
/// необязательные-по-смыслу ключи, а декодирование идёт через `try?` —
|
||||
/// новое обязательное поле молча стёрло бы все сохранённые профили.
|
||||
var nodeAddr: String?
|
||||
|
||||
var transportKind: TransportKind { TransportKind(rawValue: transport) ?? .yandex }
|
||||
|
||||
var isValid: Bool {
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
/// Per-profile encryption secrets, stored in the Keychain group shared by the
|
||||
/// app and the tunnel extension.
|
||||
///
|
||||
/// The secret deliberately does NOT travel in the VPN `providerConfiguration`:
|
||||
/// that dictionary is persisted with the system VPN profile, so a secret placed
|
||||
/// there would sit on disk in the clear. The app writes the secret here, and the
|
||||
/// extension reads it back by profile id — only the id crosses the process
|
||||
/// boundary through the provider configuration.
|
||||
///
|
||||
/// Both targets carry `keychain-access-groups` with the group below; without it
|
||||
/// each process would silently get its own keychain partition and the extension
|
||||
/// would find nothing.
|
||||
enum Secrets {
|
||||
/// Team-prefixed access group shared by app and extension. The team id is
|
||||
/// fixed for this app, so it is spelled out rather than read back from the
|
||||
/// entitlement at runtime.
|
||||
static let accessGroup = "8GQH8GQ252.com.p1neapplexpress-saharev.openflux"
|
||||
|
||||
private static let service = "openflux.encryption"
|
||||
private static let captchaService = "openflux.captcha"
|
||||
private static let captchaAccount = "cookies"
|
||||
|
||||
// MARK: - Captcha cookies
|
||||
//
|
||||
// Хранятся, а не передаются в живой транспорт, потому что капчу приходится
|
||||
// проходить с ВЫКЛЮЧЕННЫМ туннелем: пакеты в мёртвый туннель — чёрная дыра,
|
||||
// и страница капчи просто не грузится. Значит к моменту получения кук ядра
|
||||
// может не быть вовсе, и они должны дожить до следующего старта.
|
||||
// Keychain, а не UserDefaults: это, по сути, токен доступа.
|
||||
|
||||
static func captchaCookies() -> String? {
|
||||
var q = captchaQuery()
|
||||
q[kSecReturnData as String] = true
|
||||
q[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
var out: CFTypeRef?
|
||||
guard SecItemCopyMatching(q as CFDictionary, &out) == errSecSuccess,
|
||||
let data = out as? Data, let s = String(data: data, encoding: .utf8), !s.isEmpty
|
||||
else { return nil }
|
||||
return s
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func setCaptchaCookies(_ header: String) -> Bool {
|
||||
let trimmed = header.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else {
|
||||
return SecItemDelete(captchaQuery() as CFDictionary) == errSecSuccess
|
||||
}
|
||||
guard let data = trimmed.data(using: .utf8) else { return false }
|
||||
let q = captchaQuery()
|
||||
if SecItemUpdate(q as CFDictionary, [kSecValueData as String: data] as CFDictionary) == errSecSuccess {
|
||||
return true
|
||||
}
|
||||
var add = q
|
||||
add[kSecValueData as String] = data
|
||||
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
|
||||
return SecItemAdd(add as CFDictionary, nil) == errSecSuccess
|
||||
}
|
||||
|
||||
// MARK: - Ключ прямого канала
|
||||
//
|
||||
// Отдельный слот: у докового профиля поле шифрования — это ключ САМИХ доков
|
||||
// (для узла classic он должен быть пустым), а прямой канал до узла требует
|
||||
// своего ключа. Один слот на оба назначения привёл бы к тому, что ключ
|
||||
// direct уехал бы в доковый транспорт и тот начал бы молча ронять пакеты.
|
||||
|
||||
static func directKey(for profileID: UUID) -> String? {
|
||||
var q = slotQuery(directKeyService, profileID)
|
||||
q[kSecReturnData as String] = true
|
||||
q[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
var out: CFTypeRef?
|
||||
guard SecItemCopyMatching(q as CFDictionary, &out) == errSecSuccess,
|
||||
let data = out as? Data, let s = String(data: data, encoding: .utf8), !s.isEmpty
|
||||
else { return nil }
|
||||
return s
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func setDirectKey(_ secret: String, for profileID: UUID) -> Bool {
|
||||
let trimmed = secret.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let q = slotQuery(directKeyService, profileID)
|
||||
guard !trimmed.isEmpty else {
|
||||
let st = SecItemDelete(q as CFDictionary)
|
||||
return st == errSecSuccess || st == errSecItemNotFound
|
||||
}
|
||||
guard let data = trimmed.data(using: .utf8) else { return false }
|
||||
if SecItemUpdate(q as CFDictionary, [kSecValueData as String: data] as CFDictionary) == errSecSuccess {
|
||||
return true
|
||||
}
|
||||
var add = q
|
||||
add[kSecValueData as String] = data
|
||||
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
|
||||
return SecItemAdd(add as CFDictionary, nil) == errSecSuccess
|
||||
}
|
||||
|
||||
private static let directKeyService = "openflux.directkey"
|
||||
|
||||
private static func slotQuery(_ service: String, _ profileID: UUID) -> [String: Any] {
|
||||
[
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: profileID.uuidString,
|
||||
kSecAttrAccessGroup as String: accessGroup,
|
||||
]
|
||||
}
|
||||
|
||||
private static func captchaQuery() -> [String: Any] {
|
||||
[
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: captchaService,
|
||||
kSecAttrAccount as String: captchaAccount,
|
||||
kSecAttrAccessGroup as String: accessGroup,
|
||||
]
|
||||
}
|
||||
|
||||
/// Reads the secret for a profile, or nil when none is stored.
|
||||
static func encryptionKey(for profileID: UUID) -> String? {
|
||||
var q = baseQuery(profileID)
|
||||
q[kSecReturnData as String] = true
|
||||
q[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
|
||||
var out: CFTypeRef?
|
||||
let status = SecItemCopyMatching(q as CFDictionary, &out)
|
||||
guard status == errSecSuccess,
|
||||
let data = out as? Data,
|
||||
let s = String(data: data, encoding: .utf8)
|
||||
else { return nil }
|
||||
return s
|
||||
}
|
||||
|
||||
static func hasEncryptionKey(for profileID: UUID) -> Bool {
|
||||
encryptionKey(for: profileID) != nil
|
||||
}
|
||||
|
||||
/// Stores (or, for an empty string, removes) a profile's secret.
|
||||
@discardableResult
|
||||
static func setEncryptionKey(_ secret: String, for profileID: UUID) -> Bool {
|
||||
let trimmed = secret.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return removeEncryptionKey(for: profileID) }
|
||||
guard let data = trimmed.data(using: .utf8) else { return false }
|
||||
|
||||
let q = baseQuery(profileID)
|
||||
let attrs: [String: Any] = [kSecValueData as String: data]
|
||||
|
||||
let status = SecItemUpdate(q as CFDictionary, attrs as CFDictionary)
|
||||
if status == errSecSuccess { return true }
|
||||
if status == errSecItemNotFound {
|
||||
var add = q
|
||||
add[kSecValueData as String] = data
|
||||
// The extension may need the secret while the device is locked (the
|
||||
// tunnel reconnects on its own after a network change), so this is
|
||||
// the after-first-unlock class rather than WhenUnlocked.
|
||||
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
|
||||
return SecItemAdd(add as CFDictionary, nil) == errSecSuccess
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func removeEncryptionKey(for profileID: UUID) -> Bool {
|
||||
let status = SecItemDelete(baseQuery(profileID) as CFDictionary)
|
||||
return status == errSecSuccess || status == errSecItemNotFound
|
||||
}
|
||||
|
||||
private static func baseQuery(_ profileID: UUID) -> [String: Any] {
|
||||
[
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: profileID.uuidString,
|
||||
kSecAttrAccessGroup as String: accessGroup,
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ enum TransportKind: String, CaseIterable, Identifiable {
|
||||
case yandex = "yandex"
|
||||
case volga = "volga"
|
||||
case boards = "boards"
|
||||
case direct = "direct"
|
||||
case mail = "mailru"
|
||||
case max = "oneme"
|
||||
var id: String { rawValue }
|
||||
@@ -13,12 +14,15 @@ enum TransportKind: String, CaseIterable, Identifiable {
|
||||
case .yandex: return "Yandex Docs"
|
||||
case .volga: return "VOLGA"
|
||||
case .boards: return "Yandex Boards"
|
||||
case .direct: return "Прямой TCP (нужен ключ)"
|
||||
case .mail: return "Mail.ru"
|
||||
case .max: return "MAX"
|
||||
}
|
||||
}
|
||||
/// Document-based transports that take a public document URL / weblink.
|
||||
var usesDocURL: Bool { self == .yandex || self == .volga || self == .boards || self == .mail }
|
||||
/// direct берёт в том же поле не ссылку на документ, а host:port узла.
|
||||
var usesNodeAddr: Bool { self == .direct }
|
||||
}
|
||||
|
||||
/// Swift wrapper around the OpenFlux Go static library (liboflux.a).
|
||||
@@ -36,11 +40,29 @@ final class TunnelController: ObservableObject {
|
||||
|
||||
/// Starts the client tunnel over the selected transport.
|
||||
/// - port: local SOCKS5 port to listen on (127.0.0.1:port).
|
||||
func start(transport: TransportKind, url: String, maxToken: String, maxUid: String, port: Int) {
|
||||
func start(transport: TransportKind, url: String, maxToken: String, maxUid: String,
|
||||
port: Int, encryptionKey: String = "") {
|
||||
guard !running else { return }
|
||||
let addr = "127.0.0.1:\(port)"
|
||||
socksAddr = addr
|
||||
|
||||
// Must precede the start call: the core reads the secret when it builds
|
||||
// the transport stack. Empty clears any previously set secret, so a
|
||||
// profile without encryption never inherits the last one's.
|
||||
encryptionKey.withCString {
|
||||
OpenFluxSetEncryption(UnsafeMutablePointer(mutating: $0))
|
||||
}
|
||||
if !encryptionKey.isEmpty { appendLog("[app] encryption: on") }
|
||||
|
||||
// Сохранённые куки капчи — как и в расширении, ДО старта, иначе первый
|
||||
// же запрос к документу упрётся в ту же капчу, хотя куки уже есть.
|
||||
// Раньше это делало только расширение, и локальный прокси стартовал без
|
||||
// кук вслепую.
|
||||
let savedCookies = Secrets.captchaCookies() ?? ""
|
||||
savedCookies.withCString {
|
||||
OpenFluxSetInitialCookies(UnsafeMutablePointer(mutating: $0))
|
||||
}
|
||||
|
||||
let rc = transport.rawValue.withCString { tt in
|
||||
url.withCString { u in
|
||||
addr.withCString { a in
|
||||
@@ -70,6 +92,8 @@ final class TunnelController: ObservableObject {
|
||||
appendLog("[app] transport failed to start")
|
||||
case 4:
|
||||
appendLog("[app] port \(port) is busy — pick another port")
|
||||
case 6:
|
||||
appendLog("[app] encryption key rejected (min 16 characters)")
|
||||
default:
|
||||
appendLog("[app] start failed (code \(rc))")
|
||||
}
|
||||
@@ -105,6 +129,7 @@ final class TunnelController: ObservableObject {
|
||||
stats = String(cString: c)
|
||||
OpenFluxFreeString(c)
|
||||
}
|
||||
pollCaptcha()
|
||||
}
|
||||
|
||||
private func appendLog(_ s: String) {
|
||||
@@ -114,6 +139,67 @@ final class TunnelController: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Lines relayed from the packet-tunnel extension (a separate process, whose
|
||||
/// Go core has its own log buffer). Wired up by the view via VPNController.
|
||||
func appendExternal(_ s: String) {
|
||||
appendLog(s)
|
||||
}
|
||||
|
||||
/// Clears the on-screen log. The Go ring buffer keeps filling; this only
|
||||
/// drops what has already been shown.
|
||||
func clearLog() {
|
||||
log = ""
|
||||
}
|
||||
|
||||
// MARK: - Interactive captcha (in-app core only)
|
||||
//
|
||||
// When the local SOCKS core runs, the transport lives in THIS process, so the
|
||||
// captcha state is a direct C call. The system-VPN case goes through
|
||||
// VPNController's IPC instead, because there the core is in the extension.
|
||||
|
||||
/// Document URL waiting on an interactive captcha, or nil.
|
||||
@Published var captchaURL: String?
|
||||
/// Проверка, которую надо пройти в интересах УЗЛА: не отключая туннель,
|
||||
/// чтобы куки были выданы на его адрес.
|
||||
@Published var remoteCaptchaURL: String?
|
||||
|
||||
private func pollCaptcha() {
|
||||
guard running else {
|
||||
if captchaURL != nil { captchaURL = nil }
|
||||
return
|
||||
}
|
||||
if let c = OpenFluxCaptchaPending() {
|
||||
let s = String(cString: c)
|
||||
OpenFluxFreeString(c)
|
||||
let next = s.isEmpty ? nil : s
|
||||
if next != captchaURL { captchaURL = next }
|
||||
}
|
||||
if let c = OpenFluxRemoteCaptchaPending() {
|
||||
let s = String(cString: c)
|
||||
OpenFluxFreeString(c)
|
||||
let next = s.isEmpty ? nil : s
|
||||
if next != remoteCaptchaURL { remoteCaptchaURL = next }
|
||||
}
|
||||
}
|
||||
|
||||
/// Отдаёт узлу куки, добытые через туннель.
|
||||
func offerCaptchaCookies(_ header: String) {
|
||||
let n = header.withCString {
|
||||
OpenFluxOfferCaptchaCookies(UnsafeMutablePointer(mutating: $0))
|
||||
}
|
||||
appendLog("[app] offered \(n) cookies to the exit node")
|
||||
remoteCaptchaURL = nil
|
||||
}
|
||||
|
||||
/// Hands cookies solved in the WebView to the in-app core.
|
||||
func applyCaptchaCookies(_ header: String) {
|
||||
let n = header.withCString {
|
||||
OpenFluxApplyCaptchaCookies(UnsafeMutablePointer(mutating: $0))
|
||||
}
|
||||
appendLog("[app] captcha cookies handed to \(n) transport(s)")
|
||||
captchaURL = nil
|
||||
}
|
||||
|
||||
/// Connectivity check WITHOUT the local proxy — used when the system VPN is
|
||||
/// active (all device traffic already routes through the tunnel), so a plain
|
||||
/// request exercises the VPN path itself.
|
||||
|
||||
@@ -27,7 +27,9 @@ final class VPNController: ObservableObject {
|
||||
}
|
||||
|
||||
func start(transport: String, url: String, maxToken: String, maxUid: String,
|
||||
dns: String, tunnelUDP: Bool, split: String = "") {
|
||||
dns: String, tunnelUDP: Bool, split: String = "",
|
||||
directDomains: String = "", profileID: UUID? = nil,
|
||||
keySlot: String = "") {
|
||||
Task {
|
||||
let m = manager ?? NETunnelProviderManager()
|
||||
let proto = NETunnelProviderProtocol()
|
||||
@@ -39,6 +41,12 @@ final class VPNController: ObservableObject {
|
||||
"dns": dns,
|
||||
"udp": tunnelUDP ? "1" : "0",
|
||||
"split": split, // "ru-direct" = GeoIP RU bypasses the tunnel
|
||||
"directDomains": directDomains, // user's own bypass suffixes
|
||||
// Only the profile id — the encryption secret itself stays in the
|
||||
// shared Keychain and is fetched by the extension. This dictionary
|
||||
// is persisted with the VPN profile, so it must not hold secrets.
|
||||
"profileID": profileID?.uuidString ?? "",
|
||||
"keySlot": keySlot, // "direct" = взять ключ прямого канала
|
||||
]
|
||||
m.protocolConfiguration = proto
|
||||
m.localizedDescription = "OpenFlux"
|
||||
@@ -72,6 +80,98 @@ final class VPNController: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Log relay
|
||||
//
|
||||
// The tunnel runs in a separate process, so the app cannot read the Go core's
|
||||
// log directly — it asks the extension for it over the provider IPC channel.
|
||||
// Set `logSink` to receive lines; polling runs only while the tunnel is up.
|
||||
|
||||
var logSink: ((String) -> Void)?
|
||||
|
||||
/// Document URL waiting on an interactive captcha inside the extension, or
|
||||
/// nil. Polled over the same IPC channel as the log.
|
||||
@Published var captchaURL: String?
|
||||
/// Проверка в интересах узла: проходить НЕ отключая туннель.
|
||||
@Published var remoteCaptchaURL: String?
|
||||
|
||||
private var logTimer: Timer?
|
||||
|
||||
private func startLogPolling() {
|
||||
guard logTimer == nil else { return }
|
||||
logTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
|
||||
Task { @MainActor in self?.pullLog() }
|
||||
}
|
||||
}
|
||||
|
||||
private func stopLogPolling() {
|
||||
logTimer?.invalidate()
|
||||
logTimer = nil
|
||||
}
|
||||
|
||||
private func pullLog() {
|
||||
guard let session = activeSession, let msg = "log".data(using: .utf8) else { return }
|
||||
// Throws if the extension isn't up yet; that is normal during startup.
|
||||
try? session.sendProviderMessage(msg) { [weak self] data in
|
||||
guard let data = data, !data.isEmpty,
|
||||
let s = String(data: data, encoding: .utf8), !s.isEmpty
|
||||
else { return }
|
||||
Task { @MainActor in self?.logSink?(s) }
|
||||
}
|
||||
pullCaptcha(session)
|
||||
pullRemoteCaptcha(session)
|
||||
}
|
||||
|
||||
private var activeSession: NETunnelProviderSession? {
|
||||
guard let s = manager?.connection as? NETunnelProviderSession,
|
||||
s.status == .connected || s.status == .reasserting
|
||||
else { return nil }
|
||||
return s
|
||||
}
|
||||
|
||||
private func pullCaptcha(_ session: NETunnelProviderSession) {
|
||||
guard let msg = "captcha".data(using: .utf8) else { return }
|
||||
try? session.sendProviderMessage(msg) { [weak self] data in
|
||||
let url = data.flatMap { String(data: $0, encoding: .utf8) } ?? ""
|
||||
Task { @MainActor in
|
||||
self?.captchaURL = url.isEmpty ? nil : url
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func pullRemoteCaptcha(_ session: NETunnelProviderSession) {
|
||||
guard let msg = "remotecaptcha".data(using: .utf8) else { return }
|
||||
try? session.sendProviderMessage(msg) { [weak self] data in
|
||||
let url = data.flatMap { String(data: $0, encoding: .utf8) } ?? ""
|
||||
Task { @MainActor in self?.remoteCaptchaURL = url.isEmpty ? nil : url }
|
||||
}
|
||||
}
|
||||
|
||||
/// Отдаёт узлу куки, добытые через туннель (проверка пройдена с его адреса).
|
||||
func offerCaptchaCookies(_ header: String) {
|
||||
guard let session = activeSession,
|
||||
let msg = "offer:\(header)".data(using: .utf8) else { return }
|
||||
try? session.sendProviderMessage(msg) { [weak self] data in
|
||||
let n = data.flatMap { String(data: $0, encoding: .utf8) } ?? "0"
|
||||
Task { @MainActor in
|
||||
self?.logSink?("[app] offered \(n) cookies to the exit node")
|
||||
self?.remoteCaptchaURL = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Relays cookies solved in the app's WebView into the extension's core.
|
||||
func applyCaptchaCookies(_ header: String) {
|
||||
guard let session = activeSession,
|
||||
let msg = "cookies:\(header)".data(using: .utf8) else { return }
|
||||
try? session.sendProviderMessage(msg) { [weak self] data in
|
||||
let n = data.flatMap { String(data: $0, encoding: .utf8) } ?? "0"
|
||||
Task { @MainActor in
|
||||
self?.logSink?("[app] captcha cookies handed to \(n) transport(s)")
|
||||
self?.captchaURL = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func statusChanged() { refreshStatus() }
|
||||
|
||||
private func refreshStatus() {
|
||||
@@ -83,5 +183,10 @@ final class VPNController: ObservableObject {
|
||||
case .reasserting: status = "Reasserting…"; active = true
|
||||
default: status = "Disconnected"; active = false
|
||||
}
|
||||
if conn.status == .connected || conn.status == .reasserting {
|
||||
startLogPolling()
|
||||
} else {
|
||||
stopLogPolling()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,13 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.developer.networking.networkextension</key>
|
||||
<array>
|
||||
<string>packet-tunnel-provider</string>
|
||||
</array>
|
||||
<key>com.apple.developer.networking.networkextension</key>
|
||||
<array>
|
||||
<string>packet-tunnel-provider</string>
|
||||
</array>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)com.p1neapplexpress-saharev.openflux</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -7,6 +7,32 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
|
||||
/// Networks that must NOT go through the tunnel: the Yandex backend the
|
||||
/// transport talks to, plus the DoT DNS resolvers. Otherwise the
|
||||
/// extension's own traffic loops back into itself.
|
||||
/// Домены, которые всегда идут мимо туннеля: собственный бэкенд транспорта
|
||||
/// и всё, из чего состоит страница капчи (её скрипты и статика живут на
|
||||
/// отдельных хостах). Попадают в тот же GeoSite-путь, что и RU-список, то
|
||||
/// есть их IP добавляются в excludedRoutes по факту DNS-ответа.
|
||||
static let alwaysDirectHosts = """
|
||||
yandex.ru
|
||||
yandex.com
|
||||
yandex.net
|
||||
yastatic.net
|
||||
captcha-api.yandex.ru
|
||||
smartcaptcha.yandexcloud.net
|
||||
passport.yandex.ru
|
||||
passport.yandex.com
|
||||
"""
|
||||
|
||||
/// Литерал IPv4 или имя хоста — от этого зависит, можно ли добавить
|
||||
/// статический /32 или надо ждать DNS-ответа.
|
||||
static func isIPv4(_ s: String) -> Bool {
|
||||
let parts = s.split(separator: ".")
|
||||
guard parts.count == 4 else { return false }
|
||||
return parts.allSatisfy { p in
|
||||
guard let v = Int(p), v >= 0, v <= 255, !p.isEmpty else { return false }
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
static let bypassRoutes: [NEIPv4Route] = {
|
||||
let cidrs: [(String, String)] = [
|
||||
("5.45.192.0", "255.255.192.0"),
|
||||
@@ -71,6 +97,16 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
|
||||
private var dynamicDirectSeen = Set<String>()
|
||||
private var dynamicDirectRoutes: [NEIPv4Route] = []
|
||||
private var geoTimer: Timer?
|
||||
/// True when there is any direct-domain list at all — the bundled RU set,
|
||||
/// the user's own suffixes, or both. Gates the GeoSite poller, which used to
|
||||
/// hang off the RU split alone.
|
||||
private var geoSiteActive = false
|
||||
|
||||
/// Rolling tail of the Go core's log, drained on a timer so the app can pull
|
||||
/// it over IPC. The extension runs in its own process, so without this the
|
||||
/// app's log panel would only ever show its own lines.
|
||||
private var logTail: [String] = []
|
||||
private var logTimer: Timer?
|
||||
|
||||
override func startTunnel(options: [String: NSObject]?, completionHandler: @escaping (Error?) -> Void) {
|
||||
let conf = (protocolConfiguration as? NETunnelProviderProtocol)?.providerConfiguration ?? [:]
|
||||
@@ -81,6 +117,37 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
|
||||
let dnsSpec = (conf["dns"] as? String) ?? ""
|
||||
let tunnelUDP = (conf["udp"] as? String) == "1"
|
||||
let splitRU = (conf["split"] as? String) == "ru-direct"
|
||||
// User-added direct domains (newline-separated suffixes). They ride the
|
||||
// same GeoSite path as the bundled list, so they work with the RU split
|
||||
// off as well — hence they are read independently of `splitRU`.
|
||||
let customDirect = ((conf["directDomains"] as? String) ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
// End-to-end encryption: the secret never rides in providerConfiguration
|
||||
// (which is persisted with the VPN profile), only the profile id does —
|
||||
// the secret itself comes from the Keychain group shared with the app.
|
||||
// Must be set before the core builds its transport stack.
|
||||
// Слот ключа: у докового профиля их два — свой ключ доков и ключ прямого
|
||||
// канала до узла. Спутать их значит отдать доковому транспорту чужой
|
||||
// ключ и молча ронять все пакеты.
|
||||
var encryptionKey = ""
|
||||
if let idString = conf["profileID"] as? String, let id = UUID(uuidString: idString) {
|
||||
if (conf["keySlot"] as? String) == "direct" {
|
||||
encryptionKey = Secrets.directKey(for: id) ?? ""
|
||||
} else {
|
||||
encryptionKey = Secrets.encryptionKey(for: id) ?? ""
|
||||
}
|
||||
}
|
||||
encryptionKey.withCString { k in
|
||||
OpenFluxSetEncryption(UnsafeMutablePointer(mutating: k))
|
||||
}
|
||||
|
||||
// Куки капчи, пройденной с выключенным туннелем. Отдаём ДО старта, чтобы
|
||||
// первый же запрос к документу пошёл с ними и не упёрся в ту же капчу.
|
||||
let savedCookies = Secrets.captchaCookies() ?? ""
|
||||
savedCookies.withCString { c in
|
||||
OpenFluxSetInitialCookies(UnsafeMutablePointer(mutating: c))
|
||||
}
|
||||
|
||||
// Override the DNS-over-TLS upstream if the user configured one (empty =
|
||||
// built-in defaults). Must run in the extension process before start.
|
||||
@@ -101,7 +168,29 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
|
||||
// servers so the extension's own connections bypass the tunnel instead
|
||||
// of looping back into it. With split tunneling on, also exclude the
|
||||
// GeoIP RU set so Russian destinations go direct.
|
||||
self.baseExcluded = Self.bypassRoutes + (splitRU ? Self.ruDirectRoutes : [])
|
||||
// КРИТИЧНО для транспорта direct: адрес самого узла обязан идти мимо
|
||||
// туннеля. Иначе соединение расширения к узлу захватывается туннелем,
|
||||
// который оно же и поднимает, — петля, и не соединяется ничего. На маке
|
||||
// это не проявляется: там CLI-клиент отдаёт SOCKS5 без системного
|
||||
// туннеля, поэтому проверка проходила, а на телефоне всё вставало.
|
||||
var nodeRoutes: [NEIPv4Route] = []
|
||||
var nodeHostForDNS = ""
|
||||
if transport == "direct" {
|
||||
let hostPart = url.split(separator: ":").first.map(String.init) ?? url
|
||||
let host = hostPart.trimmingCharacters(in: .whitespaces)
|
||||
if !host.isEmpty {
|
||||
if Self.isIPv4(host) {
|
||||
nodeRoutes.append(NEIPv4Route(destinationAddress: host,
|
||||
subnetMask: "255.255.255.255"))
|
||||
} else {
|
||||
// Имя, а не адрес: пустим его через тот же GeoSite-путь —
|
||||
// маршрут добавится по DNS-ответу.
|
||||
nodeHostForDNS = host
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.baseExcluded = Self.bypassRoutes + nodeRoutes + (splitRU ? Self.ruDirectRoutes : [])
|
||||
ipv4.excludedRoutes = self.baseExcluded
|
||||
settings.ipv4Settings = ipv4
|
||||
settings.mtu = 1500
|
||||
@@ -114,10 +203,27 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
|
||||
|
||||
// GeoSite: load the direct-domain list so the DNS proxy tags matching
|
||||
// answers; the poller (started after the core is up) routes them direct.
|
||||
// The bundled RU set comes in only under the RU split; the user's own
|
||||
// suffixes are always appended, so they work on their own too.
|
||||
// Всегда напрямую — хосты, нужные самому транспорту и странице капчи.
|
||||
//
|
||||
// Без этого интерактивная капча нерешаема в принципе: она появляется
|
||||
// именно тогда, когда туннель НЕ работает, а весь трафик телефона идёт
|
||||
// в туннель. HTML ещё мог прийти через жёсткий список IP-диапазонов
|
||||
// ниже, но её скрипты с yastatic.net — уже нет, и WebView показывал
|
||||
// белый лист. Домены, а не IP: CDN меняет адреса, а имена стабильны.
|
||||
var geoLines: [String] = [Self.alwaysDirectHosts]
|
||||
if !nodeHostForDNS.isEmpty { geoLines.append(nodeHostForDNS) }
|
||||
if splitRU, let url = Bundle(for: PacketTunnelProvider.self)
|
||||
.url(forResource: "geosite-ru", withExtension: "txt"),
|
||||
let list = try? String(contentsOf: url, encoding: .utf8) {
|
||||
list.withCString { OpenFluxSetGeositeDirect(UnsafeMutablePointer(mutating: $0)) }
|
||||
geoLines.append(list)
|
||||
}
|
||||
if !customDirect.isEmpty { geoLines.append(customDirect) }
|
||||
self.geoSiteActive = !geoLines.isEmpty
|
||||
if self.geoSiteActive {
|
||||
let merged = geoLines.joined(separator: "\n")
|
||||
merged.withCString { OpenFluxSetGeositeDirect(UnsafeMutablePointer(mutating: $0)) }
|
||||
}
|
||||
|
||||
setTunnelNetworkSettings(settings) { error in
|
||||
@@ -146,7 +252,8 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
|
||||
self.startReadLoop()
|
||||
self.startWriteLoop()
|
||||
self.startHealthMonitor()
|
||||
if splitRU { self.startGeoSiteMonitor() }
|
||||
if self.geoSiteActive { self.startGeoSiteMonitor() }
|
||||
self.startLogDrain()
|
||||
completionHandler(nil)
|
||||
}
|
||||
}
|
||||
@@ -154,10 +261,93 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
|
||||
override func stopTunnel(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) {
|
||||
stopHealthMonitor()
|
||||
stopGeoSiteMonitor()
|
||||
stopLogDrain()
|
||||
OpenFluxStopPacketTunnel()
|
||||
completionHandler()
|
||||
}
|
||||
|
||||
// MARK: - Log relay to the app
|
||||
//
|
||||
// OpenFluxReadLog() DRAINS the Go ring buffer, so it must be called from one
|
||||
// place only — here. Letting the app call it directly would race the two
|
||||
// readers and each would see half the lines.
|
||||
|
||||
private func startLogDrain() {
|
||||
DispatchQueue.main.async {
|
||||
self.logTimer?.invalidate()
|
||||
self.logTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
|
||||
self?.drainLog()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopLogDrain() {
|
||||
DispatchQueue.main.async {
|
||||
self.logTimer?.invalidate()
|
||||
self.logTimer = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func drainLog() {
|
||||
guard let c = OpenFluxReadLog() else { return }
|
||||
let s = String(cString: c)
|
||||
OpenFluxFreeString(c)
|
||||
guard !s.isEmpty else { return }
|
||||
logTail.append(contentsOf: s.split(separator: "\n").map(String.init))
|
||||
if logTail.count > 600 {
|
||||
logTail.removeFirst(logTail.count - 600)
|
||||
}
|
||||
}
|
||||
|
||||
/// IPC from the containing app. "log" hands back everything accumulated since
|
||||
/// the last request and clears the tail, so the app can append incrementally.
|
||||
override func handleAppMessage(_ messageData: Data, completionHandler: ((Data?) -> Void)?) {
|
||||
let cmd = String(data: messageData, encoding: .utf8) ?? ""
|
||||
switch cmd {
|
||||
case "log":
|
||||
DispatchQueue.main.async {
|
||||
self.drainLog() // pick up the last second too
|
||||
let out = self.logTail.joined(separator: "\n")
|
||||
self.logTail.removeAll(keepingCapacity: true)
|
||||
completionHandler?(out.data(using: .utf8) ?? Data())
|
||||
}
|
||||
|
||||
// The core lives in THIS process, so only the extension can see that a
|
||||
// SmartCaptcha is pending — and only it can hand the solved cookies to
|
||||
// the transport. The app drives the WebView and relays through here.
|
||||
case "remotecaptcha":
|
||||
var url = ""
|
||||
if let c = OpenFluxRemoteCaptchaPending() {
|
||||
url = String(cString: c)
|
||||
OpenFluxFreeString(c)
|
||||
}
|
||||
completionHandler?(url.data(using: .utf8) ?? Data())
|
||||
|
||||
case "captcha":
|
||||
var url = ""
|
||||
if let c = OpenFluxCaptchaPending() {
|
||||
url = String(cString: c)
|
||||
OpenFluxFreeString(c)
|
||||
}
|
||||
completionHandler?(url.data(using: .utf8) ?? Data())
|
||||
|
||||
default:
|
||||
if cmd.hasPrefix("offer:") {
|
||||
let raw = String(cmd.dropFirst("offer:".count))
|
||||
let n = raw.withCString { OpenFluxOfferCaptchaCookies(UnsafeMutablePointer(mutating: $0)) }
|
||||
completionHandler?("\(n)".data(using: .utf8) ?? Data())
|
||||
return
|
||||
}
|
||||
if cmd.hasPrefix("cookies:") {
|
||||
let raw = String(cmd.dropFirst("cookies:".count))
|
||||
let n = raw.withCString { OpenFluxApplyCaptchaCookies(UnsafeMutablePointer(mutating: $0)) }
|
||||
completionHandler?("\(n)".data(using: .utf8) ?? Data())
|
||||
return
|
||||
}
|
||||
completionHandler?(Data())
|
||||
}
|
||||
}
|
||||
|
||||
/// Polls the Go DNS proxy for GeoSite-matched direct IPs and appends them to
|
||||
/// excludedRoutes so those domains go direct — even on foreign CDN IPs not in
|
||||
/// the GeoIP RU set. Batched: settings are re-applied at most once per tick
|
||||
|
||||
+11
-2
@@ -7,8 +7,8 @@ options:
|
||||
|
||||
settings:
|
||||
base:
|
||||
MARKETING_VERSION: "1.0.0"
|
||||
CURRENT_PROJECT_VERSION: "46"
|
||||
MARKETING_VERSION: "1.2.0"
|
||||
CURRENT_PROJECT_VERSION: "65"
|
||||
DEVELOPMENT_TEAM: "8GQH8GQ252"
|
||||
|
||||
targets:
|
||||
@@ -41,6 +41,10 @@ targets:
|
||||
properties:
|
||||
com.apple.developer.networking.networkextension:
|
||||
- packet-tunnel-provider
|
||||
# Shared with the tunnel extension so the encryption secret lives in the
|
||||
# Keychain instead of the VPN provider configuration.
|
||||
keychain-access-groups:
|
||||
- $(AppIdentifierPrefix)com.p1neapplexpress-saharev.openflux
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.p1neapplexpress-saharev.openflux
|
||||
@@ -68,6 +72,9 @@ targets:
|
||||
platform: iOS
|
||||
sources:
|
||||
- path: OpenFluxTunnel
|
||||
# Shared with the app: the extension reads the encryption secret from the
|
||||
# same Keychain group, so it needs the same accessor.
|
||||
- path: OpenFlux/Secrets.swift
|
||||
- path: Lib
|
||||
buildPhase: none
|
||||
dependencies:
|
||||
@@ -87,6 +94,8 @@ targets:
|
||||
properties:
|
||||
com.apple.developer.networking.networkextension:
|
||||
- packet-tunnel-provider
|
||||
keychain-access-groups:
|
||||
- $(AppIdentifierPrefix)com.p1neapplexpress-saharev.openflux
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.p1neapplexpress-saharev.openflux.tunnel
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
_ "github.com/wlynxg/anet"
|
||||
_ "github.com/wlynxg/anet"
|
||||
"universal-bypass-tool/socks5"
|
||||
"universal-bypass-tool/transport"
|
||||
"universal-bypass-tool/transport/mailru"
|
||||
@@ -58,7 +58,7 @@ func buildMuxTransport(urlSpec string, factory func(string) transport.Transport)
|
||||
|
||||
func main() {
|
||||
//os.Setenv("GODEBUG", "netdns=go")
|
||||
fmt.Print("written by p1neappleXpress\n")
|
||||
fmt.Print("written by p1neappleXpress\n")
|
||||
|
||||
exitNode := flag.Bool("exit-node", false, "Run as exit node (needs root)")
|
||||
client := flag.Bool("client", false, "Run as client")
|
||||
@@ -69,6 +69,17 @@ func main() {
|
||||
flag.StringVar(&maxToken, "maxToken", "", "MAX call user id. If u use MAX transport")
|
||||
flag.StringVar(&maxUid, "maxUid", "", "MAX Web token. If u use MAX transport")
|
||||
localIP := flag.String("local-ip", "", "Exit node egress IP (use a dedicated alias IP so the RST-drop rule can be scoped with -s)")
|
||||
directAddr := flag.String("direct-addr", "",
|
||||
"host:port for --transport=direct. Exit node listens on it, client dials it. "+
|
||||
"Requires --encryption-key-file: a plain TCP carrier exposes the node, so it "+
|
||||
"must never run unencrypted.")
|
||||
cookiesFile := flag.String("cookies-file", "",
|
||||
"Path to a file with cookies (\"a=1; b=2\") for the yandex transport. Watched "+
|
||||
"live: solve an interactive captcha in a browser, drop the cookies here, and "+
|
||||
"the node picks them up without a restart.")
|
||||
encryptionKeyFile := flag.String("encryption-key-file", "",
|
||||
"Path to a file holding the shared secret for end-to-end AES-256-GCM. "+
|
||||
"Both peers must pass the same secret; empty = no encryption.")
|
||||
flag.Parse()
|
||||
|
||||
if *localIP != "" {
|
||||
@@ -102,9 +113,27 @@ func main() {
|
||||
trans = buildMuxTransport(globalDocUrl, func(u string) transport.Transport {
|
||||
return yandex.NewBoardsTransport(u, config)
|
||||
})
|
||||
case "direct":
|
||||
// Обычный TCP до ноды. Не скрытый канал: адрес ноды виден и режется
|
||||
// тривиально — зато он доступен, когда носитель с документом упёрся в
|
||||
// капчу, и по нему можно доставить ноде куки. Шифрование обязательно.
|
||||
if *directAddr == "" {
|
||||
log.Fatalf("--transport=direct requires --direct-addr host:port")
|
||||
}
|
||||
if *encryptionKeyFile == "" {
|
||||
log.Fatalf("--transport=direct requires --encryption-key-file (a plain TCP carrier must not run unencrypted)")
|
||||
}
|
||||
dcfg := transport.DefaultDirectConfig()
|
||||
dcfg.IsExit = *exitNode
|
||||
if *exitNode {
|
||||
dcfg.ListenAddr = *directAddr
|
||||
} else {
|
||||
dcfg.DialAddr = *directAddr
|
||||
}
|
||||
trans = transport.NewAdaptiveTransport(transport.NewDirectTransport(config, dcfg))
|
||||
case "yandex":
|
||||
trans = buildMuxTransport(globalDocUrl, func(u string) transport.Transport {
|
||||
return yandex.NewYandexDocsTransport(u, config)
|
||||
return newYandexDocs(u, config)
|
||||
})
|
||||
case "vyandex", "volga":
|
||||
trans = buildMuxTransport(globalDocUrl, func(u string) transport.Transport {
|
||||
@@ -121,6 +150,48 @@ func main() {
|
||||
log.Fatalf("Unknown transport type: %s", *transportType)
|
||||
}
|
||||
|
||||
// Optional AES-256-GCM, outermost — the same position the upstream CLI uses,
|
||||
// so a node built from this branch and one built from upstream master speak
|
||||
// the same wire format: each tunnel packet is sealed first, and the codec
|
||||
// layer below batches the ciphertext.
|
||||
//
|
||||
// The context string is a public KDF salt, not a secret — but both peers must
|
||||
// derive from the SAME one, and upstream derives it from the document URL.
|
||||
// Keep that rule identical here: a differing URL silently yields a different
|
||||
// key and every packet is dropped as unauthenticated.
|
||||
if *encryptionKeyFile != "" {
|
||||
secretBytes, err := os.ReadFile(*encryptionKeyFile)
|
||||
if err != nil {
|
||||
log.Fatalf("Read encryption key file: %v", err)
|
||||
}
|
||||
// Контекст — публичная соль вывода ключа, но обе стороны обязаны взять
|
||||
// ОДНУ И ТУ ЖЕ строку. Для доковых транспортов это URL документа, он у
|
||||
// клиента и ноды одинаков. Для direct так нельзя: нода слушает
|
||||
// 0.0.0.0:9443, а клиент набирает 64.118.154.75:9443 — строки разные, и
|
||||
// ключи молча разъехались бы (каждый пакет не проходит аутентификацию,
|
||||
// снаружи это выглядит как таймауты). Поэтому у direct контекст — имя
|
||||
// транспорта, единственное, в чём стороны заведомо согласны.
|
||||
context := *transportType
|
||||
if globalDocUrl != "" && *transportType != "direct" {
|
||||
context = globalDocUrl
|
||||
}
|
||||
encrypted, err := transport.NewEncryptedTransport(
|
||||
trans, strings.TrimSpace(string(secretBytes)), context, *exitNode)
|
||||
if err != nil {
|
||||
log.Fatalf("Configure encrypted transport: %v", err)
|
||||
}
|
||||
trans = encrypted
|
||||
log.Printf("Transport encryption: AES-256-GCM enabled (KDF context=%q)", context)
|
||||
}
|
||||
|
||||
// Контрольный канал: обмен куками для обхода интерактивной капчи. Оборачивает
|
||||
// уже собранный стек, поэтому его кадры проходят через слой шифрования ниже и
|
||||
// уезжают зашифрованными наравне с данными туннеля.
|
||||
trans = wrapControl(trans, *exitNode)
|
||||
if !*exitNode {
|
||||
startCookieOffering()
|
||||
}
|
||||
|
||||
if err := trans.Start(); err != nil {
|
||||
log.Fatalf("Failed to start transport: %v", err)
|
||||
}
|
||||
@@ -129,6 +200,14 @@ func main() {
|
||||
log.Printf("OPENFLUX_READY transport=%s mode=%s", *transportType,
|
||||
map[bool]string{true: "exit-node", false: "client"}[*exitNode])
|
||||
|
||||
// Живое подхватывание кук: единственный путь пройти интерактивную капчу на
|
||||
// ноде, где нет браузера. Запускаем после Start, чтобы транспорты успели
|
||||
// зарегистрироваться.
|
||||
if *cookiesFile != "" {
|
||||
setCookiesFilePath(*cookiesFile)
|
||||
utils.SafeGo("cookiesFileWatch", func() { watchCookiesFile(*cookiesFile) })
|
||||
}
|
||||
|
||||
tun := tunnel.NewTCPTunnel(trans, *exitNode)
|
||||
|
||||
if *exitNode {
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"universal-bypass-tool/utils"
|
||||
)
|
||||
|
||||
// ControlTransport carries small out-of-band messages alongside tunnel traffic
|
||||
// on the same channel, so the two peers can talk about the channel itself.
|
||||
//
|
||||
// Its reason for existing is cookies. Yandex can put an interactive captcha in
|
||||
// front of a document; the exit node cannot solve it (no browser, no user) while
|
||||
// the client can. So the client hands over cookies and the node keeps working.
|
||||
//
|
||||
// IMPORTANT — what this can and cannot do. The captcha only blocks
|
||||
// fetchDocInfo, i.e. RE-connection. While the node's current session is alive,
|
||||
// this channel works and cookies get through. Once the node has lost the session
|
||||
// and cannot re-open the document, there is no channel left and nothing here can
|
||||
// help: that state needs an out-of-band unblock (--cookies-file). Hence the
|
||||
// design goal is to deliver cookies EARLY — while the link is healthy — so the
|
||||
// node never reaches the dead state, rather than to rescue it afterwards.
|
||||
//
|
||||
// Framing: the tunnel's own payload is raw IPv4 packets, whose first byte is
|
||||
// always 0x4X. 0xFF can therefore never start a tunnel packet and is free to
|
||||
// mark a control frame:
|
||||
//
|
||||
// [0] 0xFF
|
||||
// [1] message type
|
||||
// [2:] JSON body
|
||||
//
|
||||
// The layer sits between the tunnel and the codec, so control frames are
|
||||
// compressed and encrypted exactly like data.
|
||||
type ControlTransport struct {
|
||||
Transport
|
||||
|
||||
mu sync.RWMutex
|
||||
dataCb func([]byte)
|
||||
handler func(msgType byte, body []byte)
|
||||
}
|
||||
|
||||
const controlMagic = 0xFF
|
||||
|
||||
// Control message types.
|
||||
const (
|
||||
// CtrlCookiesRequest — "I need cookies for this transport." Sent by the side
|
||||
// that hit a captcha (in practice the exit node).
|
||||
CtrlCookiesRequest = byte(1)
|
||||
// CtrlCookiesOffer — "here are cookies", name -> value.
|
||||
CtrlCookiesOffer = byte(2)
|
||||
// CtrlAuthRequired — the peer (in practice the exit node) cannot pass an
|
||||
// interactive check and reports WHERE it is stuck, so the client can solve it.
|
||||
//
|
||||
// The client must solve it THROUGH the tunnel: the check is bound to the
|
||||
// address that passed it, and the cookies have to be valid for the exit
|
||||
// node's address, not the phone's. Solving it with the tunnel down produces
|
||||
// cookies that are useless to the node.
|
||||
CtrlAuthRequired = byte(3)
|
||||
)
|
||||
|
||||
// CookiesPayload is the body of both cookie messages. Reason is free-form and
|
||||
// only for logging.
|
||||
type CookiesPayload struct {
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Cookies map[string]string `json:"cookies,omitempty"`
|
||||
}
|
||||
|
||||
// AuthRequiredPayload tells the peer which of ITS transports is stuck and where.
|
||||
type AuthRequiredPayload struct {
|
||||
Transport string `json:"transport,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
func NewControlTransport(inner Transport) *ControlTransport {
|
||||
return &ControlTransport{Transport: inner}
|
||||
}
|
||||
|
||||
// SetControlHandler installs the callback for incoming control messages.
|
||||
func (c *ControlTransport) SetControlHandler(fn func(msgType byte, body []byte)) {
|
||||
c.mu.Lock()
|
||||
c.handler = fn
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// SendControl sends one control message.
|
||||
func (c *ControlTransport) SendControl(msgType byte, payload interface{}) error {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("control marshal: %w", err)
|
||||
}
|
||||
frame := make([]byte, 0, 2+len(body))
|
||||
frame = append(frame, controlMagic, msgType)
|
||||
frame = append(frame, body...)
|
||||
return c.Transport.Send(frame)
|
||||
}
|
||||
|
||||
// Receive splits the inbound stream: control frames go to the handler, anything
|
||||
// else is a tunnel packet and goes to the tunnel unchanged.
|
||||
func (c *ControlTransport) Receive(callback func([]byte)) {
|
||||
c.mu.Lock()
|
||||
c.dataCb = callback
|
||||
c.mu.Unlock()
|
||||
|
||||
c.Transport.Receive(func(data []byte) {
|
||||
if len(data) >= 2 && data[0] == controlMagic {
|
||||
c.mu.RLock()
|
||||
h := c.handler
|
||||
c.mu.RUnlock()
|
||||
if h != nil {
|
||||
h(data[1], data[2:])
|
||||
} else {
|
||||
utils.Debugf("[CTRL] control frame type %d with no handler", data[1])
|
||||
}
|
||||
return
|
||||
}
|
||||
c.mu.RLock()
|
||||
cb := c.dataCb
|
||||
c.mu.RUnlock()
|
||||
if cb != nil {
|
||||
cb(data)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Разделение потока — вся суть слоя: контрольный кадр не должен попасть в
|
||||
// туннель как пакет, а пакет не должен уйти в обработчик. Перепутанная ветка
|
||||
// означала бы, что gVisor получает JSON, а капча-логика — IP-пакеты.
|
||||
func TestControlSplitsStream(t *testing.T) {
|
||||
inner := &recordingTransport{}
|
||||
c := NewControlTransport(inner)
|
||||
|
||||
var mu sync.Mutex
|
||||
var packets [][]byte
|
||||
var ctrl []struct {
|
||||
typ byte
|
||||
body []byte
|
||||
}
|
||||
|
||||
c.Receive(func(p []byte) {
|
||||
mu.Lock()
|
||||
packets = append(packets, append([]byte(nil), p...))
|
||||
mu.Unlock()
|
||||
})
|
||||
c.SetControlHandler(func(typ byte, body []byte) {
|
||||
mu.Lock()
|
||||
ctrl = append(ctrl, struct {
|
||||
typ byte
|
||||
body []byte
|
||||
}{typ, append([]byte(nil), body...)})
|
||||
mu.Unlock()
|
||||
})
|
||||
|
||||
// Настоящий IPv4-пакет: первый байт 0x45.
|
||||
ipPacket := []byte{0x45, 0x00, 0x00, 0x14, 0, 0, 0, 0, 64, 6, 0, 0, 10, 0, 0, 2, 1, 1, 1, 1}
|
||||
inner.push(ipPacket)
|
||||
inner.push([]byte{controlMagic, CtrlCookiesRequest, '{', '}'})
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(packets) != 1 || packets[0][0] != 0x45 {
|
||||
t.Fatalf("IP packet did not reach the tunnel: %v", packets)
|
||||
}
|
||||
if len(ctrl) != 1 || ctrl[0].typ != CtrlCookiesRequest {
|
||||
t.Fatalf("control frame did not reach the handler: %v", ctrl)
|
||||
}
|
||||
}
|
||||
|
||||
// 0xFF невозможен как первый байт IPv4-пакета (там всегда 0x4X) — на этом
|
||||
// держится всё разделение, поэтому проверяем, что данные с таким байтом в
|
||||
// туннель не уходят и наоборот.
|
||||
func TestControlMagicCannotCollideWithIPv4(t *testing.T) {
|
||||
for v := 0x40; v <= 0x4F; v++ {
|
||||
if byte(v) == controlMagic {
|
||||
t.Fatalf("control magic %#x collides with an IPv4 first byte", controlMagic)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestControlRoundTripCookies(t *testing.T) {
|
||||
inner := &recordingTransport{}
|
||||
c := NewControlTransport(inner)
|
||||
c.Receive(func([]byte) {})
|
||||
|
||||
want := map[string]string{"spravka": "abc", "yandexuid": "42"}
|
||||
if err := c.SendControl(CtrlCookiesOffer, CookiesPayload{Reason: "captcha", Cookies: want}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
frames := inner.sentFrames()
|
||||
if len(frames) != 1 {
|
||||
t.Fatalf("expected one frame, got %d", len(frames))
|
||||
}
|
||||
f := frames[0]
|
||||
if f[0] != controlMagic || f[1] != CtrlCookiesOffer {
|
||||
t.Fatalf("bad header: %#v", f[:2])
|
||||
}
|
||||
var got CookiesPayload
|
||||
if err := json.Unmarshal(f[2:], &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Reason != "captcha" || got.Cookies["spravka"] != "abc" || got.Cookies["yandexuid"] != "42" {
|
||||
t.Fatalf("payload mangled: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Кадр без обработчика не должен утечь в туннель как пакет: gVisor принял бы
|
||||
// его за мусор, но хуже — мы бы не заметили потерю контрольного сообщения.
|
||||
func TestControlFrameWithoutHandlerIsNotDelivered(t *testing.T) {
|
||||
inner := &recordingTransport{}
|
||||
c := NewControlTransport(inner)
|
||||
|
||||
var mu sync.Mutex
|
||||
var packets int
|
||||
c.Receive(func([]byte) { mu.Lock(); packets++; mu.Unlock() })
|
||||
|
||||
inner.push([]byte{controlMagic, CtrlCookiesRequest, '{', '}'})
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if packets != 0 {
|
||||
t.Fatalf("control frame leaked into the tunnel as a packet")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"universal-bypass-tool/utils"
|
||||
)
|
||||
|
||||
// DirectConfig configures the DirectTransport.
|
||||
//
|
||||
// DirectTransport is a plain TCP byte-stream carrier between a client and an
|
||||
// exit node. It carries *already framed* tunnel packets (the same wire that
|
||||
// BatchedTransport / NegotiatedTransport produce), so every packet on the wire
|
||||
// is exactly one length-prefixed record:
|
||||
//
|
||||
// [2-byte big-endian length][payload]
|
||||
//
|
||||
// It is intended to be wrapped by EncryptedTransport (AES-256-GCM) on both
|
||||
// sides. main.go MUST refuse to start --transport=direct without a key.
|
||||
type DirectConfig struct {
|
||||
// ListenAddr is the local address the exit node binds to.
|
||||
// Empty or ":0" means "any free port on all interfaces".
|
||||
// Always set to 0.0.0.0:PORT when running as an exit node reachable from
|
||||
// the internet, so the socket is not loopback-only.
|
||||
ListenAddr string
|
||||
|
||||
// DialAddr is the remote address the client dials.
|
||||
// "host:port". Ignored when ListenAddr is set (server mode).
|
||||
DialAddr string
|
||||
|
||||
// IsExit chooses listen-vs-dial. Exactly one of the two must be true on
|
||||
// each peer.
|
||||
IsExit bool
|
||||
|
||||
// HandshakeTimeout bounds the initial TCP connect / accept.
|
||||
HandshakeTimeout time.Duration
|
||||
|
||||
// ReadTimeout bounds a single ReadMessage on the wire. A zero value
|
||||
// disables the deadline and relies on TCP keepalives.
|
||||
ReadTimeout time.Duration
|
||||
|
||||
// KeepAliveInterval controls the TCP keepalive probe interval on the
|
||||
// underlying net.Conn. Zero disables keepalive.
|
||||
KeepAliveInterval time.Duration
|
||||
|
||||
// ReconnectMin / ReconnectMax / ReconnectMultiplier control the client
|
||||
// reconnect backoff. The exit node does not reconnect; it accepts.
|
||||
ReconnectMinDelay time.Duration
|
||||
ReconnectMaxDelay time.Duration
|
||||
ReconnectMultiplier float64
|
||||
|
||||
// MaxRecordBytes caps a single framed record (2-byte length prefix means
|
||||
// 65535 is the hard ceiling).
|
||||
MaxRecordBytes int
|
||||
}
|
||||
|
||||
// DefaultDirectConfig returns conservative defaults matching the rest of the
|
||||
// project's style.
|
||||
func DefaultDirectConfig() DirectConfig {
|
||||
return DirectConfig{
|
||||
HandshakeTimeout: 15 * time.Second,
|
||||
ReadTimeout: 0, // rely on TCP keepalive
|
||||
KeepAliveInterval: 30 * time.Second,
|
||||
ReconnectMinDelay: 200 * time.Millisecond,
|
||||
ReconnectMaxDelay: 15 * time.Second,
|
||||
ReconnectMultiplier: 1.4,
|
||||
MaxRecordBytes: 65535,
|
||||
}
|
||||
}
|
||||
|
||||
// DirectTransport is a minimal TCP carrier for already-framed tunnel records.
|
||||
//
|
||||
// Client mode dials DialAddr and reconnects on drop.
|
||||
// Exit mode listens on ListenAddr and accepts a single active peer at a time.
|
||||
//
|
||||
// The transport does not encrypt, compress, or batch. Wrap it with
|
||||
// EncryptedTransport (and optionally NegotiatedTransport) in main.go.
|
||||
type DirectTransport struct {
|
||||
*BaseTransport
|
||||
|
||||
config DirectConfig
|
||||
|
||||
// Active connection. Swap under mu.
|
||||
mu sync.RWMutex
|
||||
conn net.Conn
|
||||
|
||||
// Accept loop / dial loop lifecycle.
|
||||
done chan struct{}
|
||||
once sync.Once
|
||||
|
||||
// Send serialization: one writer at a time.
|
||||
writeMu sync.Mutex
|
||||
|
||||
// Outbound queue. Direct writes into the socket are cheap, but we still
|
||||
// need a queue so a slow peer doesn't block the caller (e.g. gVisor).
|
||||
queue chan []byte
|
||||
|
||||
// Exit-mode listener.
|
||||
listener net.Listener
|
||||
|
||||
// Stats not tracked by BaseTransport directly.
|
||||
reconnects atomic.Uint64
|
||||
drops atomic.Uint64
|
||||
}
|
||||
|
||||
// NewDirectTransport builds a DirectTransport from a base config and a
|
||||
// transport.DirectConfig.
|
||||
//
|
||||
// The caller is expected to have already chosen IsExit and DialAddr/ListenAddr.
|
||||
func NewDirectTransport(base TransportConfig, cfg DirectConfig) *DirectTransport {
|
||||
t := &DirectTransport{
|
||||
BaseTransport: NewBaseTransport(base),
|
||||
config: cfg,
|
||||
done: make(chan struct{}),
|
||||
queue: make(chan []byte, base.MaxQueueSize),
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// Start launches the accept (exit) or dial (client) loop.
|
||||
func (t *DirectTransport) Start() error {
|
||||
if err := t.BaseTransport.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if t.config.IsExit {
|
||||
addr := t.config.ListenAddr
|
||||
if addr == "" {
|
||||
addr = "0.0.0.0:0"
|
||||
}
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("direct: listen %s: %w", addr, err)
|
||||
}
|
||||
t.listener = ln
|
||||
utils.Debugf("[DIRECT] exit listening on %s", ln.Addr().String())
|
||||
go t.acceptLoop()
|
||||
} else {
|
||||
if t.config.DialAddr == "" {
|
||||
return fmt.Errorf("direct: DialAddr is empty")
|
||||
}
|
||||
go t.dialLoop()
|
||||
}
|
||||
|
||||
go t.writerLoop()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop closes the listener, the active connection and the outbound queue.
|
||||
func (t *DirectTransport) Stop() error {
|
||||
t.once.Do(func() {
|
||||
close(t.done)
|
||||
})
|
||||
t.mu.Lock()
|
||||
if t.listener != nil {
|
||||
_ = t.listener.Close()
|
||||
t.listener = nil
|
||||
}
|
||||
if t.conn != nil {
|
||||
_ = t.conn.Close()
|
||||
t.conn = nil
|
||||
}
|
||||
t.mu.Unlock()
|
||||
t.SetConnected(false)
|
||||
return t.BaseTransport.Stop()
|
||||
}
|
||||
|
||||
// Send enqueues an already-framed record for delivery. The payload MUST be a
|
||||
// complete wire frame as expected by the peer (e.g. the output of
|
||||
// encodeBatch / encodeBatchV3 / NegotiatedTransport's envelope).
|
||||
//
|
||||
// A full queue drops the datagram; upper layers (TCP) retransmit, UDP loses.
|
||||
func (t *DirectTransport) Send(data []byte) error {
|
||||
if !t.IsRunning() {
|
||||
return fmt.Errorf("direct: not running")
|
||||
}
|
||||
if len(data) == 0 || len(data) > t.config.MaxRecordBytes {
|
||||
return fmt.Errorf("direct: record size %d outside 1..%d", len(data), t.config.MaxRecordBytes)
|
||||
}
|
||||
cp := make([]byte, len(data))
|
||||
copy(cp, data)
|
||||
select {
|
||||
case t.queue <- cp:
|
||||
return nil
|
||||
default:
|
||||
t.drops.Add(1)
|
||||
return fmt.Errorf("direct: queue full")
|
||||
}
|
||||
}
|
||||
|
||||
// Receive installs the user callback. DirectTransport forwards every framed
|
||||
// record it reads from the wire.
|
||||
func (t *DirectTransport) Receive(cb func([]byte)) {
|
||||
t.BaseTransport.Receive(cb)
|
||||
}
|
||||
|
||||
// IsConnected reports whether a live TCP connection exists.
|
||||
func (t *DirectTransport) IsConnected() bool {
|
||||
return t.BaseTransport.IsConnected()
|
||||
}
|
||||
|
||||
// Stats returns counters plus reconnect/drop counts specific to DirectTransport.
|
||||
func (t *DirectTransport) Stats() TransportStats {
|
||||
s := t.BaseTransport.Stats()
|
||||
s.Reconnects = t.reconnects.Load()
|
||||
s.Connected = t.IsConnected()
|
||||
return s
|
||||
}
|
||||
|
||||
// Drops returns the number of Send calls dropped because the queue was full.
|
||||
func (t *DirectTransport) Drops() uint64 { return t.drops.Load() }
|
||||
|
||||
// ---- exit mode ----
|
||||
|
||||
func (t *DirectTransport) acceptLoop() {
|
||||
for {
|
||||
select {
|
||||
case <-t.done:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
conn, err := t.listener.Accept()
|
||||
if err != nil {
|
||||
select {
|
||||
case <-t.done:
|
||||
return
|
||||
default:
|
||||
}
|
||||
utils.Debugf("[DIRECT] accept: %v", err)
|
||||
continue
|
||||
}
|
||||
utils.Debugf("[DIRECT] accepted %s", conn.RemoteAddr())
|
||||
t.serveConn(conn)
|
||||
// After serveConn returns, loop and accept the next peer.
|
||||
}
|
||||
}
|
||||
|
||||
// ---- client mode ----
|
||||
|
||||
func (t *DirectTransport) dialLoop() {
|
||||
delay := t.config.ReconnectMinDelay
|
||||
if delay <= 0 {
|
||||
delay = 200 * time.Millisecond
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-t.done:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
d := net.Dialer{Timeout: t.config.HandshakeTimeout}
|
||||
conn, err := d.Dial("tcp", t.config.DialAddr)
|
||||
if err != nil {
|
||||
utils.Debugf("[DIRECT] dial %s: %v", t.config.DialAddr, err)
|
||||
} else {
|
||||
utils.Debugf("[DIRECT] connected to %s", t.config.DialAddr)
|
||||
t.serveConn(conn)
|
||||
t.reconnects.Add(1)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-t.done:
|
||||
return
|
||||
case <-time.After(delay):
|
||||
}
|
||||
delay = time.Duration(float64(delay) * t.config.ReconnectMultiplier)
|
||||
if delay > t.config.ReconnectMaxDelay {
|
||||
delay = t.config.ReconnectMaxDelay
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- shared conn lifecycle ----
|
||||
|
||||
func (t *DirectTransport) serveConn(conn net.Conn) {
|
||||
if tc, ok := conn.(*net.TCPConn); ok {
|
||||
_ = tc.SetNoDelay(true)
|
||||
if t.config.KeepAliveInterval > 0 {
|
||||
_ = tc.SetKeepAlive(true)
|
||||
_ = tc.SetKeepAlivePeriod(t.config.KeepAliveInterval)
|
||||
}
|
||||
}
|
||||
|
||||
t.mu.Lock()
|
||||
t.conn = conn
|
||||
t.mu.Unlock()
|
||||
t.SetConnected(true)
|
||||
|
||||
defer func() {
|
||||
t.mu.Lock()
|
||||
if t.conn == conn {
|
||||
t.conn = nil
|
||||
}
|
||||
t.mu.Unlock()
|
||||
t.SetConnected(false)
|
||||
_ = conn.Close()
|
||||
}()
|
||||
|
||||
buf := make([]byte, t.config.MaxRecordBytes)
|
||||
for {
|
||||
select {
|
||||
case <-t.done:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
if t.config.ReadTimeout > 0 {
|
||||
_ = conn.SetReadDeadline(time.Now().Add(t.config.ReadTimeout))
|
||||
}
|
||||
if _, err := io.ReadFull(conn, buf[:2]); err != nil {
|
||||
if err != io.EOF {
|
||||
utils.Debugf("[DIRECT] read header: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
n := int(buf[0])<<8 | int(buf[1])
|
||||
if n == 0 || n > t.config.MaxRecordBytes {
|
||||
utils.Debugf("[DIRECT] invalid record length %d", n)
|
||||
return
|
||||
}
|
||||
if _, err := io.ReadFull(conn, buf[:n]); err != nil {
|
||||
utils.Debugf("[DIRECT] read body: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
pkt := make([]byte, n)
|
||||
copy(pkt, buf[:n])
|
||||
t.RecordReceive(n)
|
||||
t.CallReceive(pkt)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- writer ----
|
||||
|
||||
func (t *DirectTransport) writerLoop() {
|
||||
var pending []byte
|
||||
for {
|
||||
if pending == nil {
|
||||
select {
|
||||
case <-t.done:
|
||||
return
|
||||
case pkt := <-t.queue:
|
||||
pending = pkt
|
||||
}
|
||||
}
|
||||
|
||||
t.mu.RLock()
|
||||
conn := t.conn
|
||||
t.mu.RUnlock()
|
||||
if conn == nil {
|
||||
// Mid-reconnect: hold the packet and retry rather than drop it.
|
||||
select {
|
||||
case <-t.done:
|
||||
return
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if len(pending) > t.config.MaxRecordBytes {
|
||||
utils.Debugf("[DIRECT] dropping oversized record %d", len(pending))
|
||||
pending = nil
|
||||
continue
|
||||
}
|
||||
hdr := [2]byte{byte(len(pending) >> 8), byte(len(pending))}
|
||||
|
||||
t.writeMu.Lock()
|
||||
_, err := conn.Write(hdr[:])
|
||||
if err == nil {
|
||||
_, err = conn.Write(pending)
|
||||
}
|
||||
t.writeMu.Unlock()
|
||||
|
||||
if err != nil {
|
||||
utils.Debugf("[DIRECT] write: %v", err)
|
||||
select {
|
||||
case <-t.done:
|
||||
return
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
}
|
||||
continue // keep pending; reconnect will bring up a new conn
|
||||
}
|
||||
t.RecordSend(len(pending))
|
||||
pending = nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestDirectTransportLoopback pipes a client and an exit DirectTransport
|
||||
// through a real TCP listener on localhost.
|
||||
func TestDirectTransportLoopback(t *testing.T) {
|
||||
ln, err := netListen()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
addr := ln.Addr().String()
|
||||
_ = ln.Close()
|
||||
|
||||
exit := NewDirectTransport(DefaultConfig(), DirectConfig{
|
||||
IsExit: true,
|
||||
ListenAddr: addr,
|
||||
HandshakeTimeout: 2 * time.Second,
|
||||
MaxRecordBytes: 65535,
|
||||
})
|
||||
if err := exit.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer exit.Stop()
|
||||
|
||||
client := NewDirectTransport(DefaultConfig(), DirectConfig{
|
||||
IsExit: false,
|
||||
DialAddr: addr,
|
||||
HandshakeTimeout: 2 * time.Second,
|
||||
ReconnectMinDelay: 50 * time.Millisecond,
|
||||
ReconnectMaxDelay: 200 * time.Millisecond,
|
||||
ReconnectMultiplier: 1.2,
|
||||
MaxRecordBytes: 65535,
|
||||
})
|
||||
|
||||
got := make(chan []byte, 1)
|
||||
exit.Receive(func(p []byte) { got <- append([]byte(nil), p...) })
|
||||
|
||||
if err := client.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer client.Stop()
|
||||
|
||||
// Wait until the exit sees the client's connection.
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for !exit.IsConnected() {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("exit did not accept within 3s")
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
|
||||
want := []byte("hello through the wire")
|
||||
if err := client.Send(want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case p := <-got:
|
||||
if !bytes.Equal(p, want) {
|
||||
t.Fatalf("got %q, want %q", p, want)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("no packet delivered")
|
||||
}
|
||||
}
|
||||
|
||||
// netListen isolates the net.Listen call so the import stays in one place.
|
||||
func netListen() (net.Listener, error) { return net.Listen("tcp", "127.0.0.1:0") }
|
||||
|
||||
var _ = sync.WaitGroup{}
|
||||
@@ -0,0 +1,158 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/crypto/scrypt"
|
||||
)
|
||||
|
||||
const (
|
||||
encryptedVersion = byte(1)
|
||||
encryptedHeader = 5
|
||||
maxSeenNonces = 4096
|
||||
)
|
||||
|
||||
var encryptedMagic = [3]byte{'O', 'F', 'X'}
|
||||
|
||||
// EncryptedTransport wraps another Transport with end-to-end AES-256-GCM
|
||||
// authenticated encryption, so the transport's own provider only ever
|
||||
// observes ciphertext. Keys are derived from a shared secret via scrypt; the
|
||||
// context string is just a public, per-session KDF salt - secrecy comes
|
||||
// exclusively from the secret, which both peers must share out of band.
|
||||
//
|
||||
// Each direction (client->exit, exit->client) uses its own derived key, so a
|
||||
// compromise of one direction's traffic does not help decrypt the other.
|
||||
// Every packet also carries a random nonce and is checked against a bounded
|
||||
// replay window, so a captured packet cannot be replayed back at either
|
||||
// peer.
|
||||
type EncryptedTransport struct {
|
||||
Transport
|
||||
sendAEAD cipher.AEAD
|
||||
receiveAEAD cipher.AEAD
|
||||
sendDirection byte
|
||||
recvDirection byte
|
||||
seenMu sync.Mutex
|
||||
seen map[string]struct{}
|
||||
seenOrder []string
|
||||
}
|
||||
|
||||
// NewEncryptedTransport wraps inner with a directional AES-256-GCM stream.
|
||||
// Both peers must be configured with the same secret and context, and
|
||||
// exactly one of them must set exitNode=true so the two sides pick opposite
|
||||
// send/receive key pairs.
|
||||
func NewEncryptedTransport(inner Transport, secret, context string, exitNode bool) (*EncryptedTransport, error) {
|
||||
if inner == nil {
|
||||
return nil, errors.New("inner transport is nil")
|
||||
}
|
||||
if len(secret) < 16 {
|
||||
return nil, errors.New("encryption secret must contain at least 16 characters")
|
||||
}
|
||||
|
||||
salt := sha256.Sum256([]byte("OpenFlux encrypted transport v1\x00" + context))
|
||||
master, err := scrypt.Key([]byte(secret), salt[:], 32768, 8, 1, 32)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("derive encryption key: %w", err)
|
||||
}
|
||||
clientToExit := deriveDirectionalKey(master, "client-to-exit")
|
||||
exitToClient := deriveDirectionalKey(master, "exit-to-client")
|
||||
|
||||
sendKey, receiveKey := clientToExit, exitToClient
|
||||
sendDirection, receiveDirection := byte(0), byte(1)
|
||||
if exitNode {
|
||||
sendKey, receiveKey = exitToClient, clientToExit
|
||||
sendDirection, receiveDirection = 1, 0
|
||||
}
|
||||
sendAEAD, err := newGCM(sendKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
receiveAEAD, err := newGCM(receiveKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &EncryptedTransport{
|
||||
Transport: inner,
|
||||
sendAEAD: sendAEAD,
|
||||
receiveAEAD: receiveAEAD,
|
||||
sendDirection: sendDirection,
|
||||
recvDirection: receiveDirection,
|
||||
seen: make(map[string]struct{}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func deriveDirectionalKey(master []byte, label string) []byte {
|
||||
mac := hmac.New(sha256.New, master)
|
||||
_, _ = mac.Write([]byte("OpenFlux direction v1\x00" + label))
|
||||
return mac.Sum(nil)
|
||||
}
|
||||
|
||||
func newGCM(key []byte) (cipher.AEAD, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create AES cipher: %w", err)
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create AES-GCM: %w", err)
|
||||
}
|
||||
return aead, nil
|
||||
}
|
||||
|
||||
func (e *EncryptedTransport) Send(data []byte) error {
|
||||
header := []byte{encryptedMagic[0], encryptedMagic[1], encryptedMagic[2], encryptedVersion, e.sendDirection}
|
||||
nonce := make([]byte, e.sendAEAD.NonceSize())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return fmt.Errorf("create packet nonce: %w", err)
|
||||
}
|
||||
packet := make([]byte, 0, len(header)+len(nonce)+len(data)+e.sendAEAD.Overhead())
|
||||
packet = append(packet, header...)
|
||||
packet = append(packet, nonce...)
|
||||
packet = e.sendAEAD.Seal(packet, nonce, data, header)
|
||||
return e.Transport.Send(packet)
|
||||
}
|
||||
|
||||
|
||||
func (e *EncryptedTransport) Receive(callback func([]byte)) {
|
||||
e.Transport.Receive(func(packet []byte) {
|
||||
if len(packet) < encryptedHeader+e.receiveAEAD.NonceSize()+e.receiveAEAD.Overhead() {
|
||||
return
|
||||
}
|
||||
header := packet[:encryptedHeader]
|
||||
if header[0] != encryptedMagic[0] || header[1] != encryptedMagic[1] ||
|
||||
header[2] != encryptedMagic[2] || header[3] != encryptedVersion ||
|
||||
header[4] != e.recvDirection {
|
||||
return
|
||||
}
|
||||
nonceEnd := encryptedHeader + e.receiveAEAD.NonceSize()
|
||||
nonce := packet[encryptedHeader:nonceEnd]
|
||||
plaintext, err := e.receiveAEAD.Open(nil, nonce, packet[nonceEnd:], header)
|
||||
if err != nil || !e.rememberNonce(nonce) {
|
||||
return
|
||||
}
|
||||
callback(plaintext)
|
||||
})
|
||||
}
|
||||
|
||||
func (e *EncryptedTransport) rememberNonce(nonce []byte) bool {
|
||||
key := string(nonce)
|
||||
e.seenMu.Lock()
|
||||
defer e.seenMu.Unlock()
|
||||
if _, exists := e.seen[key]; exists {
|
||||
return false
|
||||
}
|
||||
e.seen[key] = struct{}{}
|
||||
e.seenOrder = append(e.seenOrder, key)
|
||||
if len(e.seenOrder) > maxSeenNonces {
|
||||
oldest := e.seenOrder[0]
|
||||
e.seenOrder = e.seenOrder[1:]
|
||||
delete(e.seen, oldest)
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Interop between the iOS client stack in THIS branch and an exit node built
|
||||
// from upstream master.
|
||||
//
|
||||
// Upstream runs Encrypted(Batched(raw)); this branch runs
|
||||
// Encrypted(Adaptive(raw)), because the codec here self-negotiates instead of
|
||||
// being picked by a --codec flag. The two only interoperate if
|
||||
//
|
||||
// 1. the batch framing is byte-identical (it is: framing.go matches upstream), and
|
||||
// 2. the encryption layer sits in the SAME place in the stack — outermost, so
|
||||
// each tunnel packet is sealed first and the codec batches the ciphertext.
|
||||
//
|
||||
// Point 2 is the one a refactor could silently break: move the wrapper inside
|
||||
// the codec and everything still "works" locally between two peers from this
|
||||
// branch, while every packet from an upstream node fails to authenticate. These
|
||||
// tests pin it down.
|
||||
|
||||
// batchOnlyCodec models upstream's BatchedTransport on the wire: one batch frame
|
||||
// per Send, decoded back into individual packets on receive. It uses the same
|
||||
// encodeBatch/decodeBatch as the real code, so the bytes are the real bytes.
|
||||
type batchOnlyCodec struct {
|
||||
Transport
|
||||
mu sync.Mutex
|
||||
cb func([]byte)
|
||||
}
|
||||
|
||||
func (b *batchOnlyCodec) Send(data []byte) error {
|
||||
return b.Transport.Send(encodeBatch([][]byte{data}))
|
||||
}
|
||||
|
||||
func (b *batchOnlyCodec) Receive(cb func([]byte)) {
|
||||
b.mu.Lock()
|
||||
b.cb = cb
|
||||
b.mu.Unlock()
|
||||
b.Transport.Receive(func(frame []byte) {
|
||||
pkts, err := decodeBatch(frame)
|
||||
if err != nil {
|
||||
return // a legacy/probe frame an upstream node would also discard
|
||||
}
|
||||
b.mu.Lock()
|
||||
f := b.cb
|
||||
b.mu.Unlock()
|
||||
if f == nil {
|
||||
return
|
||||
}
|
||||
for _, p := range pkts {
|
||||
if len(p) > 0 {
|
||||
f(p)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// wire is a two-ended in-memory channel: what one side sends, the other receives.
|
||||
type wire struct {
|
||||
mu sync.Mutex
|
||||
peer *wire
|
||||
cb func([]byte)
|
||||
sent [][]byte
|
||||
}
|
||||
|
||||
func newWirePair() (*wire, *wire) {
|
||||
a, b := &wire{}, &wire{}
|
||||
a.peer, b.peer = b, a
|
||||
return a, b
|
||||
}
|
||||
|
||||
func (w *wire) Start() error { return nil }
|
||||
func (w *wire) Stop() error { return nil }
|
||||
func (w *wire) Send(data []byte) error {
|
||||
cp := append([]byte(nil), data...)
|
||||
w.mu.Lock()
|
||||
w.sent = append(w.sent, cp)
|
||||
w.mu.Unlock()
|
||||
|
||||
w.peer.mu.Lock()
|
||||
cb := w.peer.cb
|
||||
w.peer.mu.Unlock()
|
||||
if cb != nil {
|
||||
cb(cp)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (w *wire) Receive(cb func([]byte)) { w.mu.Lock(); w.cb = cb; w.mu.Unlock() }
|
||||
func (w *wire) IsConnected() bool { return true }
|
||||
func (w *wire) Stats() TransportStats { return TransportStats{Connected: true} }
|
||||
|
||||
func (w *wire) frames() [][]byte {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
out := make([][]byte, len(w.sent))
|
||||
copy(out, w.sent)
|
||||
return out
|
||||
}
|
||||
|
||||
const (
|
||||
interopSecret = "correct horse battery staple"
|
||||
interopCtx = "https://disk.yandex.ru/i/abcdef123456"
|
||||
)
|
||||
|
||||
// buildInteropPair wires this branch's client stack to an upstream-style node.
|
||||
func buildInteropPair(t *testing.T) (client, node Transport, clientWire, nodeWire *wire, stop func()) {
|
||||
t.Helper()
|
||||
cw, nw := newWirePair()
|
||||
|
||||
adaptive := NewAdaptiveTransport(cw)
|
||||
adaptive.forceBatch = true // an upstream node is batch-only; skip the probe wait
|
||||
|
||||
c, err := NewEncryptedTransport(adaptive, interopSecret, interopCtx, false)
|
||||
if err != nil {
|
||||
t.Fatalf("client encrypted transport: %v", err)
|
||||
}
|
||||
n, err := NewEncryptedTransport(&batchOnlyCodec{Transport: nw}, interopSecret, interopCtx, true)
|
||||
if err != nil {
|
||||
t.Fatalf("node encrypted transport: %v", err)
|
||||
}
|
||||
if err := c.Start(); err != nil {
|
||||
t.Fatalf("client start: %v", err)
|
||||
}
|
||||
return c, n, cw, nw, func() { _ = c.Stop() }
|
||||
}
|
||||
|
||||
func waitForPacket(t *testing.T, got *[][]byte, mu *sync.Mutex, want []byte) bool {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
mu.Lock()
|
||||
for _, p := range *got {
|
||||
if bytes.Equal(p, want) {
|
||||
mu.Unlock()
|
||||
return true
|
||||
}
|
||||
}
|
||||
mu.Unlock()
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TestEncryptedInteropClientToUpstreamNode: a packet sent by this branch's
|
||||
// client arrives intact at an upstream-style node, and never appears in the
|
||||
// clear on the wire.
|
||||
func TestEncryptedInteropClientToUpstreamNode(t *testing.T) {
|
||||
client, node, clientWire, _, stop := buildInteropPair(t)
|
||||
defer stop()
|
||||
|
||||
var mu sync.Mutex
|
||||
var got [][]byte
|
||||
node.Receive(func(p []byte) {
|
||||
mu.Lock()
|
||||
got = append(got, append([]byte(nil), p...))
|
||||
mu.Unlock()
|
||||
})
|
||||
|
||||
payload := []byte("client-to-exit payload, must survive intact")
|
||||
if err := client.Send(payload); err != nil {
|
||||
t.Fatalf("send: %v", err)
|
||||
}
|
||||
|
||||
if !waitForPacket(t, &got, &mu, payload) {
|
||||
t.Fatal("upstream-style node never decoded the client's packet")
|
||||
}
|
||||
for _, f := range clientWire.frames() {
|
||||
if bytes.Contains(f, payload) {
|
||||
t.Fatal("plaintext payload found on the wire — encryption not applied")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEncryptedInteropUpstreamNodeToClient: the reverse direction, which uses
|
||||
// the other derived key, must work too.
|
||||
func TestEncryptedInteropUpstreamNodeToClient(t *testing.T) {
|
||||
client, node, _, nodeWire, stop := buildInteropPair(t)
|
||||
defer stop()
|
||||
|
||||
var mu sync.Mutex
|
||||
var got [][]byte
|
||||
client.Receive(func(p []byte) {
|
||||
mu.Lock()
|
||||
got = append(got, append([]byte(nil), p...))
|
||||
mu.Unlock()
|
||||
})
|
||||
node.Receive(func([]byte) {})
|
||||
|
||||
payload := []byte("exit-to-client payload")
|
||||
if err := node.Send(payload); err != nil {
|
||||
t.Fatalf("node send: %v", err)
|
||||
}
|
||||
|
||||
if !waitForPacket(t, &got, &mu, payload) {
|
||||
t.Fatal("client never decoded the node's packet")
|
||||
}
|
||||
for _, f := range nodeWire.frames() {
|
||||
if bytes.Contains(f, payload) {
|
||||
t.Fatal("plaintext payload found on the wire — encryption not applied")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEncryptedInteropContextMismatch: the KDF context is derived from the
|
||||
// document URL on both sides. If they disagree — a trailing slash, http vs
|
||||
// https — the keys differ and nothing decodes. This is a silent, confusing
|
||||
// failure in the field, so pin the behaviour.
|
||||
func TestEncryptedInteropContextMismatch(t *testing.T) {
|
||||
cw, nw := newWirePair()
|
||||
|
||||
adaptive := NewAdaptiveTransport(cw)
|
||||
adaptive.forceBatch = true
|
||||
client, err := NewEncryptedTransport(adaptive, interopSecret, interopCtx, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Same secret, context differs by one character.
|
||||
node, err := NewEncryptedTransport(&batchOnlyCodec{Transport: nw},
|
||||
interopSecret, interopCtx+"/", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := client.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer client.Stop()
|
||||
|
||||
var mu sync.Mutex
|
||||
var got [][]byte
|
||||
node.Receive(func(p []byte) {
|
||||
mu.Lock()
|
||||
got = append(got, append([]byte(nil), p...))
|
||||
mu.Unlock()
|
||||
})
|
||||
|
||||
payload := []byte("must not be readable by the wrong context")
|
||||
if err := client.Send(payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
mu.Lock()
|
||||
n := len(got)
|
||||
mu.Unlock()
|
||||
if n != 0 {
|
||||
t.Fatalf("node decoded %d packets despite a different KDF context", n)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type testTransport struct {
|
||||
receiver func([]byte)
|
||||
sent []byte
|
||||
}
|
||||
|
||||
func (t *testTransport) Start() error { return nil }
|
||||
func (t *testTransport) Stop() error { return nil }
|
||||
func (t *testTransport) IsConnected() bool { return true }
|
||||
func (t *testTransport) Stats() TransportStats { return TransportStats{} }
|
||||
func (t *testTransport) Receive(callback func([]byte)) { t.receiver = callback }
|
||||
func (t *testTransport) Send(data []byte) error { t.sent = append([]byte(nil), data...); return nil }
|
||||
func (t *testTransport) deliver(data []byte) {
|
||||
if t.receiver != nil {
|
||||
t.receiver(data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptedTransportRoundTrip(t *testing.T) {
|
||||
clientWire := &testTransport{}
|
||||
exitWire := &testTransport{}
|
||||
client, err := NewEncryptedTransport(clientWire, "a sufficiently long shared secret", "document", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exitNode, err := NewEncryptedTransport(exitWire, "a sufficiently long shared secret", "document", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want := []byte("private IPv4 packet")
|
||||
var got []byte
|
||||
exitNode.Receive(func(data []byte) { got = append([]byte(nil), data...) })
|
||||
if err := client.Send(want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bytes.Contains(clientWire.sent, want) {
|
||||
t.Fatal("ciphertext contains plaintext")
|
||||
}
|
||||
exitWire.deliver(clientWire.sent)
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Fatalf("received %q, want %q", got, want)
|
||||
}
|
||||
|
||||
reply := []byte("private response")
|
||||
got = nil
|
||||
client.Receive(func(data []byte) { got = append([]byte(nil), data...) })
|
||||
if err := exitNode.Send(reply); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clientWire.deliver(exitWire.sent)
|
||||
if !bytes.Equal(got, reply) {
|
||||
t.Fatalf("received %q, want %q", got, reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptedTransportRejectsWrongKeyTamperingAndReplay(t *testing.T) {
|
||||
wire := &testTransport{}
|
||||
client, err := NewEncryptedTransport(wire, "first sufficiently long secret", "document", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := client.Send([]byte("packet")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
wrongWire := &testTransport{}
|
||||
wrongExit, err := NewEncryptedTransport(wrongWire, "other sufficiently long secret", "document", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
called := 0
|
||||
wrongExit.Receive(func([]byte) { called++ })
|
||||
wrongWire.deliver(wire.sent)
|
||||
if called != 0 {
|
||||
t.Fatal("wrong key was accepted")
|
||||
}
|
||||
|
||||
rightWire := &testTransport{}
|
||||
rightExit, err := NewEncryptedTransport(rightWire, "first sufficiently long secret", "document", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rightExit.Receive(func([]byte) { called++ })
|
||||
tampered := append([]byte(nil), wire.sent...)
|
||||
tampered[len(tampered)-1] ^= 1
|
||||
rightWire.deliver(tampered)
|
||||
if called != 0 {
|
||||
t.Fatal("tampered packet was accepted")
|
||||
}
|
||||
rightWire.deliver(wire.sent)
|
||||
rightWire.deliver(wire.sent)
|
||||
if called != 1 {
|
||||
t.Fatalf("replayed packet delivered %d times, want 1", called)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptedTransportRequiresStrongSecret(t *testing.T) {
|
||||
if _, err := NewEncryptedTransport(&testTransport{}, "too short", "document", false); err == nil {
|
||||
t.Fatal("short secret was accepted")
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,11 @@ const mailruUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWe
|
||||
|
||||
var cursorPayloadRe = regexp.MustCompile(`"cursor":"[^;]+;([^"]+)"`)
|
||||
|
||||
// mailruAPIURL is Mail.ru's public-document editor endpoint. A var rather than
|
||||
// a const purely so tests can point the transport at a local stub instead of
|
||||
// dialing cloud.mail.ru for real.
|
||||
var mailruAPIURL = "https://cloud.mail.ru/api/v4/r7/edit"
|
||||
|
||||
type MailruDocsInfo struct {
|
||||
Token string
|
||||
DocKey string
|
||||
@@ -437,7 +442,7 @@ func (t *MailruDocsTransport) fetchDocInfo(weblink string) (MailruDocsInfo, erro
|
||||
}
|
||||
jsonData, _ := json.Marshal(reqBody)
|
||||
|
||||
apiURL := "https://cloud.mail.ru/api/v4/r7/edit"
|
||||
apiURL := mailruAPIURL
|
||||
utils.Debugf("[M-DOCS] fetchDocInfo POST %s", apiURL)
|
||||
|
||||
req, _ := http.NewRequest("POST", apiURL, bytes.NewBuffer(jsonData))
|
||||
@@ -496,7 +501,7 @@ func (t *MailruDocsTransport) fetchDocInfo(weblink string) (MailruDocsInfo, erro
|
||||
editorUserID, _ = userObj["id"].(string)
|
||||
}
|
||||
|
||||
wsBase := strings.Replace(apiBase, "https://", "wss://", 1)
|
||||
wsBase := wsBaseFrom(apiBase)
|
||||
wsURL := fmt.Sprintf("%s/doc/%s/c/?EIO=4&transport=websocket", wsBase, docKey)
|
||||
|
||||
return MailruDocsInfo{
|
||||
@@ -512,6 +517,19 @@ func (t *MailruDocsTransport) fetchDocInfo(weblink string) (MailruDocsInfo, erro
|
||||
}, nil
|
||||
}
|
||||
|
||||
// wsBaseFrom maps the API base returned by the editor API onto its WebSocket
|
||||
// scheme. Production always hands back https; http is accepted so a local stub
|
||||
// can be dialed in tests.
|
||||
func wsBaseFrom(apiBase string) string {
|
||||
switch {
|
||||
case strings.HasPrefix(apiBase, "https://"):
|
||||
return "wss://" + strings.TrimPrefix(apiBase, "https://")
|
||||
case strings.HasPrefix(apiBase, "http://"):
|
||||
return "ws://" + strings.TrimPrefix(apiBase, "http://")
|
||||
}
|
||||
return apiBase
|
||||
}
|
||||
|
||||
func randUserID() string {
|
||||
return fmt.Sprintf("%010d", rand.New(rand.NewSource(time.Now().UnixNano())).Intn(1000000000))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
package mailru
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"universal-bypass-tool/transport"
|
||||
)
|
||||
|
||||
const testWeblink = "AbCdEfGh1/IjKlMnOp2"
|
||||
|
||||
func testConfig() transport.TransportConfig {
|
||||
cfg := transport.DefaultConfig()
|
||||
cfg.KeepAliveInterval = 500 * time.Millisecond
|
||||
return cfg
|
||||
}
|
||||
|
||||
// collector records everything a transport hands up to its user callback.
|
||||
type collector struct {
|
||||
mu sync.Mutex
|
||||
pkts [][]byte
|
||||
}
|
||||
|
||||
func (c *collector) cb(p []byte) {
|
||||
c.mu.Lock()
|
||||
c.pkts = append(c.pkts, append([]byte(nil), p...))
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *collector) all() [][]byte {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return append([][]byte(nil), c.pkts...)
|
||||
}
|
||||
|
||||
func (c *collector) has(want []byte) bool {
|
||||
for _, p := range c.all() {
|
||||
if bytes.Equal(p, want) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func waitFor(d time.Duration, cond func() bool) bool {
|
||||
deadline := time.Now().Add(d)
|
||||
for time.Now().Before(deadline) {
|
||||
if cond() {
|
||||
return true
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
return cond()
|
||||
}
|
||||
|
||||
func (c *stubCloud) participantCount() int {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return len(c.userSeq)
|
||||
}
|
||||
|
||||
func TestWsBaseFrom(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"https://r7.mail.ru": "wss://r7.mail.ru",
|
||||
"http://127.0.0.1:1": "ws://127.0.0.1:1",
|
||||
"r7.mail.ru": "r7.mail.ru",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := wsBaseFrom(in); got != want {
|
||||
t.Errorf("wsBaseFrom(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeWeblink(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"AbCdEfGh1/IjKlMnOp2": "AbCdEfGh1/IjKlMnOp2",
|
||||
"https://cloud.mail.ru/public/AbCdEfGh1/IjKlMnOp2": "AbCdEfGh1/IjKlMnOp2",
|
||||
"http://cloud.mail.ru/AbCdEfGh1/IjKlMnOp2/": "AbCdEfGh1/IjKlMnOp2",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := normalizeWeblink(in); got != want {
|
||||
t.Errorf("normalizeWeblink(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchDocInfo(t *testing.T) {
|
||||
c := newStubCloud(t)
|
||||
c.install(t)
|
||||
|
||||
tr := NewMailruDocsTransport(testWeblink, testConfig())
|
||||
info, err := tr.fetchDocInfo(testWeblink)
|
||||
if err != nil {
|
||||
t.Fatalf("fetchDocInfo: %v", err)
|
||||
}
|
||||
if info.Token != "stub-token" || info.DocKey != "stubdoc" {
|
||||
t.Errorf("token/key = %q/%q", info.Token, info.DocKey)
|
||||
}
|
||||
if info.EditorUserID != "editor-user-1" || info.CallbackURL != "https://stub/callback" {
|
||||
t.Errorf("editorConfig fields = %q/%q", info.EditorUserID, info.CallbackURL)
|
||||
}
|
||||
// permissions must survive as an object: a number here makes the real
|
||||
// editor server answer "access deny".
|
||||
if v, ok := info.Permissions["edit"].(bool); !ok || !v {
|
||||
t.Errorf("permissions = %#v, want edit:true", info.Permissions)
|
||||
}
|
||||
wantWs := strings.Replace(c.srv.URL, "http://", "ws://", 1) + "/doc/stubdoc/c/?EIO=4&transport=websocket"
|
||||
if info.WsURL != wantWs {
|
||||
t.Errorf("WsURL = %q, want %q", info.WsURL, wantWs)
|
||||
}
|
||||
}
|
||||
|
||||
// startPair brings up two transports on the same stub document and waits until
|
||||
// the coauthoring server has seen both participants.
|
||||
func startPair(t *testing.T, c *stubCloud, wrap func(transport.Transport) transport.Transport) (transport.Transport, transport.Transport, *collector, *collector) {
|
||||
t.Helper()
|
||||
|
||||
mk := func() (transport.Transport, *collector) {
|
||||
var tr transport.Transport = NewMailruDocsTransport(testWeblink, testConfig())
|
||||
if wrap != nil {
|
||||
tr = wrap(tr)
|
||||
}
|
||||
col := &collector{}
|
||||
tr.Receive(col.cb)
|
||||
if err := tr.Start(); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = tr.Stop() })
|
||||
return tr, col
|
||||
}
|
||||
|
||||
a, colA := mk()
|
||||
if !waitFor(5*time.Second, func() bool { return c.participantCount() >= 1 }) {
|
||||
t.Fatal("first participant never authenticated")
|
||||
}
|
||||
b, colB := mk()
|
||||
if !waitFor(5*time.Second, func() bool { return c.participantCount() >= 2 }) {
|
||||
t.Fatal("second participant never authenticated")
|
||||
}
|
||||
if !waitFor(5*time.Second, func() bool { return a.IsConnected() && b.IsConnected() }) {
|
||||
t.Fatal("transports never reported connected")
|
||||
}
|
||||
return a, b, colA, colB
|
||||
}
|
||||
|
||||
func TestAuthHandshakeShape(t *testing.T) {
|
||||
c := newStubCloud(t)
|
||||
c.install(t)
|
||||
startPair(t, c, nil)
|
||||
|
||||
msgs := c.controlMessages()
|
||||
var sawSocketIO, sawAuth bool
|
||||
for _, m := range msgs {
|
||||
if strings.HasPrefix(m, `40{"token":"stub-token"}`) {
|
||||
sawSocketIO = true
|
||||
}
|
||||
if strings.Contains(m, `"type":"auth"`) {
|
||||
sawAuth = true
|
||||
for _, want := range []string{`"docid":"stubdoc"`, `"mode":"edit"`, `"coEditingMode":"fast"`, `"jwtOpen":"stub-token"`} {
|
||||
if !strings.Contains(m, want) {
|
||||
t.Errorf("auth message missing %s:\n%s", want, m)
|
||||
}
|
||||
}
|
||||
if strings.Contains(m, `"permissions":1`) || strings.Contains(m, `"permissions":0`) {
|
||||
t.Errorf("permissions sent as a number, editor server would deny:\n%s", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !sawSocketIO || !sawAuth {
|
||||
t.Fatalf("handshake incomplete: socket.io=%v auth=%v (%d control frames)", sawSocketIO, sawAuth, len(msgs))
|
||||
}
|
||||
}
|
||||
|
||||
// TestCursorRoundTrip is the bare carrier: no codec on top, one peer's bytes
|
||||
// must arrive at the other verbatim through the "cursor" field.
|
||||
func TestCursorRoundTrip(t *testing.T) {
|
||||
c := newStubCloud(t)
|
||||
c.install(t)
|
||||
a, _, _, colB := startPair(t, c, nil)
|
||||
|
||||
payload := []byte{0x45, 0x00, 0xde, 0xad, 0xbe, 0xef, 0x00, 0xff}
|
||||
ok := waitFor(5*time.Second, func() bool {
|
||||
_ = a.Send(payload)
|
||||
return colB.has(payload)
|
||||
})
|
||||
if !ok {
|
||||
t.Fatalf("peer never received the payload; got %d packets", len(colB.all()))
|
||||
}
|
||||
}
|
||||
|
||||
// TestKeepAliveNotDelivered guards the ---KA--- marker: it shares the cursor
|
||||
// field with real data and must never reach the tunnel.
|
||||
func TestKeepAliveNotDelivered(t *testing.T) {
|
||||
c := newStubCloud(t)
|
||||
c.install(t)
|
||||
_, _, colA, colB := startPair(t, c, nil)
|
||||
|
||||
time.Sleep(1500 * time.Millisecond) // several keep-alive ticks at 500ms
|
||||
|
||||
for _, col := range []*collector{colA, colB} {
|
||||
for _, p := range col.all() {
|
||||
if bytes.Contains(p, []byte("---KA---")) {
|
||||
t.Fatalf("keep-alive leaked into the tunnel: %q", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// negotiationResult is what the handshake tests report.
|
||||
type negotiationResult struct {
|
||||
batchA, batchB bool
|
||||
probeA, probeB bool
|
||||
settledAt time.Duration // time to the two-sided handshake; 0 = never
|
||||
rxA, rxB int
|
||||
dials int
|
||||
}
|
||||
|
||||
// runNegotiation puts an AdaptiveTransport on each side, drives real traffic
|
||||
// for the given window, and reports whether the codec handshake completed —
|
||||
// i.e. whether each side ever put a batch frame (0x02) on the wire.
|
||||
// stopWhenSettled returns as soon as it does; otherwise the full window is used
|
||||
// to see whether the link stays healthy afterwards.
|
||||
func runNegotiation(t *testing.T, c *stubCloud, window time.Duration, stopWhenSettled bool) negotiationResult {
|
||||
t.Helper()
|
||||
wrap := func(inner transport.Transport) transport.Transport {
|
||||
return transport.NewAdaptiveTransport(inner)
|
||||
}
|
||||
a, b, colA, colB := startPair(t, c, wrap)
|
||||
|
||||
var res negotiationResult
|
||||
start := time.Now()
|
||||
deadline := start.Add(window)
|
||||
for i := 0; time.Now().Before(deadline); i++ {
|
||||
_ = a.Send([]byte{0x45, 0x00, byte(i), 0xaa})
|
||||
_ = b.Send([]byte{0x45, 0x00, byte(i), 0xbb})
|
||||
|
||||
if res.settledAt == 0 && c.sentBatchFrame(0) && c.sentBatchFrame(1) {
|
||||
res.settledAt = time.Since(start)
|
||||
if stopWhenSettled && len(colA.all()) > 0 && len(colB.all()) > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
|
||||
res.batchA = c.sentBatchFrame(0)
|
||||
res.batchB = c.sentBatchFrame(1)
|
||||
res.probeA = c.sentProbe(0)
|
||||
res.probeB = c.sentProbe(1)
|
||||
res.rxA = len(colA.all())
|
||||
res.rxB = len(colB.all())
|
||||
res.dials = c.dialCount()
|
||||
return res
|
||||
}
|
||||
|
||||
// TestNegotiationHandshake is the baseline: two healthy peers must leave legacy
|
||||
// and settle on the batch codec within a few probe intervals.
|
||||
func TestNegotiationHandshake(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("probe interval is 3s")
|
||||
}
|
||||
c := newStubCloud(t)
|
||||
c.install(t)
|
||||
|
||||
r := runNegotiation(t, c, 15*time.Second, true)
|
||||
t.Logf("baseline: batchA=%v batchB=%v probeA=%v probeB=%v settled=%v rxA=%d rxB=%d dials=%d",
|
||||
r.batchA, r.batchB, r.probeA, r.probeB, r.settledAt, r.rxA, r.rxB, r.dials)
|
||||
|
||||
if !r.batchA || !r.batchB {
|
||||
t.Errorf("codec handshake did not complete: batchA=%v batchB=%v", r.batchA, r.batchB)
|
||||
}
|
||||
if r.rxA == 0 || r.rxB == 0 {
|
||||
t.Errorf("no traffic crossed: rxA=%d rxB=%d", r.rxA, r.rxB)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNegotiationWithLatentPeer models a peer that is merely late: every frame
|
||||
// reaches it 5s after the fact — past both the 3s probe interval and the 4s
|
||||
// optimistic-upgrade window — but nothing is rate-limited. Negotiation should
|
||||
// still converge, just later.
|
||||
func TestNegotiationWithLatentPeer(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("takes ~25s")
|
||||
}
|
||||
c := newStubCloud(t)
|
||||
c.delayFor = func(idx int) time.Duration {
|
||||
if idx == 1 {
|
||||
return 5 * time.Second
|
||||
}
|
||||
return 0
|
||||
}
|
||||
c.install(t)
|
||||
|
||||
r := runNegotiation(t, c, 25*time.Second, true)
|
||||
t.Logf("latent peer: batchA=%v batchB=%v probeA=%v probeB=%v settled=%v rxA=%d rxB=%d dials=%d",
|
||||
r.batchA, r.batchB, r.probeA, r.probeB, r.settledAt, r.rxA, r.rxB, r.dials)
|
||||
|
||||
if !r.batchA || !r.batchB {
|
||||
t.Errorf("codec handshake did not complete under pure latency: batchA=%v batchB=%v", r.batchA, r.batchB)
|
||||
}
|
||||
if r.rxA == 0 || r.rxB == 0 {
|
||||
t.Errorf("no traffic crossed: rxA=%d rxB=%d", r.rxA, r.rxB)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNegotiationWithThrottledPeer models the low-priority process on the VM:
|
||||
// it is scheduled rarely enough that it only gets through a couple of frames a
|
||||
// second, so its inbound stream head-of-line blocks behind the peer's early
|
||||
// legacy traffic. This is the case the handshake is expected to lose.
|
||||
func TestNegotiationWithThrottledPeer(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("takes ~30s")
|
||||
}
|
||||
c := newStubCloud(t)
|
||||
c.throttleFor = func(idx int) time.Duration {
|
||||
if idx == 1 {
|
||||
return 2 * time.Second
|
||||
}
|
||||
return 0
|
||||
}
|
||||
c.install(t)
|
||||
|
||||
r := runNegotiation(t, c, 30*time.Second, true)
|
||||
t.Logf("throttled peer: batchA=%v batchB=%v probeA=%v probeB=%v settled=%v rxA=%d rxB=%d dials=%d",
|
||||
r.batchA, r.batchB, r.probeA, r.probeB, r.settledAt, r.rxA, r.rxB, r.dials)
|
||||
|
||||
if !r.batchA || !r.batchB {
|
||||
t.Errorf("codec handshake did not complete under CPU starvation: batchA=%v batchB=%v", r.batchA, r.batchB)
|
||||
}
|
||||
if r.rxA == 0 || r.rxB == 0 {
|
||||
t.Errorf("no traffic crossed: rxA=%d rxB=%d", r.rxA, r.rxB)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNegotiationUnderReconnectChurn models the same starvation the other way
|
||||
// round: the process is descheduled long enough to lose its socket, so it
|
||||
// reconnects constantly. probeLoop only fires while IsConnected, and the
|
||||
// backoff grows on every short-lived session, so this is where the handshake is
|
||||
// expected to stall.
|
||||
func TestNegotiationUnderReconnectChurn(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("takes ~30s")
|
||||
}
|
||||
c := newStubCloud(t)
|
||||
c.dropAfter = func(idx int) time.Duration {
|
||||
if idx == 1 {
|
||||
return 1200 * time.Millisecond
|
||||
}
|
||||
return 0
|
||||
}
|
||||
c.install(t)
|
||||
|
||||
// Run the whole window: the interesting part is not only whether the
|
||||
// handshake ever happens but whether the link survives the churn after it.
|
||||
r := runNegotiation(t, c, 25*time.Second, false)
|
||||
t.Logf("reconnect churn: batchA=%v batchB=%v probeA=%v probeB=%v settled=%v rxA=%d rxB=%d dials=%d",
|
||||
r.batchA, r.batchB, r.probeA, r.probeB, r.settledAt, r.rxA, r.rxB, r.dials)
|
||||
|
||||
if r.dials < 4 {
|
||||
t.Fatalf("churn never materialised: only %d dials", r.dials)
|
||||
}
|
||||
if !r.batchA || !r.batchB {
|
||||
t.Errorf("codec handshake did not complete under reconnect churn: batchA=%v batchB=%v", r.batchA, r.batchB)
|
||||
}
|
||||
if r.rxA == 0 || r.rxB == 0 {
|
||||
t.Errorf("link dead under churn: rxA=%d rxB=%d", r.rxA, r.rxB)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
package mailru
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// stubCloud stands in for cloud.mail.ru: the editor API that hands out a token
|
||||
// and a WebSocket base, plus the coauthoring socket itself. The socket models
|
||||
// the one property the transport relies on — a cursor message is broadcast to
|
||||
// the OTHER participants and never echoed back to its sender.
|
||||
//
|
||||
// Participants are identified by the username in their auth message, not by
|
||||
// connection, so a peer keeps its identity across reconnects. Two knobs model a
|
||||
// process that the VM scheduler is starving:
|
||||
//
|
||||
// delayFor — pure latency: that peer sees every frame N later, order kept
|
||||
// throttleFor — pure starvation: that peer is fed at most one frame per N
|
||||
// dropAfter — that peer's socket is torn down every N, forcing reconnect churn
|
||||
type stubCloud struct {
|
||||
srv *httptest.Server
|
||||
|
||||
// Set before the first dial; idx is the participant's first-seen order.
|
||||
delayFor func(idx int) time.Duration
|
||||
throttleFor func(idx int) time.Duration
|
||||
dropAfter func(idx int) time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
conns []*stubConn
|
||||
userSeq map[string]int
|
||||
frames []stubFrame
|
||||
ctrl []string
|
||||
dials int
|
||||
}
|
||||
|
||||
type stubFrame struct {
|
||||
from int // participant index, -1 before auth
|
||||
data []byte
|
||||
}
|
||||
|
||||
// stubOut carries the enqueue time so delivery delay is modelled as latency
|
||||
// (deliver at enqueue+delay) rather than as a sleep before every write, which
|
||||
// would silently be a throttle instead.
|
||||
type stubOut struct {
|
||||
data []byte
|
||||
at time.Time
|
||||
}
|
||||
|
||||
type stubConn struct {
|
||||
conn *websocket.Conn
|
||||
out chan stubOut
|
||||
|
||||
mu sync.Mutex
|
||||
part int // participant index, -1 until auth seen
|
||||
}
|
||||
|
||||
func (s *stubConn) participant() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.part
|
||||
}
|
||||
|
||||
var (
|
||||
stubUpgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(*http.Request) bool { return true },
|
||||
}
|
||||
stubUsernameRe = regexp.MustCompile(`"username":"([^"]+)"`)
|
||||
)
|
||||
|
||||
func newStubCloud(t *testing.T) *stubCloud {
|
||||
t.Helper()
|
||||
c := &stubCloud{userSeq: map[string]int{}}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v4/r7/edit", c.handleEdit)
|
||||
mux.HandleFunc("/doc/", c.handleSocket)
|
||||
c.srv = httptest.NewServer(mux)
|
||||
|
||||
t.Cleanup(c.srv.Close)
|
||||
return c
|
||||
}
|
||||
|
||||
// install points the transport package at this stub for the duration of a test.
|
||||
func (c *stubCloud) install(t *testing.T) {
|
||||
t.Helper()
|
||||
prev := mailruAPIURL
|
||||
mailruAPIURL = c.srv.URL + "/api/v4/r7/edit"
|
||||
t.Cleanup(func() { mailruAPIURL = prev })
|
||||
}
|
||||
|
||||
func (c *stubCloud) handleEdit(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
resp := map[string]interface{}{
|
||||
"api": c.srv.URL,
|
||||
"token": "stub-token",
|
||||
"document": map[string]interface{}{
|
||||
"key": "stubdoc",
|
||||
"fileType": "docx",
|
||||
"url": "https://stub/doc.docx",
|
||||
"title": "stub.docx",
|
||||
"permissions": map[string]interface{}{"edit": true, "download": false},
|
||||
},
|
||||
"editorConfig": map[string]interface{}{
|
||||
"callbackUrl": "https://stub/callback",
|
||||
"user": map[string]interface{}{"id": "editor-user-1"},
|
||||
},
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
func (c *stubCloud) handleSocket(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := stubUpgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
sc := &stubConn{conn: conn, out: make(chan stubOut, 256), part: -1}
|
||||
c.mu.Lock()
|
||||
c.conns = append(c.conns, sc)
|
||||
c.dials++
|
||||
c.mu.Unlock()
|
||||
|
||||
go c.writePump(sc)
|
||||
|
||||
defer func() {
|
||||
c.mu.Lock()
|
||||
for i, x := range c.conns {
|
||||
if x == sc {
|
||||
c.conns = append(c.conns[:i], c.conns[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
close(sc.out)
|
||||
conn.Close()
|
||||
}()
|
||||
|
||||
for {
|
||||
_, msg, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
c.onMessage(sc, msg)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *stubCloud) writePump(sc *stubConn) {
|
||||
var lastWrite time.Time
|
||||
for msg := range sc.out {
|
||||
part := sc.participant()
|
||||
if d := c.delay(part); d > 0 {
|
||||
if wait := time.Until(msg.at.Add(d)); wait > 0 {
|
||||
time.Sleep(wait)
|
||||
}
|
||||
}
|
||||
if g := c.gap(part); g > 0 && !lastWrite.IsZero() {
|
||||
if wait := time.Until(lastWrite.Add(g)); wait > 0 {
|
||||
time.Sleep(wait)
|
||||
}
|
||||
}
|
||||
if err := sc.conn.WriteMessage(websocket.TextMessage, msg.data); err != nil {
|
||||
return
|
||||
}
|
||||
lastWrite = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *stubCloud) delay(part int) time.Duration {
|
||||
if c.delayFor == nil || part < 0 {
|
||||
return 0
|
||||
}
|
||||
return c.delayFor(part)
|
||||
}
|
||||
|
||||
func (c *stubCloud) gap(part int) time.Duration {
|
||||
if c.throttleFor == nil || part < 0 {
|
||||
return 0
|
||||
}
|
||||
return c.throttleFor(part)
|
||||
}
|
||||
|
||||
// bindParticipant resolves the auth message's username to a stable participant
|
||||
// index and arms the drop timer the first time this connection is identified.
|
||||
func (c *stubCloud) bindParticipant(sc *stubConn, text string) {
|
||||
m := stubUsernameRe.FindStringSubmatch(text)
|
||||
if len(m) < 2 {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
idx, ok := c.userSeq[m[1]]
|
||||
if !ok {
|
||||
idx = len(c.userSeq)
|
||||
c.userSeq[m[1]] = idx
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
sc.mu.Lock()
|
||||
sc.part = idx
|
||||
sc.mu.Unlock()
|
||||
|
||||
if c.dropAfter != nil {
|
||||
if d := c.dropAfter(idx); d > 0 {
|
||||
time.AfterFunc(d, func() { sc.conn.Close() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *stubCloud) onMessage(from *stubConn, msg []byte) {
|
||||
text := string(msg)
|
||||
|
||||
if !strings.Contains(text, `"type":"cursor"`) {
|
||||
if strings.Contains(text, `"type":"auth"`) {
|
||||
c.bindParticipant(from, text)
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.ctrl = append(c.ctrl, text)
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
// Record the decoded payload so a test can inspect what actually went on
|
||||
// the wire — in particular the codec family byte. Keep-alives carry a
|
||||
// non-base64 marker and fall out here.
|
||||
if m := cursorPayloadRe.FindStringSubmatch(text); len(m) > 1 {
|
||||
if data, err := base64.StdEncoding.DecodeString(m[1]); err == nil {
|
||||
c.mu.Lock()
|
||||
c.frames = append(c.frames, stubFrame{from: from.participant(), data: data})
|
||||
c.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
src := from.participant()
|
||||
c.mu.Lock()
|
||||
dsts := make([]*stubConn, 0, len(c.conns))
|
||||
for _, sc := range c.conns {
|
||||
if sc != from && sc.participant() != src {
|
||||
dsts = append(dsts, sc)
|
||||
}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
for _, dst := range dsts {
|
||||
select {
|
||||
case dst.out <- stubOut{data: append([]byte(nil), msg...), at: time.Now()}:
|
||||
default: // slow participant: drop, exactly as a real bounded queue would
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// framesFrom returns the payloads participant idx put on the wire.
|
||||
func (c *stubCloud) framesFrom(idx int) [][]byte {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
var out [][]byte
|
||||
for _, f := range c.frames {
|
||||
if f.from == idx {
|
||||
out = append(out, f.data)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sentBatchFrame reports whether participant idx ever emitted a batch frame
|
||||
// CARRYING DATA — i.e. whether the codec negotiation actually completed on that
|
||||
// side. The capability probe is also a 0x02 frame, but an empty one, exactly
|
||||
// two bytes ([version, flags]); counting it would report a handshake that never
|
||||
// happened, since every side probes unconditionally.
|
||||
func (c *stubCloud) sentBatchFrame(idx int) bool {
|
||||
for _, f := range c.framesFrom(idx) {
|
||||
if len(f) > 2 && f[0] == 0x02 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// sentProbe reports whether participant idx got an empty capability probe out —
|
||||
// the precondition for the peer ever upgrading.
|
||||
func (c *stubCloud) sentProbe(idx int) bool {
|
||||
for _, f := range c.framesFrom(idx) {
|
||||
if len(f) == 2 && f[0] == 0x02 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// controlMessages returns the non-cursor frames (the socket.io auth handshake).
|
||||
func (c *stubCloud) controlMessages() []string {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return append([]string(nil), c.ctrl...)
|
||||
}
|
||||
|
||||
func (c *stubCloud) dialCount() int {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.dials
|
||||
}
|
||||
@@ -54,16 +54,27 @@ type BaseTransport struct {
|
||||
Mu sync.RWMutex
|
||||
|
||||
reconnectAttempts atomic.Int32
|
||||
|
||||
// done закрывается в Stop(), чтобы ожидания в подклассах (бэкоффы,
|
||||
// пауза на капче) прерывались сразу, а не досыпали свой интервал.
|
||||
// Отдельный мьютекс, а не Mu: подклассы держат Mu вокруг своей сессии и
|
||||
// зовут Stop() из-под него — общий мьютекс дал бы взаимную блокировку.
|
||||
doneMu sync.Mutex
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func NewBaseTransport(config TransportConfig) *BaseTransport {
|
||||
return &BaseTransport{
|
||||
config: config,
|
||||
startTime: time.Now(),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (b *BaseTransport) Start() error {
|
||||
b.doneMu.Lock()
|
||||
b.done = make(chan struct{})
|
||||
b.doneMu.Unlock()
|
||||
b.running.Store(1)
|
||||
b.startTime = time.Now()
|
||||
return nil
|
||||
@@ -72,9 +83,30 @@ func (b *BaseTransport) Start() error {
|
||||
func (b *BaseTransport) Stop() error {
|
||||
b.running.Store(0)
|
||||
b.connected.Store(0)
|
||||
b.doneMu.Lock()
|
||||
if b.done != nil {
|
||||
select {
|
||||
case <-b.done: // уже закрыт
|
||||
default:
|
||||
close(b.done)
|
||||
}
|
||||
}
|
||||
b.doneMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Done возвращает канал, который закрывается при Stop(). Подклассы селектятся
|
||||
// на нём, чтобы прервать сон.
|
||||
func (b *BaseTransport) Done() <-chan struct{} {
|
||||
b.doneMu.Lock()
|
||||
d := b.done
|
||||
b.doneMu.Unlock()
|
||||
if d == nil {
|
||||
return make(chan struct{}) // не стартовали — блокируется навсегда
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func (b *BaseTransport) IsRunning() bool {
|
||||
return b.running.Load() == 1
|
||||
}
|
||||
|
||||
@@ -96,7 +96,8 @@ func solveCaptcha(docURL string, jar http.CookieJar, userAgent string) (string,
|
||||
}
|
||||
utils.Debugf("[CAPTCHA] showcaptcha: %d bytes", len(body))
|
||||
|
||||
ssr, formAction, err := parseCaptchaHTML(string(body))
|
||||
origin := captchaOrigin(captchaURL)
|
||||
ssr, formAction, err := parseCaptchaHTML(string(body), origin)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -123,7 +124,7 @@ func solveCaptcha(docURL string, jar http.CookieJar, userAgent string) (string,
|
||||
req2, _ := http.NewRequest("POST", formAction, strings.NewReader(form.Encode()))
|
||||
setBrowserHeaders(req2, userAgent)
|
||||
req2.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req2.Header.Set("Origin", "https://docs.yandex.ru")
|
||||
req2.Header.Set("Origin", origin)
|
||||
req2.Header.Set("Referer", captchaURL)
|
||||
|
||||
resp2, err := client.Do(req2)
|
||||
@@ -166,7 +167,18 @@ var (
|
||||
reFormAction = regexp.MustCompile(`<form[^>]*id="tmgrdfrend-form"[^>]*action="([^"]+)"`)
|
||||
)
|
||||
|
||||
func parseCaptchaHTML(html string) (*captchaSSRData, string, error) {
|
||||
// captchaOrigin возвращает scheme://host страницы капчи. Домен НЕЛЬЗЯ
|
||||
// хардкодить: документ может жить и на docs.yandex.ru, и на docs.yandex.com —
|
||||
// POST формы на чужой домен сервер отвергает с 400.
|
||||
func captchaOrigin(pageURL string) string {
|
||||
u, err := url.Parse(pageURL)
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
return "https://docs.yandex.ru"
|
||||
}
|
||||
return u.Scheme + "://" + u.Host
|
||||
}
|
||||
|
||||
func parseCaptchaHTML(html, origin string) (*captchaSSRData, string, error) {
|
||||
m := reSSRData.FindStringSubmatch(html)
|
||||
if len(m) < 2 {
|
||||
return nil, "", fmt.Errorf("captcha: __SSR_DATA__ not found")
|
||||
@@ -186,7 +198,7 @@ func parseCaptchaHTML(html string) (*captchaSSRData, string, error) {
|
||||
}
|
||||
formAction := strings.ReplaceAll(m2[1], "&", "&")
|
||||
if strings.HasPrefix(formAction, "/") {
|
||||
formAction = "https://docs.yandex.ru" + formAction
|
||||
formAction = origin + formAction
|
||||
}
|
||||
|
||||
return &ssr, formAction, nil
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package yandex
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A redirect to showcaptcha?cc=1 (SmartCaptcha, second tier) must be returned
|
||||
// as ErrCaptchaRequired, not as a generic error.
|
||||
func TestFetchDocInfoSmartCaptchaReturnsSentinel(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/doc":
|
||||
http.Redirect(w, r, "/showcaptcha?cc=1&form-fb-hint=2.73&mt=deadbeef", http.StatusFound)
|
||||
case "/showcaptcha":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("<html><body>captcha</body></html>"))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tr := &YandexDocsTransport{url: srv.URL + "/doc"}
|
||||
_, err := tr.fetchDocInfo(srv.URL+"/doc", "0000000001")
|
||||
if !errors.Is(err, ErrCaptchaRequired) {
|
||||
t.Fatalf("expected ErrCaptchaRequired, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A redirect to passport.yandex.ru (login page) must be returned as
|
||||
// ErrLoginRequired.
|
||||
func TestFetchDocInfoLoginPageReturnsSentinel(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/doc":
|
||||
http.Redirect(w, r, "/passport.yandex/auth", http.StatusFound)
|
||||
case "/passport.yandex/auth":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("<html>login</html>"))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tr := &YandexDocsTransport{url: srv.URL + "/doc"}
|
||||
_, err := tr.fetchDocInfo(srv.URL+"/doc", "0000000001")
|
||||
if !errors.Is(err, ErrLoginRequired) {
|
||||
t.Fatalf("expected ErrLoginRequired, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package yandex
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"universal-bypass-tool/transport"
|
||||
)
|
||||
|
||||
// captchaDoc serves a document that always redirects to SmartCaptcha and
|
||||
// reports each fetch on hits.
|
||||
func captchaDoc(t *testing.T) (url string, hits chan time.Time) {
|
||||
t.Helper()
|
||||
hits = make(chan time.Time, 16)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/doc" {
|
||||
hits <- time.Now()
|
||||
http.Redirect(w, r, "/showcaptcha?cc=1", http.StatusFound)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv.URL + "/doc", hits
|
||||
}
|
||||
|
||||
func waitHit(t *testing.T, hits chan time.Time, within time.Duration) time.Time {
|
||||
t.Helper()
|
||||
select {
|
||||
case at := <-hits:
|
||||
return at
|
||||
case <-time.After(within):
|
||||
t.Fatalf("no document fetch within %v", within)
|
||||
return time.Time{}
|
||||
}
|
||||
}
|
||||
|
||||
func captchaWaiters() int {
|
||||
buf := make([]byte, 1<<20)
|
||||
return strings.Count(string(buf[:runtime.Stack(buf, true)]), "scheduleReconnectNoCaptcha")
|
||||
}
|
||||
|
||||
func startCaptchaTransport(t *testing.T, url string) (*YandexDocsTransport, *atomic.Int32) {
|
||||
t.Helper()
|
||||
tr := NewYandexDocsTransport(url, transport.DefaultConfig())
|
||||
var notified atomic.Int32
|
||||
tr.SetErrorNotifier(func(error, string, string, string) { notified.Add(1) })
|
||||
if err := tr.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return tr, ¬ified
|
||||
}
|
||||
|
||||
func TestStopInterruptsCaptchaWait(t *testing.T) {
|
||||
url, hits := captchaDoc(t)
|
||||
tr, notified := startCaptchaTransport(t, url)
|
||||
waitHit(t, hits, 5*time.Second)
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for captchaWaiters() == 0 && time.Now().Before(deadline) {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if notified.Load() == 0 || captchaWaiters() == 0 {
|
||||
t.Fatal("transport did not enter the captcha wait")
|
||||
}
|
||||
|
||||
_ = tr.Stop()
|
||||
deadline = time.Now().Add(2 * time.Second)
|
||||
for captchaWaiters() > 0 && time.Now().Before(deadline) {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if n := captchaWaiters(); n > 0 {
|
||||
t.Fatalf("captcha wait still running after Stop (%d goroutines)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Cookies arriving during the captcha wait must end that wait and retry
|
||||
// right away, instead of scheduling a second reconnect next to it (which
|
||||
// opened a duplicate session to the document once the wait expired).
|
||||
func TestApplyCookiesWakesCaptchaWait(t *testing.T) {
|
||||
url, hits := captchaDoc(t)
|
||||
tr, _ := startCaptchaTransport(t, url)
|
||||
defer tr.Stop()
|
||||
waitHit(t, hits, 5*time.Second)
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for captchaWaiters() == 0 && time.Now().Before(deadline) {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
if err := tr.ApplyCookies(map[string]string{"spravka": "s1"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if took := time.Since(start); took > 500*time.Millisecond {
|
||||
t.Fatalf("ApplyCookies blocked for %v on its own reconnect", took)
|
||||
}
|
||||
if at := waitHit(t, hits, 5*time.Second); at.Sub(start) > time.Second {
|
||||
t.Fatalf("retry came %v after ApplyCookies, want an immediate wake", at.Sub(start))
|
||||
}
|
||||
}
|
||||
@@ -198,6 +198,18 @@ func authorize(docURL string) (*volgaAuth, error) {
|
||||
return nil, fmt.Errorf("redirect without Location from %s", currentURL)
|
||||
}
|
||||
|
||||
// SmartCaptcha — PoW-солвер её не берёт. Проверяем ПЕРВОЙ:
|
||||
// "showcaptcha" — подстрока "showcaptchafast", обратный порядок
|
||||
// увёл бы её в солвер, который на ней всегда падает.
|
||||
if strings.Contains(loc, "showcaptcha") && !strings.Contains(loc, "showcaptchafast") {
|
||||
utils.Debugf("[VOLGA] SmartCaptcha detected, external solver required")
|
||||
return nil, ErrCaptchaRequired
|
||||
}
|
||||
|
||||
if strings.Contains(loc, "passport.yandex") {
|
||||
return nil, ErrLoginRequired
|
||||
}
|
||||
|
||||
// Капча — проходим и повторяем ИСХОДНЫЙ url (не loc).
|
||||
if strings.Contains(loc, "showcaptchafast") {
|
||||
utils.Debugf("[VOLGA] captcha required, solving...")
|
||||
|
||||
+199
-4
@@ -3,12 +3,14 @@ package yandex
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
neturl "net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -68,6 +70,18 @@ func (s *DocSession) forceClose() {
|
||||
})
|
||||
}
|
||||
|
||||
// Яндекс отдаёт капчу двух разных видов, и путать их нельзя.
|
||||
//
|
||||
// showcaptchafast — PoW-челлендж, его решает solveCaptcha прямо здесь.
|
||||
// showcaptcha?cc=1 — SmartCaptcha, интерактивная: PoW-солвер её не берёт, и
|
||||
// повторные попытки лишь упираются в неё снова. Нужны свежие куки, полученные
|
||||
// снаружи (браузер/WebView) и переданные через ApplyCookies.
|
||||
var ErrCaptchaRequired = errors.New("yandex docs: captcha required")
|
||||
|
||||
// ErrLoginRequired — редирект на паспорт: документ не публичен с этого IP.
|
||||
// Тоже не лечится повтором.
|
||||
var ErrLoginRequired = errors.New("yandex docs: login required")
|
||||
|
||||
type YandexDocsTransport struct {
|
||||
*transport.BaseTransport
|
||||
|
||||
@@ -76,17 +90,140 @@ type YandexDocsTransport struct {
|
||||
|
||||
userCounter atomic.Int32
|
||||
baseUserID string
|
||||
|
||||
// cookieJar переживает реконнекты и может быть заменён из ApplyCookies:
|
||||
// в этом весь смысл внешнего решения капчи — куки, добытые снаружи,
|
||||
// должны попасть в следующий fetchDocInfo.
|
||||
cookieJar *cookiejar.Jar
|
||||
jarMu sync.RWMutex
|
||||
|
||||
errNotifier func(err error, transportName, url, reason string)
|
||||
|
||||
// cookiesApplied будит ожидание в scheduleReconnectNoCaptcha досрочно.
|
||||
// Небуферизованный намеренно: отправка проходит только если кто-то ждёт.
|
||||
cookiesApplied chan struct{}
|
||||
}
|
||||
|
||||
func NewYandexDocsTransport(url string, config transport.TransportConfig) *YandexDocsTransport {
|
||||
jar, _ := cookiejar.New(nil)
|
||||
t := &YandexDocsTransport{
|
||||
BaseTransport: transport.NewBaseTransport(config),
|
||||
url: url,
|
||||
BaseTransport: transport.NewBaseTransport(config),
|
||||
url: url,
|
||||
cookieJar: jar,
|
||||
cookiesApplied: make(chan struct{}),
|
||||
}
|
||||
t.baseUserID = randUserID()
|
||||
return t
|
||||
}
|
||||
|
||||
// URL возвращает адрес документа этого транспорта.
|
||||
func (t *YandexDocsTransport) URL() string { return t.url }
|
||||
|
||||
// SetErrorNotifier ставит колбэк для ошибок, которые транспорт сам не решит
|
||||
// (ErrCaptchaRequired / ErrLoginRequired) — чтобы UI мог сказать пользователю,
|
||||
// что именно требуется, вместо молчаливого реконнект-цикла.
|
||||
func (t *YandexDocsTransport) SetErrorNotifier(fn func(err error, transportName, url, reason string)) {
|
||||
t.errNotifier = fn
|
||||
}
|
||||
|
||||
// ---- CookieExchanger ----
|
||||
//
|
||||
// Сигнатуры намеренно повторяют flx-kernel (map[string]string), чтобы этот
|
||||
// транспорт подошёл под тамошний интерфейс CookieExchanger без правок, когда
|
||||
// ветки будут сводиться.
|
||||
|
||||
// FetchCookies отдаёт снимок текущей банки кук как name -> value.
|
||||
func (t *YandexDocsTransport) FetchCookies() (map[string]string, error) {
|
||||
t.jarMu.RLock()
|
||||
jar := t.cookieJar
|
||||
t.jarMu.RUnlock()
|
||||
if jar == nil {
|
||||
return nil, fmt.Errorf("ydocs: cookie jar is nil")
|
||||
}
|
||||
u := mustParseURL(t.url)
|
||||
out := make(map[string]string)
|
||||
for _, c := range jar.Cookies(u) {
|
||||
out[c.Name] = c.Value
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ApplyCookies подменяет банку кук на добытые снаружи (например, после того как
|
||||
// пользователь прошёл SmartCaptcha в браузере) и роняет текущую сессию, чтобы
|
||||
// следующий fetchDocInfo пошёл уже с новыми куками. Идемпотентна.
|
||||
func (t *YandexDocsTransport) ApplyCookies(values map[string]string) error {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
u := mustParseURL(t.url)
|
||||
jar, _ := cookiejar.New(nil)
|
||||
cookies := siteCookies(u, values)
|
||||
jar.SetCookies(u, cookies)
|
||||
|
||||
t.jarMu.Lock()
|
||||
t.cookieJar = jar
|
||||
t.jarMu.Unlock()
|
||||
|
||||
utils.Debugf("[YDOCS] applied %d cookies, forcing reconnect", len(cookies))
|
||||
|
||||
t.Mu.Lock()
|
||||
session := t.session
|
||||
t.session = nil
|
||||
t.SetConnected(false)
|
||||
t.Mu.Unlock()
|
||||
if session != nil && session.Conn != nil {
|
||||
_ = session.Conn.Close()
|
||||
}
|
||||
if t.IsRunning() {
|
||||
select {
|
||||
case t.cookiesApplied <- struct{}{}:
|
||||
// Ожидающий капча-реконнект проснулся и пойдёт сам; второй
|
||||
// реконнект здесь открыл бы дублирующую сессию к документу.
|
||||
default:
|
||||
// Никто не ждал — реконнектим сами, но в фоне: бэкофф спит, а
|
||||
// ApplyCookies зовут из UI-потока и блокировать его нельзя.
|
||||
utils.SafeGo("yandex.applyCookiesReconnect", func() { t.scheduleReconnect(0) })
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// siteCookies раскладывает пары в куки, привязанные к домену второго уровня,
|
||||
// чтобы они действовали и на редиректных хостах (disk → docs).
|
||||
func siteCookies(u *neturl.URL, values map[string]string) []*http.Cookie {
|
||||
domain := ""
|
||||
if u != nil {
|
||||
if labels := strings.Split(u.Hostname(), "."); len(labels) >= 3 {
|
||||
domain = strings.Join(labels[1:], ".")
|
||||
}
|
||||
}
|
||||
cookies := make([]*http.Cookie, 0, len(values))
|
||||
for k, v := range values {
|
||||
cookies = append(cookies, &http.Cookie{Name: k, Value: v, Path: "/", Domain: domain})
|
||||
}
|
||||
return cookies
|
||||
}
|
||||
|
||||
func mustParseURL(rawURL string) *neturl.URL {
|
||||
u, err := neturl.Parse(rawURL)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func (t *YandexDocsTransport) jar() *cookiejar.Jar {
|
||||
t.jarMu.RLock()
|
||||
defer t.jarMu.RUnlock()
|
||||
if t.cookieJar == nil {
|
||||
// Транспорт мог быть собран литералом структуры (так делают тесты) —
|
||||
// не роняем запрос из-за отсутствующей банки.
|
||||
jar, _ := cookiejar.New(nil)
|
||||
return jar
|
||||
}
|
||||
return t.cookieJar
|
||||
}
|
||||
|
||||
func (t *YandexDocsTransport) Start() error {
|
||||
if err := t.BaseTransport.Start(); err != nil {
|
||||
return err
|
||||
@@ -148,6 +285,20 @@ func (t *YandexDocsTransport) connectToDoc(attempt int) {
|
||||
|
||||
info, err := t.fetchDocInfo(t.url, userID)
|
||||
if err != nil {
|
||||
// SmartCaptcha и логин повтором не лечатся — обычный бэкофф здесь
|
||||
// выродится в бесконечную долбёжку по той же стене.
|
||||
if errors.Is(err, ErrCaptchaRequired) || errors.Is(err, ErrLoginRequired) {
|
||||
reason := "smartcaptcha"
|
||||
if errors.Is(err, ErrLoginRequired) {
|
||||
reason = "login"
|
||||
}
|
||||
utils.Debugf("[YDOCS] needs external help (%s): %v", reason, err)
|
||||
if t.errNotifier != nil {
|
||||
t.errNotifier(err, "yandex", t.url, reason)
|
||||
}
|
||||
t.scheduleReconnectNoCaptcha(attempt)
|
||||
return
|
||||
}
|
||||
utils.Debugf("[YDOCS] fetchDocInfo failed: %v", err)
|
||||
t.scheduleReconnect(attempt)
|
||||
return
|
||||
@@ -377,7 +528,11 @@ func (t *YandexDocsTransport) scheduleReconnect(attempt int) {
|
||||
// turn into a tight connect/close loop (previously reconnect was instant).
|
||||
d := reconnectBackoff(next)
|
||||
utils.Debugf("[YDOCS] reconnecting in %v (attempt %d)", d, next)
|
||||
time.Sleep(d)
|
||||
select {
|
||||
case <-time.After(d):
|
||||
case <-t.Done():
|
||||
return
|
||||
}
|
||||
if !t.IsRunning() {
|
||||
return
|
||||
}
|
||||
@@ -386,6 +541,30 @@ func (t *YandexDocsTransport) scheduleReconnect(attempt int) {
|
||||
t.connectToDoc(next)
|
||||
}
|
||||
|
||||
// scheduleReconnectNoCaptcha — путь для ошибок, которые повтор не чинит
|
||||
// (SmartCaptcha, логин). Обычный бэкофф упрётся в ту же капчу через секунду,
|
||||
// поэтому ждём фиксированно долго и надеемся на внешние куки; ApplyCookies
|
||||
// будит это ожидание досрочно.
|
||||
func (t *YandexDocsTransport) scheduleReconnectNoCaptcha(attempt int) {
|
||||
if !t.IsRunning() {
|
||||
return
|
||||
}
|
||||
const longDelay = 30 * time.Second
|
||||
utils.Debugf("[YDOCS] external solver needed; waiting %v (or until cookies arrive)", longDelay)
|
||||
select {
|
||||
case <-time.After(longDelay):
|
||||
case <-t.cookiesApplied:
|
||||
utils.Debugf("[YDOCS] cookies arrived, retrying immediately")
|
||||
case <-t.Done():
|
||||
return
|
||||
}
|
||||
if !t.IsRunning() {
|
||||
return
|
||||
}
|
||||
t.RecordReconnect()
|
||||
t.connectToDoc(attempt + 1)
|
||||
}
|
||||
|
||||
// reconnectBackoff returns an exponential backoff with jitter, capped at 15s.
|
||||
func reconnectBackoff(n int) time.Duration {
|
||||
if n < 1 {
|
||||
@@ -405,7 +584,9 @@ func reconnectBackoff(n int) time.Duration {
|
||||
}
|
||||
|
||||
func (t *YandexDocsTransport) fetchDocInfo(url, userID string) (YandexDocsInfo, error) {
|
||||
jar, _ := cookiejar.New(nil)
|
||||
// Общая банка кук транспорта, а не локальная: иначе куки, добытые снаружи
|
||||
// через ApplyCookies, не дожили бы до этого запроса.
|
||||
jar := t.jar()
|
||||
client := &http.Client{
|
||||
Jar: jar,
|
||||
// НЕ следуем редиректам автоматически — иначе редирект на капчу
|
||||
@@ -452,6 +633,20 @@ func (t *YandexDocsTransport) fetchDocInfo(url, userID string) (YandexDocsInfo,
|
||||
return YandexDocsInfo{}, fmt.Errorf("redirect without Location from %s", currentURL)
|
||||
}
|
||||
|
||||
// SmartCaptcha (showcaptcha?cc=1) — второй тип, PoW-солвер её не
|
||||
// берёт. Проверяем ПЕРВОЙ: подстрока "showcaptcha" содержится и в
|
||||
// "showcaptchafast", так что обратный порядок увёл бы SmartCaptcha
|
||||
// в солвер, который на ней всегда падает.
|
||||
if strings.Contains(loc, "showcaptcha") && !strings.Contains(loc, "showcaptchafast") {
|
||||
utils.Debugf("[YDOCS] SmartCaptcha detected, external solver required")
|
||||
return YandexDocsInfo{}, ErrCaptchaRequired
|
||||
}
|
||||
|
||||
// Страница логина: не капча и в этом канале не лечится.
|
||||
if strings.Contains(loc, "passport.yandex") {
|
||||
return YandexDocsInfo{}, ErrLoginRequired
|
||||
}
|
||||
|
||||
// Капча — проходим и повторяем ИСХОДНЫЙ url (не loc).
|
||||
if strings.Contains(loc, "showcaptchafast") {
|
||||
utils.Debugf("[YDOCS] captcha detected, solving...")
|
||||
|
||||
@@ -0,0 +1,507 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"universal-bypass-tool/transport"
|
||||
"universal-bypass-tool/transport/yandex"
|
||||
"universal-bypass-tool/utils"
|
||||
)
|
||||
|
||||
// notifyTransportError печатает человекочитаемую причину для случаев, когда
|
||||
// транспорт сам себе помочь не может: интерактивная SmartCaptcha или редирект
|
||||
// на логин. Раньше это выглядело как молчаливый бесконечный реконнект — самая
|
||||
// дорогая в диагностике ситуация (см. ноды, накрутившие 2700 попыток).
|
||||
//
|
||||
// Печатаем через log, а не utils.Debugf: сообщение должно быть видно без
|
||||
// --debug, а на iOS log перенаправлен в кольцевой буфер и доезжает до панели
|
||||
// журнала в приложении.
|
||||
func notifyTransportError(err error, transportName, url, reason string) {
|
||||
switch reason {
|
||||
case "smartcaptcha":
|
||||
log.Printf("!! %s: Яндекс требует интерактивную капчу (SmartCaptcha) для %s", transportName, url)
|
||||
log.Printf("!! Внутренний PoW-солвер её не проходит. Откройте документ в браузере, " +
|
||||
"пройдите капчу и передайте свежие куки (ApplyCookies), либо смените IP/документ.")
|
||||
case "login":
|
||||
log.Printf("!! %s: документ %s требует входа (редирект на паспорт) — он не публичен с этого адреса", transportName, url)
|
||||
default:
|
||||
log.Printf("!! %s: %v (%s)", transportName, err, reason)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- реестр живых Yandex.Docs транспортов ----
|
||||
//
|
||||
// Нужен, чтобы ApplyCookies дошёл до транспорта снаружи: к моменту вызова он
|
||||
// завёрнут в Adaptive/Multiplex/Encrypted, и достать его из обёрток нельзя.
|
||||
// Реестр сбрасывается при каждом старте, иначе в него копились бы транспорты
|
||||
// прошлых сессий.
|
||||
var (
|
||||
ydocsMu sync.Mutex
|
||||
ydocsActive []*yandex.YandexDocsTransport
|
||||
|
||||
// captchaPending — URL документа, упёршегося в SmartCaptcha У НАС. Решать с
|
||||
// ВЫКЛЮЧЕННЫМ туннелем (иначе страница не загрузится: пакеты уходят в мёртвый
|
||||
// туннель) и применять локально.
|
||||
captchaPending string
|
||||
|
||||
// remoteCaptchaPending — капча у ПИРА (ноды). Решать наоборот, с ПОДНЯТЫМ
|
||||
// туннелем: проверка привязывается к адресу, который её прошёл, а куки нужны
|
||||
// годные для адреса ноды. С погашенным туннелем выход был бы с адреса
|
||||
// телефона, и ноде такие куки бесполезны.
|
||||
remoteCaptchaPending string
|
||||
)
|
||||
|
||||
// resetYandexRegistry вызывается перед сборкой нового стека транспортов.
|
||||
func resetYandexRegistry() {
|
||||
ydocsMu.Lock()
|
||||
ydocsActive = nil
|
||||
captchaPending = ""
|
||||
ydocsMu.Unlock()
|
||||
}
|
||||
|
||||
// newYandexDocs собирает Yandex.Docs транспорт с подключённым уведомителем.
|
||||
// Общая точка для CLI и обоих iOS-мостов, чтобы причина не терялась ни в одном
|
||||
// из них.
|
||||
func newYandexDocs(u string, config transport.TransportConfig) transport.Transport {
|
||||
t := yandex.NewYandexDocsTransport(u, config)
|
||||
t.SetErrorNotifier(func(err error, transportName, url, reason string) {
|
||||
if reason == "smartcaptcha" {
|
||||
ydocsMu.Lock()
|
||||
captchaPending = url
|
||||
ydocsMu.Unlock()
|
||||
// Просим куки сразу: если сессия ещё жива, они доедут и нода
|
||||
// переживёт следующий реконнект.
|
||||
requestCookies(reason)
|
||||
reportAuthRequired("yandex", url, reason)
|
||||
}
|
||||
notifyTransportError(err, transportName, url, reason)
|
||||
})
|
||||
ydocsMu.Lock()
|
||||
ydocsActive = append(ydocsActive, t)
|
||||
seed := initialCookies
|
||||
ydocsMu.Unlock()
|
||||
|
||||
// Засеваем до Start: транспорт ещё не запущен, поэтому ApplyCookies просто
|
||||
// наполняет банку и никакого реконнекта не планирует.
|
||||
if seed != "" {
|
||||
if values := parseCookieHeader(seed); len(values) > 0 {
|
||||
if err := t.ApplyCookies(values); err != nil {
|
||||
log.Printf("seed cookies: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// initialCookies — куки, добытые ДО старта туннеля (пользователь прошёл капчу
|
||||
// при выключенном VPN). Засеваются в банку транспорта при создании, поэтому
|
||||
// первый же fetchDocInfo идёт уже с ними: иначе он гарантированно упёрся бы в
|
||||
// ту же капчу, а ApplyCookies после старта означал бы лишний реконнект.
|
||||
var initialCookies string
|
||||
|
||||
// setInitialCookies задаёт (или очищает пустой строкой) эти куки.
|
||||
func setInitialCookies(raw string) {
|
||||
ydocsMu.Lock()
|
||||
initialCookies = strings.TrimSpace(raw)
|
||||
n := len(parseCookieHeader(initialCookies))
|
||||
ydocsMu.Unlock()
|
||||
if n > 0 {
|
||||
log.Printf("Captcha cookies preloaded (%d): %s — will seed the transport",
|
||||
n, cookieNames(parseCookieHeader(initialCookies)))
|
||||
}
|
||||
}
|
||||
|
||||
// pendingCaptchaURL — URL, для которого нужна интерактивная капча ("" = нет).
|
||||
//
|
||||
// Живое соединение снимает флаг: капча могла отвалиться сама (сменился IP,
|
||||
// истёк её срок), и тогда никто бы флаг не сбросил — баннер висел бы поверх
|
||||
// рабочего туннеля.
|
||||
func pendingCaptchaURL() string {
|
||||
ydocsMu.Lock()
|
||||
defer ydocsMu.Unlock()
|
||||
if captchaPending == "" {
|
||||
return ""
|
||||
}
|
||||
for _, t := range ydocsActive {
|
||||
if t.IsConnected() {
|
||||
captchaPending = ""
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return captchaPending
|
||||
}
|
||||
|
||||
// applyCookiesToYandex раздаёт куки всем живым Yandex.Docs транспортам. Куки —
|
||||
// в формате заголовка Cookie ("a=1; b=2"), как их отдаёт WebView.
|
||||
//
|
||||
// Раздаём всем, а не только тому, чей URL совпал: при мультиплексе документы
|
||||
// живут на одном домене, и куки капчи привязаны к домену, а не к документу.
|
||||
func applyCookiesToYandex(rawCookies string) int {
|
||||
values := parseCookieHeader(rawCookies)
|
||||
if len(values) == 0 {
|
||||
return 0
|
||||
}
|
||||
ydocsMu.Lock()
|
||||
targets := make([]*yandex.YandexDocsTransport, len(ydocsActive))
|
||||
copy(targets, ydocsActive)
|
||||
captchaPending = ""
|
||||
ydocsMu.Unlock()
|
||||
|
||||
applied := 0
|
||||
for _, t := range targets {
|
||||
if err := t.ApplyCookies(values); err != nil {
|
||||
log.Printf("apply cookies: %v", err)
|
||||
continue
|
||||
}
|
||||
applied++
|
||||
}
|
||||
log.Printf("Captcha cookies applied to %d transport(s): %s", applied, cookieNames(values))
|
||||
return applied
|
||||
}
|
||||
|
||||
// cookiesFilePath — путь, заданный --cookies-file. Нужен не только для чтения:
|
||||
// нода, получившая куки по контрольному каналу, пишет их сюда, и тогда их
|
||||
// подхватывают ДРУГИЕ процессы на той же машине. Это единственный способ
|
||||
// передать куки между сервисами: у каждого свой процесс и свой реестр, а
|
||||
// spravka привязана к IP машины, который у них общий.
|
||||
var cookiesFilePath atomic.Pointer[string]
|
||||
|
||||
func setCookiesFilePath(path string) {
|
||||
if path == "" {
|
||||
return
|
||||
}
|
||||
cookiesFilePath.Store(&path)
|
||||
}
|
||||
|
||||
// persistCookies пишет куки в файл --cookies-file, если он задан. Пишем только
|
||||
// при изменении: watcher того же процесса иначе увидел бы свою же запись и
|
||||
// применил её по кругу.
|
||||
func persistCookies(header string) {
|
||||
p := cookiesFilePath.Load()
|
||||
if p == nil || *p == "" || header == "" {
|
||||
return
|
||||
}
|
||||
if old, err := os.ReadFile(*p); err == nil && strings.TrimSpace(string(old)) == header {
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(*p, []byte(header+"\n"), 0o600); err != nil {
|
||||
log.Printf("persist cookies to %s: %v", *p, err)
|
||||
return
|
||||
}
|
||||
log.Printf("Cookies written to %s for the other services on this host", *p)
|
||||
}
|
||||
|
||||
// watchCookiesFile следит за файлом с куками и применяет его содержимое к живым
|
||||
// Yandex.Docs транспортам — при старте (если файл уже есть) и дальше при каждом
|
||||
// изменении.
|
||||
//
|
||||
// Это единственный способ пройти SmartCaptcha на exit-ноде: PoW она считает
|
||||
// сама, а интерактивную капчу решить не может — нет ни браузера, ни человека.
|
||||
// Оператор видит причину в журнале, проходит капчу в браузере у себя, кладёт
|
||||
// строку кук в этот файл, и нода подхватывает её на ходу.
|
||||
//
|
||||
// Следим, а не читаем однократно при старте: капча возникает на уже работающей
|
||||
// ноде, и перезапуск ради кук сбросил бы все туннели.
|
||||
//
|
||||
// Формат файла — как заголовок Cookie: "a=1; b=2" (то, что даёт devtools
|
||||
// «copy as cURL»). Действует только на транспорт yandex: остальные в реестр не
|
||||
// попадают.
|
||||
func watchCookiesFile(path string) {
|
||||
var lastMod time.Time
|
||||
var lastSize int64
|
||||
|
||||
apply := func(why string) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
log.Printf("cookies file: %v", err)
|
||||
return
|
||||
}
|
||||
raw := strings.TrimSpace(string(data))
|
||||
if raw == "" {
|
||||
return
|
||||
}
|
||||
log.Printf("Cookies file %s (%s): applying", path, why)
|
||||
applyCookiesToYandex(raw)
|
||||
}
|
||||
|
||||
if st, err := os.Stat(path); err == nil {
|
||||
lastMod, lastSize = st.ModTime(), st.Size()
|
||||
apply("present at startup")
|
||||
} else {
|
||||
log.Printf("Cookies file %s not present yet — will pick it up when it appears", path)
|
||||
}
|
||||
|
||||
for {
|
||||
time.Sleep(3 * time.Second)
|
||||
st, err := os.Stat(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// И mtime, и размер: запись через ">" на той же секунде меняет размер,
|
||||
// но может не сдвинуть mtime с секундной гранулярностью.
|
||||
if st.ModTime().Equal(lastMod) && st.Size() == lastSize {
|
||||
continue
|
||||
}
|
||||
lastMod, lastSize = st.ModTime(), st.Size()
|
||||
apply("changed")
|
||||
}
|
||||
}
|
||||
|
||||
func parseCookieHeader(raw string) map[string]string {
|
||||
out := make(map[string]string)
|
||||
for _, part := range strings.Split(raw, ";") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
eq := strings.IndexByte(part, '=')
|
||||
if eq <= 0 {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(part[:eq])
|
||||
value := strings.TrimSpace(part[eq+1:])
|
||||
if name != "" {
|
||||
out[name] = value
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- обмен куками по контрольному каналу ----
|
||||
//
|
||||
// Нода не может пройти интерактивную капчу (нет браузера), клиент может.
|
||||
// Поэтому нода просит куки, клиент отдаёт. Границы применимости — в
|
||||
// transport.ControlTransport: пока у ноды жива сессия, канал есть; если она уже
|
||||
// не может открыть документ, спасать поздно, нужен --cookies-file. Отсюда и
|
||||
// правило «слать заранее»: клиент отдаёт куки сам, как только видит канал, а не
|
||||
// ждёт просьбы.
|
||||
|
||||
var (
|
||||
ctrlMu sync.Mutex
|
||||
ctrlConn *transport.ControlTransport
|
||||
ctrlExit bool
|
||||
)
|
||||
|
||||
// wrapControl ставит контрольный слой снаружи всего остального (но внутри
|
||||
// шифрования, если оно есть), чтобы контрольные кадры шифровались как данные.
|
||||
func wrapControl(inner transport.Transport, exitNode bool) transport.Transport {
|
||||
ct := transport.NewControlTransport(inner)
|
||||
ctrlMu.Lock()
|
||||
ctrlConn = ct
|
||||
ctrlExit = exitNode
|
||||
ctrlMu.Unlock()
|
||||
|
||||
ct.SetControlHandler(func(typ byte, body []byte) {
|
||||
var p transport.CookiesPayload
|
||||
if err := json.Unmarshal(body, &p); err != nil {
|
||||
utils.Debugf("[CTRL] bad payload type=%d: %v", typ, err)
|
||||
return
|
||||
}
|
||||
switch typ {
|
||||
case transport.CtrlCookiesRequest:
|
||||
if exitNode {
|
||||
return // просить должна нода, а не отвечать на просьбу
|
||||
}
|
||||
log.Printf("Peer asked for cookies (%s) — sending", p.Reason)
|
||||
offerCookies()
|
||||
case transport.CtrlAuthRequired:
|
||||
if exitNode {
|
||||
return // сообщает нода, реагирует клиент
|
||||
}
|
||||
var ap transport.AuthRequiredPayload
|
||||
if err := json.Unmarshal(body, &ap); err != nil || ap.URL == "" {
|
||||
return
|
||||
}
|
||||
ydocsMu.Lock()
|
||||
remoteCaptchaPending = ap.URL
|
||||
ydocsMu.Unlock()
|
||||
log.Printf("!! Узел не может пройти проверку для %s (%s)", ap.URL, ap.Reason)
|
||||
log.Printf("!! Пройдите её НЕ отключая туннель — куки должны быть выданы на адрес узла.")
|
||||
case transport.CtrlCookiesOffer:
|
||||
if !exitNode {
|
||||
return // на клиенте свои куки лучше присланных
|
||||
}
|
||||
if len(p.Cookies) == 0 {
|
||||
return
|
||||
}
|
||||
log.Printf("Peer sent %d cookies (%s) — applying", len(p.Cookies), p.Reason)
|
||||
header := cookieHeaderFrom(p.Cookies)
|
||||
applyCookiesToYandex(header)
|
||||
// Ключевое: раздаём куки и соседним сервисам на этой же машине.
|
||||
// Клиент прошёл проверку через туннель, значит spravka выдана на
|
||||
// наш IP и годится всем процессам здесь, не только этому.
|
||||
persistCookies(header)
|
||||
}
|
||||
})
|
||||
return ct
|
||||
}
|
||||
|
||||
func cookieHeaderFrom(values map[string]string) string {
|
||||
parts := make([]string, 0, len(values))
|
||||
for k, v := range values {
|
||||
parts = append(parts, k+"="+v)
|
||||
}
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
// requestCookies — нода упёрлась в капчу и просит куки у клиента. Отправка
|
||||
// провалится, если сессии уже нет; это ожидаемо и не ошибка.
|
||||
func requestCookies(reason string) {
|
||||
ctrlMu.Lock()
|
||||
ct, isExit := ctrlConn, ctrlExit
|
||||
ctrlMu.Unlock()
|
||||
if ct == nil || !isExit {
|
||||
return
|
||||
}
|
||||
if err := ct.SendControl(transport.CtrlCookiesRequest,
|
||||
transport.CookiesPayload{Reason: reason}); err != nil {
|
||||
utils.Debugf("[CTRL] cookie request not sent (no live channel): %v", err)
|
||||
return
|
||||
}
|
||||
log.Printf("Asked the client for cookies (%s)", reason)
|
||||
}
|
||||
|
||||
// currentCookies — что клиент может предложить: сначала то, что добыто снаружи
|
||||
// (капча пройдена в браузере/WebView), иначе живая банка транспорта.
|
||||
func currentCookies() map[string]string {
|
||||
ydocsMu.Lock()
|
||||
seed := initialCookies
|
||||
targets := make([]*yandex.YandexDocsTransport, len(ydocsActive))
|
||||
copy(targets, ydocsActive)
|
||||
ydocsMu.Unlock()
|
||||
|
||||
if v := parseCookieHeader(seed); len(v) > 0 {
|
||||
return v
|
||||
}
|
||||
for _, t := range targets {
|
||||
if v, err := t.FetchCookies(); err == nil && len(v) > 0 {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// offerCookies — клиент отдаёт куки ноде.
|
||||
func offerCookies() {
|
||||
ctrlMu.Lock()
|
||||
ct, isExit := ctrlConn, ctrlExit
|
||||
ctrlMu.Unlock()
|
||||
if ct == nil || isExit {
|
||||
return
|
||||
}
|
||||
values := currentCookies()
|
||||
if len(values) == 0 {
|
||||
// Нечего отдать — пусть UI попросит пользователя пройти капчу.
|
||||
ydocsMu.Lock()
|
||||
if captchaPending == "" && len(ydocsActive) > 0 {
|
||||
captchaPending = ydocsActive[0].URL()
|
||||
}
|
||||
ydocsMu.Unlock()
|
||||
log.Printf("Peer needs cookies but we have none — user must pass the captcha")
|
||||
return
|
||||
}
|
||||
if !ct.IsConnected() {
|
||||
utils.Debugf("[CTRL] cookie offer skipped: carrier not connected")
|
||||
return
|
||||
}
|
||||
if err := ct.SendControl(transport.CtrlCookiesOffer,
|
||||
transport.CookiesPayload{Reason: "client", Cookies: values}); err != nil {
|
||||
utils.Debugf("[CTRL] cookie offer not sent: %v", err)
|
||||
return
|
||||
}
|
||||
log.Printf("Sent %d cookies to the peer", len(values))
|
||||
}
|
||||
|
||||
// startCookieOffering — клиентская проактивная отдача. Смысл именно в «заранее»:
|
||||
// к моменту, когда нода упрётся в капчу, у неё уже должны быть свежие куки, ведь
|
||||
// просить будет поздно — канала не станет.
|
||||
func startCookieOffering() {
|
||||
ctrlMu.Lock()
|
||||
isExit := ctrlExit
|
||||
ctrlMu.Unlock()
|
||||
if isExit {
|
||||
return
|
||||
}
|
||||
utils.SafeGo("cookieOffering", func() {
|
||||
for {
|
||||
time.Sleep(10 * time.Minute)
|
||||
if len(currentCookies()) > 0 {
|
||||
offerCookies()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// reportAuthRequired — нода сообщает клиенту, ГДЕ она застряла, чтобы тот прошёл
|
||||
// проверку через туннель (с адреса ноды) и отдал куки.
|
||||
func reportAuthRequired(name, url, reason string) {
|
||||
ctrlMu.Lock()
|
||||
ct, isExit := ctrlConn, ctrlExit
|
||||
ctrlMu.Unlock()
|
||||
if ct == nil || !isExit {
|
||||
return
|
||||
}
|
||||
if err := ct.SendControl(transport.CtrlAuthRequired,
|
||||
transport.AuthRequiredPayload{Transport: name, URL: url, Reason: reason}); err != nil {
|
||||
utils.Debugf("[CTRL] AuthRequired not sent (no live channel): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// pendingRemoteCaptchaURL — адрес, для которого проверку должна пройти наша
|
||||
// сторона в интересах ноды ("" = нечего проходить).
|
||||
func pendingRemoteCaptchaURL() string {
|
||||
ydocsMu.Lock()
|
||||
defer ydocsMu.Unlock()
|
||||
return remoteCaptchaPending
|
||||
}
|
||||
|
||||
// offerCookiesToPeer отдаёт ноде куки, добытые через туннель. Возвращает число
|
||||
// переданных кук.
|
||||
func offerCookiesToPeer(rawCookies string) int {
|
||||
values := parseCookieHeader(rawCookies)
|
||||
if len(values) == 0 {
|
||||
return 0
|
||||
}
|
||||
ctrlMu.Lock()
|
||||
ct, isExit := ctrlConn, ctrlExit
|
||||
ctrlMu.Unlock()
|
||||
if ct == nil || isExit {
|
||||
return 0
|
||||
}
|
||||
// Проверяем канал ДО отправки: Send у кодека лишь ставит кадр в очередь и
|
||||
// возвращает nil, поэтому «успех» без живого носителя ничего не значит —
|
||||
// раньше лог рапортовал об отправке в мёртвый транспорт.
|
||||
if !ct.IsConnected() {
|
||||
log.Printf("!! Куки НЕ отправлены: носитель не подключён. Подключитесь " +
|
||||
"рабочим транспортом (direct/boards) — выход должен идти с адреса узла.")
|
||||
return 0
|
||||
}
|
||||
if err := ct.SendControl(transport.CtrlCookiesOffer,
|
||||
transport.CookiesPayload{Reason: "solved-through-tunnel", Cookies: values}); err != nil {
|
||||
log.Printf("offer cookies to peer: %v", err)
|
||||
return 0
|
||||
}
|
||||
ydocsMu.Lock()
|
||||
remoteCaptchaPending = ""
|
||||
ydocsMu.Unlock()
|
||||
log.Printf("Sent %d cookies to the exit node (solved through the tunnel)", len(values))
|
||||
return len(values)
|
||||
}
|
||||
|
||||
// cookieNames — только имена, без значений: значения это секреты, а для
|
||||
// диагностики важно лишь, есть ли среди них нужное (у Яндекса — spravka).
|
||||
func cookieNames(values map[string]string) string {
|
||||
names := make([]string, 0, len(values))
|
||||
for k := range values {
|
||||
names = append(names, k)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return strings.Join(names, ",")
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"universal-bypass-tool/transport"
|
||||
)
|
||||
|
||||
func TestParseCookieHeader(t *testing.T) {
|
||||
got := parseCookieHeader(" spravka=abc ; yandexuid=42; broken ; =nokey; k=v=w ")
|
||||
want := map[string]string{"spravka": "abc", "yandexuid": "42", "k": "v=w"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
for k, v := range want {
|
||||
if got[k] != v {
|
||||
t.Fatalf("key %q: got %q, want %q", k, got[k], v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Файл с куками — единственный путь пройти интерактивную капчу на exit-ноде,
|
||||
// поэтому проверяем весь путь: файл появился после старта наблюдателя -> куки
|
||||
// доехали до зарегистрированного транспорта.
|
||||
func TestWatchCookiesFilePicksUpLateFile(t *testing.T) {
|
||||
resetYandexRegistry()
|
||||
tr := newYandexDocs("https://disk.yandex.ru/i/test", transport.DefaultConfig())
|
||||
defer tr.Stop()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "cookies.txt")
|
||||
go watchCookiesFile(path) // файла ещё нет — наблюдатель должен дождаться
|
||||
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
if err := os.WriteFile(path, []byte("spravka=s1; yandexuid=u1\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ydocsMu.Lock()
|
||||
target := ydocsActive[0]
|
||||
ydocsMu.Unlock()
|
||||
|
||||
deadline := time.Now().Add(15 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if got, err := target.FetchCookies(); err == nil && got["spravka"] == "s1" {
|
||||
if got["yandexuid"] != "u1" {
|
||||
t.Fatalf("second cookie lost: %v", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("cookies from the watched file never reached the transport")
|
||||
}
|
||||
|
||||
// Флаг существует только чтобы оператор мог разблокировать ноду; пустой файл не
|
||||
// должен ничего затирать.
|
||||
func TestWatchCookiesFileIgnoresEmpty(t *testing.T) {
|
||||
resetYandexRegistry()
|
||||
tr := newYandexDocs("https://disk.yandex.ru/i/test2", transport.DefaultConfig())
|
||||
defer tr.Stop()
|
||||
|
||||
ydocsMu.Lock()
|
||||
target := ydocsActive[0]
|
||||
ydocsMu.Unlock()
|
||||
if err := target.ApplyCookies(map[string]string{"keep": "me"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
path := filepath.Join(t.TempDir(), "empty.txt")
|
||||
if err := os.WriteFile(path, []byte(" \n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go watchCookiesFile(path)
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
got, err := target.FetchCookies()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got["keep"] != "me" {
|
||||
t.Fatalf("empty cookies file clobbered the jar: %v", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user