diff --git a/.gitignore b/.gitignore index 911b944..f0272c6 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,14 @@ Thumbs.db .gradle/ *.hprof + +# iOS build artifacts +output/ +ios-app/build/ +ios-app/Lib/*.a +ios-app/Lib/*.h +ios-app/OpenFlux.xcodeproj/ +ios-app/DerivedData/ + +# App Store Connect API private key — never commit +*.p8 diff --git a/README.md b/README.md index 86f4a13..50defe4 100644 --- a/README.md +++ b/README.md @@ -26,19 +26,25 @@ Client side runs a SOCKS5 proxy, exit node decapsulates and forwards packets to ## Structure ``` -universal-bypass-tool/ -├── main.go +OpenFlux/ +├── main.go # CLI entry (client / exit-node) +├── export_ios.go # cgo bridge for the iOS static library (build tag: ios) ├── transport/ -│ ├── transport.go # Transport interface -│ └── yandex/ # Yandex Docs backend -│ └── oneme/ # MAX Messenger backend +│ ├── transport.go # Transport interface +│ ├── compressor.go # Compression wrapper +│ ├── yandex/ # Yandex Docs backend +│ └── oneme/ # MAX Messenger backend ├── tunnel/ -│ ├── tunnel.go # TCP tunnel core -│ ├── endpoint.go # Virtual NIC -│ └── rawsocket.go # Raw socket (exit node) -├── socks5/ # SOCKS5 server -├── network/ # Checksums, packet parsing -└── utils/ # Debug logging +│ ├── tunnel.go # TCP tunnel core +│ ├── endpoint.go # Virtual NIC +│ └── rawsocket_{linux,darwin,windows}.go # Raw socket (exit node), per-OS +├── socks5/ # SOCKS5 server +├── network/ # Checksums, packet parsing +├── utils/ # Logging +├── ios-app/ # SwiftUI iOS client (XcodeGen), links liboflux.a +├── build_ios.sh # Build the iOS static library (liboflux.a) +├── build_ios_app.sh # Build + archive + export the iOS app IPA +└── build_android.sh # Build the Android client binary ``` ## Build (desktop client / exit-node binary) @@ -66,7 +72,26 @@ export XCODE_PATH="" # optional, defaults to /Applications/ 1. You must have root access on exit node machine; 2. Only legacy Yandex document editor is supported (you can toggle this setting from the interface). -Setup commands for exit node: +The exit node's TCP connections live in a userspace stack (gvisor), so the +kernel has no socket for them and would send an RST on every reply, tearing +the tunnel down. That RST must be suppressed — but do it **scoped**, not +host-wide. A blanket `-j DROP` on all outbound RSTs makes every closed port +answer with silence (scanners see `filtered` instead of `closed`) and stops +the host from resetting unrelated connections. + +Recommended (scoped to a dedicated egress IP): +```bash +# give the box a second/alias IP for the tunnel, e.g. 203.0.113.10 +sudo iptables -A OUTPUT -p tcp --tcp-flags RST RST -s 203.0.113.10 -j DROP +sudo ./universal-bypass-tool --exit-node --local-ip 203.0.113.10 \ + --url "YOUR_YANDEX_DOC_URL" --debug +``` +Even cleaner: run the exit node in its own network namespace / container so the +rule never touches the host's main services. Note that `-m owner --uid-owner` +does **not** work here — the tunnel-breaking RSTs are generated by the kernel +with no owning socket, so the owner match never fires. + +Host-wide fallback (only on a single-purpose box, understanding the trade-off): ```bash sudo iptables -A OUTPUT -p tcp --tcp-flags RST RST -j DROP sudo ./universal-bypass-tool --exit-node --url "YOUR_YANDEX_DOC_URL" --debug diff --git a/README.ru.md b/README.ru.md index 3540696..50a9231 100644 --- a/README.ru.md +++ b/README.ru.md @@ -26,19 +26,25 @@ TCP-пакеты передаются через Transport. На данный м ## Структура ``` -universal-bypass-tool/ -├── main.go +OpenFlux/ +├── main.go # Точка входа CLI (клиент / выходная нода) +├── export_ios.go # cgo-мост для статической библиотеки iOS (build tag: ios) ├── transport/ -│ ├── transport.go # Transport interface -│ └── yandex/ # Yandex Docs backend -│ └── oneme/ # MAX Messenger backend +│ ├── transport.go # Интерфейс Transport +│ ├── compressor.go # Обёртка сжатия +│ ├── yandex/ # Бэкенд Yandex Docs +│ └── oneme/ # Бэкенд MAX Messenger ├── tunnel/ -│ ├── tunnel.go # TCP tunnel core -│ ├── endpoint.go # Virtual NIC -│ └── rawsocket.go # Raw socket (exit node) -├── socks5/ # SOCKS5 server -├── network/ # Checksums, packet parsing -└── utils/ # Debug logging +│ ├── tunnel.go # Ядро TCP-тоннеля +│ ├── endpoint.go # Виртуальный NIC +│ └── rawsocket_{linux,darwin,windows}.go # Raw-сокет (выходная нода), по ОС +├── socks5/ # SOCKS5-сервер +├── network/ # Контрольные суммы, разбор пакетов +├── utils/ # Логирование +├── ios-app/ # iOS-клиент на SwiftUI (XcodeGen), линкует liboflux.a +├── build_ios.sh # Сборка статической библиотеки iOS (liboflux.a) +├── build_ios_app.sh # Сборка + архив + экспорт IPA приложения iOS +└── build_android.sh # Сборка клиентского бинарника Android ``` ## Сборка (бинарник десктоп-клиента / выходной ноды) @@ -66,7 +72,27 @@ export XCODE_PATH="<путь до вашего Xcode.app>" # опциональ 1. У вас должен быть root-доступ выходной ноде; 2. Поддерживается только устаревший редактор документов Yandex (переключается в настройках интерфейса). -Команды для настройки выходной ноды: +TCP-соединения выходной ноды живут в userspace-стеке (gvisor), у ядра нет для +них сокета, и оно слало бы RST на каждый ответный пакет — туннель бы рвался. +Этот RST надо подавить, но **точечно**, не на весь хост. Глухое +`-j DROP` на все исходящие RST превращает закрытые порты в «молчащие» +(сканер видит `filtered` вместо `closed`) и мешает хосту нормально сбрасывать +посторонние соединения. + +Рекомендуется (сужение по выделенному egress-IP): +```bash +# повесьте на машину второй/алиас IP под туннель, напр. 203.0.113.10 +sudo iptables -A OUTPUT -p tcp --tcp-flags RST RST -s 203.0.113.10 -j DROP +sudo ./universal-bypass-tool --exit-node --local-ip 203.0.113.10 \ + --url "YOUR_YANDEX_DOC_URL" --debug +``` +Ещё чище — запускать ноду в отдельном network namespace / контейнере, тогда +правило вообще не трогает сервисы хоста. `-m owner --uid-owner` тут **не +работает**: рвущие туннель RST генерит ядро без сокета-владельца, и owner-матч +не срабатывает. + +Запасной вариант на весь хост (только на однозадачной машине, с пониманием +последствий): ```bash sudo iptables -A OUTPUT -p tcp --tcp-flags RST RST -j DROP sudo ./universal-bypass-tool --exit-node --url "YOUR_YANDEX_DOC_URL" --debug diff --git a/USAGE.md b/USAGE.md new file mode 100644 index 0000000..ee0d342 --- /dev/null +++ b/USAGE.md @@ -0,0 +1,159 @@ +# Usage + +How to run an exit node and point a client at it. See [README](README.md) for +build instructions. + +``` +Client (iOS app / SOCKS5) ──▶ Transport (Yandex.Docs) ──▶ Exit node ──▶ Internet +``` + +The client and the exit node talk **through a shared Yandex document**: the +client writes tunnel data into the document's editor channel, the exit node +reads it, forwards it to the real Internet, and writes the replies back. + +## 1. Prepare a document + +1. Create a document on Yandex.Docs / Yandex.Disk (a collaborative editor + document, not a plain file). +2. Make it **publicly accessible with edit rights** (anyone with the link can + edit) and copy the share link, e.g. `https://disk.yandex.ru/i/XXXXXXXXXXXX`. +3. Match the document type to the transport: + - `yandex` (classic) — a standard collaborative document. + - `volga` / `vyandex` — a document served through the office-online editor. + +The same link is used on **both** the exit node and the client. + +## 2. Run the exit node + +The exit node needs root (raw sockets) and a Linux host with a public IP. + +```bash +sudo ./universal-bypass-tool \ + --exit-node \ + --transport yandex \ + --url "https://disk.yandex.ru/i/XXXXXXXXXXXX" +``` + +The exit node forwards raw traffic, so the kernel's own RST/ICMP replies would +tear down tunneled connections. Scope the drop rules to a dedicated egress IP: + +```bash +# assign an alias IP, then run the node with --local-ip and: +sudo iptables -A OUTPUT -p tcp --tcp-flags RST RST -s -j DROP +sudo iptables -A OUTPUT -p icmp --icmp-type port-unreachable -s -j DROP +``` + +A healthy start prints `OPENFLUX_READY transport=… mode=exit-node` and, per +document, `[YDOCS] WebSocket connected …`. + +## 3. Configure the client + +### iOS app + +1. **Transport** — pick the same transport the exit node runs (`yandex` or + `volga`). They are not interchangeable. +2. **Document URL** — paste the same link the exit node uses. +3. Connect. + +Instead of typing the fields by hand you can paste a single **`OFLUX1:`** +string (a compact base64url bundle of the transport type and document URL) and +tap **Import** — it fills in the fields for you. + +Optional: choose a **DNS-over-TLS** resolver, and toggle **UDP** (QUIC/HTTP3) +forwarding. + +### Desktop (SOCKS5) + +```bash +./universal-bypass-tool \ + --client \ + --transport yandex \ + --url "https://disk.yandex.ru/i/XXXXXXXXXXXX" \ + --socks5 127.0.0.1:1080 +``` + +Then point your application at the SOCKS5 proxy on `127.0.0.1:1080`. + +## 4. Multiple documents (throughput / failover) + +`--url` accepts a **comma-separated list** of documents. The client stripes +flows across them (one TCP flow stays on one document, in order) and keeps +working if one document's channel drops. + +```bash +--url "https://disk.yandex.ru/i/AAAA,https://disk.yandex.ru/i/BBBB" +``` + +In the iOS app, use the **+ (Add second document)** button to enter the second +document (VOLGA is single-document only, so it has no + button). + +## 5. Import config (`OFLUX1:`) + +Instead of entering the transport and document URL(s) by hand, the app can +import them from a single `OFLUX1:` string — the **Import** button reads it from +the clipboard and fills in the fields. + +Format: + +``` +OFLUX1: +``` + +where the JSON is: + +```json +{"t":"yandex","u":"https://disk.yandex.ru/i/XXXXXXXXXXXX"} +``` + +- `t` — transport: `yandex` (classic) or `volga`. Optional (defaults to volga); + `vyandex` is accepted as an alias for `volga`. +- `u` — document URL, or a comma-separated list for a Yandex multi-document + setup. Required. +- The payload is standard base64 with `+`→`-`, `/`→`_`, and `=` padding removed + (base64url); the app re-adds the padding on import. + +Generate one from the shell: + +```bash +echo -n '{"t":"yandex","u":"https://disk.yandex.ru/i/XXXXXXXXXXXX"}' | \ + { printf 'OFLUX1:'; base64 | tr '+/' '-_' | tr -d '='; } +``` + +Examples (placeholder document IDs): + +``` +# single Yandex document +OFLUX1:eyJ0IjoieWFuZGV4IiwidSI6Imh0dHBzOi8vZGlzay55YW5kZXgucnUvaS9YWFhYWFhYWFhYWFgifQ +# two Yandex documents +OFLUX1:eyJ0IjoieWFuZGV4IiwidSI6Imh0dHBzOi8vZGlzay55YW5kZXgucnUvaS9BQUFBLGh0dHBzOi8vZGlzay55YW5kZXgucnUvaS9CQkJCIn0 +# single VOLGA document +OFLUX1:eyJ0Ijoidm9sZ2EiLCJ1IjoiaHR0cHM6Ly9kaXNrLnlhbmRleC5ydS9pL1hYWFhYWFhYWFhYWCJ9 +``` + +## Rules that must hold (client ↔ exit node) + +These are the common causes of a stuck **`connecting`** state: + +| Rule | Why | +|------|-----| +| **Same document list** on both sides — exact same URLs, same order. | With a multi-document exit node, reply traffic is spread across all documents; a client listening on fewer documents never receives the replies routed to the ones it is missing. | +| **Same transport** on both sides. | `yandex` and `volga` use different document channels and are not wire-compatible. | +| **Mixed build versions are OK.** | The codec self-negotiates: a newer client and an older exit node (or vice versa) fall back to the legacy per-packet format and keep working; they upgrade to batching only when both support it. Updating one side no longer breaks the other. | + +If the client is stuck on `connecting`, check those three first, then confirm +the exit node log shows `WebSocket connected` for every document. + +## Advanced: batch tuning + +Outgoing packets are coalesced into one compressed frame per channel message. +The batch size can be tuned at runtime via environment variables (defaults in +parentheses): + +| Variable | Meaning | Default | +|----------|---------|---------| +| `OPENFLUX_BATCH_BYTES` | max bytes per batch | `8192` | +| `OPENFLUX_BATCH_COUNT` | max packets per batch | `64` | +| `OPENFLUX_BATCH_LINGER_MS` | how long to wait for stragglers | `5` | + +Larger batches cut the message count further but add latency; match them to the +channel's per-message limits. diff --git a/build_ios.sh b/build_ios.sh index c54db72..d994dcf 100755 --- a/build_ios.sh +++ b/build_ios.sh @@ -38,32 +38,12 @@ echo "Building for iOS (arm64)..." # Build static library if go build \ -buildmode=c-archive \ - -ldflags="-s -w" \ + -ldflags="-w" \ -trimpath \ -o "$OUTPUT_DIR/$LIBRARY_NAME.a" \ . ; then - # Create header if not auto-generated - if [ ! -f "$OUTPUT_DIR/$LIBRARY_NAME.h" ]; then - cat > "$OUTPUT_DIR/$LIBRARY_NAME.h" << 'HEADEREOF' -#ifndef LIBTUNNEL_H -#define LIBTUNNEL_H - -#ifdef __cplusplus -extern "C" { -#endif - -void RunMain(void); -void RunMainClient(char* url); -void RunMainExitNode(void); - -#ifdef __cplusplus -} -#endif - -#endif /* LIBTUNNEL_H */ -HEADEREOF - fi + # Header liboflux.h is generated automatically by cgo from //export directives. echo "Build complete: $OUTPUT_DIR/$LIBRARY_NAME.a" ls -lh "$OUTPUT_DIR/$LIBRARY_NAME.a" diff --git a/build_ios_app.sh b/build_ios_app.sh new file mode 100755 index 0000000..c6a9c04 --- /dev/null +++ b/build_ios_app.sh @@ -0,0 +1,54 @@ +#!/bin/bash +# Full OpenFlux iOS pipeline: build Go lib -> generate Xcode project -> +# archive -> export App Store IPA -> (optional) upload to TestFlight. +# +# Requirements: Xcode, xcodegen (brew install xcodegen), Go 1.26+. +# Signing: automatic; your Apple ID must be logged into Xcode +# (Xcode > Settings > Accounts) and belong to team 8GQH8GQ252. +set -e + +ROOT="$(cd "$(dirname "$0")" && pwd)" +APP_DIR="$ROOT/ios-app" +TEAM_ID="8GQH8GQ252" + +echo "==> [1/5] Building Go static library (arm64, iOS)" +"$ROOT/build_ios.sh" + +echo "==> [2/5] Syncing library into the app" +mkdir -p "$APP_DIR/Lib" + cp "$ROOT/output/ios/liboflux.a" "$APP_DIR/Lib/liboflux.a" +cp "$ROOT/output/ios/liboflux.h" "$APP_DIR/Lib/liboflux.h" + +echo "==> [3/5] Generating Xcode project" +cd "$APP_DIR" +xcodegen generate + +echo "==> [4/5] Archiving (Release)" +rm -rf build/OpenFlux.xcarchive +xcodebuild -project OpenFlux.xcodeproj -scheme OpenFlux -configuration Release \ + -destination 'generic/platform=iOS' \ + -archivePath build/OpenFlux.xcarchive \ + -allowProvisioningUpdates \ + clean archive + +echo "==> [5/5] Exporting App Store IPA" +rm -rf build/export +xcodebuild -exportArchive \ + -archivePath build/OpenFlux.xcarchive \ + -exportPath build/export \ + -exportOptionsPlist ExportOptions.plist \ + -allowProvisioningUpdates + +echo "" +echo "IPA ready: $APP_DIR/build/export/OpenFlux.ipa" +echo "" +echo "To upload to TestFlight, first create the app record in App Store Connect" +echo "(My Apps > + > New App, bundle id com.p1neapplexpress-saharev.openflux), then run:" +echo "" +echo " # Option A - app-specific password (appleid.apple.com > App-Specific Passwords):" +echo " xcrun altool --upload-app -f build/export/OpenFlux.ipa -t ios \\" +echo " -u YOUR_APPLE_ID -p xxxx-xxxx-xxxx-xxxx" +echo "" +echo " # Option B - App Store Connect API key (.p8 in ~/.appstoreconnect/private_keys/):" +echo " xcrun altool --upload-app -f build/export/OpenFlux.ipa -t ios \\" +echo " --apiKey KEY_ID --apiIssuer ISSUER_ID" diff --git a/export_ios.go b/export_ios.go new file mode 100644 index 0000000..710adbc --- /dev/null +++ b/export_ios.go @@ -0,0 +1,427 @@ +//go:build ios + +package main + +/* +#include +*/ +import "C" + +import ( + "context" + "crypto/tls" + "fmt" + "net" + "os" + "path/filepath" + "runtime/debug" + "strconv" + "strings" + "sync" + "time" + "unsafe" + + "universal-bypass-tool/socks5" + "universal-bypass-tool/transport" + "universal-bypass-tool/transport/mailru" + "universal-bypass-tool/transport/oneme" + "universal-bypass-tool/transport/yandex" + "universal-bypass-tool/tunnel" + "universal-bypass-tool/utils" +) + +// ---- log ring buffer piped into the app UI ---- + +type ringLog struct { + mu sync.Mutex + lines []string +} + +func (r *ringLog) Write(p []byte) (int, error) { + r.mu.Lock() + defer r.mu.Unlock() + r.lines = append(r.lines, strings.TrimRight(string(p), "\n")) + if len(r.lines) > 1000 { + r.lines = r.lines[len(r.lines)-1000:] + } + return len(p), nil +} + +func (r *ringLog) drain() string { + r.mu.Lock() + defer r.mu.Unlock() + if len(r.lines) == 0 { + return "" + } + out := strings.Join(r.lines, "\n") + r.lines = r.lines[:0] + return out +} + +var logbuf = &ringLog{} + +// ---- running client state ---- + +var ( + stateMu sync.Mutex + running bool + socks *socks5.SOCKS5Server + trans transport.Transport +) + +func init() { + // Route log output into the ring buffer, but leave verbose logging OFF by + // default (production). The app can turn it on via OpenFluxSetDebug; the + // per-packet logging is expensive. + utils.SetOutput(logbuf) + + // Capture fatal Go crashes (throw/unrecovered panic — invisible in iOS crash + // logs) to a file, and surface the previous crash's traceback in the log on + // the next launch so we can diagnose device-only aborts. + setupCrashCapture() + + // The client's local (mobile) DNS may be poisoned for censored hosts + // (observed: ifconfig.me -> 240.0.1.72, a reserved address). Resolve names + // over DNS-over-TLS instead so DialTCP gets real IPs to hand the exit node. + net.DefaultResolver = &net.Resolver{ + PreferGo: true, + StrictErrors: false, + Dial: dialSecureDNS, + } +} + +// crashFile keeps the fatal-crash sink fd open for the process lifetime. +var crashFile *os.File + +func setupCrashCapture() { + path := filepath.Join(os.TempDir(), "oflux-crash.log") + // Surface the previous run's crash traceback (if any) into the app log. + if b, err := os.ReadFile(path); err == nil && len(b) > 0 { + logbuf.Write([]byte("===== PREVIOUS CRASH (Go traceback) =====")) + logbuf.Write(b) + logbuf.Write([]byte("===== END PREVIOUS CRASH =====")) + } + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) + if err != nil { + return + } + crashFile = f + debug.SetCrashOutput(f, debug.CrashOptions{}) +} + +// dotServer is a DNS-over-TLS endpoint (addr:853 + TLS SNI). +type dotServer struct { + addr string + sni string +} + +func defaultDoTServers() []dotServer { + return []dotServer{ + {"77.88.8.8:853", "common.dot.dns.yandex.net"}, // Yandex, reachable in-region + {"8.8.8.8:853", "dns.google"}, + {"1.1.1.1:853", "cloudflare-dns.com"}, + } +} + +var ( + dotMu sync.RWMutex + dotServers = defaultDoTServers() +) + +// getDoTServers returns a snapshot of the configured DoT resolvers. Callers must +// not mutate the result; it is safe to read concurrently with OpenFluxSetDoTResolver. +func getDoTServers() []dotServer { + dotMu.RLock() + defer dotMu.RUnlock() + out := make([]dotServer, len(dotServers)) + copy(out, dotServers) + return out +} + +// OpenFluxSetDoTResolver overrides the DNS-over-TLS upstreams used to resolve +// names (defeating local DNS poisoning). spec is a ";"-separated list of +// "addr[:port]@sni" entries, e.g. "1.1.1.1@cloudflare-dns.com". Port defaults to +// 853 and SNI defaults to the host if omitted. An empty spec restores the +// built-in defaults (Yandex/Google/Cloudflare). Call before starting a tunnel. +// +//export OpenFluxSetDoTResolver +func OpenFluxSetDoTResolver(spec *C.char) { + s := strings.TrimSpace(C.GoString(spec)) + dotMu.Lock() + defer dotMu.Unlock() + if s == "" { + dotServers = defaultDoTServers() + utils.Debugf("[DNS] resolver reset to defaults") + return + } + var servers []dotServer + for _, part := range strings.Split(s, ";") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + addr, sni := part, "" + if i := strings.LastIndex(part, "@"); i >= 0 { + addr, sni = strings.TrimSpace(part[:i]), strings.TrimSpace(part[i+1:]) + } + if !strings.Contains(addr, ":") { + addr += ":853" + } + if sni == "" { + if host, _, err := net.SplitHostPort(addr); err == nil { + sni = host + } else { + sni = addr + } + } + servers = append(servers, dotServer{addr: addr, sni: sni}) + } + if len(servers) > 0 { + dotServers = servers + utils.Debugf("[DNS] resolver set: %v", servers) + } +} + +// dialSecureDNS opens a DNS-over-TLS connection for net.Resolver, trying the +// configured servers in order. +func dialSecureDNS(ctx context.Context, _, _ string) (net.Conn, error) { + var lastErr error + for _, s := range getDoTServers() { + d := tls.Dialer{ + NetDialer: &net.Dialer{Timeout: 6 * time.Second}, + Config: &tls.Config{ServerName: s.sni, MinVersion: tls.VersionTLS12}, + } + conn, err := d.DialContext(ctx, "tcp", s.addr) + if err == nil { + return conn, nil + } + lastErr = err + utils.Debugf("[DNS] DoT %s failed: %v", s.addr, err) + } + return nil, lastErr +} + +// Return codes for OpenFluxStartClient. +const ( + startOK = 0 + startAlreadyRunning = 1 + startBadTransport = 2 + startTransportError = 3 + startAddrInUse = 4 // SOCKS5 port could not be bound (e.g. already in use) + startPanic = 5 +) + +// 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 +// AdaptiveTransport, a self-negotiating codec that starts legacy and upgrades to +// batching once the peer proves it speaks batch — so an updated app keeps +// working against an old exit node instead of breaking. Shared by both the +// in-app SOCKS core and the packet-tunnel extension. +func buildDocTransport(spec string, config transport.TransportConfig, factory func(string) transport.Transport) transport.Transport { + var urls []string + for _, u := range strings.Split(spec, ",") { + if u = strings.TrimSpace(u); u != "" { + urls = append(urls, u) + } + } + + if len(urls) <= 1 { + u := spec + if len(urls) == 1 { + u = urls[0] + } + return transport.NewAdaptiveTransport(factory(u)) + } + + channels := make([]transport.Transport, 0, len(urls)) + for _, u := range urls { + channels = append(channels, transport.NewAdaptiveTransport(factory(u))) + } + utils.Debugf("[BRIDGE] multiplex: %d channels", len(channels)) + return transport.NewMultiplexTransport(channels) +} + +// OpenFluxStartClient starts the SOCKS5 client tunnel. +// +// transportType: "yandex" or "oneme". +// url: Yandex.Docs document URL (yandex transport). A comma-separated +// list multiplexes across those documents. +// socksAddr: e.g. "127.0.0.1:1080". +// maxToken/maxUid: credentials for the "oneme" (MAX) transport; pass "" for yandex. +// +// Returns 0 on success, non-zero on error (see start* codes; details go to the log). +// +//export OpenFluxStartClient +func OpenFluxStartClient(transportType, url, socksAddr, maxToken, maxUid *C.char) (rc C.int) { + tt := C.GoString(transportType) + docURL := C.GoString(url) + addr := C.GoString(socksAddr) + mToken := C.GoString(maxToken) + mUid := C.GoString(maxUid) + + // Never let a panic unwind into the C/Swift caller and crash the app. + defer func() { + if r := recover(); r != nil { + utils.Debugf("[BRIDGE] Recovered from panic in start: %v", r) + rc = C.int(startPanic) + } + }() + + stateMu.Lock() + defer stateMu.Unlock() + if running { + utils.Debugf("[BRIDGE] Start ignored: already running") + return C.int(startAlreadyRunning) + } + + // Bind the SOCKS5 port up front so "address already in use" is reported + // cleanly to the UI instead of failing later in a background goroutine. + probe, err := net.Listen("tcp", addr) + if err != nil { + utils.Debugf("[BRIDGE] Cannot bind %s: %v", addr, err) + return C.int(startAddrInUse) + } + probe.Close() + + 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) + }) + case "volga", "vyandex": + t = buildDocTransport(docURL, config, func(u string) transport.Transport { + return yandex.NewYandexVolgaTransportWithConfig(u, config, yandex.SlimVolgaConfig()) + }) + case "mailru", "mail": + t = buildDocTransport(docURL, config, func(u string) transport.Transport { + return mailru.NewMailruDocsTransport(u, config) + }) + case "oneme": + uidint, _ := strconv.ParseInt(mUid, 10, 64) + t = transport.NewCompressedTransport(oneme.NewOneMeTransport(false, mToken, uidint, config)) + default: + utils.Debugf("[BRIDGE] Unknown transport type: %s", tt) + return C.int(startBadTransport) + } + + if err := t.Start(); err != nil { + utils.Debugf("[BRIDGE] Failed to start transport: %v", err) + return C.int(startTransportError) + } + + tun := tunnel.NewTCPTunnel(t, false) + srv := socks5.NewSOCKS5Server(addr, tun) + if err := srv.Bind(); err != nil { + utils.Debugf("[BRIDGE] Cannot bind %s: %v", addr, err) + t.Stop() + return C.int(startAddrInUse) + } + + trans = t + socks = srv + running = true + + go func() { + defer func() { + if r := recover(); r != nil { + utils.Debugf("[BRIDGE] Recovered from panic in SOCKS5 loop: %v", r) + } + }() + utils.Debugf("[BRIDGE] Client running (SOCKS5 on %s, transport %s)", addr, tt) + if err := srv.Start(); err != nil { + utils.Debugf("[BRIDGE] SOCKS5 server stopped: %v", err) + } + }() + + return C.int(startOK) +} + +// OpenFluxStop stops the running client (transport + SOCKS5 listener). +// +//export OpenFluxStop +func OpenFluxStop() { + stateMu.Lock() + defer stateMu.Unlock() + if !running { + return + } + if socks != nil { + socks.Close() + } + if trans != nil { + trans.Stop() + } + socks = nil + trans = nil + running = false + utils.Debugf("[BRIDGE] Stopped") +} + +// OpenFluxIsRunning returns 1 if the client is running, 0 otherwise. +// +//export OpenFluxIsRunning +func OpenFluxIsRunning() C.int { + stateMu.Lock() + defer stateMu.Unlock() + if running { + return C.int(1) + } + return C.int(0) +} + +// OpenFluxIsConnected returns 1 if the transport reports a live connection. +// +//export OpenFluxIsConnected +func OpenFluxIsConnected() C.int { + stateMu.Lock() + defer stateMu.Unlock() + if trans != nil && trans.IsConnected() { + return C.int(1) + } + return C.int(0) +} + +// OpenFluxStatsJSON returns a small JSON blob with transport stats. +// The returned string is C-allocated; free it with OpenFluxFreeString. +// +//export OpenFluxStatsJSON +func OpenFluxStatsJSON() *C.char { + stateMu.Lock() + defer stateMu.Unlock() + if trans == nil { + return C.CString(`{"running":false}`) + } + s := trans.Stats() + js := fmt.Sprintf( + `{"running":%t,"connected":%t,"bytesSent":%d,"bytesReceived":%d,"packetsSent":%d,"packetsRecv":%d,"reconnects":%d,"uptimeSec":%d}`, + running, s.Connected, s.BytesSent, s.BytesReceived, s.PacketsSent, s.PacketsRecv, s.Reconnects, + int64(s.Uptime/time.Second), + ) + return C.CString(js) +} + +// OpenFluxReadLog drains buffered log lines (newline-separated). +// The returned string is C-allocated; free it with OpenFluxFreeString. +// +//export OpenFluxReadLog +func OpenFluxReadLog() *C.char { + return C.CString(logbuf.drain()) +} + +// OpenFluxFreeString frees a string returned by this library. +// +//export OpenFluxFreeString +func OpenFluxFreeString(s *C.char) { + C.free(unsafe.Pointer(s)) +} + +// OpenFluxSetDebug toggles verbose (per-packet) logging at runtime. Off by +// default; enabling it costs CPU, so only turn it on while debugging. +// +//export OpenFluxSetDebug +func OpenFluxSetDebug(on C.int) { + utils.SetDebug(on != 0) +} diff --git a/export_ios_packet.go b/export_ios_packet.go new file mode 100644 index 0000000..757bdd4 --- /dev/null +++ b/export_ios_packet.go @@ -0,0 +1,372 @@ +//go:build ios + +package main + +/* +#include +*/ +import "C" + +import ( + "context" + "crypto/tls" + "encoding/binary" + "io" + "net" + "runtime/debug" + "strconv" + "sync" + "sync/atomic" + "time" + "unsafe" + + "universal-bypass-tool/network" + "universal-bypass-tool/transport" + "universal-bypass-tool/transport/mailru" + "universal-bypass-tool/transport/oneme" + "universal-bypass-tool/transport/yandex" + "universal-bypass-tool/utils" +) + +// Packet-tunnel (NEPacketTunnelProvider) mode — pure L3 forwarding. +// +// The device is given tunnel address 10.10.10.2, which is exactly what the exit +// node expects (it hardcodes returns to 10.10.10.2). So we forward the device's +// raw IP packets straight over the transport — no gvisor stack on the client, +// which keeps the extension well under its memory cap and preserves full TCP +// throughput end-to-end. Only TCP is forwarded (the exit node is TCP-only); +// DNS (UDP 53) is answered locally over DNS-over-TLS. +// +// Uses startOK / start* codes and dotServers from export_ios.go. + +const tunClientIP = "10.10.10.2" + +var ( + ptMu sync.Mutex + ptOn bool + ptTrans transport.Transport + ptOutQ chan []byte + ptCtx context.Context + ptCancel context.CancelFunc +) + +//export OpenFluxStartPacketTunnel +func OpenFluxStartPacketTunnel(transportType, url, maxToken, maxUid *C.char) (rc C.int) { + tt := C.GoString(transportType) + docURL := C.GoString(url) + mToken := C.GoString(maxToken) + mUid := C.GoString(maxUid) + + defer func() { + if r := recover(); r != nil { + utils.Debugf("[PKT] Recovered from panic in start: %v", r) + rc = C.int(startPanic) + } + }() + + ptMu.Lock() + defer ptMu.Unlock() + if ptOn { + return C.int(startAlreadyRunning) + } + + // Memory strategy: SetMemoryLimit is the hard backstop that keeps us under + // the NE cap; GCPercent then only controls how eagerly we collect BELOW that + // limit. GCPercent=20 forced a GC on every 20% heap growth — under a + // throughput load (compression + WebSocket framing + packet copies) that + // pins the phone CPU in near-continuous GC and caps throughput. Raise it so + // GC is driven by the memory limit, not by needless frequent cycles; peak + // memory is still bounded by SetMemoryLimit, so this does not risk jetsam. + debug.SetMemoryLimit(40 << 20) + debug.SetGCPercent(100) + + 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) + }) + case "volga", "vyandex": + // Slim VOLGA profile so the relay worker pool + queues stay under the + // NE memory cap (the default is a server profile). Note: multiplexing + // VOLGA multiplies that pool per channel, so keep VOLGA lists short. + t = buildDocTransport(docURL, config, func(u string) transport.Transport { + return yandex.NewYandexVolgaTransportWithConfig(u, config, yandex.SlimVolgaConfig()) + }) + case "mailru", "mail": + t = buildDocTransport(docURL, config, func(u string) transport.Transport { + return mailru.NewMailruDocsTransport(u, config) + }) + case "oneme": + uidint, _ := strconv.ParseInt(mUid, 10, 64) + t = transport.NewCompressedTransport(oneme.NewOneMeTransport(false, mToken, uidint, config)) + default: + return C.int(startBadTransport) + } + + // 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 + // absorbs bursts; entries are transient and bounded by the memory limit. + outQ := make(chan []byte, 4096) + // Packets coming back from the exit node -> queue for the device. + t.Receive(func(data []byte) { + select { + case outQ <- append([]byte(nil), data...): + default: // queue full: drop, TCP will retransmit + } + }) + + if err := t.Start(); err != nil { + utils.Debugf("[PKT] transport start failed: %v", err) + return C.int(startTransportError) + } + + ptTrans = t + ptOutQ = outQ + ptCtx, ptCancel = context.WithCancel(context.Background()) + ptOn = true + utils.Debugf("[PKT] L3 packet tunnel started (transport %s)", tt) + return C.int(startOK) +} + +// OpenFluxTunWritePacket forwards one device IPv4 packet: TCP goes over the +// transport, DNS (UDP 53) is answered locally, other UDP is dropped. +// +//export OpenFluxTunWritePacket +func OpenFluxTunWritePacket(buf *C.char, length C.int) { + defer func() { _ = recover() }() // never let a bad packet crash the extension + if buf == nil || length < 20 { + return + } + ptMu.Lock() + t := ptTrans + outQ := ptOutQ + ptMu.Unlock() + if t == nil { + return + } + pkt := C.GoBytes(unsafe.Pointer(buf), length) + if pkt[0]>>4 != 4 { // IPv4 only + return + } + switch pkt[9] { // protocol + case 6: // TCP + t.Send(pkt) + case 17: // UDP + ihl := int(pkt[0]&0x0f) * 4 + if len(pkt) < ihl+8 { + return + } + dstPort := binary.BigEndian.Uint16(pkt[ihl+2 : ihl+4]) + if dstPort == 53 { + // DNS is answered locally over DNS-over-TLS (defeats poisoning and + // avoids a round-trip through the covert channel for every query). + // Bound concurrent resolutions so a burst can't spawn an unbounded + // pile of goroutines + TLS handshakes (memory). + select { + case dnsSem <- struct{}{}: + go func() { defer func() { <-dnsSem }(); handleDNSPacket(pkt, outQ) }() + default: // too many in flight: drop, the client retries + } + } else if tunnelUDP.Load() { + // UDP tunneling ON: forward all other UDP (QUIC/HTTP3, games, …) + // over the transport; the exit node NATs it on its raw socket. + // Requires a UDP-capable exit node. + t.Send(pkt) + } else { + // UDP tunneling OFF (default, legacy-safe): reply ICMP + // port-unreachable so apps fall back from QUIC/UDP:443 to TCP fast + // instead of stalling — works against any (TCP-only) exit node. + sendICMPPortUnreachable(pkt, outQ) + } + } +} + +// tunnelUDP controls whether non-DNS UDP is forwarded over the transport (ON, +// needs a UDP-capable exit node) or fast-failed with ICMP (OFF, legacy-safe on +// any exit node). Default OFF so a single build works against both node types. +var tunnelUDP atomic.Bool + +// OpenFluxSetTunnelUDP toggles UDP forwarding. Set before starting the tunnel +// (the extension reads it from providerConfiguration). +// +//export OpenFluxSetTunnelUDP +func OpenFluxSetTunnelUDP(on C.int) { tunnelUDP.Store(on != 0) } + +// sendICMPPortUnreachable enqueues an ICMP "destination/port unreachable" for a +// UDP datagram we won't forward, so the sender falls back to TCP fast. +func sendICMPPortUnreachable(orig []byte, outQ chan []byte) { + ihl := int(orig[0]&0x0f) * 4 + if len(orig) < ihl+8 { + return + } + quote := orig[:ihl+8] // original IP header + 8 bytes (per RFC 792) + icmp := make([]byte, 8+len(quote)) + icmp[0] = 3 // Destination Unreachable + icmp[1] = 3 // Port Unreachable + copy(icmp[8:], quote) + ck := network.IPChecksum(icmp) + icmp[2] = byte(ck >> 8) + icmp[3] = byte(ck & 0xFF) + + total := 20 + len(icmp) + ip := make([]byte, total) + ip[0] = 0x45 + binary.BigEndian.PutUint16(ip[2:4], uint16(total)) + 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]) + ip[10] = byte(ck2 >> 8) + ip[11] = byte(ck2 & 0xFF) + copy(ip[20:], icmp) + + select { + case outQ <- ip: + default: + } +} + +// dnsSem caps concurrent DNS-over-TLS resolutions. +var dnsSem = make(chan struct{}, 16) + +// OpenFluxTunReadPacket blocks for the next packet destined to the device. +// +//export OpenFluxTunReadPacket +func OpenFluxTunReadPacket(buf *C.char, max C.int) C.int { + ptMu.Lock() + outQ := ptOutQ + ctx := ptCtx + ptMu.Unlock() + if outQ == nil || ctx == nil { + return 0 + } + select { + case data := <-outQ: + n := len(data) + if n > int(max) { + n = int(max) + } + dst := unsafe.Slice((*byte)(unsafe.Pointer(buf)), int(max)) + copy(dst[:n], data[:n]) + return C.int(n) + case <-ctx.Done(): + return 0 + } +} + +//export OpenFluxStopPacketTunnel +func OpenFluxStopPacketTunnel() { + ptMu.Lock() + defer ptMu.Unlock() + if !ptOn { + return + } + if ptCancel != nil { + ptCancel() + } + if ptTrans != nil { + ptTrans.Stop() + } + ptTrans = nil + ptOutQ = nil + ptOn = false + utils.Debugf("[PKT] L3 packet tunnel stopped") +} + +// handleDNSPacket answers a device DNS query over DNS-over-TLS and enqueues a +// UDP response packet back to the device. +func handleDNSPacket(req []byte, outQ chan []byte) { + defer func() { _ = recover() }() + ihl := int(req[0]&0x0f) * 4 + if len(req) < ihl+8 { + return + } + srcIP := req[12:16] + dstIP := req[16:20] + srcPort := req[ihl : ihl+2] + dstPort := req[ihl+2 : ihl+4] + query := req[ihl+8:] + if len(query) == 0 { + return + } + + answer, err := dnsOverTLS(query) + if err != nil || len(answer) == 0 { + utils.Debugf("[DNS] resolve failed: %v", err) + return + } + + // Build the response: swap addresses/ports (dst<->src), UDP checksum 0. + udpLen := 8 + len(answer) + total := ihl + udpLen + resp := make([]byte, total) + // IP header: copy version/IHL/TOS, set total length, TTL/proto, addresses. + resp[0] = req[0] + resp[1] = req[1] + binary.BigEndian.PutUint16(resp[2:4], uint16(total)) + 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 + ipck := network.IPChecksum(resp[:20]) + resp[10] = byte(ipck >> 8) + resp[11] = byte(ipck & 0xFF) + // UDP header + copy(resp[ihl:ihl+2], dstPort) // src port = 53 + copy(resp[ihl+2:ihl+4], srcPort) // dst port = device's + binary.BigEndian.PutUint16(resp[ihl+4:ihl+6], uint16(udpLen)) + // checksum 0 (allowed for IPv4 UDP) + copy(resp[ihl+8:], answer) + + select { + case outQ <- resp: + default: + } +} + +// dnsOverTLS sends a DNS query to a DoT resolver (RFC 7858, length-prefixed) +// and returns the raw DNS answer, trying each server in turn. +func dnsOverTLS(query []byte) ([]byte, error) { + var lastErr error + for _, s := range getDoTServers() { + ans, err := dotQueryOne(s, query) + if err == nil { + return ans, nil + } + lastErr = err + } + return nil, lastErr +} + +func dotQueryOne(s dotServer, query []byte) ([]byte, error) { + d := tls.Dialer{ + NetDialer: &net.Dialer{Timeout: 6 * time.Second}, + Config: &tls.Config{ServerName: s.sni, MinVersion: tls.VersionTLS12}, + } + conn, err := d.DialContext(context.Background(), "tcp", s.addr) + if err != nil { + return nil, err + } + defer conn.Close() + conn.SetDeadline(time.Now().Add(6 * time.Second)) + + var lp [2]byte + binary.BigEndian.PutUint16(lp[:], uint16(len(query))) + if _, err := conn.Write(append(lp[:], query...)); err != nil { + return nil, err + } + hdr := make([]byte, 2) + if _, err := io.ReadFull(conn, hdr); err != nil { + return nil, err + } + ans := make([]byte, binary.BigEndian.Uint16(hdr)) + if _, err := io.ReadFull(conn, ans); err != nil { + return nil, err + } + return ans, nil +} diff --git a/go.mod b/go.mod index 2760631..4684afa 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,7 @@ require ( 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 diff --git a/go.sum b/go.sum index 2aa0d61..f7dbb94 100644 --- a/go.sum +++ b/go.sum @@ -9,6 +9,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= +github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= diff --git a/ios-app/DISTRIBUTION.ru.md b/ios-app/DISTRIBUTION.ru.md new file mode 100644 index 0000000..90db4c7 --- /dev/null +++ b/ios-app/DISTRIBUTION.ru.md @@ -0,0 +1,89 @@ +# OpenFlux iOS — распространение (TestFlight / App Store Connect) + +Документ фиксирует, что вопрос сборки и распространения iOS-приложения OpenFlux +был проработан: приложение собрано с нуля, подписано и загружено в App Store Connect. + +## Что сделано + +1. **Go-ядро → статическая библиотека.** Добавлен cgo-слой экспортов + (`../export_ios.go`, сборка по тегу `ios`): `OpenFluxStartClient`, + `OpenFluxStop`, `OpenFluxIsRunning`, `OpenFluxIsConnected`, + `OpenFluxStatsJSON`, `OpenFluxReadLog`, `OpenFluxFreeString`. + Сборка: `../build_ios.sh` → `../output/ios/liboflux.a` (+ авто-заголовок `liboflux.h`). +2. **iOS-приложение (SwiftUI, XcodeGen).** Экран с полем Yandex.Docs URL, + Start/Stop, индикатор состояния, живой лог и кнопка Test (проверяет тоннель + запросом через локальный SOCKS5 `127.0.0.1:1080`). Линкует `liboflux.a`. +3. **Иконка** 1024×1024 в asset-каталоге (обязательна для загрузки). +4. **Подпись и архив.** Automatic signing, team `8GQH8GQ252`, Cloud Managed + Apple Distribution. Экспортирован App Store `.ipa`. +5. **Загрузка в App Store Connect** через ASC API-ключ (см. ниже). + Флаг `ITSAppUsesNonExemptEncryption = false` — экспортная документация по + шифрованию не требуется (используется только стандартный HTTPS/TLS). + +## Параметры + +| Параметр | Значение | +|---|---| +| Bundle ID | `com.p1neapplexpress-saharev.openflux` | +| Team ID | `8GQH8GQ252` (Alexandr Revin, Individual) | +| Marketing version | `1.0.0` | +| Deployment target | iOS 15.0 | +| Architecture | arm64 (device) | +| ASC API Key ID | `` | +| ASC Issuer ID | `` | +| Приватный ключ | `~/.appstoreconnect/private_keys/AuthKey_.p8` (НЕ коммитить) | + +## Полная сборка одной командой + +Из корня репозитория: +```bash +./build_ios_app.sh +``` +Результат: `ios-app/build/export/OpenFlux.ipa` (подписан для App Store). + +## Загрузка в TestFlight + +```bash +xcrun altool --upload-app -f ios-app/build/export/OpenFlux.ipa -t ios \ + --apiKey --apiIssuer +``` +Билд появляется в TestFlight через несколько минут после обработки Apple. + +### Новый билд +Перед каждой новой загрузкой поднять номер сборки в `ios-app/project.yml`: +```yaml +settings: + base: + CURRENT_PROJECT_VERSION: "3" # +1 +``` +Apple не принимает повторно тот же номер сборки. + +## История загрузок + +| Дата | Build | Delivery UUID | Примечание | +|---|---|---|---| +| 2026-09-10 | 1 | `` | первый билд | +| 2026-09-10 | 2 | `` | + флаг шифрования, без экспортной документации | +| 2026-09-10 | 3 | `` | + транспорт MAX в UI, настраиваемый порт, no-crash bind | + +## Ограничения и риски распространения + +- Приложение — инструмент обхода блокировок. Для **публичного** распространения + в App Store действуют Guideline **5.4 (VPN)**: требуется `NetworkExtension` + и аккаунт-**организация** (не Individual); текущий вид почти наверняка получит + reject на ревью. +- **TestFlight Internal Testing** (разработчик + внутренние пользователи) Beta App + Review не проходит — работает уже сейчас. **External Testing** и релиз — проходят. +- Возможно региональное снятие (Apple удаляла обходные приложения из ряда сторов + по требованию регуляторов). +- Тоннелирование через Яндекс.Документы / MAX вероятно нарушает их ToS. +- Приложение поднимает **локальный** SOCKS5; системное туннелирование всего + устройства потребует отдельного таргета `NEPacketTunnelProvider` — в этот билд + не входит. + +## Безопасность + +- `AuthKey_*.p8` — **секрет**, даёт доступ к App Store Connect. Не коммитить в git, + хранить только в `~/.appstoreconnect/private_keys/`. Компрометацию — отзывать в + App Store Connect → Users and Access → Integrations. +- Key ID и Issuer ID сами по себе не секретны, но без `.p8` бесполезны. diff --git a/ios-app/ExportOptions.plist b/ios-app/ExportOptions.plist new file mode 100644 index 0000000..786d064 --- /dev/null +++ b/ios-app/ExportOptions.plist @@ -0,0 +1,18 @@ + + + + + method + app-store + destination + export + teamID + 8GQH8GQ252 + signingStyle + automatic + uploadSymbols + + manageAppVersionAndBuildNumber + + + diff --git a/ios-app/OpenFlux/Assets.xcassets/AccentColor.colorset/Contents.json b/ios-app/OpenFlux/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..b2a5261 --- /dev/null +++ b/ios-app/OpenFlux/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,4 @@ +{ + "colors": [ { "idiom": "universal" } ], + "info": { "author": "xcode", "version": 1 } +} diff --git a/ios-app/OpenFlux/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios-app/OpenFlux/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..efca0a1 --- /dev/null +++ b/ios-app/OpenFlux/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,11 @@ +{ + "images": [ + { + "filename": "icon-1024.png", + "idiom": "universal", + "platform": "ios", + "size": "1024x1024" + } + ], + "info": { "author": "xcode", "version": 1 } +} diff --git a/ios-app/OpenFlux/Assets.xcassets/AppIcon.appiconset/icon-1024.png b/ios-app/OpenFlux/Assets.xcassets/AppIcon.appiconset/icon-1024.png new file mode 100644 index 0000000..ffac646 Binary files /dev/null and b/ios-app/OpenFlux/Assets.xcassets/AppIcon.appiconset/icon-1024.png differ diff --git a/ios-app/OpenFlux/Assets.xcassets/Contents.json b/ios-app/OpenFlux/Assets.xcassets/Contents.json new file mode 100644 index 0000000..ac502da --- /dev/null +++ b/ios-app/OpenFlux/Assets.xcassets/Contents.json @@ -0,0 +1 @@ +{ "info": { "author": "xcode", "version": 1 } } diff --git a/ios-app/OpenFlux/ContentView.swift b/ios-app/OpenFlux/ContentView.swift new file mode 100644 index 0000000..ebaa2d3 --- /dev/null +++ b/ios-app/OpenFlux/ContentView.swift @@ -0,0 +1,394 @@ +import SwiftUI +import UIKit + +struct ContentView: View { + @StateObject private var tunnel = TunnelController() + @StateObject private var vpn = VPNController() + + @AppStorage("transportKind") private var transportRaw: String = TransportKind.yandex.rawValue + // Each transport keeps its OWN document field so switching methods doesn't + // carry a Yandex link into the Mail.ru/VOLGA field and vice versa. + @AppStorage("docURL") private var docURL: String = "" // Yandex Docs #1 + @AppStorage("docURL2") private var docURL2: String = "" // Yandex Docs #2 (optional) + @AppStorage("volgaURL") private var volgaURL: String = "" // VOLGA (single) + @AppStorage("mailURL") private var mailURL: String = "" // Mail.ru (single) + @State private var showSecondDoc = false + @AppStorage("maxToken") private var maxToken: String = "" + @AppStorage("maxUid") private var maxUid: String = "" + // Uncommon default port to avoid clashing with other local proxies. + @AppStorage("socksPort") private var socksPort: String = "10808" + @AppStorage("debugLog") private var debugLog: Bool = false + @AppStorage("dnsPreset") private var dnsPreset: String = "default" + @AppStorage("dnsCustom") private var dnsCustom: String = "" + @AppStorage("tunnelUDP") private var tunnelUDP: Bool = false + @State private var showInfo = false + @State private var showImportResult = false + @State private var importOK = false + + private var transport: TransportKind { + TransportKind(rawValue: transportRaw) ?? .yandex + } + + /// DoT resolver spec passed to the Go core ("" = built-in defaults). + private var dnsSpec: String { + switch dnsPreset { + case "cloudflare": return "1.1.1.1@cloudflare-dns.com" + case "google": return "8.8.8.8@dns.google" + case "quad9": return "9.9.9.9@dns.quad9.net" + case "adguard": return "94.140.14.14@dns.adguard-dns.com" + case "custom": return dnsCustom.trimmingCharacters(in: .whitespaces) + default: return "" + } + } + + /// Push the current DoT resolver into the in-app Go core (the SOCKS/test + /// path). The VPN extension gets it separately via providerConfiguration. + private func applyDNS() { + dnsSpec.withCString { OpenFluxSetDoTResolver(UnsafeMutablePointer(mutating: $0)) } + } + + /// Import an "OFLUX1:" config string (base64url of {t,u}) — sets the + /// transport and document URL in one paste. Returns false if it can't parse. + @discardableResult + private func importConfig(_ raw: String) -> Bool { + let s = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard s.hasPrefix("OFLUX1:") else { return false } + var b64 = String(s.dropFirst("OFLUX1:".count)) + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + while b64.count % 4 != 0 { b64 += "=" } + guard let data = Data(base64Encoded: b64), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let u = obj["u"] as? String, !u.isEmpty else { return false } + let t = (obj["t"] as? String) ?? "volga" + let kind = (t == "vyandex" ? "volga" : t) + transportRaw = kind + // A config may bundle a comma-separated document list. Route it into the + // field that belongs to the imported transport. + let parts = u.split(separator: ",").map { + $0.trimmingCharacters(in: .whitespaces) + }.filter { !$0.isEmpty } + let first = parts.first ?? u + switch kind { + case "yandex": + docURL = first + if parts.count > 1 { docURL2 = parts[1]; showSecondDoc = true } + else { docURL2 = ""; showSecondDoc = false } + case "volga": + volgaURL = first + case "mailru": + mailURL = first + default: + docURL = first + } + return true + } + + /// Document URL(s) handed to the Go core. Yandex.Docs may run two channels + /// (comma-joined); VOLGA is single-document only, so its second field is + /// never included even if one was left over from a previous transport. + private var effectiveURL: String { + switch transport { + case .yandex: + let a = docURL.trimmingCharacters(in: .whitespaces) + let b = docURL2.trimmingCharacters(in: .whitespaces) + return b.isEmpty ? a : "\(a),\(b)" + case .volga: return volgaURL.trimmingCharacters(in: .whitespaces) + case .mail: return mailURL.trimmingCharacters(in: .whitespaces) + case .max: return "" + } + } + + private var canStart: Bool { + guard (Int(socksPort) ?? 0) > 0 else { return false } + switch transport { + case .yandex: return !docURL.trimmingCharacters(in: .whitespaces).isEmpty + case .volga: return !volgaURL.trimmingCharacters(in: .whitespaces).isEmpty + case .mail: return !mailURL.trimmingCharacters(in: .whitespaces).isEmpty + case .max: return !maxToken.isEmpty && !maxUid.isEmpty + } + } + + var body: some View { + NavigationView { + ScrollView { + VStack(spacing: 16) { + statusHeader + + Picker("Transport", selection: $transportRaw) { + ForEach(TransportKind.allCases) { t in + Text(t.title).tag(t.rawValue) + } + } + .pickerStyle(.segmented) + .disabled(tunnel.running) + + Button { + importOK = importConfig(UIPasteboard.general.string ?? "") + showImportResult = true + } label: { + Label("Import config from clipboard", systemImage: "square.and.arrow.down") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .disabled(tunnel.running) + + connectionFields + + portField + + dnsSection + + controls + + vpnSection + + logView + } + .padding() + } + .navigationTitle("OpenFlux") + .onAppear { + OpenFluxSetDebug(debugLog ? 1 : 0) + applyDNS() + } + .onChange(of: dnsPreset) { _ in applyDNS() } + .onChange(of: dnsCustom) { _ in applyDNS() } + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { showInfo = true } label: { + Image(systemName: "info.circle") + } + } + } + .sheet(isPresented: $showInfo) { InfoView() } + .alert(importOK ? "Config imported" : "No valid config", + isPresented: $showImportResult) { + Button("OK", role: .cancel) {} + } message: { + Text(importOK + ? "Transport and document URL were filled in. Tap Start VPN." + : "Copy an OFLUX1:… config string, then tap Import again.") + } + } + .navigationViewStyle(.stack) + } + + @ViewBuilder + private var connectionFields: some View { + switch transport { + case .yandex: + field(title: "Yandex Docs URL", + placeholder: "https://disk.yandex.ru/i/…", + text: $docURL) + if showSecondDoc || !docURL2.isEmpty { + field(title: "Yandex Docs URL 2", + placeholder: "second document (optional)", + text: $docURL2) + Button(role: .destructive) { + docURL2 = "" + showSecondDoc = false + } label: { + Label("Remove second document", systemImage: "minus.circle") + } + .font(.footnote) + .disabled(tunnel.running) + } else { + Button { showSecondDoc = true } label: { + Label("Add second document", systemImage: "plus.circle") + } + .font(.footnote) + .disabled(tunnel.running) + } + Text("Two documents run in parallel for more speed and failover. The exit node must serve the same documents.") + .font(.caption2).foregroundColor(.secondary) + case .volga: + field(title: "VOLGA document URL", + placeholder: "https://disk.yandex.ru/i/…", + text: $volgaURL) + Text("VOLGA supports a single document only.") + .font(.caption2).foregroundColor(.secondary) + case .mail: + field(title: "Mail.ru public link", + placeholder: "https://cloud.mail.ru/public/…", + text: $mailURL) + Text("Mail.ru Cloud public document link (single document).") + .font(.caption2).foregroundColor(.secondary) + case .max: + field(title: "MAX token", placeholder: "auth token", text: $maxToken) + field(title: "MAX user ID", placeholder: "numeric id", text: $maxUid, + keyboard: .numberPad) + } + } + + private var portField: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Local SOCKS5 port").font(.caption).foregroundColor(.secondary) + TextField("10808", text: $socksPort) + .keyboardType(.numberPad) + .textFieldStyle(.roundedBorder) + .disabled(tunnel.running) + } + } + + private var controls: some View { + VStack(spacing: 12) { + HStack(spacing: 12) { + if tunnel.running { + Button(role: .destructive) { tunnel.stop() } label: { + Label("Stop", systemImage: "stop.fill").frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + } else { + Button { + tunnel.start(transport: transport, + url: effectiveURL, + maxToken: maxToken, + maxUid: maxUid, + port: Int(socksPort) ?? 10808) + } label: { + Label("Start", systemImage: "play.fill").frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .disabled(!canStart || vpn.active) + } + Button { + // In-app core running -> test via SOCKS; system VPN active + // -> test the VPN path directly (no proxy). + if tunnel.running { tunnel.testThroughProxy() } + else { tunnel.testDirect() } + } label: { + Label("Test", systemImage: "network").frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .disabled(!tunnel.running && !vpn.active) + } + if tunnel.running { + Text("SOCKS5 proxy: \(tunnel.socksAddr)") + .font(.footnote).foregroundColor(.secondary) + } + if vpn.active { + Text("System VPN is active — the in-app proxy is off (they can't run together). Test connectivity by opening a site.") + .font(.caption2).foregroundColor(.secondary) + } + } + } + + private var dnsSection: some View { + VStack(alignment: .leading, spacing: 6) { + Text("DNS (DNS-over-TLS)").font(.caption).foregroundColor(.secondary) + Picker("DNS", selection: $dnsPreset) { + Text("Default (Yandex/Google/CF)").tag("default") + Text("Cloudflare").tag("cloudflare") + Text("Google").tag("google") + Text("Quad9").tag("quad9") + Text("AdGuard").tag("adguard") + Text("Custom…").tag("custom") + } + .pickerStyle(.menu) + .disabled(tunnel.running) + if dnsPreset == "custom" { + TextField("1.1.1.1@cloudflare-dns.com", text: $dnsCustom) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + .keyboardType(.URL) + .textFieldStyle(.roundedBorder) + .disabled(tunnel.running) + Text("Format: address[:port]@tls-hostname") + .font(.caption2).foregroundColor(.secondary) + } + } + } + + private var vpnSection: some View { + VStack(alignment: .leading, spacing: 8) { + Divider() + HStack { + Text("System VPN (all traffic)").font(.subheadline).bold() + Spacer() + Text(vpn.status).font(.caption).foregroundColor(.secondary) + } + if vpn.active { + Button(role: .destructive) { vpn.stop() } label: { + Label("Stop VPN", systemImage: "bolt.slash.fill").frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + } else { + Button { + // Mutual exclusion: the in-app SOCKS core and the system VPN + // are both "the client" and would collide on the same document + // (both use client IP 10.10.10.2). Stop the in-app core first. + tunnel.stop() + vpn.start(transport: transport.rawValue, url: effectiveURL, + maxToken: maxToken, maxUid: maxUid, dns: dnsSpec, + tunnelUDP: tunnelUDP) + } label: { + Label("Start VPN", systemImage: "bolt.fill").frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .disabled(!canStart) + } + Text("Routes the whole device through the exit node (TCP + DNS-over-TCP).") + .font(.caption2).foregroundColor(.secondary) + Toggle(isOn: $tunnelUDP) { + Text("Tunnel UDP / QUIC").font(.caption) + } + .disabled(vpn.active) + Text("Off = QUIC falls back to TCP (works on any node). On = tunnel UDP — needs a UDP-capable exit node.") + .font(.caption2).foregroundColor(.secondary) + } + } + + private func field(title: String, placeholder: String, text: Binding, + keyboard: UIKeyboardType = .default) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(title).font(.caption).foregroundColor(.secondary) + TextField(placeholder, text: text) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + .keyboardType(keyboard) + .textFieldStyle(.roundedBorder) + .disabled(tunnel.running) + } + } + + private var statusHeader: some View { + HStack { + Circle() + .fill(tunnel.connected ? Color.green : (tunnel.running ? Color.orange : Color.gray)) + .frame(width: 12, height: 12) + Text(tunnel.connected ? "Connected" : (tunnel.running ? "Connecting…" : "Stopped")) + .font(.headline) + Spacer() + } + } + + private var logView: some View { + VStack(alignment: .leading, spacing: 4) { + Toggle(isOn: $debugLog) { + Text("Verbose log").font(.caption).foregroundColor(.secondary) + } + .onChange(of: debugLog) { on in OpenFluxSetDebug(on ? 1 : 0) } + Text("Log").font(.caption).foregroundColor(.secondary) + ScrollViewReader { proxy in + ScrollView { + Text(tunnel.log.isEmpty ? "—" : tunnel.log) + .font(.system(.caption2, design: .monospaced)) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + .id("logtail") + } + .onChange(of: tunnel.log) { _ in + withAnimation { proxy.scrollTo("logtail", anchor: .bottom) } + } + } + .frame(height: 240) + .background(Color(.secondarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + } +} + +#Preview { + ContentView() +} diff --git a/ios-app/OpenFlux/Info.plist b/ios-app/OpenFlux/Info.plist new file mode 100644 index 0000000..497aaa7 --- /dev/null +++ b/ios-app/OpenFlux/Info.plist @@ -0,0 +1,37 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + ITSAppUsesNonExemptEncryption + + LSRequiresIPhoneOS + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + UILaunchScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + + diff --git a/ios-app/OpenFlux/InfoView.swift b/ios-app/OpenFlux/InfoView.swift new file mode 100644 index 0000000..5004359 --- /dev/null +++ b/ios-app/OpenFlux/InfoView.swift @@ -0,0 +1,80 @@ +import SwiftUI +import UIKit + +/// About screen with donation addresses (tap a row to copy). +struct InfoView: View { + @Environment(\.dismiss) private var dismiss + @State private var copied: String? + + private let sol = "7yXWW2iAkKadyVvMjZLPYo1PqizvKqseG2ZQ1sZk9X1k" + private let eth = "0xd043E852158C13C8064a73b9cDd920DaAa80f0c1" + + var body: some View { + NavigationView { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + VStack(alignment: .leading, spacing: 6) { + Text("OpenFlux").font(.title2).bold() + Text("TCP-туннель через скрытый транспорт. Клиент поднимает локальный SOCKS5 и системный VPN, трафик идёт через exit-node.") + .font(.footnote).foregroundColor(.secondary) + } + + VStack(alignment: .leading, spacing: 12) { + Text("Поддержать разработку ♥") + .font(.headline) + Text("Нажми на адрес, чтобы скопировать.") + .font(.caption).foregroundColor(.secondary) + + donationRow(title: "Solana (SOL)", address: sol) + donationRow(title: "Ethereum (ETH)", address: eth) + + if let c = copied { + Label("\(c) скопирован", systemImage: "checkmark.circle.fill") + .font(.caption).foregroundColor(.green) + } + } + .padding() + .background(Color(.secondarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + + Spacer(minLength: 0) + } + .padding() + } + .navigationTitle("О приложении") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Закрыть") { dismiss() } + } + } + } + .navigationViewStyle(.stack) + } + + private func donationRow(title: String, address: String) -> some View { + Button { + UIPasteboard.general.string = address + copied = title + } label: { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(title).font(.subheadline).bold() + Spacer() + Image(systemName: "doc.on.doc").font(.caption) + } + Text(address) + .font(.system(.caption2, design: .monospaced)) + .foregroundColor(.secondary) + .multilineTextAlignment(.leading) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background(Color(.tertiarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + } +} diff --git a/ios-app/OpenFlux/OpenFlux-Bridging-Header.h b/ios-app/OpenFlux/OpenFlux-Bridging-Header.h new file mode 100644 index 0000000..a007add --- /dev/null +++ b/ios-app/OpenFlux/OpenFlux-Bridging-Header.h @@ -0,0 +1,6 @@ +#ifndef OpenFlux_Bridging_Header_h +#define OpenFlux_Bridging_Header_h + +#include "liboflux.h" + +#endif diff --git a/ios-app/OpenFlux/OpenFlux.entitlements b/ios-app/OpenFlux/OpenFlux.entitlements new file mode 100644 index 0000000..c3f437e --- /dev/null +++ b/ios-app/OpenFlux/OpenFlux.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.developer.networking.networkextension + + packet-tunnel-provider + + + diff --git a/ios-app/OpenFlux/OpenFluxApp.swift b/ios-app/OpenFlux/OpenFluxApp.swift new file mode 100644 index 0000000..26b70ef --- /dev/null +++ b/ios-app/OpenFlux/OpenFluxApp.swift @@ -0,0 +1,10 @@ +import SwiftUI + +@main +struct OpenFluxApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/ios-app/OpenFlux/TunnelController.swift b/ios-app/OpenFlux/TunnelController.swift new file mode 100644 index 0000000..492fbfb --- /dev/null +++ b/ios-app/OpenFlux/TunnelController.swift @@ -0,0 +1,167 @@ +import Foundation +import Combine + +enum TransportKind: String, CaseIterable, Identifiable { + case yandex = "yandex" + case volga = "volga" + case mail = "mailru" + case max = "oneme" + var id: String { rawValue } + var title: String { + switch self { + case .yandex: return "Yandex Docs" + case .volga: return "VOLGA" + 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 == .mail } +} + +/// Swift wrapper around the OpenFlux Go static library (liboflux.a). +@MainActor +final class TunnelController: ObservableObject { + @Published var running = false + @Published var connected = false + @Published var log: String = "" + @Published var stats: String = "" + + private var timer: Timer? + + /// Local SOCKS5 listen address for the currently running session. + private(set) var socksAddr = "" + + /// 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) { + guard !running else { return } + let addr = "127.0.0.1:\(port)" + socksAddr = addr + + let rc = transport.rawValue.withCString { tt in + url.withCString { u in + addr.withCString { a in + maxToken.withCString { tok in + maxUid.withCString { uid in + OpenFluxStartClient( + UnsafeMutablePointer(mutating: tt), + UnsafeMutablePointer(mutating: u), + UnsafeMutablePointer(mutating: a), + UnsafeMutablePointer(mutating: tok), + UnsafeMutablePointer(mutating: uid) + ) + } + } + } + } + } + + switch rc { + case 0: + appendLog("[app] started on \(addr) via \(transport.title)") + case 1: + appendLog("[app] already running") + case 2: + appendLog("[app] unknown transport") + case 3: + appendLog("[app] transport failed to start") + case 4: + appendLog("[app] port \(port) is busy — pick another port") + default: + appendLog("[app] start failed (code \(rc))") + } + + running = OpenFluxIsRunning() != 0 + startPolling() + } + + func stop() { + OpenFluxStop() + running = false + connected = false + pollOnce() + } + + private func startPolling() { + timer?.invalidate() + timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in + Task { @MainActor in self?.pollOnce() } + } + } + + private func pollOnce() { + running = OpenFluxIsRunning() != 0 + connected = OpenFluxIsConnected() != 0 + + if let c = OpenFluxReadLog() { + let s = String(cString: c) + OpenFluxFreeString(c) + if !s.isEmpty { appendLog(s) } + } + if let c = OpenFluxStatsJSON() { + stats = String(cString: c) + OpenFluxFreeString(c) + } + } + + private func appendLog(_ s: String) { + log += (log.isEmpty ? "" : "\n") + s + if log.count > 20000 { + log = String(log.suffix(20000)) + } + } + + /// 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. + func testDirect() { + appendLog("[app] test request (system VPN path) ...") + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = 20 + let session = URLSession(configuration: config) + let url = URL(string: "http://ifconfig.me/ip")! + let task = session.dataTask(with: url) { [weak self] data, _, err in + Task { @MainActor in + if let err = err { + self?.appendLog("[app] test failed: \(err.localizedDescription)") + } else if let data = data, let body = String(data: data, encoding: .utf8) { + self?.appendLog("[app] test OK, exit IP: \(body.trimmingCharacters(in: .whitespacesAndNewlines))") + } else { + self?.appendLog("[app] test returned no data") + } + } + } + task.resume() + } + + /// Connectivity check routed through the local SOCKS5 proxy. + func testThroughProxy() { + guard !socksAddr.isEmpty else { return } + appendLog("[app] test request via SOCKS5 \(socksAddr) ...") + let config = URLSessionConfiguration.ephemeral + let parts = socksAddr.split(separator: ":") + let host = String(parts.first ?? "127.0.0.1") + let port = Int(parts.last ?? "1080") ?? 1080 + config.connectionProxyDictionary = [ + "SOCKSEnable": 1, + "SOCKSProxy": host, + "SOCKSPort": port + ] + config.timeoutIntervalForRequest = 20 + let session = URLSession(configuration: config) + let url = URL(string: "http://ifconfig.me/ip")! + let task = session.dataTask(with: url) { [weak self] data, _, err in + Task { @MainActor in + if let err = err { + self?.appendLog("[app] test failed: \(err.localizedDescription)") + } else if let data = data, let body = String(data: data, encoding: .utf8) { + self?.appendLog("[app] test OK, exit IP: \(body.trimmingCharacters(in: .whitespacesAndNewlines))") + } else { + self?.appendLog("[app] test returned no data") + } + } + } + task.resume() + } +} diff --git a/ios-app/OpenFlux/VPNController.swift b/ios-app/OpenFlux/VPNController.swift new file mode 100644 index 0000000..308b7a7 --- /dev/null +++ b/ios-app/OpenFlux/VPNController.swift @@ -0,0 +1,86 @@ +import Foundation +import NetworkExtension +import Combine + +/// Installs and controls the system VPN profile backed by the packet-tunnel +/// extension. The transport config (URL / MAX creds) is passed to the extension +/// through the tunnel protocol's providerConfiguration. +@MainActor +final class VPNController: ObservableObject { + @Published var status: String = "Disconnected" + @Published var active = false + + private var manager: NETunnelProviderManager? + private let extensionBundleId = "com.p1neapplexpress-saharev.openflux.tunnel" + + init() { + NotificationCenter.default.addObserver( + self, selector: #selector(statusChanged), + name: .NEVPNStatusDidChange, object: nil) + Task { await load() } + } + + private func load() async { + let managers = (try? await NETunnelProviderManager.loadAllFromPreferences()) ?? [] + manager = managers.first + refreshStatus() + } + + func start(transport: String, url: String, maxToken: String, maxUid: String, + dns: String, tunnelUDP: Bool) { + Task { + let m = manager ?? NETunnelProviderManager() + let proto = NETunnelProviderProtocol() + proto.providerBundleIdentifier = extensionBundleId + proto.serverAddress = "OpenFlux" + proto.providerConfiguration = [ + "transport": transport, "url": url, + "maxToken": maxToken, "maxUid": maxUid, + "dns": dns, + "udp": tunnelUDP ? "1" : "0", + ] + m.protocolConfiguration = proto + m.localizedDescription = "OpenFlux" + m.isEnabled = true + // Auto-reconnect: with on-demand enabled, iOS relaunches the tunnel + // whenever it drops (extension killed, network change, etc.) instead + // of leaving the user to toggle it back on manually. + m.isOnDemandEnabled = true + m.onDemandRules = [NEOnDemandRuleConnect()] + do { + try await m.saveToPreferences() + try await m.loadFromPreferences() // required before starting + self.manager = m + try m.connection.startVPNTunnel() + } catch { + self.status = "Error: \(error.localizedDescription)" + } + } + } + + func stop() { + Task { + // Disable on-demand first, otherwise iOS would immediately reconnect + // the tunnel we're trying to stop. + if let m = manager { + m.isOnDemandEnabled = false + try? await m.saveToPreferences() + try? await m.loadFromPreferences() + } + manager?.connection.stopVPNTunnel() + } + } + + @objc private func statusChanged() { refreshStatus() } + + private func refreshStatus() { + guard let conn = manager?.connection else { active = false; status = "Disconnected"; return } + switch conn.status { + case .connected: status = "Connected"; active = true + case .connecting: status = "Connecting…"; active = true + case .disconnecting: status = "Disconnecting…"; active = true + case .reasserting: status = "Reasserting…"; active = true + default: status = "Disconnected"; active = false + } + } +} diff --git a/ios-app/OpenFluxTunnel/Info.plist b/ios-app/OpenFluxTunnel/Info.plist new file mode 100644 index 0000000..8d095e3 --- /dev/null +++ b/ios-app/OpenFluxTunnel/Info.plist @@ -0,0 +1,31 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + OpenFlux Tunnel + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionPointIdentifier + com.apple.networkextension.packet-tunnel + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).PacketTunnelProvider + + + diff --git a/ios-app/OpenFluxTunnel/OpenFluxTunnel.entitlements b/ios-app/OpenFluxTunnel/OpenFluxTunnel.entitlements new file mode 100644 index 0000000..c3f437e --- /dev/null +++ b/ios-app/OpenFluxTunnel/OpenFluxTunnel.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.developer.networking.networkextension + + packet-tunnel-provider + + + diff --git a/ios-app/OpenFluxTunnel/PacketTunnelProvider.swift b/ios-app/OpenFluxTunnel/PacketTunnelProvider.swift new file mode 100644 index 0000000..d7ab7c2 --- /dev/null +++ b/ios-app/OpenFluxTunnel/PacketTunnelProvider.swift @@ -0,0 +1,132 @@ +import NetworkExtension + +/// System VPN entry point. Bridges the device's IP packets to the OpenFlux Go +/// tun2socks stack (TCP forwarded through the transport; DNS proxied over TCP). +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. + static let bypassRoutes: [NEIPv4Route] = { + let cidrs: [(String, String)] = [ + ("5.45.192.0", "255.255.192.0"), + ("5.255.192.0", "255.255.192.0"), + ("37.9.64.0", "255.255.192.0"), + ("37.140.128.0", "255.255.192.0"), + ("77.88.0.0", "255.255.192.0"), + ("84.201.128.0", "255.255.192.0"), + ("87.250.224.0", "255.255.224.0"), + ("90.156.176.0", "255.255.252.0"), + ("93.158.128.0", "255.255.192.0"), + ("95.108.128.0", "255.255.128.0"), + ("100.43.64.0", "255.255.224.0"), + ("178.154.128.0", "255.255.128.0"), + ("213.180.192.0", "255.255.224.0"), + // DoT DNS resolvers used by the Go client. + ("8.8.8.8", "255.255.255.255"), + ("1.1.1.1", "255.255.255.255"), + ] + return cidrs.map { NEIPv4Route(destinationAddress: $0.0, subnetMask: $0.1) } + }() + + + override func startTunnel(options: [String: NSObject]?, completionHandler: @escaping (Error?) -> Void) { + let conf = (protocolConfiguration as? NETunnelProviderProtocol)?.providerConfiguration ?? [:] + let transport = (conf["transport"] as? String) ?? "yandex" + let url = (conf["url"] as? String) ?? "" + let maxToken = (conf["maxToken"] as? String) ?? "" + let maxUid = (conf["maxUid"] as? String) ?? "" + let dnsSpec = (conf["dns"] as? String) ?? "" + let tunnelUDP = (conf["udp"] as? String) == "1" + + // Override the DNS-over-TLS upstream if the user configured one (empty = + // built-in defaults). Must run in the extension process before start. + dnsSpec.withCString { d in + OpenFluxSetDoTResolver(UnsafeMutablePointer(mutating: d)) + } + // UDP tunneling (default off = legacy-safe on any exit node). + OpenFluxSetTunnelUDP(tunnelUDP ? 1 : 0) + + // Virtual interface: capture all IPv4 + all DNS. + let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "127.0.0.1") + // 10.10.10.2 is the address the exit node expects the client to use + // (it hardcodes return packets to 10.10.10.2), enabling pure L3 + // forwarding with no gvisor stack in the extension. + let ipv4 = NEIPv4Settings(addresses: ["10.10.10.2"], subnetMasks: ["255.255.255.0"]) + ipv4.includedRoutes = [NEIPv4Route.default()] + // Exclude the transport's own backend (Yandex ranges) and the DoT DNS + // servers so the extension's own connections bypass the tunnel instead + // of looping back into it. + ipv4.excludedRoutes = Self.bypassRoutes + settings.ipv4Settings = ipv4 + settings.mtu = 1500 + // A benign in-tunnel DNS address: queries to it are captured and + // answered locally over DoT (the real resolvers are excluded above). + let dns = NEDNSSettings(servers: ["198.18.0.1"]) + dns.matchDomains = [""] + settings.dnsSettings = dns + + setTunnelNetworkSettings(settings) { error in + if let error = error { + completionHandler(error) + return + } + let rc = transport.withCString { tt in + url.withCString { u in + maxToken.withCString { tok in + maxUid.withCString { uid in + OpenFluxStartPacketTunnel( + UnsafeMutablePointer(mutating: tt), + UnsafeMutablePointer(mutating: u), + UnsafeMutablePointer(mutating: tok), + UnsafeMutablePointer(mutating: uid)) + } + } + } + } + if rc != 0 { + completionHandler(NSError(domain: "OpenFlux", code: Int(rc), + userInfo: [NSLocalizedDescriptionKey: "start failed (\(rc))"])) + return + } + self.startReadLoop() + self.startWriteLoop() + completionHandler(nil) + } + } + + override func stopTunnel(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) { + OpenFluxStopPacketTunnel() + completionHandler() + } + + /// Device -> Go stack. + private func startReadLoop() { + packetFlow.readPackets { [weak self] packets, _ in + guard let self = self else { return } + for p in packets { + p.withUnsafeBytes { raw in + if let base = raw.bindMemory(to: CChar.self).baseAddress { + OpenFluxTunWritePacket(UnsafeMutablePointer(mutating: base), Int32(p.count)) + } + } + } + self.startReadLoop() + } + } + + /// Go stack -> device. + private func startWriteLoop() { + DispatchQueue.global(qos: .userInitiated).async { + let maxLen: Int32 = 4096 + let buf = UnsafeMutablePointer.allocate(capacity: Int(maxLen)) + defer { buf.deallocate() } + while true { + let n = OpenFluxTunReadPacket(buf, maxLen) + if n <= 0 { break } + let data = Data(bytes: buf, count: Int(n)) + self.packetFlow.writePackets([data], withProtocols: [NSNumber(value: AF_INET)]) + } + } + } +} diff --git a/ios-app/OpenFluxTunnel/Tunnel-Bridging-Header.h b/ios-app/OpenFluxTunnel/Tunnel-Bridging-Header.h new file mode 100644 index 0000000..2dda335 --- /dev/null +++ b/ios-app/OpenFluxTunnel/Tunnel-Bridging-Header.h @@ -0,0 +1,4 @@ +#ifndef Tunnel_Bridging_Header_h +#define Tunnel_Bridging_Header_h +#include "liboflux.h" +#endif diff --git a/ios-app/README.md b/ios-app/README.md new file mode 100644 index 0000000..19e519a --- /dev/null +++ b/ios-app/README.md @@ -0,0 +1,47 @@ +# OpenFlux iOS app + +SwiftUI client that links the OpenFlux Go core (`liboflux.a`) and runs the +SOCKS5 tunnel over the Yandex.Docs transport on `127.0.0.1:1080`. + +## Layout +- `project.yml` — XcodeGen project definition (run `xcodegen generate` to produce `OpenFlux.xcodeproj`). +- `OpenFlux/` — Swift sources, bridging header, Info.plist, assets. +- `Lib/liboflux.a`, `Lib/liboflux.h` — Go static library + generated header (copied from `../output/ios`). +- `ExportOptions.plist` — App Store export options (team 8GQH8GQ252, automatic signing). + +## Go bridge API (liboflux.h) +- `OpenFluxStartClient(transportType, url, socksAddr, maxToken, maxUid)` — start the client (returns 0 on success). +- `OpenFluxStop()` — stop transport + SOCKS5 listener. +- `OpenFluxIsRunning()` / `OpenFluxIsConnected()` — state. +- `OpenFluxStatsJSON()` / `OpenFluxReadLog()` — stats + log tail (free with `OpenFluxFreeString`). + +## Build + archive + export (one command) +From the repo root: +```bash +./build_ios_app.sh +``` +Produces `ios-app/build/export/OpenFlux.ipa`, distribution-signed for the App Store. + +## Upload to TestFlight +1. Create the app record once: App Store Connect > My Apps > **+** > New App, + bundle id `com.p1neapplexpress-saharev.openflux`, platform iOS. +2. Upload the IPA (either option): + ```bash + # A) app-specific password (appleid.apple.com) + xcrun altool --upload-app -f ios-app/build/export/OpenFlux.ipa -t ios \ + -u YOUR_APPLE_ID -p xxxx-xxxx-xxxx-xxxx + + # B) App Store Connect API key (.p8 in ~/.appstoreconnect/private_keys/) + xcrun altool --upload-app -f ios-app/build/export/OpenFlux.ipa -t ios \ + --apiKey KEY_ID --apiIssuer ISSUER_ID + ``` + Or open `ios-app/build/OpenFlux.xcarchive` in Xcode Organizer and use **Distribute App**. +3. The build appears in TestFlight after Apple processing (a few minutes). + +## Notes / follow-ups +- The app runs a **local** SOCKS5 proxy. The in-app **Test** button proves the + tunnel carries traffic (fetches the exit IP through the proxy). Routing the + whole device requires a Network Extension (`NEPacketTunnelProvider`) target + with the Network Extensions capability — not included in this first build. +- Deployment target: iOS 15.0 (SwiftUI App lifecycle). The Go lib is built with + `-miphoneos-version-min=13.0`, so it is compatible. diff --git a/ios-app/project.yml b/ios-app/project.yml new file mode 100644 index 0000000..7b57587 --- /dev/null +++ b/ios-app/project.yml @@ -0,0 +1,106 @@ +name: OpenFlux +options: + bundleIdPrefix: com.p1neapplexpress-saharev + deploymentTarget: + iOS: "15.0" + createIntermediateGroups: true + +settings: + base: + MARKETING_VERSION: "1.0.0" + CURRENT_PROJECT_VERSION: "36" + DEVELOPMENT_TEAM: "8GQH8GQ252" + +targets: + OpenFlux: + type: application + platform: iOS + sources: + - path: OpenFlux + - path: Lib + buildPhase: none + dependencies: + - framework: Lib/liboflux.a + embed: false + - target: OpenFluxTunnel + info: + path: OpenFlux/Info.plist + properties: + ITSAppUsesNonExemptEncryption: false + CFBundleShortVersionString: $(MARKETING_VERSION) + CFBundleVersion: $(CURRENT_PROJECT_VERSION) + LSRequiresIPhoneOS: true + UILaunchScreen: {} + UISupportedInterfaceOrientations: + - UIInterfaceOrientationPortrait + NSAppTransportSecurity: + NSAllowsArbitraryLoads: true + entitlements: + path: OpenFlux/OpenFlux.entitlements + properties: + com.apple.developer.networking.networkextension: + - packet-tunnel-provider + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.p1neapplexpress-saharev.openflux + PRODUCT_NAME: OpenFlux + INFOPLIST_KEY_CFBundleDisplayName: OpenFlux + SWIFT_OBJC_BRIDGING_HEADER: OpenFlux/OpenFlux-Bridging-Header.h + SWIFT_VERSION: "5.0" + TARGETED_DEVICE_FAMILY: "1" + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor + HEADER_SEARCH_PATHS: + - "$(SRCROOT)/Lib" + LIBRARY_SEARCH_PATHS: + - "$(SRCROOT)/Lib" + OTHER_LDFLAGS: + - "-lresolv" + - "-framework" + - "CoreFoundation" + - "-framework" + - "Security" + CODE_SIGN_STYLE: Automatic + + OpenFluxTunnel: + type: app-extension + platform: iOS + sources: + - path: OpenFluxTunnel + - path: Lib + buildPhase: none + dependencies: + - framework: Lib/liboflux.a + embed: false + info: + path: OpenFluxTunnel/Info.plist + properties: + CFBundleShortVersionString: $(MARKETING_VERSION) + CFBundleVersion: $(CURRENT_PROJECT_VERSION) + CFBundleDisplayName: OpenFlux Tunnel + NSExtension: + NSExtensionPointIdentifier: com.apple.networkextension.packet-tunnel + NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).PacketTunnelProvider + entitlements: + path: OpenFluxTunnel/OpenFluxTunnel.entitlements + properties: + com.apple.developer.networking.networkextension: + - packet-tunnel-provider + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.p1neapplexpress-saharev.openflux.tunnel + PRODUCT_NAME: OpenFluxTunnel + SWIFT_OBJC_BRIDGING_HEADER: OpenFluxTunnel/Tunnel-Bridging-Header.h + SWIFT_VERSION: "5.0" + TARGETED_DEVICE_FAMILY: "1" + HEADER_SEARCH_PATHS: + - "$(SRCROOT)/Lib" + LIBRARY_SEARCH_PATHS: + - "$(SRCROOT)/Lib" + OTHER_LDFLAGS: + - "-lresolv" + - "-framework" + - "CoreFoundation" + - "-framework" + - "Security" + CODE_SIGN_STYLE: Automatic diff --git a/main.go b/main.go index d3e2102..cae5685 100644 --- a/main.go +++ b/main.go @@ -5,11 +5,14 @@ import ( "fmt" "log" "os" + godebug "runtime/debug" "strconv" + "strings" _ "github.com/wlynxg/anet" "universal-bypass-tool/socks5" "universal-bypass-tool/transport" + "universal-bypass-tool/transport/mailru" "universal-bypass-tool/transport/oneme" "universal-bypass-tool/transport/yandex" "universal-bypass-tool/tunnel" @@ -22,6 +25,37 @@ var ( maxUid string ) +// buildMuxTransport turns a document-URL spec into a transport. The spec is a +// comma-separated list: a single URL yields one channel, while multiple URLs +// yield a MultiplexTransport that stripes flows across the documents. Each inner +// document is wrapped in its own AdaptiveTransport, a self-negotiating codec +// that starts in the legacy per-packet format and upgrades to batching once the +// peer proves it speaks batch — so a new build interoperates with an old peer +// (staying legacy) instead of breaking, and runs fast when both are new. +func buildMuxTransport(urlSpec string, factory func(string) transport.Transport) transport.Transport { + var urls []string + for _, u := range strings.Split(urlSpec, ",") { + if u = strings.TrimSpace(u); u != "" { + urls = append(urls, u) + } + } + + if len(urls) <= 1 { + u := urlSpec + if len(urls) == 1 { + u = urls[0] + } + return transport.NewAdaptiveTransport(factory(u)) + } + + channels := make([]transport.Transport, 0, len(urls)) + for _, u := range urls { + channels = append(channels, transport.NewAdaptiveTransport(factory(u))) + } + log.Printf("Multiplex: %d channels", len(channels)) + return transport.NewMultiplexTransport(channels) +} + func main() { //os.Setenv("GODEBUG", "netdns=go") fmt.Print("written by p1neappleXpress\n") @@ -31,11 +65,22 @@ func main() { debug := flag.Bool("debug", false, "Enable verbose debug logging") socksAddr := flag.String("socks5", ":1080", "SOCKS5 address") transportType := flag.String("transport", "yandex", "Transport type (yandex, google, custom)") - flag.StringVar(&globalDocUrl, "url", "http://#", "Document URL. If u use Yandex.Docs transport") + flag.StringVar(&globalDocUrl, "url", "http://#", "Document URL for Yandex.Docs transport. Comma-separated list = multiplex across N documents (client and exit node must pass the same list)") 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)") flag.Parse() + if *localIP != "" { + tunnel.SetLocalIP(*localIP) + } + + // The exit node often runs on a tiny VPS; keep the heap tight under load + // (GC aggressively). Set GOMEMLIMIT in the environment for a hard soft-cap. + if *exitNode { + godebug.SetGCPercent(20) + } + if !*exitNode && !*client { flag.Usage() os.Exit(1) @@ -54,7 +99,17 @@ func main() { switch *transportType { case "yandex": - trans = transport.NewCompressedTransport(yandex.NewYandexDocsTransport(globalDocUrl, config)) + trans = buildMuxTransport(globalDocUrl, func(u string) transport.Transport { + return yandex.NewYandexDocsTransport(u, config) + }) + case "vyandex", "volga": + trans = buildMuxTransport(globalDocUrl, func(u string) transport.Transport { + return yandex.NewYandexVolgaTransport(u, config) + }) + case "mailru", "mail": + trans = buildMuxTransport(globalDocUrl, func(u string) transport.Transport { + return mailru.NewMailruDocsTransport(u, config) + }) case "oneme": uidint, _ := strconv.ParseInt(maxUid, 10, 64) trans = transport.NewCompressedTransport(oneme.NewOneMeTransport(*exitNode, maxToken, uidint, config)) @@ -65,12 +120,31 @@ func main() { if err := trans.Start(); err != nil { log.Fatalf("Failed to start transport: %v", err) } + // Always-on readiness marker (independent of --debug) so a supervisor or + // service manager can detect a healthy start by scanning the journal. + log.Printf("OPENFLUX_READY transport=%s mode=%s", *transportType, + map[bool]string{true: "exit-node", false: "client"}[*exitNode]) tun := tunnel.NewTCPTunnel(trans, *exitNode) if *exitNode { log.Printf("Running as EXIT NODE (needs root for raw socket)") - log.Printf("! Run: sudo iptables -A OUTPUT -p tcp --tcp-flags RST RST -j DROP") + if *localIP != "" { + // Scoped: only touch traffic originating from the tunnel's egress IP, + // leaving the host's other services untouched. The kernel would + // otherwise reset TCP (RST) and reject inbound UDP replies (ICMP + // port-unreachable) for the userspace-owned connections. + log.Printf("! Run: sudo iptables -A OUTPUT -p tcp --tcp-flags RST RST -s %s -j DROP", *localIP) + log.Printf("! sudo iptables -A OUTPUT -p icmp --icmp-type port-unreachable -s %s -j DROP", *localIP) + } else { + log.Printf("! Kernel RSTs/ICMP would tear down tunnel connections. Prefer scoped rules:") + log.Printf("! assign a dedicated alias IP, run with --local-ip , then:") + log.Printf("! sudo iptables -A OUTPUT -p tcp --tcp-flags RST RST -s -j DROP") + log.Printf("! sudo iptables -A OUTPUT -p icmp --icmp-type port-unreachable -s -j DROP") + log.Printf("! Host-wide fallback (affects the whole host; closed ports look filtered):") + log.Printf("! sudo iptables -A OUTPUT -p tcp --tcp-flags RST RST -j DROP") + log.Printf("! sudo iptables -A OUTPUT -p icmp --icmp-type port-unreachable -j DROP") + } select {} } else { log.Printf("Running as CLIENT (SOCKS5 on %s)", *socksAddr) diff --git a/network/checksum.go b/network/checksum.go index 45bff8f..7043904 100644 --- a/network/checksum.go +++ b/network/checksum.go @@ -37,6 +37,39 @@ func TCPChecksum(tcpData []byte, srcIP, dstIP [4]byte) uint16 { return uint16(^sum) } +// UDPChecksum computes the UDP checksum over the pseudo-header + UDP datagram. +// The caller must zero the checksum field (bytes 6-7 of udpData) beforehand. +// Per RFC 768 a computed value of 0 is transmitted as 0xFFFF (0 means "none"). +func UDPChecksum(udpData []byte, srcIP, dstIP [4]byte) uint16 { + udpLen := len(udpData) + pseudoHeader := []byte{ + srcIP[0], srcIP[1], srcIP[2], srcIP[3], + dstIP[0], dstIP[1], dstIP[2], dstIP[3], + 0, 17, + byte(udpLen >> 8), byte(udpLen & 0xff), + } + + all := make([]byte, 0, len(pseudoHeader)+udpLen) + all = append(all, pseudoHeader...) + all = append(all, udpData...) + + sum := uint32(0) + for i := 0; i < len(all)-1; i += 2 { + sum += uint32(all[i])<<8 | uint32(all[i+1]) + } + if len(all)%2 == 1 { + sum += uint32(all[len(all)-1]) << 8 + } + for sum>>16 > 0 { + sum = (sum & 0xffff) + (sum >> 16) + } + csum := uint16(^sum) + if csum == 0 { + return 0xFFFF + } + return csum +} + func IPChecksum(b []byte) uint16 { sum := uint32(0) for i := 0; i < len(b)-1; i += 2 { diff --git a/socks5/socks5.go b/socks5/socks5.go index c34e0c1..eecebb5 100644 --- a/socks5/socks5.go +++ b/socks5/socks5.go @@ -16,17 +16,44 @@ type Dialer interface { type SOCKS5Server struct { listenAddr string dialer Dialer + + mu sync.Mutex + listener net.Listener + closed bool } func NewSOCKS5Server(addr string, dialer Dialer) *SOCKS5Server { return &SOCKS5Server{listenAddr: addr, dialer: dialer} } -func (s *SOCKS5Server) Start() error { +// Bind reserves the listen address so callers can detect "address already in +// use" synchronously, before serving. Safe to call once; Start binds lazily if +// it wasn't called. +func (s *SOCKS5Server) Bind() error { + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return net.ErrClosed + } + if s.listener != nil { + return nil + } listener, err := net.Listen("tcp", s.listenAddr) if err != nil { return err } + s.listener = listener + return nil +} + +func (s *SOCKS5Server) Start() error { + if err := s.Bind(); err != nil { + return err + } + + s.mu.Lock() + listener := s.listener + s.mu.Unlock() defer listener.Close() utils.Debugf("[SOCKS5] Listening on %s", s.listenAddr) @@ -34,6 +61,13 @@ func (s *SOCKS5Server) Start() error { for { conn, err := listener.Accept() if err != nil { + s.mu.Lock() + closed := s.closed + s.mu.Unlock() + if closed { + utils.Debugf("[SOCKS5] Listener closed, stopping") + return net.ErrClosed + } utils.Debugf("[SOCKS5] Accept error: %v", err) continue } @@ -41,7 +75,25 @@ func (s *SOCKS5Server) Start() error { } } +// Close stops the server, unblocking Start's accept loop. +func (s *SOCKS5Server) Close() error { + s.mu.Lock() + defer s.mu.Unlock() + s.closed = true + if s.listener != nil { + return s.listener.Close() + } + return nil +} + func (s *SOCKS5Server) handleConnection(clientConn net.Conn) { + // A malformed request must never crash the host process; contain any + // panic to this connection. + defer func() { + if r := recover(); r != nil { + utils.Debugf("[SOCKS5] Recovered from panic in handler: %v", r) + } + }() defer clientConn.Close() buf := make([]byte, 256) @@ -65,6 +117,12 @@ func (s *SOCKS5Server) handleConnection(clientConn net.Conn) { uint16(buf[8])<<8|uint16(buf[9])) case 0x03: domainLen := int(buf[4]) + // Bounds-check against what was actually read: address (domainLen + // bytes) starts at index 5 and is followed by a 2-byte port. + if domainLen == 0 || 5+domainLen+2 > n { + utils.Debugf("[SOCKS5] Bad domain request (len=%d, n=%d)", domainLen, n) + return + } targetAddr = fmt.Sprintf("%s:%d", string(buf[5:5+domainLen]), uint16(buf[5+domainLen])<<8|uint16(buf[6+domainLen])) diff --git a/transport/adaptive.go b/transport/adaptive.go new file mode 100644 index 0000000..5078809 --- /dev/null +++ b/transport/adaptive.go @@ -0,0 +1,235 @@ +package transport + +import ( + "fmt" + "os" + "strconv" + "sync" + "sync/atomic" + "time" + + "universal-bypass-tool/utils" +) + +// Tunables for the coalescing layer, overridable at runtime so batch size can +// be matched to the channel's per-message limits without a rebuild. +const ( + defaultMaxBatchBytes = 8192 + defaultMaxBatchCount = 64 + defaultLingerMs = 5 + batchQueueDepth = 4096 + probeInterval = 3 * time.Second +) + +func envInt(name string, def int) int { + if v := os.Getenv(name); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + return n + } + } + return def +} + +// AdaptiveTransport is a self-negotiating codec over an inner transport. It +// lets a new (batching) build talk to an old (per-packet) peer without either +// side breaking, and upgrades to batching automatically once both sides +// support it — no handshake, no user setting. +// +// The two wire frame families never collide on their first byte: +// - legacy : 0x00 (raw) or 0x1F (LZ4) — one packet per frame (compressor.go) +// - batch : 0x02 — many packets per zstd frame (framing.go) +// +// A peer on the OLD codec cannot decode a batch frame, but it drops it +// harmlessly: the bytes reach gVisor, which sees a non-IPv4 version nibble and +// discards them. That makes an empty batch frame safe to use as a capability +// probe. +// +// Negotiation: +// - Receive accepts BOTH families (dispatch on the first byte). +// - Send starts in LEGACY (compatible with any peer). +// - Each side periodically emits a tiny empty-batch probe; a legacy peer +// drops it, a batch-capable peer recognises it. +// - The moment a side RECEIVES any batch frame it flips its own Send to batch. +// +// Outcome — new<->new upgrades and runs fast; new<->old keeps working in +// legacy; nobody breaks when only one side updates. +// +// Self-echo safety: this relies on the transport NOT echoing a sender its own +// frames (the same property that already lets the plain tunnel work — data +// frames would otherwise be re-injected). So a received batch frame always came +// from the peer, never from us. +type AdaptiveTransport struct { + Transport // inner; IsConnected/Stats delegate to it + + queue chan []byte + peerBatch atomic.Bool + running atomic.Bool + + // forceLegacy pins this side to the legacy per-packet codec (no batching, + // no probes) regardless of the peer — a debug override, set via + // OPENFLUX_FORCE_LEGACY=1, for A/B measurement or interop testing. + forceLegacy bool + + lingerMs int + maxBatchBytes int + maxBatchCount int + + mu sync.RWMutex + userCb func([]byte) +} + +func NewAdaptiveTransport(inner Transport) *AdaptiveTransport { + return &AdaptiveTransport{ + Transport: inner, + queue: make(chan []byte, batchQueueDepth), + forceLegacy: os.Getenv("OPENFLUX_FORCE_LEGACY") == "1", + lingerMs: envInt("OPENFLUX_BATCH_LINGER_MS", defaultLingerMs), + maxBatchBytes: envInt("OPENFLUX_BATCH_BYTES", defaultMaxBatchBytes), + maxBatchCount: envInt("OPENFLUX_BATCH_COUNT", defaultMaxBatchCount), + } +} + +func (a *AdaptiveTransport) Start() error { + if err := a.Transport.Start(); err != nil { + return err + } + a.running.Store(true) + go a.flushLoop() + go a.probeLoop() + return nil +} + +func (a *AdaptiveTransport) Stop() error { + a.running.Store(false) + return a.Transport.Stop() +} + +// Send copies the packet (gVisor reuses the caller's buffer) and enqueues it. A +// full queue drops the packet; the tunneled TCP retransmits. +func (a *AdaptiveTransport) Send(data []byte) error { + p := make([]byte, len(data)) + copy(p, data) + select { + case a.queue <- p: + return nil + default: + return fmt.Errorf("adaptive queue full") + } +} + +func (a *AdaptiveTransport) Receive(callback func([]byte)) { + a.mu.Lock() + a.userCb = callback + a.mu.Unlock() + + a.Transport.Receive(func(data []byte) { + if len(data) > 0 && data[0] == batchFormatVersion { + // A batch frame proves the peer speaks batch — upgrade our sends. + a.peerBatch.Store(true) + pkts, err := decodeBatch(data) + if err != nil { + utils.Debugf("[ADAPT] batch decode error (%d bytes): %v", len(data), err) + return + } + a.deliver(pkts) + return + } + // Legacy family (0x00 raw / 0x1F LZ4). decompress falls back to the raw + // bytes on error, matching the old CompressedTransport behavior. + pkt, err := decompress(data) + if err != nil { + a.deliver([][]byte{data}) + return + } + a.deliver([][]byte{pkt}) + }) +} + +func (a *AdaptiveTransport) deliver(pkts [][]byte) { + a.mu.RLock() + cb := a.userCb + a.mu.RUnlock() + if cb == nil { + return + } + for _, p := range pkts { + if len(p) > 0 { + cb(p) + } + } +} + +// probeLoop advertises batch capability. The empty-batch frame is 2 bytes; a +// legacy peer discards it, a batch peer flips to sending us batches. We keep +// probing (slowly) even after negotiation so a peer that reconnects re-learns. +func (a *AdaptiveTransport) probeLoop() { + if a.forceLegacy { + return // never advertise batch capability + } + probe := encodeBatch(nil) // [0x02, 0x00] + for a.running.Load() { + time.Sleep(probeInterval) + if !a.running.Load() { + return + } + if a.IsConnected() { + _ = a.Transport.Send(probe) + } + } +} + +func (a *AdaptiveTransport) flushLoop() { + for a.running.Load() { + first, ok := <-a.queue + if !ok { + return + } + + // Legacy mode: the peer hasn't proven batch support yet (or we're pinned + // to legacy). Send this packet on its own, compressed exactly like the + // old codec so any old peer decodes it. + if a.forceLegacy || !a.peerBatch.Load() { + _ = a.Transport.Send(compress(first)) + continue + } + + // Batch mode: coalesce the queued burst (+ a short linger) into one + // frame — the throughput win, since the channel is message-bound. + batch := [][]byte{first} + size := 2 + len(first) + drainNow: + for size < a.maxBatchBytes && len(batch) < a.maxBatchCount { + select { + case p, ok := <-a.queue: + if !ok { + _ = a.Transport.Send(encodeBatch(batch)) + return + } + batch = append(batch, p) + size += 2 + len(p) + default: + break drainNow + } + } + if a.lingerMs > 0 && size < a.maxBatchBytes && len(batch) < a.maxBatchCount { + timer := time.NewTimer(time.Duration(a.lingerMs) * time.Millisecond) + linger: + for size < a.maxBatchBytes && len(batch) < a.maxBatchCount { + select { + case p, ok := <-a.queue: + if !ok { + timer.Stop() + _ = a.Transport.Send(encodeBatch(batch)) + return + } + batch = append(batch, p) + size += 2 + len(p) + case <-timer.C: + break linger + } + } + timer.Stop() + } + _ = a.Transport.Send(encodeBatch(batch)) + } +} diff --git a/transport/adaptive_test.go b/transport/adaptive_test.go new file mode 100644 index 0000000..46399f7 --- /dev/null +++ b/transport/adaptive_test.go @@ -0,0 +1,177 @@ +package transport + +import ( + "bytes" + "sync" + "sync/atomic" + "testing" + "time" +) + +// recordingTransport is a fake inner transport: it records every frame Send +// receives and lets the test push frames into the registered receive callback. +type recordingTransport struct { + connected atomic.Bool + + mu sync.Mutex + sent [][]byte + rxcb func([]byte) + onSnd func([]byte) +} + +func (r *recordingTransport) Start() error { r.connected.Store(true); return nil } +func (r *recordingTransport) Stop() error { r.connected.Store(false); return nil } +func (r *recordingTransport) Send(data []byte) error { + cp := append([]byte(nil), data...) + r.mu.Lock() + r.sent = append(r.sent, cp) + cb := r.onSnd + r.mu.Unlock() + if cb != nil { + cb(cp) + } + return nil +} +func (r *recordingTransport) Receive(cb func([]byte)) { r.mu.Lock(); r.rxcb = cb; r.mu.Unlock() } +func (r *recordingTransport) IsConnected() bool { return r.connected.Load() } +func (r *recordingTransport) Stats() TransportStats { return TransportStats{Connected: r.connected.Load()} } + +func (r *recordingTransport) push(frame []byte) { + r.mu.Lock() + cb := r.rxcb + r.mu.Unlock() + if cb != nil { + cb(frame) + } +} + +func (r *recordingTransport) sentFrames() [][]byte { + r.mu.Lock() + defer r.mu.Unlock() + out := make([][]byte, len(r.sent)) + copy(out, r.sent) + return out +} + +// TestAdaptiveReceiveDispatch: the receive path must decode BOTH families and +// deliver the original packets, and a batch frame must flip peerBatch. +func TestAdaptiveReceiveDispatch(t *testing.T) { + inner := &recordingTransport{} + a := NewAdaptiveTransport(inner) + + var got [][]byte + var mu sync.Mutex + a.Receive(func(p []byte) { mu.Lock(); got = append(got, append([]byte(nil), p...)); mu.Unlock() }) + + // A legacy frame must decode without flipping peerBatch. + pkt := buildTCP([4]byte{10, 0, 0, 2}, [4]byte{1, 1, 1, 1}, 1234, 443) + inner.push(compress(pkt)) + if a.peerBatch.Load() { + t.Fatal("legacy frame must NOT flip peerBatch") + } + + // A batch frame must decode all its packets AND flip peerBatch. + p1 := []byte("hello-packet-one") + p2 := []byte("second-packet-two") + inner.push(encodeBatch([][]byte{p1, p2})) + if !a.peerBatch.Load() { + t.Fatal("batch frame must flip peerBatch") + } + + mu.Lock() + defer mu.Unlock() + if len(got) != 3 { + t.Fatalf("expected 3 delivered packets, got %d", len(got)) + } + if !bytes.Equal(got[0], pkt) || !bytes.Equal(got[1], p1) || !bytes.Equal(got[2], p2) { + t.Fatalf("delivered packets do not match originals") + } +} + +// TestAdaptiveEmptyProbeDeliversNothing: the capability probe (empty batch) +// flips peerBatch but must not deliver a phantom packet. +func TestAdaptiveEmptyProbeDeliversNothing(t *testing.T) { + inner := &recordingTransport{} + a := NewAdaptiveTransport(inner) + + var count atomic.Int32 + a.Receive(func([]byte) { count.Add(1) }) + + inner.push(encodeBatch(nil)) // the probe + if !a.peerBatch.Load() { + t.Fatal("probe should flip peerBatch") + } + if count.Load() != 0 { + t.Fatalf("probe must deliver 0 packets, delivered %d", count.Load()) + } +} + +// TestAdaptiveSendStartsLegacyThenUpgrades: Send must emit legacy frames until +// the peer proves batch, then coalesce into batch frames. +func TestAdaptiveSendStartsLegacyThenUpgrades(t *testing.T) { + inner := &recordingTransport{} + inner.connected.Store(true) + a := NewAdaptiveTransport(inner) + a.Receive(func([]byte) {}) + if err := a.Start(); err != nil { + t.Fatal(err) + } + defer a.Stop() + + // Phase 1: peer unknown -> legacy. Each Send should become a legacy frame + // (first byte 0x00 or 0x1F), never a batch frame (0x02). + pkt := buildTCP([4]byte{10, 0, 0, 2}, [4]byte{1, 1, 1, 1}, 1000, 443) + _ = a.Send(pkt) + waitFor(t, func() bool { return len(nonProbe(inner.sentFrames())) >= 1 }) + + for _, f := range nonProbe(inner.sentFrames()) { + if f[0] == batchFormatVersion { + t.Fatal("sent a batch frame before peer proved batch support") + } + // legacy frame must round-trip back to the original packet + dec, err := decompress(f) + if err != nil || !bytes.Equal(dec, pkt) { + t.Fatalf("legacy frame did not round-trip: err=%v", err) + } + } + + // Phase 2: peer proves batch -> subsequent sends coalesce into batch frames. + a.peerBatch.Store(true) + before := len(inner.sentFrames()) + for i := 0; i < 20; i++ { + _ = a.Send(pkt) + } + waitFor(t, func() bool { + for _, f := range inner.sentFrames()[before:] { + if len(f) > 0 && f[0] == batchFormatVersion && len(f) > 2 { + return true // a non-empty batch frame appeared + } + } + return false + }) +} + +// nonProbe filters out the 2-byte empty-batch probes so send-path assertions +// look only at real data frames. +func nonProbe(frames [][]byte) [][]byte { + var out [][]byte + for _, f := range frames { + if len(f) == 2 && f[0] == batchFormatVersion { + continue // empty-batch probe + } + out = append(out, f) + } + return out +} + +func waitFor(t *testing.T, cond func() bool) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if cond() { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatal("condition not met within timeout") +} diff --git a/transport/framing.go b/transport/framing.go new file mode 100644 index 0000000..fba9ab3 --- /dev/null +++ b/transport/framing.go @@ -0,0 +1,120 @@ +package transport + +import ( + "encoding/binary" + "fmt" + + "github.com/klauspost/compress/zstd" +) + +// Wire format for a batched frame (one Yandex/transport message can now carry +// many tunnel packets): +// +// [0] version byte (batchFormatVersion) +// [1] flags (bit0 = payload is zstd-compressed) +// [2:] payload: a sequence of [2-byte big-endian length][packet] records, +// optionally zstd-compressed as a whole. +const ( + batchFormatVersion = 0x02 + batchFlagZstd = 0x01 +) + +var ( + zstdEnc *zstd.Encoder + zstdDec *zstd.Decoder +) + +func init() { + var err error + // SpeedDefault (~level 3): far better ratio than LZ4 at a CPU cost that is + // irrelevant next to the Yandex channel's latency. Single-shot EncodeAll / + // DecodeAll are safe for concurrent use on a shared instance. + zstdEnc, err = zstd.NewWriter(nil, + zstd.WithEncoderLevel(zstd.SpeedDefault), + zstd.WithEncoderConcurrency(1), + ) + if err != nil { + panic(fmt.Sprintf("zstd encoder init: %v", err)) + } + zstdDec, err = zstd.NewReader(nil, + zstd.WithDecoderConcurrency(1), + // Bound the damage from a malformed/hostile frame injected into the + // shared document: cap decompressed memory. + zstd.WithDecoderMaxMemory(8<<20), + ) + if err != nil { + panic(fmt.Sprintf("zstd decoder init: %v", err)) + } +} + +// frameBatch concatenates packets into length-prefixed records. +func frameBatch(pkts [][]byte) []byte { + total := 0 + for _, p := range pkts { + total += 2 + len(p) + } + out := make([]byte, 0, total) + var lenbuf [2]byte + for _, p := range pkts { + binary.BigEndian.PutUint16(lenbuf[:], uint16(len(p))) + out = append(out, lenbuf[:]...) + out = append(out, p...) + } + return out +} + +// encodeBatch serializes packets into a single wire frame, compressing the +// whole batch with zstd only when that actually shrinks it. +func encodeBatch(pkts [][]byte) []byte { + framed := frameBatch(pkts) + compressed := zstdEnc.EncodeAll(framed, nil) + + if len(compressed) < len(framed) { + out := make([]byte, 2, 2+len(compressed)) + out[0] = batchFormatVersion + out[1] = batchFlagZstd + return append(out, compressed...) + } + out := make([]byte, 2, 2+len(framed)) + out[0] = batchFormatVersion + out[1] = 0 + return append(out, framed...) +} + +// decodeBatch reverses encodeBatch, returning the original packets. +func decodeBatch(data []byte) ([][]byte, error) { + if len(data) < 2 { + return nil, fmt.Errorf("batch frame too short: %d bytes", len(data)) + } + if data[0] != batchFormatVersion { + return nil, fmt.Errorf("unknown batch version 0x%02x", data[0]) + } + flags := data[1] + payload := data[2:] + + framed := payload + if flags&batchFlagZstd != 0 { + var err error + framed, err = zstdDec.DecodeAll(payload, nil) + if err != nil { + return nil, fmt.Errorf("zstd decode: %w", err) + } + } + + var pkts [][]byte + for len(framed) > 0 { + if len(framed) < 2 { + return nil, fmt.Errorf("truncated length prefix") + } + n := int(binary.BigEndian.Uint16(framed[:2])) + framed = framed[2:] + if len(framed) < n { + return nil, fmt.Errorf("truncated packet: need %d, have %d", n, len(framed)) + } + pkt := make([]byte, n) + copy(pkt, framed[:n]) + pkts = append(pkts, pkt) + framed = framed[n:] + } + return pkts, nil +} diff --git a/transport/framing_test.go b/transport/framing_test.go new file mode 100644 index 0000000..0790109 --- /dev/null +++ b/transport/framing_test.go @@ -0,0 +1,110 @@ +package transport + +import ( + "bytes" + "crypto/rand" + "testing" +) + +// round-trips a set of packets through encode/decode and asserts equality. +func assertBatchRoundTrip(t *testing.T, pkts [][]byte) { + t.Helper() + wire := encodeBatch(pkts) + got, err := decodeBatch(wire) + if err != nil { + t.Fatalf("decodeBatch error: %v", err) + } + if len(got) != len(pkts) { + t.Fatalf("packet count = %d, want %d", len(got), len(pkts)) + } + for i := range pkts { + if !bytes.Equal(got[i], pkts[i]) { + t.Fatalf("packet %d mismatch:\n got=%v\nwant=%v", i, got[i], pkts[i]) + } + } +} + +func TestBatchRoundTripSinglePacket(t *testing.T) { + assertBatchRoundTrip(t, [][]byte{[]byte("hello world")}) +} + +func TestBatchRoundTripManyPackets(t *testing.T) { + pkts := [][]byte{ + []byte("first"), + {0x00, 0x01, 0x02, 0xfe, 0xff}, + []byte("a much longer third packet with some repetition repetition repetition"), + {}, + []byte("last"), + } + assertBatchRoundTrip(t, pkts) +} + +func TestBatchRoundTripBinaryMTUSized(t *testing.T) { + // Realistic full-MTU TCP payloads with arbitrary binary content. + mk := func(fill byte) []byte { + b := make([]byte, 1460) + for i := range b { + b[i] = fill ^ byte(i) + } + return b + } + assertBatchRoundTrip(t, [][]byte{mk(0x11), mk(0x22), mk(0x33)}) +} + +func TestBatchRoundTripEmptyList(t *testing.T) { + got, err := decodeBatch(encodeBatch(nil)) + if err != nil { + t.Fatalf("decodeBatch error: %v", err) + } + if len(got) != 0 { + t.Fatalf("expected 0 packets, got %d", len(got)) + } +} + +// A compressible batch must actually use the compressed path and shrink, +// proving zstd is wired in (not just a raw passthrough). +func TestBatchCompressesRepetitiveData(t *testing.T) { + pkt := bytes.Repeat([]byte("ABCDEFGH"), 512) // 4096 bytes, highly compressible + pkts := [][]byte{pkt, pkt, pkt} + wire := encodeBatch(pkts) + + rawFramedLen := 0 + for _, p := range pkts { + rawFramedLen += 2 + len(p) + } + if len(wire) >= rawFramedLen { + t.Fatalf("expected compression to shrink %d framed bytes, got wire len %d", rawFramedLen, len(wire)) + } + assertBatchRoundTrip(t, pkts) +} + +// Incompressible data must fall back to the uncompressed path and still +// round-trip (zstd would otherwise inflate it). +func TestBatchIncompressibleFallsBackAndRoundTrips(t *testing.T) { + pkt := make([]byte, 1200) + if _, err := rand.Read(pkt); err != nil { + t.Fatalf("rand: %v", err) + } + assertBatchRoundTrip(t, [][]byte{pkt}) +} + +func TestDecodeBatchRejectsUnknownVersion(t *testing.T) { + if _, err := decodeBatch([]byte{0xFF, 0x00}); err == nil { + t.Fatal("expected error for unknown version byte") + } +} + +func TestDecodeBatchRejectsTruncatedLengthPrefix(t *testing.T) { + // valid header, flags=0 (uncompressed), then a length prefix claiming 10 + // bytes but only 2 present. + bad := []byte{batchFormatVersion, 0x00, 0x00, 0x0A, 0x01, 0x02} + if _, err := decodeBatch(bad); err == nil { + t.Fatal("expected error for truncated length-prefixed packet") + } +} + +func TestDecodeBatchRejectsShortFrame(t *testing.T) { + if _, err := decodeBatch([]byte{batchFormatVersion}); err == nil { + t.Fatal("expected error for frame shorter than header") + } +} diff --git a/transport/mailru/mailru.go b/transport/mailru/mailru.go new file mode 100644 index 0000000..43ca0bb --- /dev/null +++ b/transport/mailru/mailru.go @@ -0,0 +1,517 @@ +// Package mailru implements a transport that tunnels packets through +// Mail.ru's cloud document editor (docs.datacloudmail.ru), the same +// coauthoring backend family as Yandex.Docs. Two peers open the same +// public document and smuggle packets through the "cursor" field of the +// collaborative editing protocol. +package mailru + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "math/rand" + "net" + "net/http" + "regexp" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/gorilla/websocket" + + "universal-bypass-tool/transport" + "universal-bypass-tool/utils" +) + +const mailruUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36" + +var cursorPayloadRe = regexp.MustCompile(`"cursor":"[^;]+;([^"]+)"`) + +type MailruDocsInfo struct { + Token string + DocKey string + WsURL string + FileType string + DocURL string + DocTitle string + Permissions map[string]interface{} + CallbackURL string + EditorUserID string +} + +type DocSession struct { + Info MailruDocsInfo + Conn *websocket.Conn + WriteQueue chan []byte + UserID string + writeMu sync.Mutex +} + +func (s *DocSession) safeWrite(messageType int, data []byte) error { + s.writeMu.Lock() + defer s.writeMu.Unlock() + return s.Conn.WriteMessage(messageType, data) +} + +type MailruDocsTransport struct { + *transport.BaseTransport + + weblink string + session *DocSession + + userCounter atomic.Int32 + baseUserID string +} + +// NewMailruDocsTransport accepts either a bare weblink ("AbCdEfGh1/IjKlMnOp2") +// or a full public URL ("https://cloud.mail.ru/public/AbCdEfGh1/IjKlMnOp2"), +// normalizing the latter to the former. +func NewMailruDocsTransport(weblink string, config transport.TransportConfig) *MailruDocsTransport { + t := &MailruDocsTransport{ + BaseTransport: transport.NewBaseTransport(config), + weblink: normalizeWeblink(weblink), + } + t.baseUserID = randUserID() + return t +} + +func normalizeWeblink(weblink string) string { + weblink = strings.TrimSpace(weblink) + for _, prefix := range []string{ + "https://cloud.mail.ru/public/", + "http://cloud.mail.ru/public/", + "https://cloud.mail.ru/", + "http://cloud.mail.ru/", + } { + if strings.HasPrefix(weblink, prefix) { + return strings.Trim(strings.TrimPrefix(weblink, prefix), "/") + } + } + return weblink +} + +func (t *MailruDocsTransport) Start() error { + if err := t.BaseTransport.Start(); err != nil { + return err + } + + t.baseUserID = randUserID() + utils.SafeGo("mailru.keepAlive", t.keepAliveLoop) + t.connectToDoc(0) + + return nil +} + +func (t *MailruDocsTransport) Send(data []byte) error { + if !t.IsConnected() { + return fmt.Errorf("transport not connected") + } + + t.Mu.RLock() + session := t.session + t.Mu.RUnlock() + + if session == nil { + return fmt.Errorf("no active session") + } + + select { + case session.WriteQueue <- data: + t.RecordSend(len(data)) + return nil + default: + return fmt.Errorf("write queue full") + } +} + +func (t *MailruDocsTransport) connectToDoc(attempt int) { + if !t.IsRunning() { + return + } + + utils.Debugf("[M-DOCS] connectToDoc attempt %d", attempt) + + go func() { + defer func() { + if r := recover(); r != nil { + utils.Debugf("[PANIC] recovered in mailru.connect: %v", r) + } + }() + t.Mu.Lock() + existingSession := t.session + t.Mu.Unlock() + + var userID string + if existingSession != nil { + userID = existingSession.UserID + } else { + suffix := fmt.Sprintf("%03d", t.userCounter.Add(1)%1000) + userID = t.baseUserID + suffix + } + + info, err := t.fetchDocInfo(t.weblink) + if err != nil { + utils.Debugf("[M-DOCS] fetchDocInfo failed: %v", err) + t.scheduleReconnect(attempt) + return + } + + dialer := websocket.Dialer{ + HandshakeTimeout: 15 * time.Second, + NetDialContext: (&net.Dialer{ + Timeout: 10 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + } + headers := http.Header{} + headers.Set("User-Agent", mailruUserAgent) + headers.Set("Origin", "https://docs.datacloudmail.ru") + + utils.Debugf("[M-DOCS] WebSocket dial %s", info.WsURL) + conn, resp, err := dialer.Dial(info.WsURL, headers) + if err != nil { + status := 0 + if resp != nil { + status = resp.StatusCode + } + utils.Debugf("[M-DOCS] WebSocket dial failed (http %d): %v", status, err) + t.scheduleReconnect(attempt) + return + } + utils.Debugf("[M-DOCS] WebSocket connected") + + writeQueue := make(chan []byte, t.GetConfig().MaxQueueSize) + if existingSession != nil { + writeQueue = existingSession.WriteQueue + } + + session := &DocSession{ + Info: info, + Conn: conn, + WriteQueue: writeQueue, + UserID: userID, + } + + t.Mu.Lock() + t.session = session + t.SetConnected(true) + t.Mu.Unlock() + + if existingSession == nil { + utils.SafeGo("mailru.writer", t.writerLoop) + } + + // Auth - fired immediately, same as the Yandex.Docs transport. No + // need to wait for the server's own "0{"/"40" handshake frames + // first: Mail.ru's coauthoring server buffers and processes these + // once its own session state catches up, and waiting for explicit + // acks here only stretches the outage window on every reconnect + // (Mail.ru can delay a fresh joiner's auth confirmation by up to + // ~30s while it reconciles with the other participant). + auth1 := fmt.Sprintf(`40{"token":"%s"}`, info.Token) + session.safeWrite(websocket.TextMessage, []byte(auth1)) + + authMsg := map[string]interface{}{ + "type": "auth", + "docid": info.DocKey, + "documentCallbackUrl": info.CallbackURL, + "token": "fghhfgsjdgfjs", + "user": map[string]interface{}{ + "id": info.EditorUserID, + "username": userID, + "indexUser": -1, + }, + "editorType": 0, + "lastOtherSaveTime": -1, + "block": []interface{}{}, + "documentFormatSave": 65, + "view": false, + "isCloseCoAuthoring": false, + "openCmd": map[string]interface{}{ + "c": "open", + "id": info.DocKey, + "userid": info.EditorUserID, + "format": info.FileType, + "url": info.DocURL, + "title": info.DocTitle, + "lcid": 25, + "nobase64": true, + "convertToOrigin": ".pdf.xps.oxps.djvu", + }, + "lang": "ru", + "mode": "edit", + "permissions": info.Permissions, + "IsAnonymousUser": false, + "timezoneOffset": -180, + "coEditingMode": "fast", + "jwtOpen": info.Token, + "time": 1000, + "supportAuthChangesAck": true, + } + messagePart, _ := json.Marshal([]interface{}{"message", authMsg}) + session.safeWrite(websocket.TextMessage, []byte(fmt.Sprintf("42%s", string(messagePart)))) + + connectedAt := time.Now() + for t.IsRunning() { + _, message, err := conn.ReadMessage() + if err != nil { + utils.Debugf("[M-DOCS] Read error: %v", err) + t.SetConnected(false) + conn.Close() + + next := attempt + if time.Since(connectedAt) > 15*time.Second { + next = -1 + } + t.scheduleReconnect(next) + return + } + t.handleMessage(session, message) + } + }() +} + +func (t *MailruDocsTransport) writerLoop() { + // The write queue is created once and preserved across reconnects, so we + // capture it and block on it instead of polling with a sleep. + var queue chan []byte + for t.IsRunning() && queue == nil { + t.Mu.Lock() + if t.session != nil { + queue = t.session.WriteQueue + } + t.Mu.Unlock() + if queue == nil { + time.Sleep(5 * time.Millisecond) + } + } + if queue == nil { + return + } + + var pending []byte + for t.IsRunning() { + if pending == nil { + packet, ok := <-queue + if !ok { + return + } + pending = packet + } + + t.Mu.RLock() + session := t.session + t.Mu.RUnlock() + if session == nil || session.Conn == nil { + // Mid-reconnect: hold the packet and retry rather than drop it. + time.Sleep(15 * time.Millisecond) + continue + } + + payload := base64.StdEncoding.EncodeToString(pending) + msg := fmt.Sprintf(`42["message",{"type":"cursor","cursor":"18;%s"}]`, payload) + if err := session.safeWrite(websocket.TextMessage, []byte(msg)); err != nil { + utils.Debugf("[M-DOCS] Write error: %v", err) + time.Sleep(15 * time.Millisecond) + continue // keep pending; the reconnect will bring up a new conn + } + pending = nil + } +} + +func (t *MailruDocsTransport) keepAliveLoop() { + ticker := time.NewTicker(t.GetConfig().KeepAliveInterval) + defer ticker.Stop() + keepAliveMsg := `42["message",{"type":"cursor","cursor":"18;---KA---"}]` + + for t.IsRunning() { + <-ticker.C + t.Mu.Lock() + session := t.session + t.Mu.Unlock() + + if session != nil && session.Conn != nil { + if err := session.safeWrite(websocket.TextMessage, []byte(keepAliveMsg)); err != nil { + utils.Debugf("[M-DOCS] Keep-alive failed: %v", err) + t.SetConnected(false) + } + } + } +} + +func (t *MailruDocsTransport) handleMessage(session *DocSession, data []byte) { + text := string(data) + + if strings.Contains(text, "---KA---") { + return + } + + // Socket.IO ping - respond with pong + if text == "2" { + if session != nil && session.Conn != nil { + session.safeWrite(websocket.TextMessage, []byte("3")) + } + return + } + if text == "3" { + return + } + + if strings.Contains(text, `"type":"auth"`) && strings.Contains(text, `"result":1`) { + utils.Debugf("[M-DOCS] Auth OK for user %s", session.UserID) + return + } + + if strings.Contains(text, "cursor") { + base64Str := t.extractBase64String(text) + if base64Str == "" { + return + } + + decoded, err := base64.StdEncoding.DecodeString(base64Str) + if err != nil { + utils.Debugf("[M-DOCS] Base64 decode error: %v", err) + return + } + + t.RecordReceive(len(decoded)) + t.CallReceive(decoded) + } +} + +func (t *MailruDocsTransport) extractBase64String(response string) string { + matches := cursorPayloadRe.FindStringSubmatch(response) + if len(matches) > 1 { + return matches[1] + } + return "" +} + +func (t *MailruDocsTransport) scheduleReconnect(attempt int) { + next := attempt + 1 + if !t.IsRunning() || next >= t.GetConfig().MaxReconnectAttempts { + return + } + + d := reconnectBackoff(next) + utils.Debugf("[M-DOCS] reconnecting in %v (attempt %d)", d, next) + time.Sleep(d) + if !t.IsRunning() { + return + } + + t.RecordReconnect() + t.connectToDoc(next) +} + +// reconnectBackoff returns an exponential backoff with jitter, capped at 15s. +func reconnectBackoff(n int) time.Duration { + if n < 1 { + n = 1 + } + shift := n - 1 + if shift > 5 { + shift = 5 + } + d := 500 * time.Millisecond * time.Duration(1< 15*time.Second { + d = 15 * time.Second + } + // add up to +50% jitter + d += time.Duration(rand.Int63n(int64(d/2) + 1)) + return d +} + +// fetchDocInfo POSTs to Mail.ru's public-document editor API and parses the +// response into the fields needed to open the collaborative WebSocket. +func (t *MailruDocsTransport) fetchDocInfo(weblink string) (MailruDocsInfo, error) { + client := &http.Client{Timeout: 15 * time.Second} + + reqBody := map[string]string{ + "x-email": "anonym", + "public": "/" + weblink, + "platform": "desktop_web", + } + jsonData, _ := json.Marshal(reqBody) + + apiURL := "https://cloud.mail.ru/api/v4/r7/edit" + utils.Debugf("[M-DOCS] fetchDocInfo POST %s", apiURL) + + req, _ := http.NewRequest("POST", apiURL, bytes.NewBuffer(jsonData)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/plain, */*") + req.Header.Set("User-Agent", mailruUserAgent) + req.Header.Set("X-Api-Version", "4") + req.Header.Set("Referer", fmt.Sprintf("https://cloud.mail.ru/public/%s?weblink=%s", weblink, weblink)) + + resp, err := client.Do(req) + if err != nil { + return MailruDocsInfo{}, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return MailruDocsInfo{}, fmt.Errorf("API returned status %d", resp.StatusCode) + } + + bodyBytes, _ := io.ReadAll(resp.Body) + + var res map[string]interface{} + if err := json.Unmarshal(bodyBytes, &res); err != nil { + return MailruDocsInfo{}, fmt.Errorf("failed to parse JSON: %w", err) + } + + apiBase, _ := res["api"].(string) + token, _ := res["token"].(string) + + document, ok := res["document"].(map[string]interface{}) + if !ok || document == nil { + return MailruDocsInfo{}, fmt.Errorf("document object missing") + } + + docKey, _ := document["key"].(string) + fileType, _ := document["fileType"].(string) + docURL, _ := document["url"].(string) + docTitle, _ := document["title"].(string) + // document.permissions is an object of booleans (comment/edit/download/…), + // not a number - sending it as anything else makes the editor server + // reject the auth message with "access deny". + permissions, _ := document["permissions"].(map[string]interface{}) + if permissions == nil { + permissions = make(map[string]interface{}) + } + + editorConfig, ok := res["editorConfig"].(map[string]interface{}) + if !ok || editorConfig == nil { + return MailruDocsInfo{}, fmt.Errorf("editorConfig object missing") + } + callbackURL, _ := editorConfig["callbackUrl"].(string) + + userObj, _ := editorConfig["user"].(map[string]interface{}) + var editorUserID string + if userObj != nil { + editorUserID, _ = userObj["id"].(string) + } + + wsBase := strings.Replace(apiBase, "https://", "wss://", 1) + wsURL := fmt.Sprintf("%s/doc/%s/c/?EIO=4&transport=websocket", wsBase, docKey) + + return MailruDocsInfo{ + Token: token, + DocKey: docKey, + WsURL: wsURL, + FileType: fileType, + DocURL: docURL, + DocTitle: docTitle, + Permissions: permissions, + CallbackURL: callbackURL, + EditorUserID: editorUserID, + }, nil +} + +func randUserID() string { + return fmt.Sprintf("%010d", rand.New(rand.NewSource(time.Now().UnixNano())).Intn(1000000000)) +} diff --git a/transport/multiplex.go b/transport/multiplex.go new file mode 100644 index 0000000..192ebf0 --- /dev/null +++ b/transport/multiplex.go @@ -0,0 +1,203 @@ +package transport + +import ( + "fmt" + "sync/atomic" + "time" + + "universal-bypass-tool/utils" +) + +// MultiplexTransport spreads packets across N independent inner transports +// (e.g. N separate Yandex.Docs documents), widening the aggregate channel +// past the per-connection / per-document throttle of a single covert link. +// +// v1 strategy: per-flow affinity. Every packet is hashed by its IP 5-tuple and +// pinned to one inner channel, so a single TCP flow always travels one link and +// stays in order (no resequencing needed). Distinct flows land on distinct +// channels, so multi-connection traffic (speedtests, browsers) fans out and the +// throughput adds up. A single lone flow does NOT speed up under v1 — that is +// what a future per-packet+seq mode (v2) would address. +// +// The wire is untouched: each inner is an ordinary, unmodified transport, so an +// exit node simply runs the same list of documents and merges their receives. +type MultiplexTransport struct { + inner []Transport + + cb func([]byte) + + running atomic.Int32 + startTime time.Time +} + +// NewMultiplexTransport wraps the given inner transports into one Transport. +// Order matters: the client and exit node must pass the SAME ordered list so a +// flow that hashes to channel k on one side is read from channel k on the other +// (though correctness does not actually depend on it — each inner is a full +// bidirectional channel; ordering only keeps behavior symmetric). +func NewMultiplexTransport(inner []Transport) Transport { + return &MultiplexTransport{ + inner: inner, + startTime: time.Now(), + } +} + +func (m *MultiplexTransport) Start() error { + m.running.Store(1) + m.startTime = time.Now() + + started := 0 + var firstErr error + for i, t := range m.inner { + if err := t.Start(); err != nil { + utils.Debugf("[MUX] channel %d failed to start: %v", i, err) + if firstErr == nil { + firstErr = err + } + continue + } + started++ + } + if started == 0 { + return fmt.Errorf("multiplex: no channels started (%d configured): %v", len(m.inner), firstErr) + } + utils.Debugf("[MUX] started %d/%d channels", started, len(m.inner)) + return nil +} + +func (m *MultiplexTransport) Stop() error { + m.running.Store(0) + for _, t := range m.inner { + _ = t.Stop() + } + return nil +} + +// Send routes one packet to a live channel chosen by its flow hash. Only +// currently-connected channels are eligible, so a channel that is mid-reconnect +// is transparently skipped and its flows rehash onto healthy links. +func (m *MultiplexTransport) Send(data []byte) error { + t := m.pick(data) + if t == nil { + return fmt.Errorf("multiplex: no connected channel") + } + return t.Send(data) +} + +// pick selects the (flowHash mod liveCount)-th connected channel without +// allocating on the hot path. If nothing is connected it returns nil. +func (m *MultiplexTransport) pick(data []byte) Transport { + n := len(m.inner) + if n == 0 { + return nil + } + + live := 0 + for _, t := range m.inner { + if t.IsConnected() { + live++ + } + } + if live == 0 { + return nil + } + + target := int(flowHash(data) % uint32(live)) + for _, t := range m.inner { + if t.IsConnected() { + if target == 0 { + return t + } + target-- + } + } + return nil // unreachable: live > 0 guarantees a hit +} + +// Receive fans a single upstream callback out to every channel. Packets arrive +// interleaved from all links; each is a whole IP packet, so the tunnel forwards +// it regardless of which channel delivered it. +func (m *MultiplexTransport) Receive(callback func([]byte)) { + m.cb = callback + for _, t := range m.inner { + t.Receive(callback) + } +} + +// IsConnected reports true if at least one channel is up. +func (m *MultiplexTransport) IsConnected() bool { + for _, t := range m.inner { + if t.IsConnected() { + return true + } + } + return false +} + +// Stats aggregates the counters of every channel. +func (m *MultiplexTransport) Stats() TransportStats { + var agg TransportStats + for _, t := range m.inner { + s := t.Stats() + agg.BytesSent += s.BytesSent + agg.BytesReceived += s.BytesReceived + agg.PacketsSent += s.PacketsSent + agg.PacketsRecv += s.PacketsRecv + agg.Reconnects += s.Reconnects + if s.Connected { + agg.Connected = true + } + } + agg.Uptime = time.Since(m.startTime) + return agg +} + +const ( + fnv32Offset uint32 = 2166136261 + fnv32Prime uint32 = 16777619 +) + +// flowHash derives a stable hash from a packet's IPv4 5-tuple (src/dst IP, +// protocol, and src/dst ports for TCP/UDP). All packets of one flow hash equal, +// so per-flow affinity holds. Non-IPv4 or truncated packets fall back to +// hashing the leading header bytes, which still pins a given peer consistently. +func flowHash(pkt []byte) uint32 { + h := fnv32Offset + mix := func(b byte) { h = (h ^ uint32(b)) * fnv32Prime } + + if len(pkt) >= 20 && pkt[0]>>4 == 4 { + ihl := int(pkt[0]&0x0f) * 4 + proto := pkt[9] + for i := 12; i < 20; i++ { // src IP (12-15) + dst IP (16-19) + mix(pkt[i]) + } + mix(proto) + if (proto == 6 || proto == 17) && len(pkt) >= ihl+4 { + for i := ihl; i < ihl+4; i++ { // src port + dst port + mix(pkt[i]) + } + } + return fmix32(h) + } + + n := len(pkt) + if n > 20 { + n = 20 + } + for i := 0; i < n; i++ { + mix(pkt[i]) + } + return fmix32(h) +} + +// fmix32 is the MurmurHash3 finalizer. FNV's low bits are poorly mixed, so a +// bare `hash % channels` split is lopsided; this avalanche spreads entropy into +// every bit position, giving an even split over any small channel count. +func fmix32(h uint32) uint32 { + h ^= h >> 16 + h *= 0x85ebca6b + h ^= h >> 13 + h *= 0xc2b2ae35 + h ^= h >> 16 + return h +} diff --git a/transport/multiplex_behavior_test.go b/transport/multiplex_behavior_test.go new file mode 100644 index 0000000..9c49456 --- /dev/null +++ b/transport/multiplex_behavior_test.go @@ -0,0 +1,164 @@ +package transport + +import ( + "sync" + "sync/atomic" + "testing" +) + +// fakeTransport is a minimal Transport for exercising MultiplexTransport wiring. +type fakeTransport struct { + id int + connected atomic.Bool + sent atomic.Int32 + cb func([]byte) + mu sync.Mutex +} + +func (f *fakeTransport) Start() error { f.connected.Store(true); return nil } +func (f *fakeTransport) Stop() error { f.connected.Store(false); return nil } +func (f *fakeTransport) Send(data []byte) error { + f.sent.Add(1) + return nil +} +func (f *fakeTransport) Receive(cb func([]byte)) { + f.mu.Lock() + f.cb = cb + f.mu.Unlock() +} +func (f *fakeTransport) IsConnected() bool { return f.connected.Load() } +func (f *fakeTransport) Stats() TransportStats { + return TransportStats{PacketsSent: uint64(f.sent.Load()), Connected: f.connected.Load()} +} +func (f *fakeTransport) deliver(pkt []byte) { + f.mu.Lock() + cb := f.cb + f.mu.Unlock() + if cb != nil { + cb(pkt) + } +} + +func newFakes(n int) []Transport { + out := make([]Transport, n) + for i := range out { + out[i] = &fakeTransport{id: i} + } + return out +} + +// TestMuxRoutesAcrossChannels: distinct flows must reach more than one channel. +func TestMuxRoutesAcrossChannels(t *testing.T) { + fakes := newFakes(4) + m := NewMultiplexTransport(fakes) + if err := m.Start(); err != nil { + t.Fatal(err) + } + + for i := 0; i < 2000; i++ { + pkt := buildTCP([4]byte{10, 0, 0, 2}, [4]byte{1, 2, byte(i >> 8), byte(i)}, uint16(40000+i), 443) + if err := m.Send(pkt); err != nil { + t.Fatal(err) + } + } + + used := 0 + for _, f := range fakes { + if f.(*fakeTransport).sent.Load() > 0 { + used++ + } + } + if used < 2 { + t.Fatalf("expected traffic on multiple channels, only %d used", used) + } +} + +// TestMuxFlowAffinity: all packets of one flow must land on the same channel. +func TestMuxFlowAffinity(t *testing.T) { + fakes := newFakes(4) + m := NewMultiplexTransport(fakes) + _ = m.Start() + + pkt := buildTCP([4]byte{10, 0, 0, 2}, [4]byte{93, 184, 216, 34}, 51000, 443) + for i := 0; i < 50; i++ { + _ = m.Send(pkt) + } + + nonZero := 0 + for _, f := range fakes { + if c := f.(*fakeTransport).sent.Load(); c > 0 { + nonZero++ + if c != 50 { + t.Fatalf("flow split: a channel got %d of 50 packets", c) + } + } + } + if nonZero != 1 { + t.Fatalf("one flow spread over %d channels, want 1", nonZero) + } +} + +// TestMuxFailover: a disconnected channel must never be selected. +func TestMuxFailover(t *testing.T) { + fakes := newFakes(3) + m := NewMultiplexTransport(fakes) + _ = m.Start() + + // Kill channel 1; its flows must rehash onto the survivors. + fakes[1].(*fakeTransport).connected.Store(false) + + for i := 0; i < 1000; i++ { + pkt := buildTCP([4]byte{10, 0, 0, 2}, [4]byte{1, 2, byte(i >> 8), byte(i)}, uint16(40000+i), 443) + _ = m.Send(pkt) + } + + if got := fakes[1].(*fakeTransport).sent.Load(); got != 0 { + t.Fatalf("dead channel received %d packets, want 0", got) + } + + // Kill everything -> Send must error, not panic. + for _, f := range fakes { + f.(*fakeTransport).connected.Store(false) + } + if err := m.Send([]byte{0x45, 0, 0, 0}); err == nil { + t.Fatal("expected error when no channel is connected") + } +} + +// TestMuxReceiveMerge: packets from any channel reach the single upstream cb. +func TestMuxReceiveMerge(t *testing.T) { + fakes := newFakes(3) + m := NewMultiplexTransport(fakes) + _ = m.Start() + + var got atomic.Int32 + m.Receive(func([]byte) { got.Add(1) }) + + for _, f := range fakes { + f.(*fakeTransport).deliver([]byte{1, 2, 3}) + } + if got.Load() != 3 { + t.Fatalf("upstream callback saw %d packets, want 3", got.Load()) + } +} + +// TestMuxStatsAggregate: Stats sums across channels and reports connectivity. +func TestMuxStatsAggregate(t *testing.T) { + fakes := newFakes(2) + m := NewMultiplexTransport(fakes) + _ = m.Start() + + fakes[0].(*fakeTransport).sent.Store(5) + fakes[1].(*fakeTransport).sent.Store(7) + + s := m.Stats() + if s.PacketsSent != 12 { + t.Fatalf("aggregate PacketsSent = %d, want 12", s.PacketsSent) + } + if !s.Connected { + t.Fatal("expected Connected=true when channels are up") + } + if !m.IsConnected() { + t.Fatal("IsConnected should be true") + } +} diff --git a/transport/multiplex_test.go b/transport/multiplex_test.go new file mode 100644 index 0000000..5be0394 --- /dev/null +++ b/transport/multiplex_test.go @@ -0,0 +1,74 @@ +package transport + +import ( + "encoding/binary" + "math" + "testing" +) + +// buildTCP builds a minimal IPv4+TCP packet with the given 5-tuple so the tests +// can exercise flowHash the way real traffic hits it. +func buildTCP(srcIP, dstIP [4]byte, srcPort, dstPort uint16) []byte { + pkt := make([]byte, 40) // 20 IP + 20 TCP + pkt[0] = 0x45 // IPv4, IHL=5 + pkt[9] = 6 // TCP + copy(pkt[12:16], srcIP[:]) + copy(pkt[16:20], dstIP[:]) + binary.BigEndian.PutUint16(pkt[20:22], srcPort) + binary.BigEndian.PutUint16(pkt[22:24], dstPort) + return pkt +} + +// TestFlowHashStable: every packet of one flow must hash to the same value, +// otherwise per-flow affinity breaks and a single TCP connection would be split +// across channels (reordering). +func TestFlowHashStable(t *testing.T) { + a := buildTCP([4]byte{10, 0, 0, 2}, [4]byte{93, 184, 216, 34}, 51000, 443) + b := buildTCP([4]byte{10, 0, 0, 2}, [4]byte{93, 184, 216, 34}, 51000, 443) + if flowHash(a) != flowHash(b) { + t.Fatalf("same flow hashed differently: %d vs %d", flowHash(a), flowHash(b)) + } + + // A different source port is a different flow and should (very likely) map + // elsewhere; at minimum it must be allowed to differ. + c := buildTCP([4]byte{10, 0, 0, 2}, [4]byte{93, 184, 216, 34}, 51001, 443) + if flowHash(a) == flowHash(c) { + t.Logf("note: distinct flows collided on hash (acceptable but rare): %d", flowHash(a)) + } +} + +// TestFlowHashDistribution: many distinct flows over N channels should spread +// reasonably evenly (no channel starved, none hogging everything). +func TestFlowHashDistribution(t *testing.T) { + const channels = 4 + const flows = 4000 + counts := make([]int, channels) + + for i := 0; i < flows; i++ { + pkt := buildTCP( + [4]byte{10, 0, 0, 2}, + [4]byte{93, 184, byte(i >> 8), byte(i)}, + uint16(40000+i%20000), + 443, + ) + counts[flowHash(pkt)%channels]++ + } + + expected := float64(flows) / float64(channels) + for ch, n := range counts { + dev := math.Abs(float64(n)-expected) / expected + if dev > 0.20 { // within 20% of even is plenty for a hash split + t.Errorf("channel %d got %d flows, expected ~%.0f (%.0f%% off)", ch, n, expected, dev*100) + } + } + t.Logf("distribution over %d channels: %v", channels, counts) +} + +// TestFlowHashNonIPv4 must not panic on short or non-IPv4 input. +func TestFlowHashNonIPv4(t *testing.T) { + flowHash([]byte{}) + flowHash([]byte{0x60, 0x00}) // IPv6-ish, too short + flowHash([]byte{0x45}) // IPv4 nibble but truncated + flowHash(make([]byte, 8)) // short + flowHash(buildTCP([4]byte{1, 1, 1, 1}, [4]byte{2, 2, 2, 2}, 1, 2)[:22]) // TCP header truncated +} diff --git a/transport/oneme/max_call.go b/transport/oneme/max_call.go index afe5b73..eaa3247 100644 --- a/transport/oneme/max_call.go +++ b/transport/oneme/max_call.go @@ -4,7 +4,6 @@ import ( "encoding/base64" "encoding/json" "fmt" - "os" "strings" "time" @@ -30,6 +29,11 @@ func (h *CallHandler) Send(data []byte) { } func (h *CallHandler) readLoop() { + defer func() { + if r := recover(); r != nil { + logError("recovered in CallHandler.readLoop: %v", r) + } + }() logInfo("[%s] Signaling connected", h.tag) for { _, message, err := h.conn.ReadMessage() @@ -83,8 +87,14 @@ func (h *CallHandler) signalReconnect() { default: } } else { - logError("[%s] Receiver connection died, exiting", h.tag) - os.Exit(1) + // Never kill the host process (this code runs inside the iOS/Android + // app as a library); just signal the reconnect channel and let the + // transport's reconnect logic handle it. + logError("[%s] Receiver connection died, signaling reconnect", h.tag) + select { + case h.reconnectCh <- struct{}{}: + default: + } } } diff --git a/transport/oneme/max_wclient.go b/transport/oneme/max_wclient.go index a38afae..25c43aa 100644 --- a/transport/oneme/max_wclient.go +++ b/transport/oneme/max_wclient.go @@ -41,6 +41,11 @@ func (c *MaxClient) Connect() error { func (c *MaxClient) SetEventCallback(cb func(MaxPacket)) { c.onEvent = cb } func (c *MaxClient) readLoop() { + defer func() { + if r := recover(); r != nil { + logError("recovered in MaxClient.readLoop: %v", r) + } + }() for { _, message, err := c.conn.ReadMessage() if err != nil { @@ -148,6 +153,11 @@ func (c *MaxClient) getUserMap(resp *MaxPacket) map[int64]UserInfo { } func (c *MaxClient) keepalive() { + defer func() { + if r := recover(); r != nil { + logError("recovered in MaxClient.keepalive: %v", r) + } + }() ticker := time.NewTicker(30 * time.Second) defer ticker.Stop() for range ticker.C { diff --git a/transport/yandex/vyandex.go b/transport/yandex/vyandex.go new file mode 100644 index 0000000..80d4060 --- /dev/null +++ b/transport/yandex/vyandex.go @@ -0,0 +1,1178 @@ +package yandex + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/binary" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/cookiejar" + "net/url" + "regexp" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/gorilla/websocket" + + "universal-bypass-tool/transport" + "universal-bypass-tool/utils" +) + +type VolgaConfig struct { + MaxIdleConnsPerHost int + MaxIdleConns int + IdleConnTimeout time.Duration + RelayTimeout time.Duration + + WorkerCount int + QueueSize int + + BatchSize int + BatchTimeout time.Duration + BatchMaxBytes int + + MaxPayloadBytes int + MinPayloadBytes int + + ReconnectMinDelay time.Duration + ReconnectMaxDelay time.Duration + ReconnectMultiplier float64 + + WSHandshakeTimeout time.Duration + WSReadTimeout time.Duration + KeepAliveInterval time.Duration +} + +func DefaultVolgaConfig() VolgaConfig { + return VolgaConfig{ + MaxIdleConnsPerHost: 2000, + MaxIdleConns: 4000, + IdleConnTimeout: 90 * time.Second, + RelayTimeout: 30 * time.Second, + + WorkerCount: 2000, + QueueSize: 1000000, + + BatchSize: 20, + BatchTimeout: 2 * time.Millisecond, + BatchMaxBytes: 4 * 1024 * 1024, + + MaxPayloadBytes: 5_000_000, + MinPayloadBytes: 200, + + ReconnectMinDelay: 500 * time.Millisecond, + ReconnectMaxDelay: 30 * time.Second, + ReconnectMultiplier: 1.5, + + WSHandshakeTimeout: 10 * time.Second, + WSReadTimeout: 60 * time.Second, + KeepAliveInterval: 10 * time.Second, + } +} + +// SlimVolgaConfig is a memory-constrained profile for the iOS Network Extension, +// which is killed if it exceeds ~40MB. DefaultVolgaConfig's 2000 workers / +// 1M-entry queues are a server profile that blows that cap; a single mobile +// tunnel needs only a handful of relay workers and a small queue. The batching +// wire format is identical, so this interoperates with a full-config exit node. +func SlimVolgaConfig() VolgaConfig { + c := DefaultVolgaConfig() + c.MaxIdleConnsPerHost = 8 + c.MaxIdleConns = 16 + c.WorkerCount = 4 + c.QueueSize = 4096 + c.BatchMaxBytes = 256 * 1024 + return c +} + +const volgaUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:153.0) Gecko/20100101 Firefox/153.0" + +var reClientConfig = regexp.MustCompile(`]*id="client-config"[^>]*>(.*?)`) + +var ( + // 256KB initial cap: base64Encode grows it on demand for larger batches, + // so a big server batch still works, but a memory-constrained client (iOS + // NE) keeps only small pooled buffers instead of 16MB each. + b64BufPool = sync.Pool{ + New: func() interface{} { return make([]byte, 0, 256*1024) }, + } + jsonBufPool = sync.Pool{ + New: func() interface{} { return bytes.NewBuffer(make([]byte, 0, 128*1024)) }, + } + blobBufPool = sync.Pool{ + New: func() interface{} { return bytes.NewBuffer(make([]byte, 0, 64*1024)) }, + } +) + +func base64Encode(data []byte) string { + buf := b64BufPool.Get().([]byte) + need := base64.StdEncoding.EncodedLen(len(data)) + if cap(buf) < need { + buf = make([]byte, need) + } else { + buf = buf[:need] + } + base64.StdEncoding.Encode(buf, data) + out := string(buf) + b64BufPool.Put(buf[:0]) + return out +} + +type VolgaStats struct { + PacketsSent atomic.Uint64 + PacketsRecv atomic.Uint64 + BytesSent atomic.Uint64 + BytesReceived atomic.Uint64 + HTTPReqsSent atomic.Uint64 + HTTPReqsFailed atomic.Uint64 + WSReconnects atomic.Uint64 + QueueDrops atomic.Uint64 + WorkerBusy atomic.Int64 + BatchesSent atomic.Uint64 + PacketsBatched atomic.Uint64 +} + +type volgaAuth struct { + Session *http.Client + AccessToken string + Token string + RequestPath string + ResourceURL string + DocID string + UserID int + UserIDStr string + Sign string + TS string + SessionID string + Cookies []*http.Cookie +} + +func authorize(docURL string) (*volgaAuth, error) { + utils.Debugf("[VOLGA] authorize(%s)", docURL) + + jar, _ := cookiejar.New(nil) + session := &http.Client{ + Jar: jar, + Transport: &http.Transport{ + MaxIdleConns: 100, + MaxIdleConnsPerHost: 100, + IdleConnTimeout: 90 * time.Second, + }, + Timeout: 30 * time.Second, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + + var finalBody []byte + var finalURL string + currentURL := docURL + + for i := 0; i < 10; i++ { + req, _ := http.NewRequest("GET", currentURL, nil) + req.Header.Set("User-Agent", volgaUserAgent) + req.Header.Set("Accept-Language", "ru-RU,ru;q=0.9") + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + if i > 0 { + req.Header.Set("Referer", docURL) + } + + resp, err := session.Do(req) + if err != nil { + return nil, fmt.Errorf("GET %s: %w", currentURL, err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + + utils.Debugf("[VOLGA] GET %s -> %d (%d bytes)", currentURL, resp.StatusCode, len(body)) + + if resp.StatusCode >= 300 && resp.StatusCode < 400 { + loc := resp.Header.Get("Location") + if loc == "" { + return nil, fmt.Errorf("redirect without Location from %s", currentURL) + } + if strings.HasPrefix(loc, "/") { + u, _ := url.Parse(currentURL) + loc = u.Scheme + "://" + u.Host + loc + } + currentURL = loc + continue + } + + finalBody = body + finalURL = currentURL + break + } + + if finalBody == nil { + return nil, fmt.Errorf("too many redirects from %s", docURL) + } + + utils.Debugf("[VOLGA] final URL: %s", finalURL) + + m := reClientConfig.FindSubmatch(finalBody) + if len(m) < 2 { + preview := string(finalBody) + if len(preview) > 3000 { + preview = preview[:3000] + } + utils.Debugf("[VOLGA] HTML preview: %s", preview) + return nil, fmt.Errorf("client-config not found in %s", finalURL) + } + + var cfg map[string]interface{} + dec := json.NewDecoder(bytes.NewReader(m[1])) + dec.UseNumber() + if err := dec.Decode(&cfg); err != nil { + return nil, fmt.Errorf("parse client-config: %w", err) + } + + utils.Debugf("[VOLGA] client-config keys: %v", mapKeys(cfg)) + + office, _ := cfg["officeActionData"].(map[string]interface{}) + editor, _ := cfg["editorParams"].(map[string]interface{}) + + if office == nil { + return nil, fmt.Errorf("officeActionData missing (keys: %v)", mapKeys(cfg)) + } + + utils.Debugf("[VOLGA] office keys: %v", mapKeys(office)) + + actionURL := getStr(office, "action_url") + accessToken := getStr(office, "access_token") + ttl := office["access_token_ttl"] + + utils.Debugf("[VOLGA] action_url: %s", actionURL) + utils.Debugf("[VOLGA] access_token: %d bytes", len(accessToken)) + utils.Debugf("[VOLGA] access_token_ttl: %v (%T)", ttl, ttl) + + a := &volgaAuth{ + Session: session, + AccessToken: accessToken, + ResourceURL: getStr(office, "resource_url"), + DocID: getStr(editor, "idDoc"), + } + + if actionURL == "" { + return nil, fmt.Errorf("action_url missing (keys: %v)", mapKeys(office)) + } + if a.AccessToken == "" { + return nil, fmt.Errorf("access_token missing") + } + + ttlStr := formatTTL(ttl) + utils.Debugf("[VOLGA] ttl formatted: %q", ttlStr) + + form := url.Values{} + form.Set("access_token", a.AccessToken) + form.Set("access_token_ttl", ttlStr) + body := form.Encode() + + utils.Debugf("[VOLGA] POST %s (body %d bytes)", actionURL, len(body)) + + req2, _ := http.NewRequest("POST", actionURL, strings.NewReader(body)) + req2.Header.Set("User-Agent", volgaUserAgent) + req2.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req2.Header.Set("Origin", "https://disk.yandex.ru") + req2.Header.Set("Referer", finalURL) + req2.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + req2.Header.Set("Accept-Language", "ru-RU,ru;q=0.9") + req2.Header.Set("Upgrade-Insecure-Requests", "1") + req2.Header.Set("Sec-Fetch-Dest", "iframe") + req2.Header.Set("Sec-Fetch-Mode", "navigate") + req2.Header.Set("Sec-Fetch-Site", "cross-site") + + resp2, err := session.Do(req2) + if err != nil { + return nil, fmt.Errorf("POST auth/initial: %w", err) + } + resp2.Body.Close() + + utils.Debugf("[VOLGA] auth/initial -> %d", resp2.StatusCode) + + if resp2.StatusCode != 302 { + return nil, fmt.Errorf("auth/initial status %d (expected 302)", resp2.StatusCode) + } + + location := resp2.Header.Get("Location") + if location == "" { + return nil, fmt.Errorf("auth/initial no Location") + } + + utils.Debugf("[VOLGA] Location: %s", location[:minInt(len(location), 300)]) + + if strings.Contains(location, "/document/error/") { + return nil, fmt.Errorf("auth/initial returned /document/error/ — check access_token_ttl and Referer") + } + + locParsed, err := url.Parse(location) + if err != nil { + return nil, fmt.Errorf("parse Location: %w", err) + } + qs := locParsed.Query() + + a.Token = qs.Get("token") + a.RequestPath = qs.Get("request-path") + + jsonStr := qs.Get("json") + if jsonStr == "" { + return nil, fmt.Errorf("no json in Location (token=%v rp=%v)", + a.Token != "", a.RequestPath != "") + } + + var jsonData map[string]interface{} + dec2 := json.NewDecoder(strings.NewReader(jsonStr)) + dec2.UseNumber() + if err := dec2.Decode(&jsonData); err != nil { + return nil, fmt.Errorf("parse Location json: %w", err) + } + + a.SessionID = getStr(jsonData, "sessionId") + a.UserID = int(getFloat(jsonData, "userId")) + + if xiva, ok := jsonData["xiva"].(map[string]interface{}); ok { + a.Sign = getStr(xiva, "sign") + a.TS = getStr(xiva, "ts") + a.UserIDStr = getStr(xiva, "user") + } + + req3, _ := http.NewRequest("GET", location, nil) + req3.Header.Set("User-Agent", volgaUserAgent) + req3.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + req3.Header.Set("Referer", actionURL) + resp3, err := session.Do(req3) + if err != nil { + return nil, fmt.Errorf("GET Location: %w", err) + } + io.Copy(io.Discard, resp3.Body) + resp3.Body.Close() + + a.Cookies = jar.Cookies(locParsed) + + if a.Token == "" || a.RequestPath == "" || a.UserIDStr == "" || a.Sign == "" { + return nil, fmt.Errorf("incomplete auth: token=%v rp=%v user=%v sign=%v", + a.Token != "", a.RequestPath != "", a.UserIDStr != "", a.Sign != "") + } + + utils.Debugf("[VOLGA] auth OK: user=%d(%s) rp=%s sign=%s ts=%s", + a.UserID, a.UserIDStr, a.RequestPath, a.Sign, a.TS) + return a, nil +} + +func getStr(m map[string]interface{}, key string) string { + if m == nil { + return "" + } + switch v := m[key].(type) { + case string: + return v + case json.Number: + return v.String() + case float64: + return strconv.FormatFloat(v, 'f', -1, 64) + case int64: + return strconv.FormatInt(v, 10) + case int: + return strconv.Itoa(v) + } + return "" +} + +func getFloat(m map[string]interface{}, key string) float64 { + if m == nil { + return 0 + } + switch v := m[key].(type) { + case float64: + return v + case json.Number: + f, _ := v.Float64() + return f + case int64: + return float64(v) + case int: + return float64(v) + case string: + f, _ := strconv.ParseFloat(v, 64) + return f + } + return 0 +} + +func formatTTL(v interface{}) string { + switch x := v.(type) { + case json.Number: + return x.String() + case float64: + return strconv.FormatInt(int64(x), 10) + case int64: + return strconv.FormatInt(x, 10) + case int: + return strconv.Itoa(x) + case string: + return x + case nil: + return "0" + default: + return fmt.Sprintf("%v", x) + } +} + +func mapKeys(m map[string]interface{}) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + return keys +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} + +type relayClient struct { + auth *volgaAuth + config VolgaConfig + stats *VolgaStats + + httpClient *http.Client + workers int + queue chan []byte + batchQueue chan []byte + wg sync.WaitGroup + ctx context.Context + cancel context.CancelFunc + + bundleID atomic.Uint64 + seq atomic.Uint64 + localID atomic.Uint64 + + mu sync.Mutex + frontier string +} + +func newRelayClient(auth *volgaAuth, cfg VolgaConfig, stats *VolgaStats) *relayClient { + tr := &http.Transport{ + MaxIdleConns: cfg.MaxIdleConns, + MaxIdleConnsPerHost: cfg.MaxIdleConnsPerHost, + IdleConnTimeout: cfg.IdleConnTimeout, + DisableCompression: true, + ForceAttemptHTTP2: true, + } + + ctx, cancel := context.WithCancel(context.Background()) + + return &relayClient{ + auth: auth, + config: cfg, + stats: stats, + httpClient: &http.Client{ + Transport: tr, + Timeout: cfg.RelayTimeout, + Jar: auth.Session.Jar, + }, + workers: cfg.WorkerCount, + queue: make(chan []byte, cfg.QueueSize), + batchQueue: make(chan []byte, cfg.QueueSize), + ctx: ctx, + cancel: cancel, + } +} + +func (r *relayClient) Start() { + for i := 0; i < r.workers; i++ { + r.wg.Add(1) + go r.worker(i) + } + utils.Debugf("[VOLGA] relay pool started: %d workers, batch=%d timeout=%v", + r.workers, r.config.BatchSize, r.config.BatchTimeout) +} + +func (r *relayClient) Stop() { + // Do NOT close(queue)/close(batchQueue): a client TCP connection tearing + // down can still call Send concurrently (gvisor emits a final FIN/RST during + // Close), and a send on a closed channel is a fatal panic that recover() + // cannot catch. Workers already exit on ctx.Done(); the channels are GC'd. + r.cancel() + r.wg.Wait() +} + +func (r *relayClient) Send(data []byte) error { + if len(data) == 0 { + return nil + } + if len(data) > r.config.MaxPayloadBytes { + return fmt.Errorf("packet too large: %d > %d", len(data), r.config.MaxPayloadBytes) + } + // Stopped: drop instead of queuing (workers are gone, nothing drains it). + if r.ctx.Err() != nil { + return fmt.Errorf("relay stopped") + } + + cp := make([]byte, len(data)) + copy(cp, data) + + select { + case r.batchQueue <- cp: + return nil + default: + r.stats.QueueDrops.Add(1) + return fmt.Errorf("queue full") + } +} + +func (r *relayClient) worker(id int) { + defer r.wg.Done() + defer func() { + if rec := recover(); rec != nil { + utils.Debugf("[VOLGA] recovered in worker %d: %v", id, rec) + } + }() + + batch := make([][]byte, 0, r.config.BatchSize) + totalBytes := 0 + timer := time.NewTimer(r.config.BatchTimeout) + if !timer.Stop() { + <-timer.C + } + defer timer.Stop() + + flush := func() { + if len(batch) == 0 { + return + } + r.stats.WorkerBusy.Add(1) + err := r.sendBatch(batch) + if err != nil { + r.stats.HTTPReqsFailed.Add(1) + utils.Debugf("[VOLGA] batch send failed: %v", err) + } else { + r.stats.HTTPReqsSent.Add(1) + r.stats.BatchesSent.Add(1) + } + r.stats.WorkerBusy.Add(-1) + batch = batch[:0] + totalBytes = 0 + } + + for { + select { + case <-r.ctx.Done(): + flush() + return + + case pkt, ok := <-r.batchQueue: + if !ok { + flush() + return + } + batch = append(batch, pkt) + totalBytes += len(pkt) + + if len(batch) >= r.config.BatchSize || totalBytes >= r.config.BatchMaxBytes { + flush() + } else if len(batch) == 1 { + timer.Reset(r.config.BatchTimeout) + } + + case <-timer.C: + flush() + } + } +} + +func (r *relayClient) sendBatch(batch [][]byte) error { + blob := blobBufPool.Get().(*bytes.Buffer) + blob.Reset() + + var lenBuf [2]byte + var totalBytes int + for _, p := range batch { + binary.BigEndian.PutUint16(lenBuf[:], uint16(len(p))) + blob.Write(lenBuf[:]) + blob.Write(p) + totalBytes += len(p) + } + + encoded := base64Encode(blob.Bytes()) + blobBufPool.Put(blob) + + frontier := r.getFrontier() + opID := fmt.Sprintf("1-%d.%d", r.auth.UserID, r.seq.Add(1)) + relayOpID := fmt.Sprintf("1-%d.%d", r.auth.UserID, r.seq.Add(1)) + + bundle := []interface{}{ + map[string]interface{}{ + "id": opID, + "frontier": frontier, + "undoable": true, + "actionName": "textInsert", + "ops": []interface{}{[]interface{}{"it", "vyd:t/00000000000008", 0, "A"}}, + "sideEffect": false, + "localId": r.localID.Add(1), + }, + map[string]interface{}{ + "id": relayOpID, + "frontier": []interface{}{opID}, + "undoable": false, + "actionName": "setCaret", + "ops": []interface{}{ + []interface{}{"us", r.auth.UserID, []interface{}{ + []interface{}{ + []interface{}{"vyd:t/00000000000008", 0, -1}, + []interface{}{"vyd:t/00000000000008", 0, -1}, + }, + }}, + }, + "sideEffect": true, + "localId": r.localID.Add(1), + }, + encoded, + } + + payload := map[string]interface{}{ + "message": map[string]interface{}{ + "bundleId": r.bundleID.Add(1), + "bundle": bundle, + }, + "targetUserId": nil, + } + + buf := jsonBufPool.Get().(*bytes.Buffer) + buf.Reset() + enc := json.NewEncoder(buf) + enc.SetEscapeHTML(false) + if err := enc.Encode(payload); err != nil { + jsonBufPool.Put(buf) + return err + } + bodyCopy := make([]byte, buf.Len()) + copy(bodyCopy, buf.Bytes()) + jsonBufPool.Put(buf) + + urlStr := fmt.Sprintf("https://volga.yandex.ru/session/main/%s/relay", r.auth.RequestPath) + req, err := http.NewRequestWithContext(r.ctx, "POST", urlStr, bytes.NewReader(bodyCopy)) + if err != nil { + return err + } + req.Header.Set("User-Agent", volgaUserAgent) + req.Header.Set("Authorization", "Bearer "+r.auth.Token) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Origin", "https://volga.yandex.ru") + req.Header.Set("Referer", "https://volga.yandex.ru/document/?request-path="+r.auth.RequestPath) + req.Header.Set("Accept", "*/*") + req.Header.Set("Sec-Fetch-Dest", "empty") + req.Header.Set("Sec-Fetch-Mode", "cors") + req.Header.Set("Sec-Fetch-Site", "same-origin") + req.ContentLength = int64(len(bodyCopy)) + + var cookieParts []string + for _, c := range r.auth.Cookies { + cookieParts = append(cookieParts, c.Name+"="+c.Value) + } + if len(cookieParts) > 0 { + req.Header.Set("Cookie", strings.Join(cookieParts, "; ")) + } + + resp, err := r.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + io.Copy(io.Discard, resp.Body) + + if resp.StatusCode != 204 && resp.StatusCode != 200 { + return fmt.Errorf("status %d", resp.StatusCode) + } + + r.stats.PacketsSent.Add(uint64(len(batch))) + r.stats.PacketsBatched.Add(uint64(len(batch))) + r.stats.BytesSent.Add(uint64(totalBytes)) + return nil +} + +func (r *relayClient) SetFrontier(opID string) { + r.mu.Lock() + r.frontier = opID + r.mu.Unlock() +} + +func (r *relayClient) getFrontier() []interface{} { + r.mu.Lock() + defer r.mu.Unlock() + if r.frontier == "" { + return []interface{}{} + } + return []interface{}{r.frontier} +} + +type wsListener struct { + auth *volgaAuth + config VolgaConfig + stats *VolgaStats + relay *relayClient + onData func([]byte) + + ctx context.Context + cancel context.CancelFunc +} + +func newWSListener(auth *volgaAuth, cfg VolgaConfig, stats *VolgaStats, + relay *relayClient, onData func([]byte)) *wsListener { + + ctx, cancel := context.WithCancel(context.Background()) + return &wsListener{ + auth: auth, + config: cfg, + stats: stats, + relay: relay, + onData: onData, + ctx: ctx, + cancel: cancel, + } +} + +func (w *wsListener) Start() { + go w.run() +} + +func (w *wsListener) Stop() { + w.cancel() +} + +func (w *wsListener) run() { + defer func() { + if r := recover(); r != nil { + utils.Debugf("[VOLGA] recovered in ws run: %v", r) + } + }() + delay := w.config.ReconnectMinDelay + + for { + select { + case <-w.ctx.Done(): + return + default: + } + + if err := w.connect(); err != nil { + utils.Debugf("[VOLGA] WS error: %v", err) + } + if w.ctx.Err() != nil { + return + } + + w.stats.WSReconnects.Add(1) + utils.Debugf("[VOLGA] WS reconnect in %v", delay) + select { + case <-time.After(delay): + case <-w.ctx.Done(): + return + } + + delay = time.Duration(float64(delay) * w.config.ReconnectMultiplier) + if delay > w.config.ReconnectMaxDelay { + delay = w.config.ReconnectMaxDelay + } + } +} + +func (w *wsListener) connect() error { + wsURL := "wss://push.yandex.ru/v2/subscribe/websocket?" + + "service=volga" + + "&user=" + url.QueryEscape(w.auth.UserIDStr) + + "&sign=" + w.auth.Sign + + "&ts=" + w.auth.TS + + "&client=web" + + "&session=" + w.auth.SessionID + + "&fetch_history=" + url.QueryEscape(w.auth.UserIDStr+":volga:0:1") + + "&x_request_attempt=0" + + header := http.Header{} + header.Set("User-Agent", volgaUserAgent) + header.Set("Origin", "https://volga.yandex.ru") + + var cookieParts []string + for _, c := range w.auth.Cookies { + cookieParts = append(cookieParts, c.Name+"="+c.Value) + } + header.Set("Cookie", strings.Join(cookieParts, "; ")) + + dialer := websocket.Dialer{ + HandshakeTimeout: w.config.WSHandshakeTimeout, + ReadBufferSize: 128 << 10, // 4MB was needlessly large; 128KB keeps the + WriteBufferSize: 128 << 10, // iOS Network Extension well under its memory cap. + } + + conn, _, err := dialer.Dial(wsURL, header) + if err != nil { + return fmt.Errorf("dial: %w", err) + } + defer conn.Close() + + utils.Debugf("[VOLGA] WS connected: user=%s", w.auth.UserIDStr) + + // Keepalive: ping periodically and extend the read deadline on pong/message. + // A silently-dropped ws (NAT/idle timeout, server drop without close) is then + // detected within ~pongWait instead of freezing the tunnel for the full read + // timeout. The relay sends over HTTP, so this ping goroutine is the ws's only + // writer — no concurrent-write hazard. + const pongWait = 35 * time.Second + const pingPeriod = 15 * time.Second + conn.SetReadDeadline(time.Now().Add(pongWait)) + conn.SetPongHandler(func(string) error { + conn.SetReadDeadline(time.Now().Add(pongWait)) + return nil + }) + done := make(chan struct{}) + defer close(done) + go func() { + t := time.NewTicker(pingPeriod) + defer t.Stop() + for { + select { + case <-t.C: + if err := conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(10*time.Second)); err != nil { + return + } + case <-done: + return + case <-w.ctx.Done(): + return + } + } + }() + + for { + select { + case <-w.ctx.Done(): + return nil + default: + } + + _, msg, err := conn.ReadMessage() + if err != nil { + return fmt.Errorf("read: %w", err) + } + conn.SetReadDeadline(time.Now().Add(pongWait)) + w.handleMessage(msg) + } +} + +func (w *wsListener) handleMessage(raw []byte) { + // A malformed/unexpected server message must never crash the process — swallow + // the panic and keep the WebSocket loop alive. + defer func() { + if r := recover(); r != nil { + utils.Debugf("[VOLGA] recovered in handleMessage: %v", r) + } + }() + var envelope struct { + Operation string `json:"operation"` + Message string `json:"message"` + } + if err := json.Unmarshal(raw, &envelope); err != nil { + return + } + + if envelope.Operation == "ping" { + return + } + if envelope.Operation != "SESSION" && envelope.Operation != "WORKER" { + return + } + if envelope.Message == "" { + return + } + + var inner struct { + T string `json:"t"` + UserID int `json:"userId"` + Bundle json.RawMessage `json:"bundle"` + Message json.RawMessage `json:"message"` + } + if err := json.Unmarshal([]byte(envelope.Message), &inner); err != nil { + return + } + + if inner.UserID == w.auth.UserID { + return + } + + switch inner.T { + case "relay": + w.handleRelayMessage(inner.Message) + case "exchange": + w.handleBundle(inner.Bundle) + } +} + +func (w *wsListener) handleRelayMessage(raw json.RawMessage) { + var relay struct { + Bundle []json.RawMessage `json:"bundle"` + } + if err := json.Unmarshal(raw, &relay); err != nil { + return + } + for _, item := range relay.Bundle { + w.handleBundleItem(item) + } +} + +func (w *wsListener) handleBundle(raw json.RawMessage) { + var asArray []json.RawMessage + if err := json.Unmarshal(raw, &asArray); err == nil { + for _, item := range asArray { + w.handleBundleItem(item) + } + return + } + + var asObject struct { + Value []json.RawMessage `json:"value"` + } + if err := json.Unmarshal(raw, &asObject); err == nil { + for _, item := range asObject.Value { + w.handleBundleItem(item) + } + } +} + +func (w *wsListener) handleBundleItem(raw json.RawMessage) { + var asObj struct { + ID string `json:"id"` + Action string `json:"actionName"` + } + if err := json.Unmarshal(raw, &asObj); err == nil && asObj.Action != "" { + if asObj.ID != "" { + w.relay.SetFrontier(asObj.ID) + } + return + } + + var asStr string + if err := json.Unmarshal(raw, &asStr); err == nil && asStr != "" { + decoded, err := base64.StdEncoding.DecodeString(asStr) + if err != nil { + return + } + packets := decodeBatch(decoded) + w.stats.PacketsRecv.Add(uint64(len(packets))) + w.stats.BytesReceived.Add(uint64(len(decoded))) + for _, pkt := range packets { + if w.onData != nil { + w.onData(pkt) + } + } + } +} + +func decodeBatch(decoded []byte) [][]byte { + var packets [][]byte + for len(decoded) >= 2 { + ln := int(binary.BigEndian.Uint16(decoded[:2])) + decoded = decoded[2:] + if ln == 0 || len(decoded) < ln { + break + } + packets = append(packets, decoded[:ln]) + decoded = decoded[ln:] + } + if len(packets) == 0 && len(decoded) > 0 { + packets = append(packets, decoded) + } + return packets +} + +type YandexVolgaTransport struct { + *transport.BaseTransport + + docURL string + config VolgaConfig + stats *VolgaStats + + auth *volgaAuth + relay *relayClient + ws *wsListener + + onDataMu sync.RWMutex + onData func([]byte) + + keepAliveStop chan struct{} +} + +func NewYandexVolgaTransport(docURL string, cfg transport.TransportConfig) *YandexVolgaTransport { + return NewYandexVolgaTransportWithConfig(docURL, cfg, DefaultVolgaConfig()) +} + +// NewYandexVolgaTransportWithConfig builds a VOLGA transport with an explicit +// resource profile (see SlimVolgaConfig for memory-constrained clients). +func NewYandexVolgaTransportWithConfig(docURL string, cfg transport.TransportConfig, volga VolgaConfig) *YandexVolgaTransport { + return &YandexVolgaTransport{ + BaseTransport: transport.NewBaseTransport(cfg), + docURL: docURL, + config: volga, + stats: &VolgaStats{}, + keepAliveStop: make(chan struct{}), + } +} + +func (t *YandexVolgaTransport) Start() error { + if err := t.BaseTransport.Start(); err != nil { + return err + } + + utils.Debugf("[VOLGA] authorizing...") + auth, err := authorize(t.docURL) + if err != nil { + return fmt.Errorf("auth: %w", err) + } + t.auth = auth + + t.relay = newRelayClient(auth, t.config, t.stats) + t.relay.Start() + + t.ws = newWSListener(auth, t.config, t.stats, t.relay, func(data []byte) { + t.onDataMu.RLock() + cb := t.onData + t.onDataMu.RUnlock() + if cb != nil { + cb(data) + } + t.RecordReceive(len(data)) + }) + t.ws.Start() + + go t.keepAliveLoop() + go t.statsLoop() + t.SetConnected(true) + + utils.Debugf("[VOLGA] transport started: user=%d(%s) rp=%s", + auth.UserID, auth.UserIDStr, auth.RequestPath) + return nil +} + +func (t *YandexVolgaTransport) Stop() error { + select { + case <-t.keepAliveStop: + default: + close(t.keepAliveStop) + } + if t.ws != nil { + t.ws.Stop() + } + if t.relay != nil { + t.relay.Stop() + } + t.SetConnected(false) + return t.BaseTransport.Stop() +} + +func (t *YandexVolgaTransport) Send(data []byte) error { + if t.relay == nil { + return fmt.Errorf("transport not started") + } + return t.relay.Send(data) +} + +func (t *YandexVolgaTransport) Receive(callback func([]byte)) { + t.onDataMu.Lock() + t.onData = callback + t.onDataMu.Unlock() +} + +func (t *YandexVolgaTransport) IsConnected() bool { + return t.BaseTransport.IsConnected() +} + +func (t *YandexVolgaTransport) Stats() transport.TransportStats { + base := t.BaseTransport.Stats() + return transport.TransportStats{ + BytesSent: t.stats.BytesSent.Load(), + BytesReceived: t.stats.BytesReceived.Load(), + PacketsSent: t.stats.PacketsSent.Load(), + PacketsRecv: t.stats.PacketsRecv.Load(), + Reconnects: t.stats.WSReconnects.Load(), + Connected: t.IsConnected(), + Uptime: base.Uptime, + } +} + +func (t *YandexVolgaTransport) keepAliveLoop() { + defer func() { + if r := recover(); r != nil { + utils.Debugf("[VOLGA] recovered in keepAliveLoop: %v", r) + } + }() + ticker := time.NewTicker(t.config.KeepAliveInterval) + defer ticker.Stop() + + for { + select { + case <-t.keepAliveStop: + return + case <-ticker.C: + if !t.IsRunning() { + return + } + _ = t.relay.Send([]byte{0x00}) + } + } +} + +func (t *YandexVolgaTransport) statsLoop() { + defer func() { + if r := recover(); r != nil { + utils.Debugf("[VOLGA] recovered in statsLoop: %v", r) + } + }() + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + var lastSent, lastBytes, lastHTTP, lastFailed, lastRecv, lastRecvBytes, lastBatches, lastBatched uint64 + + for { + select { + case <-t.keepAliveStop: + return + case <-ticker.C: + sent := t.stats.PacketsSent.Load() + bytes := t.stats.BytesSent.Load() + httpReqs := t.stats.HTTPReqsSent.Load() + failed := t.stats.HTTPReqsFailed.Load() + recv := t.stats.PacketsRecv.Load() + recvBytes := t.stats.BytesReceived.Load() + batches := t.stats.BatchesSent.Load() + batched := t.stats.PacketsBatched.Load() + + utils.Debugf("[VOLGA-STATS] send %d pkt/s (%d KB/s) | http %d req/s fail %d | batch %d (avg %.1f pkt) | recv %d pkt/s (%d KB/s) | busy %d/%d", + (sent-lastSent)/5, (bytes-lastBytes)/5/1024, + (httpReqs-lastHTTP)/5, failed-lastFailed, + (batches-lastBatches)/5, + float64(batched-lastBatched)/float64(maxU64(batches-lastBatches, 1)), + (recv-lastRecv)/5, (recvBytes-lastRecvBytes)/5/1024, + t.stats.WorkerBusy.Load(), t.config.WorkerCount) + + lastSent, lastBytes = sent, bytes + lastHTTP, lastFailed = httpReqs, failed + lastRecv, lastRecvBytes = recv, recvBytes + lastBatches, lastBatched = batches, batched + } + } +} + +func maxU64(a, b uint64) uint64 { + if a > b { + return a + } + return b +} diff --git a/transport/yandex/yandex.go b/transport/yandex/yandex.go index 621ad04..ff3750e 100644 --- a/transport/yandex/yandex.go +++ b/transport/yandex/yandex.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "math/rand" + "net" "net/http" "regexp" "strings" @@ -19,6 +20,14 @@ import ( "universal-bypass-tool/utils" ) +// Precompiled once. cursorPayloadRe in particular runs on every inbound +// message, so compiling it per call (as before) was pure overhead on the hot +// receive path. +var ( + cursorPayloadRe = regexp.MustCompile(`"cursor":"[^;]+;([^"]+)"`) + clientConfigRe = regexp.MustCompile(`]*id="client-config"[^>]*>(.*?)`) +) + type YandexDocsInfo struct { CookieStr string Token string @@ -38,6 +47,8 @@ type DocSession struct { WriteQueue chan []byte UserID string writeMu sync.Mutex + closeOnce sync.Once + closed atomic.Bool } func (s *DocSession) safeWrite(messageType int, data []byte) error { @@ -46,6 +57,16 @@ func (s *DocSession) safeWrite(messageType int, data []byte) error { return s.Conn.WriteMessage(messageType, data) } +// forceClose closes the underlying connection exactly once. This unblocks any +// goroutine parked in conn.ReadMessage, so a half-closed socket (writes fail +// with EPIPE while reads hang forever) still drives the reconnect path. +func (s *DocSession) forceClose() { + s.closeOnce.Do(func() { + s.closed.Store(true) + s.Conn.Close() + }) +} + type YandexDocsTransport struct { *transport.BaseTransport @@ -71,7 +92,7 @@ func (t *YandexDocsTransport) Start() error { } t.baseUserID = randUserID() - go t.keepAliveLoop() + utils.SafeGo("yandex.keepAlive", t.keepAliveLoop) t.connectToDoc(0) return nil @@ -107,6 +128,11 @@ func (t *YandexDocsTransport) connectToDoc(attempt int) { utils.Debugf("[YDOCS] connectToDoc attempt ...") go func() { + defer func() { + if r := recover(); r != nil { + utils.Debugf("[PANIC] recovered in yandex.connect: %v", r) + } + }() t.Mu.Lock() existingSession := t.session t.Mu.Unlock() @@ -126,19 +152,34 @@ func (t *YandexDocsTransport) connectToDoc(attempt int) { return } - dialer := websocket.Dialer{HandshakeTimeout: 10 * time.Second} + // Hard TCP dial timeout so a stuck connect/DNS to the balancer host + // can't hang the whole transport (HandshakeTimeout alone proved + // insufficient on iOS). + dialer := websocket.Dialer{ + HandshakeTimeout: 15 * time.Second, + NetDialContext: (&net.Dialer{ + Timeout: 10 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + } headers := http.Header{} headers.Set("User-Agent", "Mozilla/5.0") headers.Set("Origin", info.Origin) headers.Set("Cookie", info.CookieStr) headers.Set("Host", info.Host) - conn, _, err := dialer.Dial(info.WsURL, headers) + utils.Debugf("[YDOCS] WebSocket dial %s", info.WsURL) + conn, resp, err := dialer.Dial(info.WsURL, headers) if err != nil { - utils.Debugf("[YDOCS] WebSocket dial failed: %v", err) + status := 0 + if resp != nil { + status = resp.StatusCode + } + utils.Debugf("[YDOCS] WebSocket dial failed (http %d): %v", status, err) t.scheduleReconnect(attempt) return } + utils.Debugf("[YDOCS] WebSocket connected to %s", info.Host) writeQueue := make(chan []byte, t.GetConfig().MaxQueueSize) if existingSession != nil { @@ -158,7 +199,7 @@ func (t *YandexDocsTransport) connectToDoc(attempt int) { t.Mu.Unlock() if existingSession == nil { - go t.writerLoop() + utils.SafeGo("yandex.writer", t.writerLoop) } // Auth - use safeWrite @@ -174,12 +215,20 @@ func (t *YandexDocsTransport) connectToDoc(attempt int) { messagePart, _ := json.Marshal([]interface{}{"message", authData}) session.safeWrite(websocket.TextMessage, []byte(fmt.Sprintf("42%s", string(messagePart)))) + connectedAt := time.Now() for t.IsRunning() { _, message, err := conn.ReadMessage() if err != nil { utils.Debugf("[YDOCS] Read error: %v", err) t.SetConnected(false) - t.scheduleReconnect(attempt) + // If the session was healthy for a while, treat the next + // connect as fresh (attempt -1 -> next attempt 0) so backoff + // doesn't keep growing across normal long-lived reconnects. + next := attempt + if time.Since(connectedAt) > 15*time.Second { + next = -1 + } + t.scheduleReconnect(next) return } t.handleMessage(session, message) @@ -188,27 +237,52 @@ func (t *YandexDocsTransport) connectToDoc(attempt int) { } func (t *YandexDocsTransport) writerLoop() { - for t.IsRunning() { + // The write queue is created once and preserved across reconnects, so we + // capture it and block on it instead of polling with a 10ms sleep. The old + // poll added up to 10ms of latency to every send and woke the CPU 100x/sec + // while idle. + var queue chan []byte + for t.IsRunning() && queue == nil { t.Mu.Lock() - session := t.session + if t.session != nil { + queue = t.session.WriteQueue + } t.Mu.Unlock() + if queue == nil { + time.Sleep(5 * time.Millisecond) + } + } + if queue == nil { + return + } + var pending []byte + for t.IsRunning() { + if pending == nil { + packet, ok := <-queue + if !ok { + return + } + pending = packet + } + + t.Mu.RLock() + session := t.session + t.Mu.RUnlock() if session == nil || session.Conn == nil { - time.Sleep(10 * time.Millisecond) + // Mid-reconnect: hold the packet and retry rather than drop it. + time.Sleep(15 * time.Millisecond) continue } - select { - case packet := <-session.WriteQueue: - payload := base64.StdEncoding.EncodeToString(packet) - msg := fmt.Sprintf(`42["message",{"type":"cursor","cursor":"18;%s"}]`, payload) - - if err := session.safeWrite(websocket.TextMessage, []byte(msg)); err != nil { - utils.Debugf("[YDOCS] Write error: %v", err) - } - default: - time.Sleep(10 * time.Millisecond) + payload := base64.StdEncoding.EncodeToString(pending) + msg := fmt.Sprintf(`42["message",{"type":"cursor","cursor":"18;%s"}]`, payload) + if err := session.safeWrite(websocket.TextMessage, []byte(msg)); err != nil { + utils.Debugf("[YDOCS] Write error: %v", err) + time.Sleep(15 * time.Millisecond) + continue // keep pending; the reconnect will bring up a new conn } + pending = nil } } @@ -223,10 +297,14 @@ func (t *YandexDocsTransport) keepAliveLoop() { session := t.session t.Mu.Unlock() - if session != nil && session.Conn != nil { + if session != nil && session.Conn != nil && !session.closed.Load() { if err := session.safeWrite(websocket.TextMessage, []byte(keepAliveMsg)); err != nil { - utils.Debugf("[YDOCS] Keep-alive failed: %v", err) + utils.Debugf("[YDOCS] Keep-alive failed, forcing reconnect: %v", err) t.SetConnected(false) + // Tear down the dead socket so the read loop unblocks and + // scheduleReconnect fires; otherwise a half-closed connection + // loops keep-alive failures forever without ever reconnecting. + session.forceClose() } } } @@ -281,8 +359,7 @@ func (t *YandexDocsTransport) extractBase64String(response string) string { return response[left : left+right] } - re := regexp.MustCompile(`"cursor":"[^;]+;([^"]+)"`) - matches := re.FindStringSubmatch(response) + matches := cursorPayloadRe.FindStringSubmatch(response) if len(matches) > 1 { return matches[1] } @@ -290,20 +367,56 @@ func (t *YandexDocsTransport) extractBase64String(response string) string { } func (t *YandexDocsTransport) scheduleReconnect(attempt int) { - if !t.IsRunning() || attempt >= t.GetConfig().MaxReconnectAttempts { + next := attempt + 1 + if !t.IsRunning() || next >= t.GetConfig().MaxReconnectAttempts { + return + } + + // Back off before retrying so a server that closes us immediately doesn't + // 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) + if !t.IsRunning() { return } t.RecordReconnect() - t.connectToDoc(attempt + 1) + t.connectToDoc(next) +} + +// reconnectBackoff returns an exponential backoff with jitter, capped at 15s. +func reconnectBackoff(n int) time.Duration { + if n < 1 { + n = 1 + } + shift := n - 1 + if shift > 5 { + shift = 5 + } + d := 500 * time.Millisecond * time.Duration(1< 15*time.Second { + d = 15 * time.Second + } + // add up to +50% jitter + d += time.Duration(rand.Int63n(int64(d/2) + 1)) + return d } func (t *YandexDocsTransport) fetchDocInfo(url, userID string) (YandexDocsInfo, error) { client := &http.Client{ - CheckRedirect: func(req *http.Request, via []*http.Request) error { return nil }, - Timeout: 30 * time.Second, + // Cap redirects so an auth/login redirect loop fails fast instead of + // hanging until the timeout (a private doc redirects to passport). + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 10 { + return fmt.Errorf("stopped after 10 redirects (login required? doc not public?)") + } + return nil + }, + Timeout: 15 * time.Second, } + utils.Debugf("[YDOCS] fetchDocInfo GET %s", url) req, _ := http.NewRequest("GET", url, nil) req.Header.Set("User-Agent", "Mozilla/5.0") resp, err := client.Do(req) @@ -314,30 +427,57 @@ func (t *YandexDocsTransport) fetchDocInfo(url, userID string) (YandexDocsInfo, htmlBytes, _ := io.ReadAll(resp.Body) html := string(htmlBytes) + utils.Debugf("[YDOCS] response status=%d finalURL=%s body=%dB", resp.StatusCode, resp.Request.URL.String(), len(html)) var cookies []string for _, c := range resp.Cookies() { cookies = append(cookies, fmt.Sprintf("%s=%s", c.Name, c.Value)) } - re := regexp.MustCompile(`]*id="client-config"[^>]*>(.*?)`) - matches := re.FindStringSubmatch(html) + matches := clientConfigRe.FindStringSubmatch(html) if len(matches) < 2 { - return YandexDocsInfo{}, fmt.Errorf("config not found") + // Help diagnose: is this a login page, a new-editor page, etc.? + hint := "no client-config script" + if strings.Contains(html, "passport") || strings.Contains(strings.ToLower(html), "login") { + hint = "looks like a login page (doc not public?)" + } + return YandexDocsInfo{}, fmt.Errorf("config not found: %s (status %d, final %s)", hint, resp.StatusCode, resp.Request.URL.String()) } var config map[string]interface{} - json.Unmarshal([]byte(matches[1]), &config) - officeAction := config["officeActionData"].(map[string]interface{}) + if err := json.Unmarshal([]byte(matches[1]), &config); err != nil { + return YandexDocsInfo{}, fmt.Errorf("client-config parse: %w", err) + } + + // Every field below is looked up defensively: a wrong URL or a doc that + // isn't the legacy Yandex editor yields a missing field, which must be a + // clean error, not a panic. + officeAction, ok := config["officeActionData"].(map[string]interface{}) + if !ok { + return YandexDocsInfo{}, fmt.Errorf("officeActionData missing (not a legacy Yandex Docs URL?)") + } editorConfigRaw, ok := officeAction["editor_config"].(map[string]interface{}) if !ok || editorConfigRaw == nil { return YandexDocsInfo{}, fmt.Errorf("editor_config nil - will reconnect") } - balancerURL := officeAction["balancer_url"].(string) + balancerURL, ok := officeAction["balancer_url"].(string) + if !ok || balancerURL == "" { + return YandexDocsInfo{}, fmt.Errorf("balancer_url missing") + } host := strings.TrimPrefix(balancerURL, "https://") - document := editorConfigRaw["document"].(map[string]interface{}) + + document, ok := editorConfigRaw["document"].(map[string]interface{}) + if !ok { + return YandexDocsInfo{}, fmt.Errorf("document missing") + } + + docKey, ok := document["key"].(string) + if !ok || docKey == "" { + return YandexDocsInfo{}, fmt.Errorf("document.key missing") + } + token, _ := editorConfigRaw["token"].(string) perms, _ := document["permissions"].(map[string]interface{}) if perms == nil { @@ -346,15 +486,15 @@ func (t *YandexDocsTransport) fetchDocInfo(url, userID string) (YandexDocsInfo, return YandexDocsInfo{ CookieStr: strings.Join(cookies, "; "), - Token: editorConfigRaw["token"].(string), - DocID: document["key"].(string), + Token: token, + DocID: docKey, Origin: balancerURL, Host: host, - WsURL: fmt.Sprintf("wss://%s/2024.1.1-375/doc/%s/c/?EIO=4&transport=websocket", host, document["key"].(string)), + WsURL: fmt.Sprintf("wss://%s/2024.1.1-375/doc/%s/c/?EIO=4&transport=websocket", host, docKey), Permissions: perms, OpenCmd: map[string]interface{}{ "c": "open", - "id": document["key"].(string), + "id": docKey, "userid": userID, "format": document["fileType"], "url": document["url"], diff --git a/tunnel/packettunnel.go b/tunnel/packettunnel.go new file mode 100644 index 0000000..7b3f809 --- /dev/null +++ b/tunnel/packettunnel.go @@ -0,0 +1,198 @@ +package tunnel + +import ( + "context" + "encoding/binary" + "fmt" + "io" + "net" + "time" + + "gvisor.dev/gvisor/pkg/buffer" + "gvisor.dev/gvisor/pkg/tcpip" + "gvisor.dev/gvisor/pkg/tcpip/adapters/gonet" + "gvisor.dev/gvisor/pkg/tcpip/header" + "gvisor.dev/gvisor/pkg/tcpip/link/channel" + "gvisor.dev/gvisor/pkg/tcpip/network/ipv4" + "gvisor.dev/gvisor/pkg/tcpip/stack" + "gvisor.dev/gvisor/pkg/tcpip/transport/tcp" + "gvisor.dev/gvisor/pkg/tcpip/transport/udp" + "gvisor.dev/gvisor/pkg/waiter" + + "universal-bypass-tool/utils" +) + +// TCPDialer originates a TCP connection to address ("host:port") through some +// upstream (here: the OpenFlux transport tunnel to the exit node). +type TCPDialer interface { + DialTCP(address string) (net.Conn, error) +} + +// PacketTunnel is a userspace TCP/IP stack (tun2socks) for an iOS +// NEPacketTunnelProvider: it accepts raw IP packets from the device, terminates +// TCP locally and forwards each flow through the given dialer. Outbound packets +// (stack -> device) are read back with ReadOutbound. +// +// The transport is TCP-only, so raw UDP is not carried; UDP port 53 is special +// cased and proxied as DNS-over-TCP through the tunnel so name resolution works +// (and bypasses local DNS poisoning). Other UDP is dropped. +type PacketTunnel struct { + stack *stack.Stack + ep *channel.Endpoint + dialer TCPDialer + nicID tcpip.NICID +} + +// NewPacketTunnel builds the stack and installs TCP + DNS forwarders. +func NewPacketTunnel(dialer TCPDialer, mtu uint32) *PacketTunnel { + s := stack.New(stack.Options{ + NetworkProtocols: []stack.NetworkProtocolFactory{ipv4.NewProtocol}, + TransportProtocols: []stack.TransportProtocolFactory{tcp.NewProtocol, udp.NewProtocol}, + }) + + SetTCPBuffers(s) + + ep := channel.New(256, mtu, "") + nicID := tcpip.NICID(1) + if err := s.CreateNIC(nicID, ep); err != nil { + utils.Debugf("[PKT] CreateNIC: %v", err) + } + // Accept packets addressed to any destination and let the stack answer + // with any source address (we are terminating arbitrary device traffic). + s.SetPromiscuousMode(nicID, true) + s.SetSpoofing(nicID, true) + s.AddRoute(tcpip.Route{Destination: header.IPv4EmptySubnet, NIC: nicID}) + + pt := &PacketTunnel{stack: s, ep: ep, dialer: dialer, nicID: nicID} + + tcpFwd := tcp.NewForwarder(s, 0, 2048, pt.handleTCP) + s.SetTransportProtocolHandler(tcp.ProtocolNumber, tcpFwd.HandlePacket) + + udpFwd := udp.NewForwarder(s, pt.handleUDP) + s.SetTransportProtocolHandler(udp.ProtocolNumber, udpFwd.HandlePacket) + return pt +} + +func (pt *PacketTunnel) handleTCP(r *tcp.ForwarderRequest) { + id := r.ID() + dest := fmt.Sprintf("%s:%d", id.LocalAddress.String(), id.LocalPort) + + var wq waiter.Queue + ep, tErr := r.CreateEndpoint(&wq) + if tErr != nil { + utils.Debugf("[PKT] CreateEndpoint %s: %v", dest, tErr) + r.Complete(true) + return + } + r.Complete(false) + local := gonet.NewTCPConn(&wq, ep) + + utils.SafeGo("pkt.flow", func() { + remote, err := pt.dialer.DialTCP(dest) + if err != nil { + utils.Debugf("[PKT] dial %s failed: %v", dest, err) + local.Close() + return + } + // Splice both directions; close when either side ends. + go func() { + io.Copy(remote, local) + remote.Close() + local.Close() + }() + io.Copy(local, remote) + local.Close() + remote.Close() + }) +} + +// handleUDP only serves DNS (port 53): the query is proxied as DNS-over-TCP +// through the tunnel. Any other UDP is dropped (TCP-only transport). +func (pt *PacketTunnel) handleUDP(r *udp.ForwarderRequest) bool { + id := r.ID() + if id.LocalPort != 53 { + return false // not handled -> dropped (only DNS is supported) + } + var wq waiter.Queue + ep, err := r.CreateEndpoint(&wq) + if err != nil { + utils.Debugf("[PKT] UDP CreateEndpoint: %v", err) + return true + } + conn := gonet.NewUDPConn(&wq, ep) + dest := fmt.Sprintf("%s:53", id.LocalAddress.String()) + + utils.SafeGo("pkt.dns", func() { + defer conn.Close() + buf := make([]byte, 1500) + for { + conn.SetReadDeadline(time.Now().Add(8 * time.Second)) + n, err := conn.Read(buf) + if err != nil || n == 0 { + return + } + resp, err := pt.dnsOverTCP(dest, buf[:n]) + if err != nil { + utils.Debugf("[PKT] DNS-over-TCP %s: %v", dest, err) + return + } + if _, err := conn.Write(resp); err != nil { + return + } + } + }) + return true +} + +// dnsOverTCP sends a DNS query to dest ("ip:53") over a TCP connection through +// the tunnel (RFC 7766 length-prefixed framing) and returns the response. +func (pt *PacketTunnel) dnsOverTCP(dest string, query []byte) ([]byte, error) { + c, err := pt.dialer.DialTCP(dest) + if err != nil { + return nil, err + } + defer c.Close() + c.SetDeadline(time.Now().Add(8 * time.Second)) + + var lp [2]byte + binary.BigEndian.PutUint16(lp[:], uint16(len(query))) + if _, err := c.Write(append(lp[:], query...)); err != nil { + return nil, err + } + + hdr := make([]byte, 2) + if _, err := io.ReadFull(c, hdr); err != nil { + return nil, err + } + resp := make([]byte, binary.BigEndian.Uint16(hdr)) + if _, err := io.ReadFull(c, resp); err != nil { + return nil, err + } + return resp, nil +} + +// WriteInbound injects one IPv4 packet coming from the device into the stack. +func (pt *PacketTunnel) WriteInbound(ipPacket []byte) { + pkt := stack.NewPacketBuffer(stack.PacketBufferOptions{ + Payload: buffer.MakeWithData(append([]byte{}, ipPacket...)), + }) + pt.ep.InjectInbound(ipv4.ProtocolNumber, pkt) + pkt.DecRef() +} + +// ReadOutbound blocks until the stack has a packet to deliver to the device, +// returning its bytes, or nil if ctx is cancelled / the tunnel is closed. +func (pt *PacketTunnel) ReadOutbound(ctx context.Context) []byte { + p := pt.ep.ReadContext(ctx) + if p == nil { + return nil + } + data := p.ToView().ToSlice() + p.DecRef() + return data +} + +func (pt *PacketTunnel) Close() { + pt.ep.Close() + pt.stack.Close() +} diff --git a/tunnel/rawsocket_darwin.go b/tunnel/rawsocket_darwin.go index 2dc73ca..5568a47 100644 --- a/tunnel/rawsocket_darwin.go +++ b/tunnel/rawsocket_darwin.go @@ -69,6 +69,10 @@ func (e *RawSocketEndpoint) SetTransportSender(sendFunc func([]byte)) { e.sendToTransport = sendFunc } +// SendUDPOut is a no-op here: UDP L3 NAT is implemented for the Linux exit node +// only (see rawsocket_linux.go). Exit nodes don't run on darwin. +func (e *RawSocketEndpoint) SendUDPOut([]byte) {} + func (e *RawSocketEndpoint) readLoop() { buf := make([]byte, 65535) diff --git a/tunnel/rawsocket_linux.go b/tunnel/rawsocket_linux.go index 2dc73ca..60f0674 100644 --- a/tunnel/rawsocket_linux.go +++ b/tunnel/rawsocket_linux.go @@ -20,11 +20,13 @@ type RawSocketEndpoint struct { dispatcher stack.NetworkDispatcher sendFd int recvFd int + udpRecvFd int nicID tcpip.NICID packetIn atomic.Uint64 packetOut atomic.Uint64 outgoingSYNs sync.Map activePorts sync.Map + activeUDP sync.Map // egress src port (uint16) -> last-seen unixnano sendToTransport func([]byte) } @@ -55,16 +57,147 @@ func NewRawSocketEndpoint(nicID tcpip.NICID) (*RawSocketEndpoint, error) { return nil, fmt.Errorf("bind failed: %v", err) } + // Separate raw socket to receive UDP replies (raw sockets are per-protocol). + udpRecvFd, err := syscall.Socket(syscall.AF_INET, syscall.SOCK_RAW, syscall.IPPROTO_UDP) + if err != nil { + syscall.Close(sendFd) + syscall.Close(recvFd) + return nil, fmt.Errorf("udp recv socket failed: %v (need root)", err) + } + if err := syscall.Bind(udpRecvFd, addr); err != nil { + syscall.Close(sendFd) + syscall.Close(recvFd) + syscall.Close(udpRecvFd) + return nil, fmt.Errorf("udp bind failed: %v", err) + } + ep := &RawSocketEndpoint{ - sendFd: sendFd, - recvFd: recvFd, - nicID: nicID, + sendFd: sendFd, + recvFd: recvFd, + udpRecvFd: udpRecvFd, + nicID: nicID, } go ep.readLoop() + go ep.udpReadLoop() + go ep.udpExpiryLoop() return ep, nil } +// SendUDPOut forwards one client UDP packet to the internet: rewrite the source +// to the egress IP, recompute checksums, remember the source port so the reply +// can be matched, and send it via the IP_HDRINCL raw socket. This bypasses the +// gvisor stack (which only speaks TCP) — UDP is pure L3 NAT here. +func (e *RawSocketEndpoint) SendUDPOut(ipPacket []byte) { + if len(ipPacket) < 28 { // 20 IP + 8 UDP minimum + return + } + pktCopy := make([]byte, len(ipPacket)) + copy(pktCopy, ipPacket) + + localIP := getLocalIP() + var localIPBytes [4]byte + fmt.Sscanf(localIP, "%d.%d.%d.%d", &localIPBytes[0], &localIPBytes[1], &localIPBytes[2], &localIPBytes[3]) + copy(pktCopy[12:16], localIPBytes[:]) + + ipHeaderLen := int(pktCopy[0]&0x0F) * 4 + if len(pktCopy) < ipHeaderLen+8 { + return + } + pktCopy[10], pktCopy[11] = 0, 0 + ipck := network.IPChecksum(pktCopy[:ipHeaderLen]) + pktCopy[10] = byte(ipck >> 8) + pktCopy[11] = byte(ipck & 0xFF) + + udp := pktCopy[ipHeaderLen:] + srcPort := uint16(udp[0])<<8 | uint16(udp[1]) + srcIPBytes := [4]byte{pktCopy[12], pktCopy[13], pktCopy[14], pktCopy[15]} + dstIPBytes := [4]byte{pktCopy[16], pktCopy[17], pktCopy[18], pktCopy[19]} + udp[6], udp[7] = 0, 0 + ck := network.UDPChecksum(udp, srcIPBytes, dstIPBytes) + udp[6] = byte(ck >> 8) + udp[7] = byte(ck & 0xFF) + + e.activeUDP.Store(srcPort, time.Now().UnixNano()) + + var dst [4]byte + copy(dst[:], pktCopy[16:20]) + if err := syscall.Sendto(e.sendFd, pktCopy, 0, &syscall.SockaddrInet4{Addr: dst}); err != nil { + utils.Debugf("[RAW-NIC%d] UDP Sendto failed: %v", e.nicID, err) + return + } + e.packetOut.Add(1) +} + +// udpReadLoop reads UDP replies from the internet and relays the ones matching a +// tracked egress port back to the client (dst rewritten to 10.10.10.2). +func (e *RawSocketEndpoint) udpReadLoop() { + buf := make([]byte, 65535) + for { + n, _, err := syscall.Recvfrom(e.udpRecvFd, buf, 0) + if err != nil { + if err == syscall.EAGAIN || err == syscall.EWOULDBLOCK { + time.Sleep(10 * time.Millisecond) + continue + } + utils.Debugf("[RAW-NIC%d] UDP read error: %v", e.nicID, err) + return + } + if n < 28 { + continue + } + ipHeaderLen := int(buf[0]&0x0F) * 4 + if n < ipHeaderLen+8 { + continue + } + if net.IP(buf[16:20]).String() != getLocalIP() { + continue + } + // The reply's UDP dst port is the egress src port we sent from. + dstPort := uint16(buf[ipHeaderLen+2])<<8 | uint16(buf[ipHeaderLen+3]) + if _, ok := e.activeUDP.Load(dstPort); !ok { + continue + } + e.activeUDP.Store(dstPort, time.Now().UnixNano()) + + pktCopy := make([]byte, n) + copy(pktCopy, buf[:n]) + copy(pktCopy[16:20], []byte{10, 10, 10, 2}) + + pktCopy[10], pktCopy[11] = 0, 0 + ipck := network.IPChecksum(pktCopy[:ipHeaderLen]) + pktCopy[10] = byte(ipck >> 8) + pktCopy[11] = byte(ipck & 0xFF) + + udp := pktCopy[ipHeaderLen:] + srcIPBytes := [4]byte{pktCopy[12], pktCopy[13], pktCopy[14], pktCopy[15]} + dstIPBytes := [4]byte{pktCopy[16], pktCopy[17], pktCopy[18], pktCopy[19]} + udp[6], udp[7] = 0, 0 + ck := network.UDPChecksum(udp, srcIPBytes, dstIPBytes) + udp[6] = byte(ck >> 8) + udp[7] = byte(ck & 0xFF) + + if e.sendToTransport != nil { + e.sendToTransport(pktCopy) + } + } +} + +// udpExpiryLoop drops idle UDP port mappings (UDP has no teardown signal). +func (e *RawSocketEndpoint) udpExpiryLoop() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for range ticker.C { + cutoff := time.Now().Add(-60 * time.Second).UnixNano() + e.activeUDP.Range(func(k, v any) bool { + if ts, ok := v.(int64); ok && ts < cutoff { + e.activeUDP.Delete(k) + } + return true + }) + } +} + func (e *RawSocketEndpoint) SetTransportSender(sendFunc func([]byte)) { e.sendToTransport = sendFunc } @@ -132,6 +265,14 @@ func (e *RawSocketEndpoint) readLoop() { if e.sendToTransport != nil { e.sendToTransport(pktCopy) } + + // RST tears the connection down: relay this one so the client + // closes, then stop tracking the port. Otherwise a server RST + // storm keeps getting forwarded and floods the low-bandwidth + // transport channel instead of real data. + if flags&0x04 != 0 { + e.activePorts.Delete(dstPort) + } } } } @@ -214,6 +355,7 @@ func (e *RawSocketEndpoint) AddHeader(*stack.PacketBuffer) {} func (e *RawSocketEndpoint) Close() { syscall.Close(e.sendFd) syscall.Close(e.recvFd) + syscall.Close(e.udpRecvFd) } func (e *RawSocketEndpoint) SetMTU(uint32) {} func (e *RawSocketEndpoint) SetLinkAddress(tcpip.LinkAddress) {} diff --git a/tunnel/rawsocket_windows.go b/tunnel/rawsocket_windows.go index 9e4aef1..37a678f 100644 --- a/tunnel/rawsocket_windows.go +++ b/tunnel/rawsocket_windows.go @@ -75,6 +75,10 @@ func (e *RawSocketEndpoint) SetTransportSender(sendFunc func([]byte)) { e.sendToTransport = sendFunc } +// SendUDPOut is a no-op here: UDP L3 NAT is implemented for the Linux exit node +// only (see rawsocket_linux.go). Exit nodes don't run on windows. +func (e *RawSocketEndpoint) SendUDPOut([]byte) {} + func (e *RawSocketEndpoint) readLoop() { buf := make([]byte, 65535) diff --git a/tunnel/tunnel.go b/tunnel/tunnel.go index 8e26804..a9a2ad7 100644 --- a/tunnel/tunnel.go +++ b/tunnel/tunnel.go @@ -27,6 +27,27 @@ type TCPTunnel struct { packetCount atomic.Uint64 } +// TCP buffer size range for gvisor stacks. Big by default (exit node on a VPS); +// the memory-constrained iOS Network Extension shrinks these before building +// its stacks (see the packet-tunnel bridge). +var ( + TCPBufMin = 65536 + TCPBufDefault = 262144 + TCPBufMax = 1048576 +) + +// SetTCPBuffers applies the configured TCP send/receive buffer ranges to s. +func SetTCPBuffers(s *stack.Stack) { + rcv := tcpip.TCPReceiveBufferSizeRangeOption{Min: TCPBufMin, Default: TCPBufDefault, Max: TCPBufMax} + if err := s.SetTransportProtocolOption(tcp.ProtocolNumber, &rcv); err != nil { + utils.Debugf("[TUNNEL] set recv buffer: %v", err) + } + snd := tcpip.TCPSendBufferSizeRangeOption{Min: TCPBufMin, Default: TCPBufDefault, Max: TCPBufMax} + if err := s.SetTransportProtocolOption(tcp.ProtocolNumber, &snd); err != nil { + utils.Debugf("[TUNNEL] set send buffer: %v", err) + } +} + func NewTCPTunnel(trans transport.Transport, isExitNode bool) *TCPTunnel { t := &TCPTunnel{ transport: trans, @@ -40,14 +61,7 @@ func NewTCPTunnel(trans transport.Transport, isExitNode bool) *TCPTunnel { TransportProtocols: []stack.TransportProtocolFactory{tcp.NewProtocol}, }) - if err := t.gvisorStack.SetTransportProtocolOption(tcp.ProtocolNumber, - &tcpip.TCPReceiveBufferSizeRangeOption{Min: 65536, Default: 262144, Max: 1048576}); err != nil { - utils.Debugf("[TUNNEL] Failed to set recv buffer: %v", err) - } - if err := t.gvisorStack.SetTransportProtocolOption(tcp.ProtocolNumber, - &tcpip.TCPSendBufferSizeRangeOption{Min: 65536, Default: 262144, Max: 1048576}); err != nil { - utils.Debugf("[TUNNEL] Failed to set send buffer: %v", err) - } + SetTCPBuffers(t.gvisorStack) tunnelEP := NewTunnelLinkEndpoint() tunnelEP.onOutgoingPacket = func(data []byte) { @@ -67,10 +81,17 @@ func NewTCPTunnel(trans transport.Transport, isExitNode bool) *TCPTunnel { } trans.Receive(func(data []byte) { + // Exit node: UDP is NATed directly on the raw socket (the gvisor stack + // only speaks TCP), so intercept it before it enters the stack. TCP and + // everything else go through gvisor as before. + if isExitNode && t.rawEP != nil && len(data) >= 20 && data[0]>>4 == 4 && data[9] == 17 { + t.rawEP.SendUDPOut(data) + return + } tunnelEP.InjectInbound(data) }) - go t.printStats() + utils.SafeGo("tunnel.printStats", t.printStats) return t } @@ -148,6 +169,7 @@ func (t *TCPTunnel) DialTCP(address string) (net.Conn, error) { if ip == nil { return nil, fmt.Errorf("IPv6 not supported") } + utils.Debugf("[TUNNEL] DialTCP %s -> %s:%d", address, ip.String(), tcpAddr.Port) nic := tcpip.NICID(1) if t.isExitNode { @@ -186,7 +208,19 @@ func (t *TCPTunnel) printStats() { } } +// localIPOverride, when set, is the address the exit node uses as its egress +// IP (both for source rewriting and the return-packet filter). Point it at a +// dedicated alias IP so the RST-drop iptables rule can be scoped with +// `-s ` instead of dropping RSTs host-wide. +var localIPOverride string + +// SetLocalIP overrides the auto-detected egress IP for the exit node. +func SetLocalIP(ip string) { localIPOverride = ip } + func getLocalIP() string { + if localIPOverride != "" { + return localIPOverride + } conn, err := net.Dial("udp", "8.8.8.8:80") if err != nil { return "192.168.1.100" diff --git a/utils/debug.go b/utils/debug.go deleted file mode 100644 index f06c87f..0000000 --- a/utils/debug.go +++ /dev/null @@ -1,28 +0,0 @@ -package utils - -import ( - "fmt" - "log" - "os" -) - -var ( - debugLog *log.Logger - verbose bool -) - -func EnableDebug() { - verbose = true - debugLog = log.New(os.Stderr, "", log.LstdFlags|log.Lmicroseconds) - log.SetFlags(log.LstdFlags | log.Lmicroseconds | log.Lshortfile) -} - -func Debugf(format string, args ...interface{}) { - if verbose { - debugLog.Output(2, fmt.Sprintf(format, args...)) - } -} - -func IsVerbose() bool { - return verbose -} diff --git a/utils/logging.go b/utils/logging.go new file mode 100644 index 0000000..c1ee153 --- /dev/null +++ b/utils/logging.go @@ -0,0 +1,64 @@ +package utils + +import ( + "fmt" + "io" + "log" + "os" +) + +var ( + debugLog *log.Logger + verbose bool + output io.Writer = os.Stderr +) + +// SetOutput redirects all debug and standard log output to w. +// Used by the mobile bridge to pipe logs into the app UI. +func SetOutput(w io.Writer) { + output = w + log.SetOutput(w) + if debugLog != nil { + debugLog.SetOutput(w) + } +} + +func EnableDebug() { + verbose = true + debugLog = log.New(output, "", log.LstdFlags|log.Lmicroseconds) + log.SetOutput(output) + log.SetFlags(log.LstdFlags | log.Lmicroseconds | log.Lshortfile) +} + +func Debugf(format string, args ...interface{}) { + if verbose { + debugLog.Output(2, fmt.Sprintf(format, args...)) + } +} + +// SetDebug toggles verbose logging at runtime (off = Debugf becomes a no-op). +func SetDebug(on bool) { + if on { + EnableDebug() + return + } + verbose = false +} + +func IsVerbose() bool { + return verbose +} + +// SafeGo runs fn in a new goroutine, recovering from any panic so a crash in +// one worker cannot take down the whole process (critical when this code runs +// embedded as a library inside a mobile app). +func SafeGo(name string, fn func()) { + go func() { + defer func() { + if r := recover(); r != nil { + Debugf("[PANIC] recovered in %s: %v", name, r) + } + }() + fn() + }() +}