iOS UI: свои домены, журнал на главном, пинг профилей

Свои домены напрямую: список в настройках, ввод нормализуется (можно
вставить целый URL). Едут в тот же GeoSite-путь, что и встроенный
RU-список, поэтому работают и при выключенном RU-сплите — раньше это было
привязано к нему, и ручной список молча не действовал.

Журнал вынесен на главный экран с очисткой и копированием. Строки из
расширения (отдельный процесс) приходят через sendProviderMessage:
OpenFluxReadLog опустошает кольцевой буфер, поэтому читатель должен быть
один — им оставлено расширение.

Пинг профилей прямо из выпадашки. ICMP приложению недоступен, поэтому это
HTTP-round-trip до хоста документа: честный ответ на «жив ли конфиг», но
не проверка выходного узла.

Шиты переведены на .sheet(item:): с isPresented содержимое строится по
состоянию на момент показа, и значение, выставленное в том же действии, до
него не доезжает — редактор открывался пустым, а капча белым экраном.

Версия сборки 44 -> 64.
This commit is contained in:
saharev1
2026-09-25 22:10:19 +04:00
parent b501eeddcd
commit dc477d0d24
35 changed files with 4851 additions and 85 deletions
+111
View File
@@ -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
View File
@@ -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
View File
@@ -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
+3 -3
View File
@@ -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
)
+203
View File
@@ -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))"
}
}
}
}
+556 -46
View File
@@ -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)
+78
View File
@@ -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)
}
}
+8 -4
View File
@@ -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>
+94
View File
@@ -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
}
}
+6
View File
@@ -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 {
+175
View File
@@ -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,
]
}
}
+87 -1
View File
@@ -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.
+106 -1
View File
@@ -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
View File
@@ -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
+82 -3
View File
@@ -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 {
+126
View File
@@ -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)
}
})
}
+107
View File
@@ -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")
}
}
+393
View File
@@ -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
}
}
+76
View File
@@ -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{}
+158
View File
@@ -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
}
+252
View File
@@ -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)
}
}
+108
View File
@@ -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")
}
}
+20 -2
View File
@@ -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))
}
+372
View File
@@ -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)
}
}
+312
View File
@@ -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
}
+32
View File
@@ -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
}
+16 -4
View File
@@ -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], "&amp;", "&")
if strings.HasPrefix(formAction, "/") {
formAction = "https://docs.yandex.ru" + formAction
formAction = origin + formAction
}
return &ssr, formAction, nil
+54
View File
@@ -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)
}
}
+104
View File
@@ -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, &notified
}
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))
}
}
+12
View File
@@ -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
View File
@@ -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...")
+507
View File
@@ -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, ",")
}
+86
View File
@@ -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)
}
}