Merge p1neappleXpress/main into node-transports

Takes node-v1.1.0 (main's release) into node-install.sh, keeps the
script's RELEASE_REPO/GITHUB_* layout. The wizard's links go through
share.MakeLink and provision.SessionContext through transport.KDFContexts,
the core's one context rule, instead of rules of their own;
TestNodeConfMatchesShareLink checks node.conf against it.
This commit is contained in:
meepo161
2026-09-28 23:29:56 +03:00
53 changed files with 4373 additions and 1013 deletions
+7 -1
View File
@@ -48,6 +48,12 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
run: |
# What the node runs: the newest released section of CHANGELOG.md.
{
echo "Ядро выходной ноды для мастера «Своя нода» в OpenFlux (десктоп и Android). Хеши сверены с deploy/node-install.sh."
echo
awk 'index($0, "## [")==1 { if (f) exit; if (index($0, "Unreleased") == 0) { f = 1; print "## Ядро " substr($0, 4) }; next } f' CHANGELOG.md
} > notes.md
gh release create "$GITHUB_REF_NAME" dist/openflux-linux-* dist/SHA256SUMS \
--title "OpenFlux node $GITHUB_REF_NAME" \
--notes "Ядро выходной ноды для мастера «Своя нода» в OpenFlux (десктоп и Android). Хеши сверены с deploy/node-install.sh."
--notes-file notes.md
+4 -1
View File
@@ -77,7 +77,10 @@ jobs:
run: |
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then tag="${GITHUB_REF#refs/tags/}"
else tag="v${{ inputs.version }}"; fi
# The release shows its CHANGELOG.md section, then what the assets are.
awk -v v="${tag#v}" 'index($0, "## [")==1 { if (f) exit; f = (index($0, "## [" v "]") == 1); next } f' CHANGELOG.md > notes.md
printf '\n%s\n' "Кросс-платформенная сборка CLI (клиент/выходная нода): \`openflux-<os>-<arch>[.exe]\`. Контрольные суммы — в \`SHA256SUMS.txt\`. Для установки выходной ноды по SSH из приложения используется отдельный релиз \`node-v*\` (см. deploy/node-install.sh)." >> notes.md
gh release create "$tag" dist/* \
--title "OpenFlux $tag" \
--generate-notes \
--notes "Кросс-платформенная сборка CLI (клиент/выходная нода): \`openflux-<os>-<arch>[.exe]\`. Контрольные суммы — в \`SHA256SUMS.txt\`. Для установки выходной ноды по SSH из приложения используется отдельный релиз \`node-v*\` (см. deploy/node-install.sh)."
--notes-file notes.md
+62
View File
@@ -19,6 +19,68 @@ All notable changes to the OpenFlux core. Format loosely follows
channel does not stay up. An app with an older pinned script no longer
downgrades a server the updater has moved on.
## [0.2.0] - 2026-09-28
Every client now behaves alike: peers of different builds and modes find
each other instead of dropping every packet in silence. The node wizard
(desktop and Android) installs this core as `node-v1.1.0`.
### Added
- Classic compatibility inside the Session (`PROTOCOL_NEGOTIATION.md`):
a classic setup with a key (`--transport=X`, the apps' classic profiles)
runs a Session and speaks classic to an exit that does not answer the
handshake, switching once it does; a classic-configured exit serves
classic and Session clients. `--negotiate` stays strict; exits configured
as a Session (wizard, `.conf`, `--transports`) serve Session clients only.
- Codec fallback: the classic codec decodes batch-v2 and legacy frames,
sends what the peer sends, and the client tries the other framing when
the peer is silent. `--codec` is a preference now, not a requirement.
- KDF context fallback: one rule for the context (`transport.KDFContexts`)
and alternates for what other builds derive; a record that fails under
the current keys is tried under them, the exit answers under the
client's context, a silent client cycles through them.
- `mobile/ios`: the iOS C library (`build_ios.sh`) on package `mobile`,
replacing the root `export_ios*.go`: the calls the iOS app makes, plus
Session profiles (`OpenFluxShareDecode` returns one ready to start),
mode and captcha calls. An app that links this core as a submodule gets
the same Session, links and fallbacks as Android.
- `mobile`: `ShareSessionSpecs`, `SetInitialCookies`, `SetLowMemory`
(Volga's new `SlimVolgaConfig` for the iOS extension), `ReadTimeout`,
`ConnectionMode`.
- Links are read and made by the core only. `share.Read` / `share.Make`
answer every entry point with the same JSON (`--parse-link`, new
`--make-link`, `mobile.ReadShareLink` / `MakeShareLink`, the iOS
`OpenFluxShareDecode` / `OpenFluxShareEncode`): the configuration and
its context, the link, or an error `code` (and `param`) that the apps
put in their own words. `share.Make` normalizes what apps spell
differently (the default codec is left out, an encrypted link always
names its context, by the one rule when not given), so one
configuration gives one link on every client; the node wizard's link,
desktop and Android, comes from one `share.NodeConfig`.
- Logs a user can act on without `-dd`: key or context mismatch, the peer
running the other layering, codec and context fallbacks, a second client
taking over the exit, carriers failing to start, documents dropping, and
a diagnosis when the handshake does not complete.
### Fixed
- A classic cupsonline client given the rooms as `--url` derived another
key than the exit that created them: nothing got through.
- boards sent engine.io pings from the client, which an EIO=4 server
answers by closing the socket: the board dropped every 20 seconds.
- A Session client's cupsonline carrier was built as an exit: with no or
dead rooms it created rooms of its own and waited in them.
- openflux:// links: base64 padding, the standard alphabet, whitespace and
line breaks are accepted; secrets are counted in characters as Kotlin
counts them, not bytes.
- Carrier names that differ between the two sides no longer break cookie
exchange and exit checks (messages carry the document URL).
- yandex / mailru: a socket whose keepalive failed is closed so the
reconnect runs; writes are bounded.
- `utils.Infof` reaches the apps' log screens.
- A Session stopped each carrier twice.
## [0.1.0] - 2026-09-27
First release from the current `main` line (encrypted-logging + the
+97 -11
View File
@@ -1,16 +1,40 @@
# Authenticated negotiation v1
Opt-in CLI extension to the existing encrypted packet stack, not a new cipher
suite or a production security certification. Both peers need `--negotiate`,
batched codec and the same encryption secret/context. Old clients require the
unchanged default mode on the exit. No automatic downgrade is implemented.
Not a new cipher suite or a production security certification: an
authenticated session (the Session) on top of the existing encrypted packet
stack. Both peers need the same encryption secret and context (see
"Encryption context"). A Session runs whenever a peer is configured with a
key: `--negotiate`, `--transports`, `.conf` transports, the apps' Session
profiles, and also classic setups (`--transport=X` with a key, the apps'
classic profiles), which keep classic compatibility (see "Classic
compatibility").
## Layering on a carrier
There are two layerings, and implementations must match the one they
speak byte for byte:
Session IPv4 -> envelope -> batch-v2 frame of envelopes -> AES-GCM record -> carrier
classic IPv4 -> AES-GCM record -> codec frame (batch-v2 or legacy) -> carrier
In the Session the batch frame (zstd when it helps) is encrypted as a whole,
so every carrier frame starts with the record header `OFX` (0x4F 0x46 0x58),
version 1 and the direction byte. In the classic layering each IPv4 packet is
encrypted on its own and the records are framed by the codec, so a carrier
frame starts with 0x02 (batch-v2) or 0x00 / 0x1F (legacy per-packet, raw /
LZ4). Receive reverses the order. Because the first byte differs, one
carrier can carry both, and a receiver can tell which layering its peer runs.
(An earlier version of this document gave the Session's order as
envelope -> AES-GCM -> batch-v2, which is the classic order; the code has
always encrypted the batch frame. A Session built from that description
does not interoperate.)
## Envelope
Send order: IPv4 -> negotiation envelope -> existing AES-GCM packet -> batch-v2
framing/compression -> carrier. Receive reverses that order. The envelope is
inside AEAD, including its type, role, identities, capabilities and sequence.
Integers are unsigned big-endian; unknown versions/types/reserved bits fail closed.
The envelope is inside AEAD, including its type, role, identities,
capabilities and sequence. Integers are unsigned big-endian; unknown
versions/types/reserved bits fail closed.
| Bytes | Meaning |
| --- | --- |
@@ -38,8 +62,10 @@ Readiness requires a valid peer hello echoing the current local challenge.
The effective policy is the capability intersection and smaller packet limit.
The established peer cannot change its policy through later hellos. Retries
with an unconfirmed ready bit receive a fresh confirmation, including after the
other side has completed Start. The client's handshake deadline is 20 seconds;
the exit never initiates and waits for a client indefinitely.
other side has completed Start. The client's handshake deadline is 20 seconds
(a client with classic compatibility starts sending classic after 3 and
keeps offering the handshake); the exit never initiates and waits for a
client indefinitely.
A hello from a different sender while established usually means the peer
restarted, but may be old traffic replayed from a carrier (anyone with access
@@ -91,7 +117,11 @@ are hashed across carriers only when they share that priority.
| 0x20 LinkPing, 0x21 LinkPong | both | none |
An empty `transport` (older peers) means the highest-priority transport that
carries cookies. Unknown subtypes are passed to the application and otherwise
carries cookies. Cookie messages and AuthRequired may carry `"doc"`, the
document URL of that transport on the sender's side: the two sides do not
always name carriers alike, so a receiver that has no carrier of that name
matches by `doc`, then by the type the name starts with when it has one
carrier of that type. Unknown subtypes are passed to the application and otherwise
ignored.
## Checks on the exit
@@ -112,6 +142,57 @@ below gVisor's ephemeral range and common OS ones; replies to those ports go to
it, everything else to the regular client path. The proxy listens on loopback
without authentication while it runs, like the SOCKS5 inbound.
## Classic compatibility
A peer configured classic with a key runs a Session with the classic
layering next to it on its one carrier:
- A client falls back: it offers the handshake and, until the exit answers
(it waits 3 seconds at start), sends IPv4 in the classic layering, so an
exit that predates Session or runs without it still works. It keeps
offering the handshake (every 2 seconds after the first 20, every 10 once
the exit answered classic) and switches to the Session when answered. A
single-carrier Session client (a Session profile or `--transports` with
one carrier) falls back the same way; `--negotiate` does not.
- An exit configured classic serves classic clients as well as Session
ones, as long as no Session client was heard within the link timeout:
classic frames that arrive while a Session client is active are dropped
(a replayed capture cannot take the replies away from it).
- An exit configured as a Session (`--negotiate`, `--transports`, `.conf`
transports, the node wizard, the apps' Session exits) serves Session
clients only; classic frames are dropped and logged with the fix.
## Codec (classic layering)
Both framings are decoded whichever the peer uses. A peer sends batch-v2
once it has received a batch frame (an empty one, `02 00`, is a capability
probe), legacy while it has received only legacy frames, and its preferred
framing (`--codec`) before it has heard anything. The side that speaks
first (the client) switches to the other framing after 2 seconds without
an answer, then every second, so a peer that decodes one framing only is
still reached. A lone 0x00 is a carrier keepalive (Volga), not a frame.
## Encryption context
The scrypt salt is `SHA-256("OpenFlux encrypted transport v1\0" + context)`.
Every peer picks the context with the same rule (`transport.KDFContexts`):
1. an explicit context (`--session-context`, the context an openflux://
link carries);
2. `--url`, unless it is a cupsonline room list;
3. the URL of the highest-priority carrier that names the channel
(not cupsonline: its exit creates the room list at start; not direct:
host:port differs between the sides; not oneme);
4. `http://#`.
Builds have derived it differently (the classic cupsonline client used the
room list; panels and an older fork used the transport name), so each peer
also knows the alternates other builds derive for its setup. A record that
does not open under the current keys is tried under them; the exit answers
under the context the client used, and a client that hears nothing moves to
the next candidate after 4 seconds, then every 2. The context is a public
salt: accepting several weakens nothing, each one still requires the secret.
## Limits and compatibility
Shared-secret holders are trusted peers. The existing static key derivation is
@@ -119,6 +200,11 @@ unchanged: no forward secrecy, automatic key rotation or protection after secret
compromise is claimed. AEAD authenticates packets; capability assertions still
describe configured software functionality, not a live Internet reachability test.
The classic layering has no challenge binding, sequence window or
capability negotiation, only AES-GCM with a bounded nonce replay cache.
A client that falls back to it can be kept there by whoever drops the
exit's handshake answers; `--negotiate` rules that out on both sides.
ICMP-error support refers to errors returned from a raw exit to the client; it
does not promise bidirectional arbitrary ICMP, IPv6, echo or redirects. There
is no active path-MTU probing. The separate raw-exit ICMP/NAT implementation
+4 -2
View File
@@ -221,8 +221,6 @@ OpenFlux/
tun_watch.go # Socket watcher for bypass routes
tun_learn.go, tun_other.go # utun helpers / non-darwin stubs
signals_{unix,windows}.go # Shutdown signals
export_ios.go # cgo bridge for the iOS static library
export_ios_packet.go # iOS packet tunnel bridge
transport/
transport.go # Transport interface
batched.go # BatchedTransport (coalescing + zstd)
@@ -265,6 +263,8 @@ OpenFlux/
network/ # Checksums, packet parsing
utils/ # Logging
ios-app/ # SwiftUI iOS client (XcodeGen)
mobile/ # App bridge: gomobile (Android) and the iOS
# C library (mobile/ios, liboflux.a)
build_all.sh # Cross-build release binaries
build_ios.sh # Build iOS static library (liboflux.a)
build_ios_app.sh # Build + archive + export iOS app IPA
@@ -613,6 +613,8 @@ Measure raw goodput through the transport, without touching the host network:
| `--share` | | `false` | Exit: print an `openflux://` link and QR code for clients |
| `--share-host` | | (first public IPv4) | Exit: address clients dial for `direct` in that link |
| `--node-wizard` | | | Sole argument: run the JSON-over-stdio provisioning protocol instead of normal CLI startup (see [Highlights](#highlights)) |
| `--parse-link` | | | `--parse-link <link\|->`: read an openflux:// link (`-`: from stdin) and print `{"config","context"}` or `{"error","code","param"}` as JSON; the reading every client uses |
| `--make-link` | | | `--make-link <json\|->`: build the link for a share configuration (`-`: from stdin) and print `{"link","config","context"}` or the error, as every client exports it |
Deprecated (kept for one release, mapped automatically to the new flags):
`--client`, `--exit-node`, `--tun`, `--socks5-mode`, `--legacy`,
+4 -3
View File
@@ -228,8 +228,6 @@ OpenFlux/
tun_watch.go # Watcher сокетов для bypass-маршрутов
tun_learn.go, tun_other.go # Хелперы utun / заглушки для не-darwin
signals_{unix,windows}.go # Сигналы завершения
export_ios.go # cgo-мост для iOS-статической библиотеки
export_ios_packet.go # Мост packet tunnel для iOS
transport/
transport.go # Интерфейс Transport
batched.go # BatchedTransport (склейка + zstd)
@@ -272,7 +270,8 @@ OpenFlux/
network/ # Контрольные суммы, разбор пакетов
utils/ # Логирование
ios-app/ # iOS-клиент на SwiftUI (XcodeGen)
mobile/ # gomobile-мост для Android/iOS (см. ниже)
mobile/ # Мост для приложений: gomobile (Android) и
# C-библиотека iOS (mobile/ios, liboflux.a)
build_all.sh # Кросс-сборка релизных бинарников
build_ios.sh # Сборка статической библиотеки iOS (liboflux.a)
build_ios_app.sh # Сборка + архив + экспорт IPA iOS
@@ -605,6 +604,8 @@ URL = YOUR_YANDEX_DOC_URL
| `--share` | | `false` | Выходная нода: напечатать ссылку `openflux://` и QR-код для клиентов |
| `--share-host` | | (первый публичный IPv4) | Выходная нода: адрес для `direct` в этой ссылке |
| `--node-wizard` | | | Единственный аргумент: запускает протокол развёртывания по JSON-over-stdio вместо обычного запуска CLI (см. [Ключевые особенности](#ключевые-особенности)) |
| `--parse-link` | | | `--parse-link <ссылка\|->`: читает ссылку openflux:// (`-`: из stdin) и печатает JSON `{"config","context"}` или `{"error","code","param"}` — тот же разбор, что у всех клиентов |
| `--make-link` | | | `--make-link <json\|->`: собирает ссылку из конфигурации (`-`: из stdin) и печатает `{"link","config","context"}` или ошибку — так ссылку выпускает любой клиент |
Устаревшие (оставлены на один релиз, автоматически маппятся на новые флаги):
`--client`, `--exit-node`, `--tun`, `--socks5-mode`, `--legacy`,
+14 -6
View File
@@ -3,6 +3,10 @@ set -e
OUTPUT_DIR="output/ios"
LIBRARY_NAME="liboflux"
# Paths are this checkout's, whatever the caller's directory: an app that
# links the core as a submodule runs core/build_ios.sh from its own root.
ROOT="$(cd "$(dirname "$0")" && pwd)"
OUT="$ROOT/$OUTPUT_DIR/$LIBRARY_NAME.a"
# Paths configuration
XCODE_PATH="${XCODE_PATH:-/Applications/Xcode.app}"
@@ -10,7 +14,7 @@ DEVELOPER_DIR="$XCODE_PATH/Contents/Developer"
SDK_PATH="$DEVELOPER_DIR/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS.sdk"
CLANG="$DEVELOPER_DIR/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang"
mkdir -p "$OUTPUT_DIR"
mkdir -p "$ROOT/$OUTPUT_DIR"
# Verify paths
if [ ! -d "$SDK_PATH" ]; then
@@ -36,17 +40,21 @@ export CGO_LDFLAGS="-isysroot $SDK_PATH -arch arm64 -miphoneos-version-min=13.0"
echo "Building for iOS (arm64)..."
# Build static library
if go build \
# The iOS C API lives in mobile/ios, on package mobile: the same Session,
# context rule, codec and link handling as the Android library. An app
# that links this checkout as a submodule gets exactly this core.
if (cd "$ROOT/mobile" && go build \
-buildmode=c-archive \
-tags ios \
-ldflags="-w" \
-trimpath \
-o "$OUTPUT_DIR/$LIBRARY_NAME.a" \
. ; then
-o "$OUT" \
./ios) ; then
# 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"
echo "Build complete: $OUT"
ls -lh "$OUT"
else
echo "Build failed"
+4 -4
View File
@@ -46,10 +46,10 @@
set -u
umask 077
CORE_VERSION="node-v1.0.1"
SHA_amd64="9fa157550d2c20c0bc03c12823b4ad0140ba070199b5548eacf98c5a2cca6cb8"
SHA_arm64="325335fa416d2f87cba84c5a85d865c596169cd79c7f4cfc916cd67a88612886"
SHA_arm="7f280b01a53bee33e84a7525e035f1e09f51e9070b612b903e492c45c6edf300"
CORE_VERSION="node-v1.1.0"
SHA_amd64="9ec36c073749c1d02ca163516ba6fc257cc624a69833fb108de65ba4400e8f41"
SHA_arm64="b6d74ae230d9f4711cc4e03ba19d9eb7b4e3987ae6eeb45f86257743da9623ed"
SHA_arm="77c5afa26566db77b465e26bc0bdcde55e9f2babb08b386953a3d2f769667cf8"
# The repository this script and its core come from: the core is one of its
# node-v* releases, and the updater follows them (UPDATE_CONF may override
# that with a "repo=owner/name" line).
-296
View File
@@ -1,296 +0,0 @@
//go:build ios
package main
/*
#include <stdlib.h>
*/
import "C"
import (
"context"
"crypto/tls"
"fmt"
"net"
"strconv"
"strings"
"sync"
"time"
"unsafe"
"openflux/socks5"
"openflux/transport"
"openflux/transport/oneme"
"openflux/transport/yandex"
"openflux/tunnel"
"openflux/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)
// 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,
}
}
// dotServer is a DNS-over-TLS endpoint (addr:853 + TLS SNI).
type dotServer struct {
addr string
sni string
}
var dotServers = []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"},
}
// 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 dotServers {
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
)
// OpenFluxStartClient starts the SOCKS5 client tunnel.
//
// transportType: "yandex" or "oneme".
// url: Yandex.Docs document URL (yandex transport).
// 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 = transport.NewCompressedTransport(yandex.NewYandexDocsTransport(docURL, 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)
}
-332
View File
@@ -1,332 +0,0 @@
//go:build ios
package main
/*
#include <stdlib.h>
*/
import "C"
import (
"context"
"crypto/tls"
"encoding/binary"
"io"
"net"
"runtime/debug"
"strconv"
"sync"
"sync/atomic"
"time"
"unsafe"
"openflux/network"
"openflux/transport"
"openflux/transport/oneme"
"openflux/transport/yandex"
"openflux/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 IPv4 TCP and UDP packets straight over the transport — no gVisor stack on
// the client, which keeps the extension well under its memory cap. UDP DNS
// retains the existing local DNS-over-TLS path for compatibility with older
// TCP-only exits. Other UDP requires explicit opt-in for a UDP-capable exit.
//
// 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)
}
// Keep the extension well under the NE memory cap.
debug.SetMemoryLimit(40 << 20)
debug.SetGCPercent(20)
config := transport.DefaultConfig()
var t transport.Transport
switch tt {
case "yandex", "":
t = transport.NewCompressedTransport(yandex.NewYandexDocsTransport(docURL, config))
case "oneme":
uidint, _ := strconv.ParseInt(mUid, 10, 64)
t = transport.NewCompressedTransport(oneme.NewOneMeTransport(false, mToken, uidint, config))
default:
return C.int(startBadTransport)
}
outQ := make(chan []byte, 1024)
// Packets coming back from the exit node -> queue for the device.
t.Receive(func(data []byte) {
network.LogPacket("PKT", network.DirInbound, data)
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)
}
var ptUDPEnabled atomic.Bool
// OpenFluxTunSetUDPEnabled enables non-DNS UDP for a known UDP-capable exit.
// Disabled by default: legacy exits otherwise silently blackhole QUIC traffic.
//
//export OpenFluxTunSetUDPEnabled
func OpenFluxTunSetUDPEnabled(enabled C.int) { ptUDPEnabled.Store(enabled != 0) }
// OpenFluxTunWritePacket forwards one device IPv4 TCP or UDP packet.
//
//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 || length > 65535 {
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
}
ihl := int(pkt[0]&0x0f) * 4
total := int(binary.BigEndian.Uint16(pkt[2:4]))
if ihl < 20 || total < ihl || total > len(pkt) {
return
}
pkt = pkt[:total]
network.LogPacket("PKT", network.DirOutbound, pkt)
switch pkt[9] { // protocol
case 6: // TCP
t.Send(pkt)
case 17: // UDP
if len(pkt) < ihl+8 || binary.BigEndian.Uint16(pkt[6:8])&0x3fff != 0 {
return
}
udpLen := int(binary.BigEndian.Uint16(pkt[ihl+4 : ihl+6]))
if udpLen < 8 || udpLen > len(pkt)-ihl {
return
}
dstPort := binary.BigEndian.Uint16(pkt[ihl+2 : ihl+4])
if dstPort != 53 {
if ptUDPEnabled.Load() {
_ = t.Send(pkt)
} else {
sendICMPPortUnreachable(pkt, outQ)
}
return
}
pkt = pkt[:ihl+udpLen]
select {
case dnsSem <- struct{}{}:
go func() { defer func() { <-dnsSem }(); handleDNSPacket(pkt, outQ) }()
default:
}
}
}
var dnsSem = make(chan struct{}, 16)
func sendICMPPortUnreachable(orig []byte, outQ chan []byte) {
ihl := int(orig[0]&0x0f) * 4
quote := orig[:ihl+8]
icmp := make([]byte, 8+len(quote))
icmp[0], icmp[1] = 3, 3
copy(icmp[8:], quote)
binary.BigEndian.PutUint16(icmp[2:4], network.IPChecksum(icmp))
pkt := make([]byte, 20+len(icmp))
pkt[0], pkt[8], pkt[9] = 0x45, 64, 1
binary.BigEndian.PutUint16(pkt[2:4], uint16(len(pkt)))
copy(pkt[12:16], orig[16:20])
copy(pkt[16:20], orig[12:16])
binary.BigEndian.PutUint16(pkt[10:12], network.IPChecksum(pkt[:20]))
copy(pkt[20:], icmp)
select {
case outQ <- pkt:
default:
}
}
// OpenFluxTunReadPacket blocks for the next packet destined to the device.
//
//export OpenFluxTunReadPacket
func OpenFluxTunReadPacket(buf *C.char, max C.int) C.int {
if buf == nil || max <= 0 {
return 0
}
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
}
udpLen := 8 + len(answer)
if udpLen > 65535-20 {
return
}
// Build a fresh minimal IPv4 header; do not advertise uncopied options.
ihl = 20
total := ihl + udpLen
resp := make([]byte, total)
resp[0] = 0x45
resp[1] = req[1]
binary.BigEndian.PutUint16(resp[2:4], uint16(total))
resp[8] = 64
resp[9] = 17
copy(resp[12:16], dstIP)
copy(resp[16:20], srcIP)
ipck := network.IPChecksum(resp[:20])
resp[10] = byte(ipck >> 8)
resp[11] = byte(ipck)
copy(resp[ihl:ihl+2], dstPort)
copy(resp[ihl+2:ihl+4], srcPort)
binary.BigEndian.PutUint16(resp[ihl+4:ihl+6], uint16(udpLen))
copy(resp[ihl+8:], answer)
select {
case outQ <- resp:
default:
}
}
func dnsOverTLS(query []byte) ([]byte, error) {
var lastErr error
for _, s := range dotServers {
answer, err := dotQueryOne(s, query)
if err == nil {
return answer, nil
}
lastErr = err
}
return nil, lastErr
}
func dotQueryOne(s dotServer, query []byte) ([]byte, error) {
dialer := tls.Dialer{
NetDialer: &net.Dialer{Timeout: 6 * time.Second},
Config: &tls.Config{ServerName: s.sni, MinVersion: tls.VersionTLS12},
}
conn, err := dialer.DialContext(context.Background(), "tcp", s.addr)
if err != nil {
return nil, err
}
defer conn.Close()
conn.SetDeadline(time.Now().Add(6 * time.Second))
var length [2]byte
binary.BigEndian.PutUint16(length[:], uint16(len(query)))
if _, err := conn.Write(append(length[:], query...)); err != nil {
return nil, err
}
if _, err := io.ReadFull(conn, length[:]); err != nil {
return nil, err
}
answer := make([]byte, binary.BigEndian.Uint16(length[:]))
if _, err := io.ReadFull(conn, answer); err != nil {
return nil, err
}
return answer, nil
}
+36 -4
View File
@@ -10,10 +10,42 @@ SOCKS5 tunnel over the Yandex.Docs transport on `127.0.0.1:1080`.
- `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`).
Built from `mobile/ios` (`./build_ios.sh`) on package `mobile`, the same code
the Android app runs: Session, KDF context, codec fallback and openflux://
links behave the same on every client. An app outside this repository links
this core as a submodule and builds the library with the same script.
Classic profiles (one carrier, as the app has always started them):
- `OpenFluxSetEncryption(secret)`, `OpenFluxSetCodec(codec)` — for the next start.
With a secret the client runs the Session and speaks classic to a node that
does not answer it (an older or classic node); without one, classic only.
- `OpenFluxStartClient(transportType, url, socksAddr, maxToken, maxUid)` — SOCKS5 client
(a comma-separated `url` runs one carrier per document).
- `OpenFluxStartPacketTunnel(transportType, url, maxToken, maxUid)` — Network Extension
(`OpenFluxTunWritePacket` / `OpenFluxTunReadPacket`, `OpenFluxPacketTunnelConnected`,
`OpenFluxStopPacketTunnel`, `OpenFluxSetTunnelUDP`, `OpenFluxSetGeositeDirect`,
`OpenFluxDrainDirectIPs`).
Session profiles (several carriers, what a node's openflux:// link describes):
- `OpenFluxShareDecode(link)` → `{"config","context","session"}`: `session` is the
profile, ready for `OpenFluxStartSession(session, secret, socksAddr)` or
`OpenFluxStartSessionPacketTunnel(session, secret)`. Do not interpret the link in
Swift: the context and carrier names in it must reach the core unchanged.
- `OpenFluxShareEncode(configJSON)` → `{"link","config","context"}`: the core fills in
the context and drops defaults, so the link is the one every client makes.
- On failure both return `{"error","code","param"}`: `code` (`share.Code*`, e.g.
`damaged`, `unknown_transport`) is what the app words for the user, `param` the value
it is about; `error` is English detail for the log.
State and checks:
- `OpenFluxStop()`, `OpenFluxIsRunning()`, `OpenFluxIsConnected()`, `OpenFluxStatsJSON()`,
`OpenFluxMode()` (`session` / `classic`), `OpenFluxActiveTransport()`.
- `OpenFluxCaptchaPending()` (this phone's carrier), `OpenFluxRemoteCaptchaPending()` and
`OpenFluxRemoteCaptchaProxy()` (the exit's), `OpenFluxCaptchaReason()`,
`OpenFluxApplyCaptchaCookies(header)` / `OpenFluxOfferCaptchaCookies(header)`,
`OpenFluxCancelCaptcha()`, `OpenFluxSetInitialCookies(header)`, `OpenFluxSetCookieStore(path)`.
- `OpenFluxReadLog()`, `OpenFluxSetDebug(on)`, `OpenFluxSetDebugLevel(0..3)`,
`OpenFluxSetDoTResolver(spec)`; free returned strings with `OpenFluxFreeString`.
## Build + archive + export (one command)
From the repo root:
+10 -1
View File
@@ -1,6 +1,7 @@
package main
import (
"openflux/transport"
"reflect"
"testing"
)
@@ -51,9 +52,17 @@ func TestPickSessionContext(t *testing.T) {
{Type: "yandex", Priority: 50, URL: "https://docs/doc"},
}, "https://docs/doc"},
{"cupsonline alone", "", "http://#", []transportSpec{{Type: "cupsonline", Priority: 100, URL: "WyIxYzE0NGQwZS1lMDQw"}}, "http://#"},
// A classic cupsonline client is given the rooms as --url; the exit
// that created them ran without one. Both must land on the same
// context (they used to differ, and nothing got through).
{"classic cupsonline client with rooms as --url", "", "WyIxYzE0NGQwZS1lMDQw",
[]transportSpec{{Type: "cupsonline", Priority: 100, URL: "WyIxYzE0NGQwZS1lMDQw"}}, "http://#"},
{"direct host:port is not a context", "", "http://#", []transportSpec{
{Type: "direct", Priority: 100, URL: "203.0.113.7:9443"},
}, "http://#"},
}
for _, c := range cases {
if got := pickSessionContext(c.explicit, c.url, c.specs); got != c.want {
if got, _ := transport.KDFContexts(c.explicit, c.url, contextSources(c.specs)); got != c.want {
t.Errorf("%s: got %q, want %q", c.name, got, c.want)
}
}
+93 -65
View File
@@ -138,46 +138,15 @@ func isNumber(s string) bool {
return err == nil
}
// pickSessionContext returns the KDF salt used to derive encryption keys.
// The same value must be produced on both peers, regardless of how the
// document URL was supplied (--url, --yandex-url, [Transport] URL, ...).
//
// Priority:
//
// explicit --session-context, if non-empty
// --url globalURL, if set and not the placeholder
// transports URL of the highest-priority transport that has one,
// cupsonline aside
// fallback the placeholder "http://#"
//
// A cupsonline "URL" is the room list the exit creates when it starts and
// prints for clients, so the exit cannot know it beforehand; letting it
// into the context gave the two sides different keys.
//
// This is what the OpenFlux-Android client derives for a Session profile,
// and the fallback is what older builds used whenever --url was unset, so a
// node without any document URL (direct, oneme) keeps its old key.
func pickSessionContext(explicit, globalURL string, specs []transportSpec) string {
const placeholder = "http://#"
if explicit != "" {
return explicit
}
if globalURL != "" && globalURL != placeholder {
return globalURL
}
best := -1
// contextSources describes the carriers for transport.KDFContexts, the one
// rule every peer (CLI, Android, Desktop, iOS) derives the encryption
// context with.
func contextSources(specs []transportSpec) []transport.ContextSource {
out := make([]transport.ContextSource, len(specs))
for i, s := range specs {
if s.Type == "cupsonline" || s.URL == "" || s.URL == placeholder {
continue
}
if best < 0 || s.Priority > specs[best].Priority {
best = i
}
out[i] = transport.ContextSource{Type: s.Type, URL: s.URL, Priority: s.Priority}
}
if best >= 0 {
return specs[best].URL
}
return placeholder
return out
}
// managerRefreshLoop periodically asks the exit node for a fresh cookie jar.
@@ -202,6 +171,15 @@ func main() {
if len(os.Args) == 2 && os.Args[1] == "--node-wizard" {
os.Exit(runNodeWizard(os.Stdin, os.Stdout))
}
// The core's own openflux:// parser and builder for apps and scripts,
// so a link is read and made the same way everywhere: JSON on stdout,
// before any banner.
if len(os.Args) == 3 && os.Args[1] == "--parse-link" {
os.Exit(runParseLink(os.Args[2], os.Stdin, os.Stdout))
}
if len(os.Args) == 3 && os.Args[1] == "--make-link" {
os.Exit(runMakeLink(os.Args[2], os.Stdin, os.Stdout))
}
fmt.Print("written by p1neappleXpress\n")
role := flag.String("role", roleClient, "client | exit | bench-send | bench-sink")
@@ -655,8 +633,10 @@ DEPRECATED (removed in v2)
}
specs = buildTransportSpecs(parsed, urls, extra)
} else {
// Named after the type, as --transports and the apps name
// carriers: cookie exchange with a Session peer is by name.
specs = []transportSpec{{
Name: "primary",
Name: *transportType,
Type: *transportType,
Priority: 100,
URL: globalDocUrl,
@@ -692,9 +672,9 @@ DEPRECATED (removed in v2)
log.Fatalf("Read encryption key file: %v", err)
}
secret = strings.TrimSpace(string(b))
if len(secret) < 16 {
log.Fatalf("Encryption key from %s is too short (%d chars, need at least 16)",
*encryptionKeyFile, len(secret))
if n := utils.SecretChars(secret); n < utils.MinSecretChars {
log.Fatalf("Encryption key from %s is too short (%d chars, need at least %d)",
*encryptionKeyFile, n, utils.MinSecretChars)
}
if strings.ContainsAny(secret, "\r\n\t") {
utils.Debugf("[KEY] WARNING: secret still contains whitespace after TrimSpace; lengths may differ across platforms")
@@ -707,10 +687,11 @@ DEPRECATED (removed in v2)
}
}
sessionContext = pickSessionContext(*sessionContextFlag, globalDocUrl, specs)
sessionContext, contextAlternates := transport.KDFContexts(*sessionContextFlag, globalDocUrl, contextSources(specs))
if *encryptionKeyFile != "" {
utils.Debugf("[KEY] context=%q sha256=%s (MUST match on both peers)",
sessionContext, utils.Sha256Hex([]byte(sessionContext)))
utils.Debugf("[KEY] context=%q sha256=%s (MUST match on both peers; %d alternates tried on mismatch)",
sessionContext, utils.Sha256Hex([]byte(sessionContext)), len(contextAlternates))
log.Printf("Encryption context: sha256 %s", utils.Sha256Short([]byte(sessionContext)))
}
// Decide whether we run the full Session path (encryption + negotiate)
@@ -722,13 +703,35 @@ DEPRECATED (removed in v2)
demux *transport.PortDemux
)
// [Transport] sections in a .conf describe a multi-transport session
// just like --transports; without this they were silently ignored and
// only the single --transport ran.
if *negotiate || *transportsFlag != "" || len(confTransports) > 0 {
// configuredSession: the operator asked for a Session. [Transport]
// sections in a .conf describe one just like --transports.
//
// A classic setup (--transport=X) with a key runs as a Session too,
// with classic compatibility: a client falls back to the classic
// layering while the exit does not answer the handshake and upgrades
// once it does; an exit serves classic clients and Session clients.
// Only --negotiate is strict. Without a key only classic is possible.
configuredSession := *negotiate || *transportsFlag != "" || len(confTransports) > 0
classicCompat := false
switch {
case *role != roleClient && *role != roleExit:
case !configuredSession && secret != "":
classicCompat = true
case configuredSession && !*negotiate && *role == roleClient && len(specs) == 1:
classicCompat = true
}
if configuredSession || classicCompat {
if secret == "" {
log.Fatal("--transports/--negotiate/.conf transports require --encryption-key-file")
}
switch {
case classicCompat && isExit:
log.Printf("Mode: Session, also serving classic clients (--negotiate makes it Session-only)")
case classicCompat:
log.Printf("Mode: Session, falling back to classic while the exit does not answer the handshake")
default:
log.Printf("Mode: Session")
}
caps := transport.CapabilityIPv4 | transport.CapabilityTCP | transport.CapabilityUDP
if *role == roleClient || exitMode == tunnel.ExitModeL3 {
@@ -742,10 +745,14 @@ DEPRECATED (removed in v2)
if err != nil {
log.Fatal(err)
}
if classicCompat {
sess.SetClassic(*codec)
}
sess.SetAlternateContexts(contextAlternates)
// Build the factory that SubtypeTransportStart will use for
// dynamic transports.
factory := transportFactory(config)
factory := transportFactory(config, isExit)
managerInst = manager.New(sess, factory, secret, sessionContext)
if err := registerBootstrapTransports(managerInst, specs, config, secret, sessionContext, rooms); err != nil {
@@ -810,7 +817,8 @@ DEPRECATED (removed in v2)
exchanger = nil // cookie handling lives in the Manager
} else {
// Legacy single-transport path (no negotiate, no multi).
// Classic single-transport path without a Session: no key (the
// Session needs one), or a bench role.
var inner transport.Transport
switch *transportType {
case "boards":
@@ -847,22 +855,21 @@ DEPRECATED (removed in v2)
}
}
switch *codec {
case codecBatched:
log.Printf("Codec: batched (zstd + coalescing)")
inner = transport.NewBatchedTransport(inner)
case codecLegacy:
log.Printf("Codec: legacy (per-packet LZ4, no batching)")
inner = transport.NewCompressedTransport(inner)
}
// Either framing is accepted; the preferred one is sent until the
// peer shows which it speaks (see transport.CodecTransport).
log.Printf("Codec: %s preferred, falls back to the other framing when the peer does not answer", *codec)
inner = transport.NewCodecTransport(inner, *codec, !isExit)
if *encryptionKeyFile != "" {
encrypted, err := transport.NewEncryptedTransport(inner, secret, sessionContext, isExit)
if err != nil {
log.Fatalf("Configure encrypted transport: %v", err)
}
encrypted.SetAlternateContexts(contextAlternates)
inner = encrypted
log.Printf("Transport encryption: AES-256-GCM enabled")
} else {
log.Printf("Transport encryption: OFF (no --encryption-key-file): the carrier sees the traffic, and a peer with a key cannot talk to this one")
}
trans = inner
@@ -916,20 +923,41 @@ DEPRECATED (removed in v2)
switch *role {
case roleExit:
var printLink func()
if *shareFlag {
session := *negotiate || *transportsFlag != "" || len(confTransports) > 0
// A classic exit keeps advertising classic: older clients
// read the link too, and updated ones upgrade on their own.
session := configuredSession
host := *shareHost
if host == "" {
host = publicIPv4()
}
printLink := func() {
printLink = func() {
printShare(shareConfig(specs, session, *codec, secret, sessionContext, host, rooms))
}
// Rooms created later (a start that failed and was retried)
// or anew change the link: print it again for the clients.
for _, r := range rooms {
r.OnRoomList(func(string) { printLink() })
}
// A cupsonline exit learns its room list only once it runs. Older
// classic clients derived their key from that list; accept it as
// an alternate context. Rooms created later (a start that failed
// and was retried) or anew change the link: print it again.
var sess *transport.Session
if managerInst != nil {
sess = managerInst.Session()
}
for _, r := range rooms {
r.OnRoomList(func(packed string) {
if sess != nil && packed != "" {
sess.SetAlternateContexts([]string{packed})
}
if printLink != nil {
printLink()
}
})
if list := r.RoomList(); list != "" && sess != nil {
sess.SetAlternateContexts([]string{list})
}
}
if printLink != nil {
printLink()
}
runExit(trans, exitMode)
+4 -2
View File
@@ -2,7 +2,10 @@ module openflux-mobile
go 1.26.4
require openflux v0.0.0
require (
golang.org/x/net v0.59.0
openflux v0.0.0
)
require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
@@ -35,7 +38,6 @@ require (
golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc // indirect
golang.org/x/mobile v0.0.0-20260908204917-8b95e45f8d3e // indirect
golang.org/x/mod v0.41.0 // indirect
golang.org/x/net v0.59.0 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/time v0.15.0 // indirect
+43
View File
@@ -0,0 +1,43 @@
package mobile
import (
"sync"
"openflux/transport/manager"
)
// initialCookies are cookies the app got before starting (a check passed
// with the tunnel down), applied to the Yandex carriers of the next start
// before their first request.
var initialCookies struct {
mu sync.Mutex
jar map[string]string
}
// SetInitialCookies takes a Cookie header ("a=1; b=2") for the Yandex
// carriers (yandex, vyandex, boards) of the next Start*. "" clears it.
func SetInitialCookies(cookieHeader string) {
jar := parseCookieHeader(cookieHeader)
initialCookies.mu.Lock()
initialCookies.jar = jar
initialCookies.mu.Unlock()
}
func applyInitialCookies(m *manager.Manager, specs []sessionSpec) {
initialCookies.mu.Lock()
jar := initialCookies.jar
initialCookies.mu.Unlock()
if len(jar) == 0 {
return
}
for _, s := range specs {
switch s.Type {
case "yandex", "vyandex", "boards":
if err := m.AcceptCookies(s.Name, jar); err != nil {
appendLog("[ANDROID] " + s.Name + ": начальные cookies не применились: " + err.Error())
} else {
appendLog("[ANDROID] " + s.Name + ": применены cookies, полученные до старта")
}
}
}
}
+378
View File
@@ -0,0 +1,378 @@
//go:build ios
// Command ios is the iOS app's C library (go build -buildmode=c-archive
// -tags ios, see build_ios.sh). It exposes the C API the app calls, built
// on package mobile: the same Session, context rule, codec and link
// handling as Android, so an iOS client behaves like every other one. The
// app links this core as a submodule instead of carrying a copy.
//
// Everything here is iOS glue: C strings, the log buffer the app drains,
// DNS-over-TLS against poisoned resolvers, the packet flow of the Network
// Extension. Nothing decides how the tunnel works.
package main
/*
#include <stdlib.h>
*/
import "C"
import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"log"
"net"
"os"
"path/filepath"
"runtime/debug"
"strings"
"sync"
"time"
"unsafe"
mobile "openflux-mobile"
"openflux/transport"
"openflux/utils"
)
func main() {}
// ---- log buffer the app drains ----
type ringLog struct {
mu sync.Mutex
lines []string
}
func (r *ringLog) Write(p []byte) (int, error) {
r.add(strings.TrimRight(string(p), "\n"))
return len(p), nil
}
func (r *ringLog) add(line string) {
r.mu.Lock()
defer r.mu.Unlock()
r.lines = append(r.lines, line)
if len(r.lines) > 1000 {
r.lines = r.lines[len(r.lines)-1000:]
}
}
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{}
func init() {
// The core's operational lines reach the app through package mobile's
// log (ReadLogs); the standard log (plain log.Printf from the core)
// goes to the ring buffer. The debug logger itself writes nowhere
// else, so no line shows up twice.
utils.SetOutput(io.Discard)
log.SetOutput(logbuf)
log.SetFlags(log.Ltime)
setupCrashCapture()
// The phone's own resolver may be poisoned for the hosts the carriers
// use; resolve over DNS-over-TLS instead.
net.DefaultResolver = &net.Resolver{PreferGo: true, Dial: dialSecureDNS}
}
// crashFile keeps the fatal-crash sink open for the process lifetime; the
// previous run's crash is shown in the log on the next launch.
var crashFile *os.File
func setupCrashCapture() {
path := filepath.Join(os.TempDir(), "oflux-crash.log")
if b, err := os.ReadFile(path); err == nil && len(b) > 0 {
logbuf.add("===== PREVIOUS CRASH (Go traceback) =====")
logbuf.add(string(b))
logbuf.add("===== 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{})
}
// OpenFluxReadLog drains buffered log lines (newline-separated). Free the
// result with OpenFluxFreeString.
//
//export OpenFluxReadLog
func OpenFluxReadLog() *C.char {
parts := make([]string, 0, 2)
if s := logbuf.drain(); s != "" {
parts = append(parts, s)
}
if s := mobile.ReadLogs(); s != "" {
parts = append(parts, s)
}
return C.CString(strings.Join(parts, "\n"))
}
//export OpenFluxFreeString
func OpenFluxFreeString(s *C.char) {
C.free(unsafe.Pointer(s))
}
// OpenFluxSetDebug turns operational logs on (level 2) or off.
//
//export OpenFluxSetDebug
func OpenFluxSetDebug(on C.int) {
level := utils.LevelOff
if on != 0 {
level = utils.LevelDebug
}
OpenFluxSetDebugLevel(C.int(level))
}
// OpenFluxSetDebugLevel sets the core's log level: 0 off, 1 packets, 2
// operational logs, 3 hexdumps (the CLI's --debug=N). Takes effect now and
// on the next start.
//
//export OpenFluxSetDebugLevel
func OpenFluxSetDebugLevel(n C.int) {
mobile.SetDebugLevel(int(n))
utils.SetLevel(int(n))
}
// ---- DNS over TLS ----
type dotServer struct {
addr string
sni string
}
func defaultDoTServers() []dotServer {
return []dotServer{
{"77.88.8.8:853", "common.dot.dns.yandex.net"},
{"8.8.8.8:853", "dns.google"},
{"1.1.1.1:853", "cloudflare-dns.com"},
}
}
var (
dotMu sync.RWMutex
dotServers = defaultDoTServers()
)
func getDoTServers() []dotServer {
dotMu.RLock()
defer dotMu.RUnlock()
return append([]dotServer(nil), dotServers...)
}
// OpenFluxSetDoTResolver sets the DNS-over-TLS upstreams: ";"-separated
// "addr[:port]@sni" entries ("1.1.1.1@cloudflare-dns.com"); port defaults
// to 853, SNI to the host. "" restores the defaults. Call before starting.
//
//export OpenFluxSetDoTResolver
func OpenFluxSetDoTResolver(spec *C.char) {
s := strings.TrimSpace(C.GoString(spec))
dotMu.Lock()
defer dotMu.Unlock()
if s == "" {
dotServers = defaultDoTServers()
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
log.Printf("[DNS] DNS-over-TLS resolvers: %v", servers)
}
}
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
}
// ---- profile settings shared by every start ----
// Start return codes.
const (
startOK = 0
startAlreadyRunning = 1
startBadTransport = 2
startTransportError = 3
startAddrInUse = 4
startPanic = 5
startBadEncryption = 6
)
var settings struct {
mu sync.Mutex
secret string
codec string
}
// OpenFluxSetEncryption sets (or clears, with "") the shared secret for the
// next start. With a secret the client runs the Session, falling back to
// the classic layering for a node that does not answer it; without one only
// classic, unencrypted, is possible.
//
//export OpenFluxSetEncryption
func OpenFluxSetEncryption(secret *C.char) {
s := ""
if secret != nil {
s = strings.TrimSpace(C.GoString(secret))
}
settings.mu.Lock()
settings.secret = s
settings.mu.Unlock()
if s == "" {
log.Printf("[BRIDGE] encryption: off")
} else {
log.Printf("[BRIDGE] encryption: secret set (%d chars)", utils.SecretChars(s))
}
}
// OpenFluxSetCodec sets the classic framing preferred for the next start,
// "batched" (default) or "legacy" (an openflux:// link's codec). The other
// one is accepted too and tried when the node does not answer.
//
//export OpenFluxSetCodec
func OpenFluxSetCodec(codec *C.char) {
c := strings.TrimSpace(C.GoString(codec))
settings.mu.Lock()
settings.codec = c
settings.mu.Unlock()
}
func currentSettings() (secret, codec string) {
settings.mu.Lock()
defer settings.mu.Unlock()
codec = settings.codec
if codec == "" {
codec = transport.CodecBatched
}
return settings.secret, codec
}
// normalizeType maps the app's transport names onto the core's.
func normalizeType(t string) string {
switch t {
case "", "yandex":
return "yandex"
case "volga":
return "vyandex"
case "mail":
return "mailru"
case "max":
return "oneme"
}
return t
}
// classicSpecs turns the app's (type, value) into a Session spec list for
// comma-separated document lists (several documents of one type): one
// carrier each, named as every client names them (type, type-2, ...).
func classicSpecs(typ, value string) []map[string]interface{} {
var specs []map[string]interface{}
for i, u := range strings.Split(value, ",") {
u = strings.TrimSpace(u)
if u == "" {
continue
}
name := typ
if i > 0 {
name = fmt.Sprintf("%s-%d", typ, i+1)
}
specs = append(specs, map[string]interface{}{
"name": name, "type": typ, "url": u, "priority": 100 - i,
})
}
return specs
}
// startCode maps package mobile's error text onto a start code.
func startCode(msg string) C.int {
switch {
case msg == "":
return startOK
case strings.Contains(msg, "занят"):
return startAddrInUse
case strings.Contains(strings.ToLower(msg), "ключ"):
return startBadEncryption
case strings.Contains(msg, "неизвестный тип"):
return startBadTransport
}
return startTransportError
}
func recoverStart(rc *C.int, where string) {
if r := recover(); r != nil {
log.Printf("[BRIDGE] recovered from a panic in %s: %v", where, r)
*rc = C.int(startPanic)
}
}
func jsonString(v interface{}) *C.char {
b, err := json.Marshal(v)
if err != nil {
return C.CString(`{"error":"marshal failed"}`)
}
return C.CString(string(b))
}
// OpenFluxMode says how traffic currently goes: "session", "classic" (the
// node does not answer the Session handshake: an older or classic node),
// or "". Free with OpenFluxFreeString.
//
//export OpenFluxMode
func OpenFluxMode() *C.char {
return C.CString(mobile.ConnectionMode())
}
// OpenFluxActiveTransport names the carrier traffic goes through now
// ("direct", "vyandex", ...), following failover. Free with
// OpenFluxFreeString.
//
//export OpenFluxActiveTransport
func OpenFluxActiveTransport() *C.char {
return C.CString(mobile.CurrentTransport())
}
+127
View File
@@ -0,0 +1,127 @@
//go:build ios
package main
/*
#include <stdlib.h>
*/
import "C"
import (
"strings"
mobile "openflux-mobile"
)
// Checks (SmartCaptcha, a login) a carrier cannot pass on its own. The
// phone's own carriers report them as OpenFluxCaptchaPending; the exit's
// (it cannot pass them, there is no browser) as
// OpenFluxRemoteCaptchaPending: that page must be passed from the exit's
// address, so with the VPN up (every page goes through the exit) or through
// OpenFluxRemoteCaptchaProxy, and its cookies go back to the exit.
// OpenFluxCaptchaPending returns the page one of this phone's carriers
// needs passed, or "". Free with OpenFluxFreeString.
//
//export OpenFluxCaptchaPending
func OpenFluxCaptchaPending() *C.char {
if mobile.PendingCaptchaProxy() != "" {
return C.CString("")
}
return C.CString(mobile.PendingCaptchaURL())
}
// OpenFluxRemoteCaptchaPending returns the page the exit needs passed, or
// "". Free with OpenFluxFreeString.
//
//export OpenFluxRemoteCaptchaPending
func OpenFluxRemoteCaptchaPending() *C.char {
if mobile.PendingCaptchaProxy() == "" {
return C.CString("")
}
return C.CString(mobile.PendingCaptchaURL())
}
// OpenFluxRemoteCaptchaProxy returns the loopback HTTP proxy (host:port)
// whose connections leave through the exit, for a web view that can take a
// proxy (iOS 17: WKWebsiteDataStore.proxyConfigurations), or "". Free with
// OpenFluxFreeString.
//
//export OpenFluxRemoteCaptchaProxy
func OpenFluxRemoteCaptchaProxy() *C.char {
return C.CString(mobile.PendingCaptchaProxy())
}
// OpenFluxCaptchaReason is "smartcaptcha" or "login" while a check is
// pending. Free with OpenFluxFreeString.
//
//export OpenFluxCaptchaReason
func OpenFluxCaptchaReason() *C.char {
return C.CString(mobile.PendingCaptchaReason())
}
// OpenFluxApplyCaptchaCookies takes the cookies of a passed check (a
// Cookie header, "a=1; b=2") and hands them to the carrier that asked:
// this phone's, or the exit's (then they are sent to it). Returns the
// number of cookies taken, 0 on failure.
//
//export OpenFluxApplyCaptchaCookies
func OpenFluxApplyCaptchaCookies(cookies *C.char) C.int {
if cookies == nil {
return 0
}
header := C.GoString(cookies)
if mobile.SubmitCaptchaCookies(header) != "" {
return 0
}
return C.int(countCookies(header))
}
// OpenFluxOfferCaptchaCookies is OpenFluxApplyCaptchaCookies for the
// exit's check (the same call: the pending check knows whose it is).
//
//export OpenFluxOfferCaptchaCookies
func OpenFluxOfferCaptchaCookies(cookies *C.char) C.int {
return OpenFluxApplyCaptchaCookies(cookies)
}
// OpenFluxCancelCaptcha drops the pending check (the user gave up); an
// exit's check stays quiet for a while.
//
//export OpenFluxCancelCaptcha
func OpenFluxCancelCaptcha() {
mobile.CancelCaptcha()
}
// OpenFluxSetInitialCookies passes cookies got before starting (a check
// passed with the tunnel down) to the Yandex carriers of the next start.
//
//export OpenFluxSetInitialCookies
func OpenFluxSetInitialCookies(cookies *C.char) {
s := ""
if cookies != nil {
s = C.GoString(cookies)
}
mobile.SetInitialCookies(s)
}
// OpenFluxSetCookieStore keeps passed checks' cookies in a file (the app's
// shared container) across starts. Returns 0, or 1 on error.
//
//export OpenFluxSetCookieStore
func OpenFluxSetCookieStore(path *C.char) C.int {
if mobile.SetCookieStorePath(C.GoString(path)) != "" {
return 1
}
return 0
}
func countCookies(header string) int {
n := 0
for _, part := range strings.Split(header, ";") {
if name, _, ok := strings.Cut(strings.TrimSpace(part), "="); ok && name != "" {
n++
}
}
return n
}
+153
View File
@@ -0,0 +1,153 @@
//go:build ios
package main
// GeoSite split tunneling (from the iOS app's fork, saharev1/OpenFlux). The packet-tunnel extension already proxies
// every device DNS query over DoT, so it sees both the queried domain and the
// resolved IPs. When a domain matches the geosite "direct" set, its answer IPs
// are recorded and handed to the Swift provider, which adds them to the tunnel's
// excludedRoutes so that domain's traffic goes direct — even when the IP is
// foreign (e.g. a Russian service on a foreign CDN) and thus not in the GeoIP RU
// set. This is the only way to route by domain on iOS, where the OS routes L3
// traffic and "direct" means "excluded from the tunnel".
import (
"net"
"strings"
"sync"
"golang.org/x/net/dns/dnsmessage"
)
var (
geoMu sync.RWMutex
geoSet map[string]struct{} // domain suffixes that go direct
directSeen = map[string]struct{}{}
directPend []string // IPs not yet handed to the Swift side
)
// setGeositeDirect loads the newline-separated domain-suffix list (one domain per
// line; leading "*." / "." and comments are tolerated).
func setGeositeDirect(list string) {
set := make(map[string]struct{})
for _, ln := range strings.Split(list, "\n") {
ln = strings.TrimSpace(strings.ToLower(ln))
if ln == "" || strings.HasPrefix(ln, "#") {
continue
}
ln = strings.TrimPrefix(ln, "*.")
ln = strings.TrimPrefix(ln, ".")
ln = strings.TrimSuffix(ln, ".")
if ln != "" {
set[ln] = struct{}{}
}
}
geoMu.Lock()
geoSet = set
geoMu.Unlock()
}
// geoMatch reports whether name (or any parent domain of it) is in the direct
// set. Walks label boundaries, so it is O(labels) per query regardless of set
// size.
func geoMatch(name string) bool {
name = strings.ToLower(strings.TrimSuffix(name, "."))
geoMu.RLock()
defer geoMu.RUnlock()
if len(geoSet) == 0 {
return false
}
for {
if _, ok := geoSet[name]; ok {
return true
}
i := strings.IndexByte(name, '.')
if i < 0 {
return false
}
name = name[i+1:]
}
}
// geositeNoteAnswer parses the DNS query name and answer A-records; if the name
// is a geosite-direct domain, records its IPv4 answers as pending direct routes.
// Best-effort: any parse error is silently ignored.
func geositeNoteAnswer(query, answer []byte) {
defer func() { _ = recover() }()
var pq dnsmessage.Parser
if _, err := pq.Start(query); err != nil {
return
}
q, err := pq.Question()
if err != nil {
return
}
if !geoMatch(q.Name.String()) {
return
}
var pa dnsmessage.Parser
if _, err := pa.Start(answer); err != nil {
return
}
if err := pa.SkipAllQuestions(); err != nil {
return
}
var ips []string
for {
h, err := pa.AnswerHeader()
if err != nil {
break
}
if h.Type == dnsmessage.TypeA {
r, err := pa.AResource()
if err != nil {
break
}
ips = append(ips, net.IP(r.A[:]).String())
} else {
if err := pa.SkipAnswer(); err != nil {
break
}
}
}
if len(ips) == 0 {
return
}
geoMu.Lock()
for _, ip := range ips {
if _, ok := directSeen[ip]; !ok {
directSeen[ip] = struct{}{}
directPend = append(directPend, ip)
}
}
geoMu.Unlock()
}
// drainDirectIPsCapped returns as many pending direct IPs (newline-joined) as fit
// in max bytes and re-queues the rest for the next drain.
func drainDirectIPsCapped(max int) string {
geoMu.Lock()
defer geoMu.Unlock()
if len(directPend) == 0 || max <= 0 {
return ""
}
var b strings.Builder
i := 0
for ; i < len(directPend); i++ {
extra := len(directPend[i])
if b.Len() > 0 {
extra++ // newline
}
if b.Len()+extra > max {
break
}
if b.Len() > 0 {
b.WriteByte('\n')
}
b.WriteString(directPend[i])
}
directPend = directPend[i:]
return b.String()
}
+364
View File
@@ -0,0 +1,364 @@
//go:build ios
package main
/*
#include <stdlib.h>
*/
import "C"
import (
"context"
"crypto/tls"
"encoding/binary"
"encoding/json"
"io"
"log"
"net"
"runtime/debug"
"strings"
"sync"
"sync/atomic"
"time"
"unsafe"
mobile "openflux-mobile"
"openflux/network"
"openflux/utils"
)
// Packet-tunnel (NEPacketTunnelProvider) mode: pure L3 forwarding.
//
// The device gets tunnel address 10.10.10.2, which every exit answers to,
// so its IPv4 packets go over the tunnel as they are (package mobile's
// Send/Read, the Android VPN path); no TCP stack runs in the extension.
// DNS (UDP 53) is answered locally over DNS-over-TLS; other UDP goes over
// the tunnel when enabled, otherwise is refused with ICMP so apps fall back
// from QUIC to TCP at once.
var pt struct {
mu sync.Mutex
on bool
outQ chan []byte
ctx context.Context
cancel context.CancelFunc
}
// tunnelUDP: forward non-DNS UDP over the tunnel (the Session negotiates
// whether the exit takes UDP; a classic exit may not).
var tunnelUDP atomic.Bool
//export OpenFluxSetTunnelUDP
func OpenFluxSetTunnelUDP(on C.int) { tunnelUDP.Store(on != 0) }
// extensionLimits keeps the Network Extension under its 50 MB cap: a Go
// heap limit with headroom for what lives outside it (TLS, WebSocket
// buffers, the runtime), and the phone resource profile of the carriers.
func extensionLimits() {
debug.SetMemoryLimit(32 << 20)
debug.SetGCPercent(100)
mobile.SetLowMemory(true)
}
// OpenFluxStartPacketTunnel starts the packet tunnel for a classic profile
// (see OpenFluxStartClient for the arguments; the secret comes from
// OpenFluxSetEncryption). Returns a start code.
//
//export OpenFluxStartPacketTunnel
func OpenFluxStartPacketTunnel(transportType, url, maxToken, maxUid *C.char) (rc C.int) {
defer recoverStart(&rc, "OpenFluxStartPacketTunnel")
typ := normalizeType(C.GoString(transportType))
value := strings.TrimSpace(C.GoString(url))
secret, codec := currentSettings()
if typ == "direct" && secret == "" {
log.Printf("[PKT] direct needs an encryption key")
return startBadEncryption
}
return startPacketTunnel(func() string {
if specs := classicSpecs(typ, value); len(specs) > 1 {
if secret == "" {
return "для нескольких документов нужен ключ шифрования"
}
b, _ := json.Marshal(specs)
return mobile.StartSession(string(b), secret)
}
return mobile.Start(typ, value, secret, codec, C.GoString(maxToken), C.GoString(maxUid))
})
}
// OpenFluxStartSessionPacketTunnel starts the packet tunnel for a Session
// profile (specsJSON as for OpenFluxStartSession).
//
//export OpenFluxStartSessionPacketTunnel
func OpenFluxStartSessionPacketTunnel(specsJSON, secret *C.char) (rc C.int) {
defer recoverStart(&rc, "OpenFluxStartSessionPacketTunnel")
specs := C.GoString(specsJSON)
key := strings.TrimSpace(C.GoString(secret))
return startPacketTunnel(func() string { return mobile.StartSession(specs, key) })
}
func startPacketTunnel(start func() string) C.int {
pt.mu.Lock()
defer pt.mu.Unlock()
if pt.on {
return startAlreadyRunning
}
extensionLimits()
if msg := start(); msg != "" {
log.Printf("[PKT] start: %s", msg)
return startCode(msg)
}
// 2048 packets (about 3 MB at full size) absorb a download burst
// without drops, within the extension's budget.
pt.outQ = make(chan []byte, 2048)
pt.ctx, pt.cancel = context.WithCancel(context.Background())
pt.on = true
go pumpFromTunnel(pt.ctx, pt.outQ)
log.Printf("[PKT] packet tunnel started (mode %q)", mobile.ConnectionMode())
return startOK
}
// pumpFromTunnel moves packets the exit sent into the device queue.
func pumpFromTunnel(ctx context.Context, outQ chan []byte) {
for ctx.Err() == nil {
p := mobile.ReadTimeout(250)
if p == nil {
continue
}
select {
case outQ <- p:
default: // device side not keeping up: drop, TCP retransmits
}
}
}
// OpenFluxTunWritePacket takes one packet from the device.
//
//export OpenFluxTunWritePacket
func OpenFluxTunWritePacket(buf *C.char, length C.int) {
defer func() { _ = recover() }()
if buf == nil || length < 20 {
return
}
pt.mu.Lock()
on, outQ := pt.on, pt.outQ
pt.mu.Unlock()
if !on {
return
}
pkt := C.GoBytes(unsafe.Pointer(buf), length)
if pkt[0]>>4 != 4 {
return
}
switch pkt[9] {
case 6: // TCP
send(pkt)
case 17: // UDP
ihl := int(pkt[0]&0x0f) * 4
if len(pkt) < ihl+8 {
return
}
switch {
case binary.BigEndian.Uint16(pkt[ihl+2:ihl+4]) == 53:
select {
case dnsSem <- struct{}{}:
go func() { defer func() { <-dnsSem }(); handleDNSPacket(pkt, outQ) }()
default: // too many in flight: the client retries
}
case tunnelUDP.Load():
send(pkt)
default:
sendICMPPortUnreachable(pkt, outQ)
}
}
}
func send(pkt []byte) {
if msg := mobile.Send(pkt); msg != "" && utils.Throttled("pkt.send", 10*time.Second) {
utils.Debugf("[PKT] send: %s", msg)
}
}
// dnsSem caps concurrent DNS-over-TLS resolutions.
var dnsSem = make(chan struct{}, 16)
// OpenFluxTunReadPacket blocks for the next packet to the device and
// returns its length (0 once the tunnel stops).
//
//export OpenFluxTunReadPacket
func OpenFluxTunReadPacket(buf *C.char, max C.int) C.int {
pt.mu.Lock()
outQ, ctx := pt.outQ, pt.ctx
pt.mu.Unlock()
if outQ == nil || ctx == nil {
return 0
}
select {
case data := <-outQ:
n := min(len(data), 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
}
}
// OpenFluxPacketTunnelConnected is 1 while the tunnel reaches the exit;
// the provider polls it to drive reasserting across carrier reconnects.
//
//export OpenFluxPacketTunnelConnected
func OpenFluxPacketTunnelConnected() C.int {
pt.mu.Lock()
on := pt.on
pt.mu.Unlock()
if on && mobile.IsConnected() {
return 1
}
return 0
}
//export OpenFluxStopPacketTunnel
func OpenFluxStopPacketTunnel() {
pt.mu.Lock()
defer pt.mu.Unlock()
if !pt.on {
return
}
pt.cancel()
mobile.Stop()
pt.outQ, pt.ctx, pt.cancel = nil, nil, nil
pt.on = false
log.Printf("[PKT] packet tunnel stopped")
}
// OpenFluxSetGeositeDirect loads the newline-separated domain suffixes whose
// traffic goes direct (GeoSite split tunneling).
//
//export OpenFluxSetGeositeDirect
func OpenFluxSetGeositeDirect(list *C.char) {
setGeositeDirect(C.GoString(list))
}
// OpenFluxDrainDirectIPs copies pending direct IPs (newline-joined) into
// buf up to max bytes and returns the byte count; the provider adds them to
// excludedRoutes. What does not fit stays queued.
//
//export OpenFluxDrainDirectIPs
func OpenFluxDrainDirectIPs(buf *C.char, max C.int) C.int {
s := drainDirectIPsCapped(int(max))
if s == "" {
return 0
}
dst := unsafe.Slice((*byte)(unsafe.Pointer(buf)), int(max))
return C.int(copy(dst, s))
}
// sendICMPPortUnreachable answers a UDP datagram that is not forwarded, so
// the sender falls back to TCP at once instead of timing out.
func sendICMPPortUnreachable(orig []byte, outQ chan []byte) {
ihl := int(orig[0]&0x0f) * 4
if len(orig) < ihl+8 {
return
}
quote := orig[:ihl+8]
icmp := make([]byte, 8+len(quote))
icmp[0], icmp[1] = 3, 3 // destination unreachable, port unreachable
copy(icmp[8:], quote)
ck := network.IPChecksum(icmp)
icmp[2], icmp[3] = byte(ck>>8), byte(ck)
total := 20 + len(icmp)
ip := make([]byte, total)
ip[0] = 0x45
binary.BigEndian.PutUint16(ip[2:4], uint16(total))
ip[8], ip[9] = 64, 1
copy(ip[12:16], orig[16:20])
copy(ip[16:20], orig[12:16])
ck2 := network.IPChecksum(ip[:20])
ip[10], ip[11] = byte(ck2>>8), byte(ck2)
copy(ip[20:], icmp)
select {
case outQ <- ip:
default:
}
}
// handleDNSPacket answers a device DNS query over DNS-over-TLS.
func handleDNSPacket(req []byte, outQ chan []byte) {
defer func() { _ = recover() }()
ihl := int(req[0]&0x0f) * 4
if len(req) < ihl+8 {
return
}
query := req[ihl+8:]
if len(query) == 0 {
return
}
answer, err := dnsOverTLS(query)
if err != nil || len(answer) == 0 {
if utils.Throttled("pkt.dns", 30*time.Second) {
log.Printf("[DNS] DNS-over-TLS failed: %v", err)
}
return
}
geositeNoteAnswer(query, answer)
udpLen := 8 + len(answer)
total := ihl + udpLen
resp := make([]byte, total)
resp[0], resp[1] = req[0], req[1]
binary.BigEndian.PutUint16(resp[2:4], uint16(total))
resp[8], resp[9] = 64, 17
copy(resp[12:16], req[16:20])
copy(resp[16:20], req[12:16])
ipck := network.IPChecksum(resp[:20])
resp[10], resp[11] = byte(ipck>>8), byte(ipck)
copy(resp[ihl:ihl+2], req[ihl+2:ihl+4])
copy(resp[ihl+2:ihl+4], req[ihl:ihl+2])
binary.BigEndian.PutUint16(resp[ihl+4:ihl+6], uint16(udpLen))
copy(resp[ihl+8:], answer)
select {
case outQ <- resp:
default:
}
}
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
}
+120
View File
@@ -0,0 +1,120 @@
//go:build ios
package main
/*
#include <stdlib.h>
*/
import "C"
import (
"encoding/json"
"fmt"
"log"
"strings"
"time"
mobile "openflux-mobile"
)
// The in-app core: a local SOCKS5 proxy over the tunnel (the app's own
// connection test and captcha pages), package mobile's Proxy mode.
var proxyStarted time.Time
// OpenFluxStartClient starts the SOCKS5 client on socksAddr for a classic
// profile: transportType ("yandex", "vyandex"/"volga", "boards", "mailru",
// "cupsonline", "direct", "oneme") and url (the document, the room list,
// host:port for direct; a comma-separated list runs one carrier per
// document). With a secret (OpenFluxSetEncryption) it is a Session that
// speaks classic to a node that does not answer the handshake. Returns a
// start code.
//
//export OpenFluxStartClient
func OpenFluxStartClient(transportType, url, socksAddr, maxToken, maxUid *C.char) (rc C.int) {
defer recoverStart(&rc, "OpenFluxStartClient")
if mobile.ProxyIsRunning() {
return startAlreadyRunning
}
typ := normalizeType(C.GoString(transportType))
value := strings.TrimSpace(C.GoString(url))
addr := C.GoString(socksAddr)
secret, codec := currentSettings()
if typ == "direct" && secret == "" {
log.Printf("[BRIDGE] direct needs an encryption key")
return startBadEncryption
}
var msg string
if specs := classicSpecs(typ, value); len(specs) > 1 {
if secret == "" {
log.Printf("[BRIDGE] several documents need an encryption key (a Session)")
return startBadEncryption
}
b, _ := json.Marshal(specs)
msg = mobile.StartSessionProxy(string(b), secret, addr, "", "", "")
} else {
msg = mobile.StartProxy(typ, value, secret, codec, C.GoString(maxToken), C.GoString(maxUid), addr, "", "", "")
}
if msg != "" {
log.Printf("[BRIDGE] start: %s", msg)
return startCode(msg)
}
proxyStarted = time.Now()
return startOK
}
// OpenFluxStartSession starts the SOCKS5 client for a Session profile:
// specsJSON is what OpenFluxShareDecode returns as "session" (or package
// mobile's StartSession takes).
//
//export OpenFluxStartSession
func OpenFluxStartSession(specsJSON, secret, socksAddr *C.char) (rc C.int) {
defer recoverStart(&rc, "OpenFluxStartSession")
if mobile.ProxyIsRunning() {
return startAlreadyRunning
}
msg := mobile.StartSessionProxy(C.GoString(specsJSON), strings.TrimSpace(C.GoString(secret)), C.GoString(socksAddr), "", "", "")
if msg != "" {
log.Printf("[BRIDGE] start: %s", msg)
return startCode(msg)
}
proxyStarted = time.Now()
return startOK
}
//export OpenFluxStop
func OpenFluxStop() {
mobile.StopProxy()
}
//export OpenFluxIsRunning
func OpenFluxIsRunning() C.int {
if mobile.ProxyIsRunning() {
return 1
}
return 0
}
//export OpenFluxIsConnected
func OpenFluxIsConnected() C.int {
if mobile.ProxyIsConnected() {
return 1
}
return 0
}
// OpenFluxStatsJSON returns the SOCKS client's state as JSON. Free with
// OpenFluxFreeString.
//
//export OpenFluxStatsJSON
func OpenFluxStatsJSON() *C.char {
running := mobile.ProxyIsRunning()
uptime := int64(0)
if running {
uptime = int64(time.Since(proxyStarted) / time.Second)
}
return C.CString(fmt.Sprintf(
`{"running":%t,"connected":%t,"bytesSent":%d,"bytesReceived":%d,"uptimeSec":%d,"mode":%q,"transport":%q}`,
running, mobile.ProxyIsConnected(), mobile.ProxyBytesSent(), mobile.ProxyBytesReceived(), uptime,
mobile.ConnectionMode(), mobile.CurrentTransport()))
}
+55
View File
@@ -0,0 +1,55 @@
//go:build ios
package main
/*
#include <stdlib.h>
*/
import "C"
import (
mobile "openflux-mobile"
"openflux/share"
)
// Links are read and made by the core only: the app hands the string (or
// the configuration) over and gets share.Result back as JSON, the same
// answer Desktop, Android and the CLI get. The app words its code.
type shareResult struct {
share.Result
// Session is the profile ready for OpenFluxStartSession /
// OpenFluxStartSessionPacketTunnel (with the link's secret): every
// carrier, its name, priority and address, and the context. A
// one-carrier link works there too, speaking classic to a classic node.
Session string `json:"session,omitempty"`
}
// OpenFluxShareDecode reads an openflux:// link: {"config":...,
// "context":...,"session":...} or {"error":...,"code":...,"param":...}.
// Free with OpenFluxFreeString.
//
//export OpenFluxShareDecode
func OpenFluxShareDecode(link *C.char) *C.char {
if link == nil {
return C.CString(share.Read("").JSON())
}
s := C.GoString(link)
r := shareResult{Result: share.Read(s)}
if r.Config != nil {
r.Session, _ = mobile.ShareSessionSpecs(s)
}
return jsonString(r)
}
// OpenFluxShareEncode builds the link for a share.Config JSON the way
// every client exports one: {"link":...,"config":...,"context":...} or the
// error. The core validates it, so no invalid link leaves the app.
//
//export OpenFluxShareEncode
func OpenFluxShareEncode(cfgJSON *C.char) *C.char {
if cfgJSON == nil {
return C.CString(share.MakeJSON("").JSON())
}
return C.CString(share.MakeJSON(C.GoString(cfgJSON)).JSON())
}
+92 -29
View File
@@ -4,11 +4,13 @@
package mobile
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"openflux/transport"
"openflux/transport/cupsonline"
@@ -44,6 +46,20 @@ type packetClient struct {
transport transport.Transport
packets [][]byte
logs []string
// arrived is signalled when a packet is queued, for ReadTimeout.
arrived chan struct{}
}
// lowMemory selects the phone resource profile for the carriers of the
// next start (see SetLowMemory).
var lowMemory atomic.Bool
// SetLowMemory picks the resource profile for the next Start*: on, the
// carriers use small queues and buffers (Volga's slim profile) - what an
// iOS Network Extension (50 MB for the whole process) needs. The wire
// format is the same either way.
func SetLowMemory(on bool) {
lowMemory.Store(on)
}
func appendLog(message string) {
@@ -89,6 +105,9 @@ func startPacket(build func() (transport.Transport, error)) string {
client.running = true
client.packets = nil
client.logs = nil
if client.arrived == nil {
client.arrived = make(chan struct{}, 1)
}
client.mu.Unlock()
utils.SetLevel(int(debugLevel.Load()))
@@ -119,7 +138,12 @@ func startPacket(build func() (transport.Transport, error)) string {
client.packets = client.packets[1:]
}
client.packets = append(client.packets, packet)
arrived := client.arrived
client.mu.Unlock()
select {
case arrived <- struct{}{}:
default:
}
})
if err := trans.Start(); err != nil {
return fail(err)
@@ -135,23 +159,41 @@ func validateClassic(transportType, documentURL, encryptionSecret string) string
if transportType != "oneme" && documentURL == "" {
return "Ссылка на документ не указана"
}
if encryptionSecret != "" && len(encryptionSecret) < 16 {
return "Ключ шифрования должен содержать не менее 16 символов"
if encryptionSecret != "" && utils.SecretChars(encryptionSecret) < utils.MinSecretChars {
return fmt.Sprintf("Ключ шифрования должен содержать не менее %d символов", utils.MinSecretChars)
}
return ""
}
// classicTransport builds the single-transport stack: carrier, codec and
// optional encryption, the same layering as the CLI without --negotiate.
// exit selects the exit node's side (the phone as the exit).
// classicTransport builds a classic single-transport profile (the CLI's
// --transport=X). exit selects the exit node's side (the phone as the exit).
//
// With a key it is a Session with the classic layering next to it, as the
// CLI does: the client speaks classic until the exit answers the Session
// handshake, then switches (so a classic profile works against every node,
// old and new); a classic exit serves both kinds of client. Without a key
// only classic is possible. Either way the codec is the preferred framing,
// not a requirement: both are accepted and the other one is tried when the
// peer stays silent.
func classicTransport(transportType, documentURL, encryptionSecret, codec, maxToken, maxUid string, exit bool) (transport.Transport, error) {
if transportType == "" {
transportType = "yandex"
}
appendLog(fmt.Sprintf("[ANDROID] Запуск транспорта %s", transportType))
params := classicParams(transportType, documentURL, maxToken, maxUid)
if encryptionSecret != "" {
specs, err := json.Marshal([]sessionSpec{{
Name: transportType, Type: transportType, URL: documentURL, Priority: 100, Params: params,
}})
if err != nil {
return nil, err
}
t, _, err := buildSessionWith(string(specs), encryptionSecret, exit, sessionOptions{classic: true, codec: codec})
return t, err
}
config := transport.DefaultConfig()
inner, err := newRawTransport(transportType, documentURL,
map[string]interface{}{"token": maxToken, "uid": maxUid}, config, exit)
inner, err := newRawTransport(transportType, documentURL, params, config, exit)
if err != nil {
return nil, err
}
@@ -160,30 +202,23 @@ func classicTransport(transportType, documentURL, encryptionSecret, codec, maxTo
if exit {
addExitRoom(transportType, inner)
}
// The codec sits under the (absent) encryption layer; see
// transport.CodecTransport for how the framing is agreed on.
inner = transport.NewCodecTransport(inner, codec, !exit)
appendLog("[ANDROID] Шифрование транспорта отключено (ключ не задан): узел с ключом с этим профилем не заговорит")
return inner, nil
}
// App-layer codec, same as the CLI's --codec flag. Both peers must use
// the same one. Applied before encryption so it compresses plaintext
// rather than ciphertext.
if codec == "legacy" {
inner = transport.NewCompressedTransport(inner)
} else {
inner = transport.NewBatchedTransport(inner)
// classicParams are the carrier parameters of a classic profile: the MAX
// token and uid, or for direct the address, dialled by a client and
// listened on by an exit.
func classicParams(transportType, documentURL, maxToken, maxUid string) map[string]interface{} {
params := map[string]interface{}{"token": maxToken, "uid": maxUid}
if transportType == "direct" {
params["dial"] = documentURL
params["listen"] = documentURL
}
if encryptionSecret == "" {
appendLog("[ANDROID] Шифрование транспорта отключено (ключ не задан)")
return inner, nil
}
// Same context as the core: the document URL, or "http://#" when there
// isn't one (oneme, direct). Both peers must derive the same context or
// the encrypted channel just won't work.
encrypted, err := transport.NewEncryptedTransport(inner, encryptionSecret,
classicContext(documentURL), exit)
if err != nil {
return nil, err
}
appendLog("[ANDROID] Шифрование транспорта: AES-256-GCM включено")
return encrypted, nil
return params
}
// newRawTransport builds one carrier, like the CLI's transportFactory.
@@ -195,10 +230,16 @@ func newRawTransport(typ, url string, params map[string]interface{}, config tran
v, _ := params[key].(string)
return v
}
if lowMemory.Load() {
config.MaxQueueSize = 512
}
switch typ {
case "", "yandex":
return yandex.NewYandexDocsTransport(url, config), nil
case "vyandex":
if lowMemory.Load() {
return yandex.NewYandexVolgaTransportWithConfig(url, config, yandex.SlimVolgaConfig()), nil
}
return yandex.NewYandexVolgaTransport(url, config), nil
case "boards":
return yandex.NewBoardsTransport(url, config), nil
@@ -280,6 +321,28 @@ func Read() []byte {
return packet
}
// ReadTimeout is Read that waits up to timeoutMs for a packet, for callers
// that block on the tunnel (the iOS packet flow) instead of polling.
func ReadTimeout(timeoutMs int) []byte {
if p := Read(); p != nil {
return p
}
client.mu.Lock()
arrived := client.arrived
client.mu.Unlock()
if arrived == nil {
return nil
}
timer := time.NewTimer(time.Duration(timeoutMs) * time.Millisecond)
defer timer.Stop()
select {
case <-arrived:
return Read()
case <-timer.C:
return nil
}
}
// ReadLogs returns and clears the pending log lines.
func ReadLogs() string {
client.mu.Lock()
+2 -1
View File
@@ -282,7 +282,8 @@ func NodeVerify(specsJSON, secret, expectHost string, timeoutSec int) string {
}
appendLog("[NODE] Проверка канала: подключение")
trans, sess, err := buildSessionWith(specsJSON, secret, false)
// Strict: the check is that the new node answers the Session.
trans, sess, err := buildSessionWith(specsJSON, secret, false, sessionOptions{strict: true})
if err != nil {
return failure(err, nil)
}
+14
View File
@@ -47,3 +47,17 @@ func CurrentTransport() string {
}
return ""
}
// ConnectionMode says how traffic currently goes: "session" (the
// authenticated Session), "classic" (the exit does not answer the Session
// handshake: an older or classic node, or it has not answered yet), or ""
// (not connected / no key).
func ConnectionMode() string {
route.mu.Lock()
s := route.session
route.mu.Unlock()
if s == nil {
return ""
}
return s.Mode()
}
+76 -40
View File
@@ -6,11 +6,13 @@ import (
"openflux/transport"
"openflux/transport/manager"
"openflux/utils"
)
// sessionSpec is one transport of a Session profile, as the app sends it.
// Names must match the exit's (the CLI names --transports entries after
// their type) because cookie exchange is addressed by name.
// their type) because cookie exchange is addressed by name; the Manager
// also matches carriers by document URL and type when they differ.
type sessionSpec struct {
Name string `json:"name"`
Type string `json:"type"`
@@ -19,30 +21,62 @@ type sessionSpec struct {
Params map[string]interface{} `json:"params"`
}
// sessionOptions tunes buildSessionWith beyond the specs.
type sessionOptions struct {
// classic enables the classic layering next to the Session (see
// transport.Session.SetClassic): a classic profile's client falls back
// to it, a classic exit serves classic clients. codec is its
// preferred framing.
classic bool
codec string
// strict turns the classic fallback a single-carrier Session client
// gets by default off.
strict bool
}
// parseSessionSpecs reads what the app sends: a JSON array of sessionSpec,
// or {"context": ..., "transports": [...]} when the profile carries the
// exit's encryption context explicitly (imported from an openflux:// link).
// Without one the context is derived with sessionContext.
func parseSessionSpecs(specsJSON string) ([]sessionSpec, string, error) {
// The context is picked by transport.KDFContexts, the rule every peer uses;
// alternates are what other builds may have derived instead.
func parseSessionSpecs(specsJSON string) (specs []sessionSpec, context string, alternates []string, err error) {
var wrapped struct {
Context string `json:"context"`
Transports []sessionSpec `json:"transports"`
}
var specs []sessionSpec
if err := json.Unmarshal([]byte(specsJSON), &specs); err != nil {
if err := json.Unmarshal([]byte(specsJSON), &wrapped); err != nil {
return nil, "", fmt.Errorf("список транспортов: %w", err)
return nil, "", nil, fmt.Errorf("список транспортов: %w", err)
}
specs = wrapped.Transports
}
if len(specs) == 0 {
return nil, "", fmt.Errorf("список транспортов пуст")
return nil, "", nil, fmt.Errorf("список транспортов пуст")
}
context := wrapped.Context
if context == "" {
context = sessionContext(specs)
seen := make(map[string]int)
for i := range specs {
if specs[i].Name == "" {
seen[specs[i].Type]++
specs[i].Name = specs[i].Type
if n := seen[specs[i].Type]; n > 1 {
specs[i].Name = fmt.Sprintf("%s-%d", specs[i].Type, n)
}
}
}
return specs, context, nil
context, alternates = transport.KDFContexts(wrapped.Context, "", contextSources(specs))
return specs, context, alternates, nil
}
func contextSources(specs []sessionSpec) []transport.ContextSource {
out := make([]transport.ContextSource, len(specs))
for i, s := range specs {
url := s.URL
if s.Type == "direct" {
url = ""
}
out[i] = transport.ContextSource{Type: s.Type, URL: url, Priority: s.Priority}
}
return out
}
// buildSession mirrors the CLI client's --negotiate / --transports path, so
@@ -50,19 +84,19 @@ func parseSessionSpecs(specsJSON string) ([]sessionSpec, string, error) {
// specsJSON is what parseSessionSpecs reads. exit builds the exit node's
// side (the phone as an l4 exit).
func buildSession(specsJSON, secret string, exit bool) (transport.Transport, error) {
t, _, err := buildSessionWith(specsJSON, secret, exit)
t, _, err := buildSessionWith(specsJSON, secret, exit, sessionOptions{})
return t, err
}
// buildSessionWith is buildSession that also returns the Session, for
// callers that need per-transport state.
func buildSessionWith(specsJSON, secret string, exit bool) (transport.Transport, *transport.Session, error) {
specs, context, err := parseSessionSpecs(specsJSON)
func buildSessionWith(specsJSON, secret string, exit bool, opt sessionOptions) (transport.Transport, *transport.Session, error) {
specs, context, alternates, err := parseSessionSpecs(specsJSON)
if err != nil {
return nil, nil, err
}
if len(secret) < 16 {
return nil, nil, fmt.Errorf("для режима Session нужен ключ шифрования не короче 16 символов")
if utils.SecretChars(secret) < utils.MinSecretChars {
return nil, nil, fmt.Errorf("для режима Session нужен ключ шифрования не короче %d символов", utils.MinSecretChars)
}
// Like the CLI: an l4 exit terminates flows in gVisor and has no raw
@@ -78,6 +112,20 @@ func buildSessionWith(specsJSON, secret string, exit bool) (transport.Transport,
if err != nil {
return nil, nil, err
}
// A single-carrier client falls back to the classic layering while the
// exit does not answer the handshake (an exit that predates Session or
// runs classic) and upgrades once it does. A classic exit also serves
// classic clients.
switch {
case opt.classic:
sess.SetClassic(opt.codec)
case !exit && !opt.strict && len(specs) == 1:
sess.SetClassic(transport.CodecBatched)
}
sess.SetAlternateContexts(alternates)
appendLog(fmt.Sprintf("[ANDROID] Session: контекст шифрования sha256 %s (запасных: %d)",
utils.Sha256Short([]byte(context)), len(alternates)))
m := manager.New(sess, nil, secret, context)
config := transport.DefaultConfig()
keys := make(map[string]string)
@@ -90,6 +138,15 @@ func buildSessionWith(specsJSON, secret string, exit bool) (transport.Transport,
}
if exit {
addExitRoom(spec.Name, raw)
// A cupsonline exit learns its rooms at start; older classic
// clients derived their key from that list.
if r, ok := raw.(interface{ OnRoomList(func(string)) }); ok {
r.OnRoomList(func(packed string) {
if packed != "" {
sess.SetAlternateContexts([]string{packed})
}
})
}
}
if err := sess.AddTransport(spec.Name, raw, secret, context, spec.Priority); err != nil {
return nil, nil, err
@@ -98,6 +155,9 @@ func buildSessionWith(specsJSON, secret string, exit bool) (transport.Transport,
if err := m.Add(spec.Name, spec.Type, raw, spec.Priority, provider); err != nil {
return nil, nil, err
}
if spec.Type != "direct" && spec.Type != "oneme" {
m.SetURL(spec.Name, spec.URL)
}
if provider != nil {
keys[spec.Name] = spec.Type + " " + spec.URL
}
@@ -105,6 +165,7 @@ func buildSessionWith(specsJSON, secret string, exit bool) (transport.Transport,
}
sess.SetControlHandler(m.DispatchControl)
setSessionRoute(sess, types)
applyInitialCookies(m, specs)
if exit {
// The exit relays its own checks to the client itself (AuthRequired);
// the phone's UI can still pass them locally.
@@ -122,28 +183,3 @@ func buildSessionWith(specsJSON, secret string, exit bool) (transport.Transport,
appendLog("[ANDROID] Session: шифрование AES-256-GCM, согласование с нодой")
return demux, sess, nil
}
// sessionContext is the encryption context, derived as the core's
// pickSessionContext does for an exit without --url: the document URL of the
// highest-priority transport that has one, cupsonline aside (its room list
// only exists once the exit is up), else "http://#". Equal priorities keep
// the first transport, as the core does.
func sessionContext(specs []sessionSpec) string {
best := -1
for i, s := range specs {
if s.Type == "cupsonline" || s.URL == "" || s.URL == placeholderURL {
continue
}
if best < 0 || s.Priority > specs[best].Priority {
best = i
}
}
if best >= 0 {
return specs[best].URL
}
return placeholderURL
}
// placeholderURL is the core's --url default, the context of a channel that
// has no document URL.
const placeholderURL = "http://#"
+45 -12
View File
@@ -1,6 +1,7 @@
package mobile
import (
"encoding/json"
"fmt"
"net"
"testing"
@@ -64,16 +65,29 @@ func TestBuildSessionConnectsOverDirect(t *testing.T) {
}
}
func contextOf(t *testing.T, specs []sessionSpec) string {
t.Helper()
b, err := json.Marshal(specs)
if err != nil {
t.Fatal(err)
}
_, ctx, _, err := parseSessionSpecs(string(b))
if err != nil {
t.Fatal(err)
}
return ctx
}
func TestSessionContextPrefersHighestPriorityURL(t *testing.T) {
specs := []sessionSpec{
{Name: "mailru", URL: "https://cloud.example/m", Priority: 10},
{Name: "direct", Priority: 100},
{Name: "yandex", URL: "https://docs.example/d", Priority: 50},
{Name: "mailru", Type: "mailru", URL: "https://cloud.example/m", Priority: 10},
{Name: "direct", Type: "direct", Priority: 100},
{Name: "yandex", Type: "yandex", URL: "https://docs.example/d", Priority: 50},
}
if got := sessionContext(specs); got != "https://docs.example/d" {
if got := contextOf(t, specs); got != "https://docs.example/d" {
t.Fatalf("context = %q", got)
}
if got := sessionContext([]sessionSpec{{Name: "direct"}}); got != "http://#" {
if got := contextOf(t, []sessionSpec{{Name: "direct", Type: "direct"}}); got != "http://#" {
t.Fatalf("context without URLs = %q", got)
}
}
@@ -85,19 +99,38 @@ func TestSessionContextSkipsCupsonline(t *testing.T) {
{Name: "cupsonline", Type: "cupsonline", URL: "room-list", Priority: 100},
{Name: "yandex", Type: "yandex", URL: "https://docs.example/d", Priority: 50},
}
if got := sessionContext(specs); got != "https://docs.example/d" {
if got := contextOf(t, specs); got != "https://docs.example/d" {
t.Fatalf("context = %q", got)
}
if got := sessionContext(specs[:1]); got != "http://#" {
if got := contextOf(t, specs[:1]); got != "http://#" {
t.Fatalf("context of a cupsonline-only session = %q", got)
}
}
func TestClassicContextFallsBackToPlaceholder(t *testing.T) {
if got := classicContext(""); got != "http://#" {
t.Fatalf("context without a document = %q", got)
// A link's explicit context wins, and the derived one stays an alternate.
func TestSessionContextFromLink(t *testing.T) {
_, ctx, alts, err := parseSessionSpecs(`{"context":"https://x/ctx","transports":[{"name":"yandex","type":"yandex","url":"https://docs.example/d","priority":100}]}`)
if err != nil {
t.Fatal(err)
}
if got := classicContext("https://docs.example/d"); got != "https://docs.example/d" {
t.Fatalf("context = %q", got)
if ctx != "https://x/ctx" {
t.Fatalf("context = %q", ctx)
}
found := false
for _, a := range alts {
found = found || a == "https://docs.example/d"
}
if !found {
t.Fatalf("derived context missing from alternates %q", alts)
}
}
// A classic exit's link names the context the classic client derives.
func TestClassicShareContext(t *testing.T) {
if c := exitShareClassic("mailru", "https://cloud.example/m", "0123456789abcdef", "batched"); c.Context != "https://cloud.example/m" {
t.Fatalf("mailru context = %q", c.Context)
}
if c := exitShareClassic("cupsonline", "room-list", "0123456789abcdef", "batched"); c.Context != "http://#" {
t.Fatalf("cupsonline context = %q", c.Context)
}
}
+69 -15
View File
@@ -3,10 +3,12 @@ package mobile
import (
"encoding/json"
"errors"
"fmt"
"net"
"sort"
"openflux/share"
"openflux/transport"
)
// ShareQRPNG renders link as a size x size QR code PNG for the app to show.
@@ -14,9 +16,24 @@ func ShareQRPNG(link string, size int) ([]byte, error) {
return share.PNG(link, size)
}
// ReadShareLink reads an openflux:// link with the core's parser and
// returns share.Result as JSON, the same answer the CLI's --parse-link and
// the iOS library give: {"config":...,"context":...} or
// {"error":...,"code":...,"param":...}. The app words the code itself.
func ReadShareLink(link string) string {
return share.Read(link).JSON()
}
// MakeShareLink builds the link for a share.Config JSON the way every
// client exports one (share.Make) and returns share.Result as JSON:
// {"link":...,"config":...,"context":...} or the error.
func MakeShareLink(configJSON string) string {
return share.MakeJSON(configJSON).JSON()
}
// ParseShareLink decodes a scanned or opened openflux:// link and returns
// its configuration as JSON (share.Config) for the app to turn into a
// profile.
// profile. ReadShareLink says why a link is rejected.
func ParseShareLink(link string) (string, error) {
c, err := share.Decode(link)
if err != nil {
@@ -67,17 +84,23 @@ func ExitShareLink(host, name string) (string, error) {
}
c.Transports = append(c.Transports, t)
}
return share.Encode(c)
return share.MakeLink(c)
}
// exitShareClassic describes a classic single-transport exit to clients.
// The link stays classic (older clients read it too; updated ones upgrade
// to a Session on their own), except for direct, which a link can only
// carry as a Session.
func exitShareClassic(transportType, documentURL, secret, codec string) *share.Config {
c := &share.Config{
Secret: secret,
Context: classicContext(documentURL),
Transports: []share.Transport{{Type: transportType, URL: documentURL}},
context, _ := transport.KDFContexts("", documentURL, []transport.ContextSource{{Type: transportType, URL: documentURL, Priority: 100}})
t := share.Transport{Type: transportType, URL: documentURL}
c := &share.Config{Secret: secret, Context: context}
if transportType == "direct" {
t = share.Transport{Type: "direct", Dial: documentURL}
c.Negotiate = true
}
if codec == "legacy" {
c.Transports = []share.Transport{t}
if codec == transport.CodecLegacy {
c.Codec = codec
}
return c
@@ -87,7 +110,7 @@ func exitShareClassic(transportType, documentURL, secret, codec string) *share.C
// transports and context; direct keeps the listen address, whose host is
// replaced in ExitShareLink. MAX is left out (per-account token).
func exitShareSession(specsJSON, secret string) *share.Config {
specs, context, err := parseSessionSpecs(specsJSON)
specs, context, _, err := parseSessionSpecs(specsJSON)
if err != nil {
return nil
}
@@ -114,12 +137,43 @@ func exitShareSession(specsJSON, secret string) *share.Config {
return c
}
// classicContext is the classic mode's encryption context, as the core
// derives it for --transport with --url: the document URL, or "http://#"
// when there is none (oneme, direct).
func classicContext(documentURL string) string {
if documentURL == "" {
return placeholderURL
// ShareSessionSpecs turns an openflux:// link into the profile StartSession
// (and StartSessionProxy / StartSessionExit) takes: {"context":...,
// "transports":[{name,type,url,priority,params}]}, with the link's own
// context and carrier names, so an app does not have to interpret the link
// itself (and cannot drift from the other clients doing so). A one-carrier
// link works there too: the Session speaks classic to a classic node.
func ShareSessionSpecs(link string) (string, error) {
c, err := share.Decode(link)
if err != nil {
return "", err
}
return documentURL
return sessionSpecsOf(c), nil
}
// sessionSpecsOf is ShareSessionSpecs for a link already read.
func sessionSpecsOf(c share.Config) string {
specs := make([]sessionSpec, 0, len(c.Transports))
seen := make(map[string]int)
for _, t := range c.Transports {
name := t.Name
if name == "" {
seen[t.Type]++
name = t.Type
if n := seen[t.Type]; n > 1 {
name = fmt.Sprintf("%s-%d", t.Type, n)
}
}
spec := sessionSpec{Name: name, Type: t.Type, URL: t.URL, Priority: t.Priority}
if t.Type == "direct" {
spec.URL = ""
spec.Params = map[string]interface{}{"dial": t.Dial}
}
specs = append(specs, spec)
}
b, _ := json.Marshal(struct {
Context string `json:"context"`
Transports []sessionSpec `json:"transports"`
}{share.ContextOf(c), specs})
return string(b)
}
+21
View File
@@ -105,3 +105,24 @@ func TestExitShareLinkCarriesCreatedCupsRooms(t *testing.T) {
t.Fatalf("transports %+v", c.Transports)
}
}
// Android reads and makes links with the same answer as the CLI and iOS:
// share.Result, byte for byte.
func TestReadMakeShareLinkAnswerLikeShare(t *testing.T) {
cfg := `{"negotiate":true,"secret":"a shared secret of 32 characters","codec":"batched",` +
`"transports":[{"type":"yandex","url":"https://disk.yandex.ru/i/abc","priority":100},{"type":"direct","dial":"203.0.113.7:9443"}]}`
made := MakeShareLink(cfg)
if made != share.MakeJSON(cfg).JSON() {
t.Fatalf("MakeShareLink %s", made)
}
link := share.MakeJSON(cfg).Link
for _, in := range []string{link, " " + link[:30] + "\n" + link[30:], "https://example.com"} {
if got := ReadShareLink(in); got != share.Read(in).JSON() {
t.Errorf("ReadShareLink(%q) = %s", in, got)
}
}
node, err := NodeShareLink("node", "https://docs.yandex.ru/edit/d/AbC", "a shared secret of 32 characters", "203.0.113.7", 9443)
if want := share.Make(share.NodeConfig("node", "https://docs.yandex.ru/edit/d/AbC", "a shared secret of 32 characters", "203.0.113.7:9443")).Link; err != nil || node != want {
t.Errorf("NodeShareLink %q (%v), want %q", node, err, want)
}
}
+9 -2
View File
@@ -16,6 +16,7 @@ import (
"openflux/provision"
"openflux/share"
"openflux/transport"
"openflux/transport/yandex"
)
@@ -230,7 +231,11 @@ func TestNodeConfMatchesShareLink(t *testing.T) {
t.Fatal(err)
}
c, _ := share.Decode(link)
if got := pickSessionContext("", conf.Interface["URL"], specs); got != c.Context {
sources := make([]transport.ContextSource, len(specs))
for i, s := range specs {
sources[i] = transport.ContextSource{Type: s.Type, URL: s.URL, Priority: s.Priority}
}
if got, _ := transport.KDFContexts("", conf.Interface["URL"], sources); got != c.Context {
t.Fatalf("%+v: node derives context %q, link says %q\n%s", ts, got, c.Context, out)
}
if len(specs) != len(c.Transports) {
@@ -238,7 +243,9 @@ func TestNodeConfMatchesShareLink(t *testing.T) {
}
for i, s := range specs {
lt := c.Transports[i]
if s.Type != lt.Type || s.Priority != lt.Priority || (s.Type != "direct" && s.URL != lt.URL) {
// share.Make drops the priority of a lone carrier: nothing to rank.
samePriority := s.Priority == lt.Priority || len(c.Transports) == 1 && lt.Priority == 0
if s.Type != lt.Type || !samePriority || (s.Type != "direct" && s.URL != lt.URL) {
t.Fatalf("%+v: carrier %d: node %+v, link %+v", ts, i, s, lt)
}
}
+9 -16
View File
@@ -10,6 +10,7 @@ import (
"strings"
"openflux/share"
"openflux/transport"
)
// ChannelTransport is one of a channel's carriers besides direct, which
@@ -28,10 +29,6 @@ var transportPriority = map[string]int{"vyandex": 100, "mailru": 90, "cupsonline
const directPriority = 50
// sessionContextNone is the core's context for a node with no document
// (pickSessionContext's placeholder).
const sessionContextNone = "http://#"
var (
volgaDocURL = regexp.MustCompile(`^https://(docs|disk)\.yandex\.(ru|com|by|kz|uz)/edit/d/[A-Za-z0-9_-]{16,200}$`)
mailruDocURL = regexp.MustCompile(`^https://cloud\.mail\.ru/public/[A-Za-z0-9_-]{2,64}/[A-Za-z0-9_-]{2,128}$`)
@@ -95,20 +92,16 @@ func TransportURL(ts []ChannelTransport, typ string) string {
}
// SessionContext is the encryption context both sides of the channel
// derive: the highest-priority document, cups.online aside (its rooms are
// no document), as the core's pickSessionContext and node-install.sh's
// session_context pick it.
// derive, by the core's one rule (transport.KDFContexts): the
// highest-priority document, cups.online aside (its rooms are no
// document). node-install.sh's session_context picks the same.
func SessionContext(ts []ChannelTransport) string {
best, prio := sessionContextNone, -1
sources := make([]transport.ContextSource, 0, len(ts))
for _, t := range ts {
if t.Type == "cupsonline" || t.URL == "" {
continue
}
if p := transportPriority[t.Type]; p > prio {
best, prio = t.URL, p
}
sources = append(sources, transport.ContextSource{Type: t.Type, URL: t.URL, Priority: transportPriority[t.Type]})
}
return best
context, _ := transport.KDFContexts("", "", sources)
return context
}
// ShareLink is the openflux:// link of a new channel: its carriers in
@@ -129,7 +122,7 @@ func ShareLink(name, key, host string, port int, ts []ChannelTransport) (string,
c.Transports = append(c.Transports, share.Transport{
Type: "direct", Dial: net.JoinHostPort(host, strconv.Itoa(port)), Priority: directPriority,
})
return share.Encode(c)
return share.MakeLink(c)
}
// transportLines is the carriers' part of node-install.sh's config.
+151
View File
@@ -0,0 +1,151 @@
package share
import (
"encoding/json"
"errors"
"strings"
"openflux/transport"
)
// Result is what every entry point answers about a link, as the same JSON
// (the CLI's --parse-link and --make-link, package mobile for Android, the
// iOS library): the configuration and its context, or the link built, or
// why it could not be. Apps map Code (and Param) to their own words; Error
// is the English detail, for logs.
type Result struct {
Config *Config `json:"config,omitempty"`
// Context is the encryption context a client of this link derives.
Context string `json:"context,omitempty"`
Link string `json:"link,omitempty"`
Error string `json:"error,omitempty"`
Code string `json:"code,omitempty"`
Param string `json:"param,omitempty"`
}
// JSON is r as the apps receive it.
func (r Result) JSON() string {
b, err := json.Marshal(r)
if err != nil {
return `{"error":"marshal failed"}`
}
return string(b)
}
// Failed is the Result for err: its Code and Param when it is a link
// problem, just the text otherwise.
func Failed(err error) Result {
r := Result{Error: err.Error()}
var e *Error
if errors.As(err, &e) {
r.Code, r.Param = e.Code, e.Param
}
return r
}
// Read parses a link the way every client does and gives the context a
// client derives for it.
func Read(link string) Result {
c, err := Decode(link)
if err != nil {
return Failed(err)
}
return Result{Config: &c, Context: ContextOf(c)}
}
// Make builds the link for c the way every client exports one, so the
// same configuration gives the same link on every client:
//
// - addresses lose surrounding spaces;
// - the default codec ("batched") is left out, and so is the priority
// of a lone carrier (there is nothing to rank it against);
// - an encrypted link always names its context: the one given, else the
// one both peers derive (transport.KDFContexts); a link without a
// secret carries none, there is nothing to derive.
//
// Result.Config is the configuration as it went into the link.
func Make(c Config) Result {
c.Name = strings.TrimSpace(c.Name)
c.Context = strings.TrimSpace(c.Context)
if c.Codec == "batched" {
c.Codec = ""
}
ts := make([]Transport, len(c.Transports))
for i, t := range c.Transports {
t.Type = strings.TrimSpace(t.Type)
t.Name = strings.TrimSpace(t.Name)
t.URL = strings.TrimSpace(t.URL)
t.Dial = strings.TrimSpace(t.Dial)
if t.Name == t.Type {
t.Name = ""
}
ts[i] = t
}
if len(ts) == 1 {
ts[0].Priority = 0
}
c.Transports = ts
switch {
case c.Secret == "":
c.Context = ""
case c.Context == "":
c.Context = ContextOf(c)
}
link, err := Encode(c)
if err != nil {
return Failed(err)
}
return Result{Config: &c, Context: c.Context, Link: link}
}
// MakeJSON is Make for a configuration given as JSON (share.Config).
func MakeJSON(configJSON string) Result {
var c Config
if err := json.Unmarshal([]byte(configJSON), &c); err != nil {
return Failed(linkError(CodeBadConfig, "", "share: bad configuration JSON", err))
}
return Make(c)
}
// ContextOf is the context a client derives for c: the link's own, else
// the one rule every peer uses.
func ContextOf(c Config) string {
sources := make([]transport.ContextSource, len(c.Transports))
for i, t := range c.Transports {
sources[i] = transport.ContextSource{Type: t.Type, URL: t.URL, Priority: t.Priority}
}
context, _ := transport.KDFContexts(c.Context, "", sources)
return context
}
// NodeConfig is the configuration of a channel the node wizard sets up
// (desktop and Android build it here, so their links are the same): the
// Yandex document first, direct to dial (host:port) as the backup, the
// document as the context, and the channel key.
func NodeConfig(name, documentURL, key, dial string) Config {
return Config{
Name: name,
Negotiate: true,
Secret: key,
Context: documentURL,
Transports: []Transport{
{Type: "vyandex", URL: documentURL, Priority: 100},
{Type: "direct", Dial: dial, Priority: 50},
},
}
}
// MakeLink is Make returning just the link, or the error behind the
// Result (a *Error for a link problem).
func MakeLink(c Config) (string, error) {
r := Make(c)
if r.Error != "" {
return "", r.err()
}
return r.Link, nil
}
// err turns a failed Result back into an error.
func (r Result) err() error {
return &Error{Code: r.Code, Param: r.Param, text: r.Error}
}
+151
View File
@@ -0,0 +1,151 @@
package share
import (
"bytes"
"compress/flate"
"encoding/base64"
"encoding/json"
"reflect"
"strings"
"testing"
)
// packed wraps raw bytes the way a link carries its JSON.
func packed(t *testing.T, raw []byte) string {
t.Helper()
var buf bytes.Buffer
w, _ := flate.NewWriter(&buf, flate.BestCompression)
w.Write(raw)
w.Close()
return Prefix + base64.RawURLEncoding.EncodeToString(buf.Bytes())
}
// Every way a link can fail has its code: the apps word the problem from
// it, so a new failure without one would reach users as raw English.
func TestReadCodes(t *testing.T) {
good, err := Encode(sample())
if err != nil {
t.Fatal(err)
}
big := `{"name":"` + strings.Repeat("x", maxPayload) + `","transports":[{"type":"yandex","url":"u"}]}`
for _, tc := range []struct{ name, link, code string }{
{"not a link", "https://example.com", CodeNotLink},
{"other version", "openflux://v2/abc", CodeUnsupportedVersion},
{"case changed", strings.ToUpper(good), CodeCaseChanged},
{"not base64", Prefix + "!!!", CodeDamaged},
{"cut short", good[:len(good)-12], CodeDamaged},
{"too large", packed(t, []byte(big)), CodeTooLarge},
{"not a config", packed(t, []byte("[1,2]")), CodeBadPayload},
} {
r := Read(tc.link)
if r.Code != tc.code || r.Error == "" || r.Config != nil {
t.Errorf("%s: %+v, want code %s", tc.name, r, tc.code)
}
}
if r := Read(good); r.Error != "" || r.Config == nil || r.Context != sample().Context {
t.Fatalf("good link: %+v", r)
}
}
func TestMakeCodes(t *testing.T) {
doc := Transport{Type: "yandex", URL: "https://disk.yandex.ru/i/abc"}
key := "a shared secret of 32 characters"
for _, tc := range []struct {
name string
c Config
code, param string
}{
{"no transports", Config{}, CodeNoTransports, ""},
{"several, classic", Config{Transports: []Transport{doc, doc}}, CodeNeedsSession, ""},
{"session, short key", Config{Negotiate: true, Secret: "short", Transports: []Transport{doc}}, CodeSessionSecret, "16"},
{"classic, short key", Config{Secret: "short", Transports: []Transport{doc}}, CodeShortSecret, "16"},
{"codec", Config{Codec: "zip", Transports: []Transport{doc}}, CodeUnknownCodec, "zip"},
{"MAX", Config{Transports: []Transport{{Type: "oneme"}}}, CodeNotShareable, "oneme"},
{"unknown", Config{Transports: []Transport{{Type: "carrier-pigeon"}}}, CodeUnknownTransport, "carrier-pigeon"},
{"direct, no address", Config{Negotiate: true, Secret: key, Transports: []Transport{{Type: "direct"}}}, CodeDirectNoDial, ""},
{"direct, classic", Config{Secret: key, Transports: []Transport{{Type: "direct", Dial: "203.0.113.7:1"}}}, CodeDirectNeedsSession, ""},
} {
r := Make(tc.c)
if r.Code != tc.code || r.Param != tc.param || r.Link != "" {
t.Errorf("%s: %+v, want %s/%q", tc.name, r, tc.code, tc.param)
}
}
if r := MakeJSON("{"); r.Code != CodeBadConfig {
t.Errorf("bad JSON: %+v", r)
}
if _, err := MakeLink(Config{}); err == nil || err.(*Error).Code != CodeNoTransports {
t.Errorf("MakeLink error %v", err)
}
}
// One configuration, however an app spells it, gives one link, and that
// link reads back as the configuration that went in.
func TestMakeIsCanonical(t *testing.T) {
const docURL = "https://docs.yandex.ru/edit/d/AbC"
key := "a shared secret of 32 characters"
base := Config{Name: "node", Negotiate: true, Secret: key, Transports: []Transport{
{Type: "vyandex", URL: docURL, Priority: 100},
{Type: "direct", Dial: "203.0.113.7:9443", Priority: 50},
}}
want := Make(base)
if want.Error != "" || want.Config.Context != docURL {
t.Fatalf("context not filled by the rule: %+v", want)
}
spelled := base
spelled.Codec = "batched"
spelled.Context = " " + docURL + " "
spelled.Transports = []Transport{
{Type: "vyandex", Name: "vyandex", URL: docURL + " ", Priority: 100},
{Type: " direct", Dial: " 203.0.113.7:9443", Priority: 50},
}
if got := Make(spelled); got.Link != want.Link {
t.Errorf("spelled differently, linked differently:\n%s\n%s", got.Link, want.Link)
}
// A lone carrier is not ranked: apps that keep a priority and apps that
// do not export the same link.
lone := Config{Secret: key, Transports: []Transport{{Type: "mailru", URL: "https://cloud.mail.ru/public/x", Priority: 100}}}
unranked := lone
unranked.Transports = []Transport{{Type: "mailru", URL: "https://cloud.mail.ru/public/x"}}
if a, b := Make(lone), Make(unranked); a.Link != b.Link || a.Config.Transports[0].Priority != 0 {
t.Errorf("lone carrier: %+v vs %+v", a, b)
}
back := Read(want.Link)
if !reflect.DeepEqual(back.Config, want.Config) || back.Context != want.Context {
t.Errorf("read back %+v, made %+v", back.Config, want.Config)
}
for _, tc := range []struct {
name string
c Config
context string
}{
{"classic document with a key", Config{Secret: key, Transports: []Transport{{Type: "mailru", URL: "https://cloud.mail.ru/public/x"}}}, "https://cloud.mail.ru/public/x"},
{"cups rooms", Config{Secret: key, Transports: []Transport{{Type: "cupsonline", URL: "WyJhIl0"}}}, "http://#"},
{"direct only", Config{Negotiate: true, Secret: key, Transports: []Transport{{Type: "direct", Dial: "203.0.113.7:1"}}}, "http://#"},
{"explicit kept", Config{Negotiate: true, Secret: key, Context: "https://x/y", Transports: []Transport{{Type: "yandex", URL: "https://z"}}}, "https://x/y"},
{"no key, no context", Config{Context: "https://x/y", Transports: []Transport{{Type: "yandex", URL: "https://z"}}}, ""},
} {
r := Make(tc.c)
if r.Error != "" || r.Config.Context != tc.context || r.Context != tc.context {
t.Errorf("%s: %+v, want context %q", tc.name, r, tc.context)
}
}
}
// The JSON the apps parse: field names are the contract.
func TestResultJSON(t *testing.T) {
var fail map[string]string
json.Unmarshal([]byte(Read("nope").JSON()), &fail)
if fail["code"] != CodeNotLink || fail["error"] == "" {
t.Errorf("failure JSON %v", fail)
}
var ok struct {
Config *Config `json:"config"`
Context string `json:"context"`
Link string `json:"link"`
}
json.Unmarshal([]byte(Make(sample()).JSON()), &ok)
if ok.Config == nil || ok.Link == "" || ok.Context != sample().Context {
t.Errorf("success JSON %+v", ok)
}
}
+100 -19
View File
@@ -12,12 +12,14 @@ import (
"compress/flate"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"strconv"
"strings"
qrcode "github.com/skip2/go-qrcode"
"openflux/utils"
)
// Prefix starts every link; the path segment is the format version.
@@ -27,6 +29,51 @@ const Prefix = "openflux://v1/"
// the decoder allocate without limit.
const maxPayload = 16 << 10
// Why a link cannot be read or made (Error.Code, Result.Code). The codes
// are the contract with the apps: they word each one for their users, the
// core only decides which it is.
const (
CodeNotLink = "not_link"
CodeUnsupportedVersion = "unsupported_version"
CodeCaseChanged = "case_changed"
CodeDamaged = "damaged" // not base64url or not DEFLATE: mangled or cut on the way
CodeTooLarge = "too_large"
CodeBadPayload = "bad_payload" // decompresses, but is not a configuration
CodeBadConfig = "bad_config" // Make: the configuration JSON does not parse
CodeNoTransports = "no_transports"
CodeNeedsSession = "several_need_session"
CodeSessionSecret = "session_secret" // Param: the minimum length
CodeShortSecret = "short_secret" // Param: the minimum length
CodeUnknownCodec = "unknown_codec" // Param: the codec
CodeNotShareable = "not_shareable" // Param: the transport type (MAX)
CodeUnknownTransport = "unknown_transport"
CodeDirectNoDial = "direct_no_dial"
CodeDirectNeedsSession = "direct_needs_session"
)
// Error is a link that cannot be used: Code says which problem it is (for
// the apps), Param the value it is about, Error() the English detail the
// CLI prints.
type Error struct {
Code string
Param string
text string
cause error
}
func (e *Error) Error() string {
if e.cause != nil {
return e.text + ": " + e.cause.Error()
}
return e.text
}
func (e *Error) Unwrap() error { return e.cause }
func linkError(code, param, text string, cause error) error {
return &Error{Code: code, Param: param, text: text, cause: cause}
}
// Transport is one transport the client should run.
type Transport struct {
Type string `json:"type"`
@@ -64,30 +111,37 @@ var knownTypes = map[string]bool{
// Validate reports whether c describes something a client can connect with.
func (c *Config) Validate() error {
if len(c.Transports) == 0 {
return errors.New("share: no transports")
return linkError(CodeNoTransports, "", "share: no transports", nil)
}
if len(c.Transports) > 1 && !c.Negotiate {
return errors.New("share: several transports need a negotiated session")
return linkError(CodeNeedsSession, "", "share: several transports need a negotiated session", nil)
}
if c.Negotiate && len(c.Secret) < 16 {
return errors.New("share: a negotiated session needs a secret of at least 16 characters")
// Characters as Kotlin and Java count them (UTF-16 units), so a link
// is valid or not the same way on every client.
if c.Negotiate && utils.SecretChars(c.Secret) < utils.MinSecretChars {
return linkError(CodeSessionSecret, strconv.Itoa(utils.MinSecretChars),
fmt.Sprintf("share: a negotiated session needs a secret of at least %d characters", utils.MinSecretChars), nil)
}
if c.Secret != "" && len(c.Secret) < 16 {
return errors.New("share: the secret must be at least 16 characters")
if c.Secret != "" && utils.SecretChars(c.Secret) < utils.MinSecretChars {
return linkError(CodeShortSecret, strconv.Itoa(utils.MinSecretChars),
fmt.Sprintf("share: the secret must be at least %d characters", utils.MinSecretChars), nil)
}
if c.Codec != "" && c.Codec != "batched" && c.Codec != "legacy" {
return fmt.Errorf("share: unknown codec %q", c.Codec)
return linkError(CodeUnknownCodec, c.Codec, fmt.Sprintf("share: unknown codec %q", c.Codec), nil)
}
for _, t := range c.Transports {
if !knownTypes[t.Type] {
return fmt.Errorf("share: unknown transport type %q", t.Type)
if t.Type == "oneme" {
return linkError(CodeNotShareable, t.Type, "share: MAX (oneme) cannot be shared: its token belongs to one account", nil)
}
return linkError(CodeUnknownTransport, t.Type, fmt.Sprintf("share: unknown transport type %q", t.Type), nil)
}
if t.Type == "direct" {
if t.Dial == "" {
return errors.New("share: direct needs the exit's address")
return linkError(CodeDirectNoDial, "", "share: direct needs the exit's address", nil)
}
if !c.Negotiate {
return errors.New("share: direct only works in a negotiated session")
return linkError(CodeDirectNeedsSession, "", "share: direct only works in a negotiated session", nil)
}
}
}
@@ -118,28 +172,38 @@ func Encode(c Config) (string, error) {
}
// Decode parses and validates an openflux:// link.
//
// It is lenient about how the link travelled, the same way on every
// client: whitespace and line breaks inside it (a link copied out of a
// terminal or a chat wraps), base64 padding ("=", which some encoders add)
// and the standard base64 alphabet ("+/" for "-_") are all accepted. The
// link Encode writes has none of them.
func Decode(link string) (Config, error) {
link = strings.TrimSpace(link)
if !strings.HasPrefix(link, Prefix) {
if strings.HasPrefix(link, "openflux://") {
return Config{}, errors.New("share: unsupported link version; update OpenFlux")
if strings.HasPrefix(strings.ToLower(link), "openflux://") && !strings.HasPrefix(link, "openflux://") {
return Config{}, linkError(CodeCaseChanged, "", "share: the link's letters changed case on the way (openflux:// links are case-sensitive); copy it again", nil)
}
return Config{}, errors.New("share: not an openflux:// link")
if strings.HasPrefix(link, "openflux://") {
return Config{}, linkError(CodeUnsupportedVersion, "", "share: unsupported link version; update OpenFlux", nil)
}
return Config{}, linkError(CodeNotLink, "", "share: not an openflux:// link", nil)
}
packed, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(link, Prefix))
body := normalizeBody(strings.TrimPrefix(link, Prefix))
packed, err := base64.RawURLEncoding.DecodeString(body)
if err != nil {
return Config{}, fmt.Errorf("share: bad link encoding: %w", err)
return Config{}, linkError(CodeDamaged, "", fmt.Sprintf("share: bad link encoding (%d characters after the prefix; truncated or mangled?)", len(body)), err)
}
raw, err := io.ReadAll(io.LimitReader(flate.NewReader(bytes.NewReader(packed)), maxPayload+1))
if err != nil {
return Config{}, fmt.Errorf("share: bad link payload: %w", err)
return Config{}, linkError(CodeDamaged, "", "share: bad link payload (not raw DEFLATE; zlib/gzip-wrapped or truncated?)", err)
}
if len(raw) > maxPayload {
return Config{}, errors.New("share: link payload too large")
return Config{}, linkError(CodeTooLarge, "", "share: link payload too large", nil)
}
var c Config
if err := json.Unmarshal(raw, &c); err != nil {
return Config{}, fmt.Errorf("share: bad link payload: %w", err)
return Config{}, linkError(CodeBadPayload, "", "share: bad link payload", err)
}
if err := c.Validate(); err != nil {
return Config{}, err
@@ -147,6 +211,23 @@ func Decode(link string) (Config, error) {
return c, nil
}
// normalizeBody undoes what copying and other encoders do to the base64url
// part of a link.
func normalizeBody(b string) string {
b = strings.Map(func(r rune) rune {
switch r {
case ' ', '\t', '\r', '\n', '\u00a0', '\u200b':
return -1
case '+':
return '-'
case '/':
return '_'
}
return r
}, b)
return strings.TrimRight(b, "=")
}
func qr(link string) (*qrcode.QRCode, error) {
// Medium correction: survives a slightly glared phone screen while
// keeping the code small enough to scan off another phone.
+55
View File
@@ -113,3 +113,58 @@ func deflated(t *testing.T, raw []byte) string {
_ = w.Close()
return base64.RawURLEncoding.EncodeToString(buf.Bytes())
}
// Links reach Decode after copying, chats and other encoders; every client
// must read them the same way.
func TestDecodeTolerant(t *testing.T) {
c := Config{Name: "tt", Secret: "0123456789abcdef0123", Context: "https://cloud.mail.ru/public/AbCd/EfGh",
Transports: []Transport{{Type: "mailru", URL: "https://cloud.mail.ru/public/AbCd/EfGh"}}}
var link string
for {
l, err := Encode(c)
if err != nil {
t.Fatal(err)
}
if len(strings.TrimPrefix(l, Prefix))%4 != 0 {
link = l
break
}
c.Name += "x"
}
body := strings.TrimPrefix(link, Prefix)
raw, err := base64.RawURLEncoding.DecodeString(body)
if err != nil {
t.Fatal(err)
}
variants := map[string]string{
"padded": Prefix + base64.URLEncoding.EncodeToString(raw),
"wrapped": link[:40] + "\n" + link[40:80] + "\r\n " + link[80:],
"std alphabet": Prefix + base64.RawStdEncoding.EncodeToString(raw),
"nbsp": link[:50] + " " + link[50:],
}
for name, v := range variants {
got, err := Decode(v)
if err != nil {
t.Errorf("%s: %v", name, err)
continue
}
if got.Name != c.Name {
t.Errorf("%s: name %q", name, got.Name)
}
}
if _, err := Decode(strings.ToUpper(link)); err == nil || !strings.Contains(err.Error(), "case") {
t.Errorf("upper-cased link: %v", err)
}
}
// The secret is counted in characters as Kotlin counts them, not bytes.
func TestSecretCountedInCharacters(t *testing.T) {
c := Config{Secret: "ключключкл", Transports: []Transport{{Type: "mailru", URL: "https://cloud.mail.ru/public/a/b"}}}
if err := c.Validate(); err == nil {
t.Fatal("10 Cyrillic letters (20 bytes) accepted as a 16-character secret")
}
c.Secret = "ключключключключ"
if err := c.Validate(); err != nil {
t.Fatalf("16 Cyrillic letters: %v", err)
}
}
+45 -3
View File
@@ -2,6 +2,7 @@ package main
import (
"fmt"
"io"
"log"
"net"
"os"
@@ -67,11 +68,12 @@ func printShare(c share.Config, skipped []string) {
for _, why := range skipped {
log.Printf("--share: left out %s", why)
}
link, err := share.Encode(c)
if err != nil {
log.Printf("--share: %v", err)
r := share.Make(c)
if r.Error != "" {
log.Printf("--share: %s", r.Error)
return
}
link := r.Link
qr, err := share.Terminal(link)
if err != nil {
log.Printf("--share: %v", err)
@@ -79,6 +81,46 @@ func printShare(c share.Config, skipped []string) {
}
log.Printf("Share link for clients (contains the encryption key): %s", link)
fmt.Fprint(os.Stderr, qr)
// The bare link on a line of its own, to copy without the log prefix.
fmt.Fprintln(os.Stderr, link)
}
// runParseLink reads an openflux:// link (arg, or stdin for "-") with the
// core's parser and prints share.Result as JSON: {"config":...,"context":...}
// or {"error":...,"code":...,"param":...}. Apps word the code themselves.
func runParseLink(arg string, stdin io.Reader, stdout io.Writer) int {
link, err := argOrStdin(arg, stdin)
if err != nil {
return writeLinkResult(stdout, share.Failed(err))
}
return writeLinkResult(stdout, share.Read(link))
}
// runMakeLink builds the link for a share.Config JSON (arg, or stdin for
// "-") the way every client exports one and prints share.Result as JSON:
// {"link":...,"config":...,"context":...} or the error.
func runMakeLink(arg string, stdin io.Reader, stdout io.Writer) int {
cfg, err := argOrStdin(arg, stdin)
if err != nil {
return writeLinkResult(stdout, share.Failed(err))
}
return writeLinkResult(stdout, share.MakeJSON(cfg))
}
func argOrStdin(arg string, stdin io.Reader) (string, error) {
if arg != "-" {
return arg, nil
}
b, err := io.ReadAll(io.LimitReader(stdin, 64<<10))
return string(b), err
}
func writeLinkResult(w io.Writer, r share.Result) int {
fmt.Fprintln(w, r.JSON())
if r.Error != "" {
return 1
}
return 0
}
// publicIPv4 guesses the address clients should dial: the first global
+36
View File
@@ -4,6 +4,7 @@ import (
"strings"
"testing"
"openflux/provision"
"openflux/share"
)
@@ -60,3 +61,38 @@ func TestShareConfigCarriesCreatedCupsRooms(t *testing.T) {
t.Fatalf("transports %+v, skipped %v", c.Transports, skipped)
}
}
// --parse-link and --make-link answer with share.Result, byte for byte
// what package mobile and the iOS library answer, and the wizard's link is
// the one share.Make builds for its configuration.
func TestLinkCommandsAnswerLikeShare(t *testing.T) {
cfg := `{"name":"node","negotiate":true,"secret":"a shared secret of 32 characters",` +
`"transports":[{"type":"vyandex","url":"https://docs.yandex.ru/edit/d/AbC","priority":100}]}`
var out strings.Builder
if code := runMakeLink("-", strings.NewReader(cfg), &out); code != 0 {
t.Fatalf("--make-link exit %d: %s", code, out.String())
}
made := share.MakeJSON(cfg)
if out.String() != made.JSON()+"\n" {
t.Fatalf("--make-link %s, share.MakeJSON %s", out.String(), made.JSON())
}
wrapped := made.Link[:40] + "\r\n" + made.Link[40:]
out.Reset()
if code := runParseLink("-", strings.NewReader(wrapped), &out); code != 0 || out.String() != share.Read(made.Link).JSON()+"\n" {
t.Fatalf("--parse-link exit %d: %s", code, out.String())
}
out.Reset()
if code := runParseLink("https://example.com", nil, &out); code != 1 || !strings.Contains(out.String(), `"code":"not_link"`) {
t.Fatalf("--parse-link on garbage: exit %d %s", code, out.String())
}
// The wizard's link for a Yandex-document channel is share.NodeConfig's.
doc := "https://docs.yandex.ru/edit/d/AbCdEfGhIjKlMnOpQrStUv"
link, err := provision.ShareLink("node", "a shared secret of 32 characters", "203.0.113.7", 9443,
[]provision.ChannelTransport{{Type: "vyandex", URL: doc}})
want := share.Make(share.NodeConfig("node", doc, "a shared secret of 32 characters", "203.0.113.7:9443"))
if err != nil || link != want.Link {
t.Fatalf("wizard link %q (%v), share.Make %q", link, err, want.Link)
}
}
+324
View File
@@ -0,0 +1,324 @@
package transport
import (
"fmt"
"sync"
"sync/atomic"
"time"
"openflux/utils"
)
// Classic-mode codec names, as --codec and openflux:// links spell them.
const (
CodecBatched = "batched"
CodecLegacy = "legacy"
)
// Codec fallback timing for the side that speaks first (the client): with
// nothing heard from the peer, the other framing is tried codecFirstFallback
// after the first send, then the two alternate every codecFallbackEvery.
// That is faster than the KDF context rotation (see EncryptedTransport), so
// both framings get tried under every candidate context.
const (
codecFirstFallback = 2 * time.Second
codecFallbackEvery = time.Second
)
// CodecTransport is the classic (non-Session) mode's codec. It replaces the
// fixed BatchedTransport / CompressedTransport pair, which had to match on
// both peers: batched against legacy dropped every packet in silence.
//
// The two framings never collide on their first byte:
//
// batch-v2 0x02 many packets per frame, zstd when it helps
// legacy 0x00 / 0x1F one packet per frame, raw / LZ4
//
// so every frame is decoded whichever framing it uses, and the sending
// framing follows the peer:
//
// - once the peer has sent a batch frame (an empty one is the probe the
// iOS fork's adaptive codec sends), frames go out batched;
// - once it has sent only legacy frames, they go out legacy;
// - before anything is heard, the preferred framing is used; the
// initiating side (the client) switches to the other one when the peer
// stays silent, so a peer that decodes only one framing still gets
// through.
//
// Old batched-only or legacy-only peers therefore keep working, and
// updated peers converge on batching.
type CodecTransport struct {
Transport
preferred bool // true: batched
initiator bool
batchNow atomic.Bool // current send framing before the peer is known
peerBatch atomic.Bool
peerLegacy atomic.Bool
heard atomic.Bool
firstSend atomic.Int64
lastSwitch atomic.Int64
queue chan []byte
lingerMs int
maxBatchBytes int
maxBatchCount int
running atomic.Bool
lifecycle sync.Mutex
stopOnce sync.Once
stopCh chan struct{}
mu sync.RWMutex
userCb func([]byte)
sendErrors atomic.Uint64
unknown atomic.Uint64
}
// NewCodecTransport wraps inner. preferred is CodecBatched (also when
// empty) or CodecLegacy; initiator is true on the side that speaks first.
func NewCodecTransport(inner Transport, preferred string, initiator bool) *CodecTransport {
c := &CodecTransport{
Transport: inner,
preferred: preferred != CodecLegacy,
initiator: initiator,
queue: make(chan []byte, batchQueueDepth),
lingerMs: envInt("OPENFLUX_BATCH_LINGER_MS", defaultLingerMs),
maxBatchBytes: min(envInt("OPENFLUX_BATCH_BYTES", defaultMaxBatchBytes), maxFrameBytes-65537),
maxBatchCount: min(envInt("OPENFLUX_BATCH_COUNT", defaultMaxBatchCount), maxFrameRecords-1),
stopCh: make(chan struct{}),
}
c.batchNow.Store(c.preferred)
utils.Debugf("[CODEC] created: preferred=%s initiator=%v (both framings accepted on receive)",
codecName(c.preferred), initiator)
return c
}
func codecName(batch bool) string {
if batch {
return CodecBatched
}
return CodecLegacy
}
// Current names the framing frames are sent in now.
func (c *CodecTransport) Current() string { return codecName(c.sendBatch()) }
func (c *CodecTransport) Start() error {
c.lifecycle.Lock()
defer c.lifecycle.Unlock()
select {
case <-c.stopCh:
return fmt.Errorf("codec transport is stopped")
default:
}
if c.running.Load() {
return nil
}
if err := c.Transport.Start(); err != nil {
return err
}
c.running.Store(true)
go c.flushLoop()
return nil
}
func (c *CodecTransport) Stop() error {
c.lifecycle.Lock()
defer c.lifecycle.Unlock()
select {
case <-c.stopCh:
return nil
default:
}
c.running.Store(false)
c.stopOnce.Do(func() { close(c.stopCh) })
return c.Transport.Stop()
}
// Send copies the packet and queues it. A full queue drops it (TCP
// retransmits, UDP loses the datagram).
func (c *CodecTransport) Send(data []byte) error {
if !c.running.Load() {
return fmt.Errorf("codec transport is not running")
}
if len(data) > 65535 {
return fmt.Errorf("packet too large for a frame: %d bytes", len(data))
}
p := append([]byte(nil), data...)
select {
case c.queue <- p:
return nil
default:
utils.Debugf("[CODEC] Send: QUEUE FULL, dropped %d bytes", len(p))
return fmt.Errorf("codec queue full")
}
}
func (c *CodecTransport) Receive(callback func([]byte)) {
c.mu.Lock()
c.userCb = callback
c.mu.Unlock()
c.Transport.Receive(c.receive)
}
func (c *CodecTransport) receive(data []byte) {
if len(data) == 0 {
return
}
var pkts [][]byte
switch data[0] {
case batchFormatVersion:
p, err := decodeBatch(data)
if err != nil {
utils.Debugf("[CODEC] batch frame (%d bytes) undecodable: %v", len(data), err)
return
}
pkts = p
if !c.peerBatch.Swap(true) {
utils.Debugf("[CODEC] peer sends batch-v2 frames: sending batched from now on")
}
case 0x00, CompressionMarker:
if len(data) == 1 {
// A lone 0x00 is a carrier keepalive (Volga sends one through
// its relay), not a frame of the peer's codec.
return
}
p, err := decompress(data)
if err != nil {
utils.Debugf("[CODEC] legacy frame (%d bytes) undecodable: %v", len(data), err)
return
}
pkts = [][]byte{p}
if !c.peerLegacy.Swap(true) && !c.peerBatch.Load() {
utils.Debugf("[CODEC] peer sends legacy frames: sending legacy until it sends a batch frame")
}
default:
n := c.unknown.Add(1)
if utils.Throttled("codec.unknown", 30*time.Second) {
utils.Infof("[CODEC] %d frame(s) from the peer in no classic framing (first bytes % x)%s",
n, data[:min(len(data), 4)], layeringHint(data))
}
return
}
if !c.heard.Swap(true) {
mode := c.Current()
if c.initiator && mode != codecName(c.preferred) {
utils.Infof("[CODEC] peer answered in %s framing (preferred was %s): staying on it", mode, codecName(c.preferred))
} else {
utils.Debugf("[CODEC] peer heard; sending %s", mode)
}
}
c.mu.RLock()
cb := c.userCb
c.mu.RUnlock()
if cb == nil {
return
}
for _, p := range pkts {
if len(p) > 0 {
cb(p)
}
}
}
// layeringHint explains a frame that is neither framing.
func layeringHint(p []byte) string {
if len(p) >= 3 && p[0] == encryptedMagic[0] && p[1] == encryptedMagic[1] && p[2] == encryptedMagic[2] {
return ": an encrypted record outside any codec, i.e. the peer runs a Session (--negotiate / --transports / .conf / a Session profile)"
}
if p[0]>>4 == 4 {
return ": a bare IPv4 packet (a peer without any codec)"
}
return ""
}
// sendBatch picks the framing for the next frame.
func (c *CodecTransport) sendBatch() bool {
if c.peerBatch.Load() {
return true
}
if c.peerLegacy.Load() {
return false
}
if !c.initiator || c.heard.Load() {
return c.batchNow.Load()
}
now := time.Now().UnixNano()
first := c.firstSend.Load()
if first == 0 {
c.firstSend.CompareAndSwap(0, now)
c.lastSwitch.Store(now)
return c.batchNow.Load()
}
last := c.lastSwitch.Load()
wait := codecFallbackEvery
if last == first {
wait = codecFirstFallback
}
if time.Duration(now-last) >= wait && c.lastSwitch.CompareAndSwap(last, now) {
was := c.batchNow.Load()
c.batchNow.Store(!was)
if utils.Throttled("codec.fallback", 10*time.Second) {
utils.Infof("[CODEC] no answer from the peer in %s framing; trying %s", codecName(was), codecName(!was))
}
}
return c.batchNow.Load()
}
func (c *CodecTransport) flushLoop() {
for c.running.Load() {
var first []byte
select {
case <-c.stopCh:
return
case first = <-c.queue:
}
if !c.sendBatch() {
c.write(compress(first))
continue
}
batch := [][]byte{first}
size := 2 + len(first)
drain:
for size < c.maxBatchBytes && len(batch) < c.maxBatchCount {
select {
case p := <-c.queue:
batch = append(batch, p)
size += 2 + len(p)
default:
break drain
}
}
if c.lingerMs > 0 && size < c.maxBatchBytes && len(batch) < c.maxBatchCount {
timer := time.NewTimer(time.Duration(c.lingerMs) * time.Millisecond)
linger:
for size < c.maxBatchBytes && len(batch) < c.maxBatchCount {
select {
case <-c.stopCh:
timer.Stop()
return
case p := <-c.queue:
batch = append(batch, p)
size += 2 + len(p)
case <-timer.C:
break linger
}
}
timer.Stop()
}
c.write(encodeBatch(batch))
}
}
func (c *CodecTransport) write(frame []byte) {
if err := c.Transport.Send(frame); err != nil {
n := c.sendErrors.Add(1)
utils.Debugf("[CODEC] send error (total=%d): %v", n, err)
}
}
// SendErrors counts Transport.Send failures seen by the codec.
func (c *CodecTransport) SendErrors() uint64 { return c.sendErrors.Load() }
+353
View File
@@ -0,0 +1,353 @@
package transport
import (
"bytes"
"sync"
"sync/atomic"
"testing"
"time"
"openflux/transport/control"
)
// Compatibility between the layerings, codecs and KDF contexts that peers
// of different builds use. "old" stacks are built exactly as the core did
// before (Encrypted(Batched|Compressed(carrier)) with a fixed context);
// "new" ones as it does now.
// asyncWire is one end of an in-memory carrier that delivers in order on
// its own goroutine, like a real one (no re-entrant Send -> Receive).
type asyncWire struct {
mu sync.Mutex
cb func([]byte)
out chan []byte
down atomic.Bool
}
func asyncPair() (*asyncWire, *asyncWire) {
a := &asyncWire{out: make(chan []byte, 4096)}
b := &asyncWire{out: make(chan []byte, 4096)}
pump := func(from, to *asyncWire) {
for p := range from.out {
to.mu.Lock()
cb := to.cb
to.mu.Unlock()
if cb != nil {
cb(p)
}
}
}
go pump(a, b)
go pump(b, a)
return a, b
}
func (w *asyncWire) Start() error { return nil }
func (w *asyncWire) Stop() error { return nil }
func (w *asyncWire) IsConnected() bool { return true }
func (w *asyncWire) Stats() TransportStats { return TransportStats{} }
func (w *asyncWire) Receive(cb func([]byte)) {
w.mu.Lock()
w.cb = cb
w.mu.Unlock()
}
func (w *asyncWire) Send(p []byte) error {
if !w.down.Load() {
w.out <- append([]byte(nil), p...)
}
return nil
}
type peer interface {
Start() error
Stop() error
Send([]byte) error
Receive(func([]byte))
}
const compatSecret = "compat test secret 0123456789"
func oldClassic(t *testing.T, w Transport, codec, context string, exit bool) peer {
t.Helper()
var inner Transport = w
if codec == CodecLegacy {
inner = NewCompressedTransport(inner)
} else {
inner = NewBatchedTransport(inner)
}
enc, err := NewEncryptedTransport(inner, compatSecret, context, exit)
if err != nil {
t.Fatal(err)
}
return enc
}
func newClassic(t *testing.T, w Transport, codec, context string, alternates []string, exit bool) peer {
t.Helper()
enc, err := NewEncryptedTransport(NewCodecTransport(w, codec, !exit), compatSecret, context, exit)
if err != nil {
t.Fatal(err)
}
enc.SetAlternateContexts(alternates)
return enc
}
func compatSession(t *testing.T, w Transport, context string, alternates []string, exit, classic bool, codec string) *Session {
t.Helper()
s, err := NewSession(PeerParameters{
Capabilities: control.CapabilityIPv4 | control.CapabilityTCP | control.CapabilityUDP,
MaxPacketSize: MaxNegotiatedPacket,
}, exit)
if err != nil {
t.Fatal(err)
}
if classic {
s.SetClassic(codec)
}
s.SetAlternateContexts(alternates)
if err := s.AddTransport("carrier", w, compatSecret, context, 100); err != nil {
t.Fatal(err)
}
return s
}
// roundTrip starts both ends, has the client send an IPv4 packet every
// 50ms and the exit echo each one, and reports how long the first echo
// took (or fails after limit).
func roundTrip(t *testing.T, client, exit peer, limit time.Duration, while ...func()) time.Duration {
t.Helper()
exit.Receive(func(p []byte) { _ = exit.Send(p) })
got := make(chan struct{}, 1)
client.Receive(func(p []byte) {
if len(p) >= 20 && p[0]>>4 == 4 {
select {
case got <- struct{}{}:
default:
}
}
})
if err := exit.Start(); err != nil {
t.Fatalf("exit start: %v", err)
}
began := time.Now()
if err := client.Start(); err != nil {
t.Fatalf("client start: %v", err)
}
defer client.Stop()
defer exit.Stop()
tick := time.NewTicker(50 * time.Millisecond)
defer tick.Stop()
deadline := time.After(limit)
for {
_ = client.Send(testIPv4(60, 6))
select {
case <-got:
d := time.Since(began)
for _, f := range while {
f()
}
return d
case <-deadline:
t.Fatalf("no echo within %v", limit)
return 0
case <-tick.C:
}
}
}
func TestCodecFallbackAgainstOldPeers(t *testing.T) {
cases := []struct {
name string
client, exit func(*asyncWire, *asyncWire) (peer, peer)
}{
{"new batched client -> old legacy exit", func(a, b *asyncWire) (peer, peer) {
return newClassic(t, a, CodecBatched, "ctx", nil, false), oldClassic(t, b, CodecLegacy, "ctx", true)
}, nil},
{"new legacy client -> old batched exit", func(a, b *asyncWire) (peer, peer) {
return newClassic(t, a, CodecLegacy, "ctx", nil, false), oldClassic(t, b, CodecBatched, "ctx", true)
}, nil},
{"old legacy client -> new batched exit", func(a, b *asyncWire) (peer, peer) {
return oldClassic(t, a, CodecLegacy, "ctx", false), newClassic(t, b, CodecBatched, "ctx", nil, true)
}, nil},
{"old batched client -> new legacy exit", func(a, b *asyncWire) (peer, peer) {
return oldClassic(t, a, CodecBatched, "ctx", false), newClassic(t, b, CodecLegacy, "ctx", nil, true)
}, nil},
{"new legacy client -> new batched exit", func(a, b *asyncWire) (peer, peer) {
return newClassic(t, a, CodecLegacy, "ctx", nil, false), newClassic(t, b, CodecBatched, "ctx", nil, true)
}, nil},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
a, b := asyncPair()
cl, ex := c.client(a, b)
d := roundTrip(t, cl, ex, 8*time.Second)
t.Logf("first echo after %v", d.Round(10*time.Millisecond))
})
}
}
// An iOS-fork peer's empty-batch probe makes the codec send batched.
func TestCodecFollowsBatchProbe(t *testing.T) {
w := &fakeTransport{}
c := NewCodecTransport(w, CodecLegacy, false)
c.Receive(func([]byte) {})
deliver := func(p []byte) {
w.mu.Lock()
cb := w.cb
w.mu.Unlock()
cb(p)
}
deliver([]byte{0x00}) // a Volga carrier keepalive: ignored
if c.heard.Load() {
t.Fatal("a lone 0x00 counted as the peer's codec")
}
deliver(encodeBatch(nil))
if c.Current() != CodecBatched {
t.Fatalf("after a batch probe the codec sends %s", c.Current())
}
}
func TestContextFallback(t *testing.T) {
t.Run("new client rotates to an old exit's context", func(t *testing.T) {
a, b := asyncPair()
cl := newClassic(t, a, CodecBatched, "https://docs/d", []string{ContextPlaceholder, "rooms"}, false)
ex := oldClassic(t, b, CodecBatched, "rooms", true)
d := roundTrip(t, cl, ex, 15*time.Second)
t.Logf("first echo after %v", d.Round(10*time.Millisecond))
})
t.Run("new exit answers an old client under its context", func(t *testing.T) {
a, b := asyncPair()
cl := oldClassic(t, a, CodecBatched, "rooms", false)
ex := newClassic(t, b, CodecBatched, ContextPlaceholder, []string{"rooms"}, true)
d := roundTrip(t, cl, ex, 5*time.Second)
if d > 2*time.Second {
t.Fatalf("exit took %v to answer under the client's context", d)
}
})
t.Run("Session over mismatched contexts", func(t *testing.T) {
a, b := asyncPair()
cl := compatSession(t, a, "https://docs/d", []string{ContextPlaceholder}, false, false, "")
ex := compatSession(t, b, ContextPlaceholder, []string{"https://docs/d"}, true, false, "")
roundTrip(t, cl, ex, 5*time.Second)
})
}
func TestSessionClassicCompat(t *testing.T) {
t.Run("new client falls back to an old classic exit", func(t *testing.T) {
a, b := asyncPair()
cl := compatSession(t, a, "ctx", nil, false, true, CodecBatched)
ex := oldClassic(t, b, CodecLegacy, "ctx", true)
d := roundTrip(t, cl, ex, 12*time.Second, func() {
if m := cl.Mode(); m != "classic" {
t.Errorf("client mode %q", m)
}
})
t.Logf("first echo after %v (classic)", d.Round(10*time.Millisecond))
})
t.Run("old classic client served by a classic-configured exit", func(t *testing.T) {
a, b := asyncPair()
cl := oldClassic(t, a, CodecBatched, "ctx", false)
ex := compatSession(t, b, "ctx", nil, true, true, CodecBatched)
roundTrip(t, cl, ex, 5*time.Second, func() {
if m := ex.Mode(); m != "classic" {
t.Errorf("exit mode %q", m)
}
})
})
t.Run("new classic profile upgrades to a Session", func(t *testing.T) {
a, b := asyncPair()
cl := compatSession(t, a, "ctx", nil, false, true, CodecLegacy)
ex := compatSession(t, b, "ctx", nil, true, true, CodecBatched)
roundTrip(t, cl, ex, 5*time.Second, func() {
deadline := time.Now().Add(3 * time.Second)
for cl.Mode() != "session" && time.Now().Before(deadline) {
time.Sleep(20 * time.Millisecond)
}
if m := cl.Mode(); m != "session" {
t.Errorf("client mode %q", m)
}
})
})
t.Run("old Session client with a classic-configured exit", func(t *testing.T) {
a, b := asyncPair()
cl := compatSession(t, a, "ctx", nil, false, false, "")
ex := compatSession(t, b, "ctx", nil, true, true, CodecBatched)
roundTrip(t, cl, ex, 5*time.Second)
})
t.Run("a Session-only exit does not serve classic clients", func(t *testing.T) {
a, b := asyncPair()
cl := oldClassic(t, a, CodecBatched, "ctx", false)
ex := compatSession(t, b, "ctx", nil, true, false, "")
var got atomic.Int64
ex.Receive(func([]byte) { got.Add(1) })
_ = ex.Start()
_ = cl.Start()
defer cl.Stop()
defer ex.Stop()
for i := 0; i < 20; i++ {
_ = cl.Send(testIPv4(60, 6))
time.Sleep(20 * time.Millisecond)
}
if got.Load() != 0 {
t.Fatalf("strict exit delivered %d classic packets", got.Load())
}
})
}
// A Session exit prefers its Session client over a classic one: classic
// frames on the carrier (a replayed capture, another device) must not
// take the replies away from it.
func TestSessionClassicYieldsToSession(t *testing.T) {
a, b := asyncPair()
cl := compatSession(t, a, "ctx", nil, false, false, "")
ex := compatSession(t, b, "ctx", nil, true, true, CodecBatched)
roundTrip(t, cl, ex, 5*time.Second, func() {
// A classic frame arrives on the exit's carrier.
intruder := oldClassic(t, &asyncWire{out: a.out}, CodecBatched, "ctx", false)
_ = intruder.Start()
_ = intruder.Send(testIPv4(60, 6))
time.Sleep(200 * time.Millisecond)
if m := ex.Mode(); m != "session" {
t.Errorf("exit switched to %q while its Session client was active", m)
}
})
}
func TestKDFContextsRule(t *testing.T) {
cases := []struct {
name, explicit, url string
specs []ContextSource
want string
}{
{"explicit", "x", "https://u", nil, "x"},
{"--url", "", "https://u", []ContextSource{{Type: "mailru", URL: "https://m"}}, "https://u"},
{"highest priority", "", ContextPlaceholder, []ContextSource{
{Type: "mailru", URL: "https://m", Priority: 10}, {Type: "vyandex", URL: "https://v", Priority: 100}}, "https://v"},
{"classic cups rooms as --url", "", "rooms", []ContextSource{{Type: "cupsonline", URL: "rooms"}}, ContextPlaceholder},
{"direct address", "", ContextPlaceholder, []ContextSource{{Type: "direct", URL: "1.2.3.4:9443"}}, ContextPlaceholder},
}
for _, c := range cases {
got, alts := KDFContexts(c.explicit, c.url, c.specs)
if got != c.want {
t.Errorf("%s: %q, want %q", c.name, got, c.want)
}
for _, a := range alts {
if a == got {
t.Errorf("%s: primary repeated among alternates", c.name)
}
}
}
_, alts := KDFContexts("", "rooms", []ContextSource{{Type: "cupsonline", URL: "rooms"}})
if !bytes.Contains([]byte(joinStrings(alts)), []byte("rooms")) {
t.Errorf("old classic cups context missing from alternates %q", alts)
}
}
func joinStrings(s []string) string {
var b bytes.Buffer
for _, x := range s {
b.WriteString(x)
b.WriteByte('|')
}
return b.String()
}
+11 -4
View File
@@ -10,10 +10,15 @@ import "encoding/json"
type CookiesPayload struct {
// Transport names the transport the cookies belong to. Empty (older
// peers) means the highest-priority transport that carries cookies.
Transport string `json:"transport,omitempty"`
Jar map[string]string `json:"jar,omitempty"`
Domain string `json:"domain,omitempty"`
Reason string `json:"reason,omitempty"`
Transport string `json:"transport,omitempty"`
// Doc is the document URL of that transport on the sender's side, so
// the receiver can find its own carrier when the two sides name them
// differently (an app naming carriers after their type, a .conf naming
// sections freely). Optional; older peers ignore it.
Doc string `json:"doc,omitempty"`
Jar map[string]string `json:"jar,omitempty"`
Domain string `json:"domain,omitempty"`
Reason string `json:"reason,omitempty"`
}
// Encode serializes the payload to JSON.
@@ -38,6 +43,8 @@ type AuthRequiredPayload struct {
Transport string `json:"transport"`
URL string `json:"url"`
Reason string `json:"reason"`
// Doc: see CookiesPayload.Doc.
Doc string `json:"doc,omitempty"`
}
// Encode serializes the payload to JSON.
+360 -56
View File
@@ -11,6 +11,7 @@ import (
"fmt"
"sync"
"sync/atomic"
"time"
"golang.org/x/crypto/scrypt"
@@ -25,6 +26,18 @@ const (
var encryptedMagic = [3]byte{'O', 'F', 'X'}
// Context fallback timing. A client that has not heard a single valid
// packet within contextFirstRotate of its first send moves on to the next
// candidate context every contextRotateEvery while it keeps sending; one
// that has, but then hears nothing for
// contextUnlockAfter while sending, starts trying again (the exit may have
// been reconfigured).
const (
contextFirstRotate = 4 * time.Second
contextRotateEvery = 2 * time.Second
contextUnlockAfter = 2 * time.Minute
)
// EncryptedTransport wraps another Transport with end-to-end AES-256-GCM
// authenticated encryption, so the transport's own provider only ever
// observes ciphertext. Keys are derived from a shared secret via scrypt; the
@@ -36,15 +49,22 @@ var encryptedMagic = [3]byte{'O', 'F', 'X'}
// Every packet also carries a random nonce and is checked against a bounded
// replay window, so a captured packet cannot be replayed back at either
// peer.
//
// Context fallback: builds have derived the context differently (see
// KDFContexts), and a mismatch used to drop every packet without a word.
// SetAlternateContexts gives the transport the contexts a peer may have
// used instead. A packet that fails under the current keys is tried under
// the others; the exit then answers under whichever the client used, and a
// client that hears nothing cycles through them until the exit answers.
// This weakens nothing: the context is a public salt and every candidate
// still requires the secret.
type EncryptedTransport struct {
Transport
sendAEAD cipher.AEAD
receiveAEAD cipher.AEAD
sendDirection byte
recvDirection byte
seenMu sync.Mutex
seen map[string]struct{}
seenOrder []string
ring *keyRing
seenMu sync.Mutex
seen map[string]struct{}
seenOrder []string
// Diagnostic counters.
sendOK atomic.Uint64
@@ -56,6 +76,47 @@ type EncryptedTransport struct {
recvReplay atomic.Uint64
}
// contextKeys are the AEADs derived for one candidate context.
type contextKeys struct {
context string
send cipher.AEAD
recv cipher.AEAD
}
// keyStore holds the keys of every candidate context of one carrier,
// derived once and shared by the EncryptedTransports on it (a Session's
// and its classic fallback's).
type keyStore struct {
secret string
exit bool
side string
sendDir byte
recvDir byte
mu sync.Mutex
keys []*contextKeys // [0] is the primary context
pending []string // candidates not derived yet
deriving bool
}
// keyRing is one EncryptedTransport's view of the store: which context it
// sends under and what it has heard. Each pipeline searches on its own (a
// Session's hellos going unanswered by a classic exit must not move the
// classic pipeline off the right context); a client's pipelines tell each
// other what they learn, since they talk to the same exit.
type keyRing struct {
*keyStore
mu sync.Mutex
send int // index into keys used for sending
locked bool // the peer answered under keys[send]
heard time.Time
started time.Time // first send while nothing was heard
rotated time.Time
failures uint64 // packets no candidate could open since the last success
siblings []*keyRing
}
// NewEncryptedTransport wraps inner with a directional AES-256-GCM stream.
// Both peers must be configured with the same secret and context, and
// exactly one of them must set exitNode=true so the two sides pick opposite
@@ -64,50 +125,113 @@ func NewEncryptedTransport(inner Transport, secret, context string, exitNode boo
if inner == nil {
return nil, errors.New("inner transport is nil")
}
if len(secret) < 16 {
return nil, errors.New("encryption secret must contain at least 16 characters")
if utils.SecretChars(secret) < utils.MinSecretChars {
return nil, fmt.Errorf("encryption secret must contain at least %d characters", utils.MinSecretChars)
}
side := "CLIENT"
st := &keyStore{secret: secret, exit: exitNode, side: "CLIENT", sendDir: 0, recvDir: 1}
if exitNode {
side = "EXIT"
st.side, st.sendDir, st.recvDir = "EXIT", 1, 0
}
k, err := st.derive(context)
if err != nil {
return nil, err
}
st.keys = []*contextKeys{k}
return &EncryptedTransport{Transport: inner, ring: &keyRing{keyStore: st}, seen: make(map[string]struct{})}, nil
}
// SharingKeys returns an EncryptedTransport over inner that shares e's
// derived keys (not its replay window or its context search). Used for the
// classic fallback path that runs next to a Session on the same carrier.
func (e *EncryptedTransport) SharingKeys(inner Transport) *EncryptedTransport {
r := &keyRing{keyStore: e.ring.keyStore}
if !e.ring.exit {
// The client's pipelines reach the same exit: what one learns
// about its context holds for the other. The exit's may serve
// different clients, so each follows its own.
e.ring.mu.Lock()
e.ring.siblings = append(e.ring.siblings, r)
r.siblings = []*keyRing{e.ring}
e.ring.mu.Unlock()
}
return &EncryptedTransport{Transport: inner, ring: r, seen: make(map[string]struct{})}
}
// SetAlternateContexts sets the contexts the peer may derive its keys from
// instead of the primary one (see KDFContexts). They are derived only when
// needed.
func (e *EncryptedTransport) SetAlternateContexts(contexts []string) {
st := e.ring.keyStore
st.mu.Lock()
defer st.mu.Unlock()
have := make(map[string]bool)
for _, k := range st.keys {
have[k.context] = true
}
for _, c := range st.pending {
have[c] = true
}
for _, c := range contexts {
if c != "" && !have[c] {
have[c] = true
st.pending = append(st.pending, c)
}
}
if len(st.pending) > 0 {
utils.Debugf("[CRYPTO] side=%s %d alternate KDF context(s) on standby (derived on demand)", st.side, len(st.pending))
}
}
// Context returns the context this side currently sends under.
func (e *EncryptedTransport) Context() string {
r := e.ring
r.mu.Lock()
idx := r.send
r.mu.Unlock()
return r.key(idx).context
}
// snapshot returns the derived keys and whether candidates are pending.
func (st *keyStore) snapshot() ([]*contextKeys, bool) {
st.mu.Lock()
defer st.mu.Unlock()
return append([]*contextKeys(nil), st.keys...), len(st.pending) > 0
}
func (st *keyStore) key(i int) *contextKeys {
st.mu.Lock()
defer st.mu.Unlock()
return st.keys[i]
}
func (st *keyStore) derive(context string) (*contextKeys, error) {
salt := sha256.Sum256([]byte("OpenFlux encrypted transport v1\x00" + context))
master, err := scrypt.Key([]byte(secret), salt[:], 32768, 8, 1, 32)
master, err := scrypt.Key([]byte(st.secret), salt[:], 32768, 8, 1, 32)
if err != nil {
return nil, fmt.Errorf("derive encryption key: %w", err)
}
clientToExit := deriveDirectionalKey(master, "client-to-exit")
exitToClient := deriveDirectionalKey(master, "exit-to-client")
sendKey, receiveKey := clientToExit, exitToClient
sendDirection, receiveDirection := byte(0), byte(1)
if exitNode {
if st.exit {
sendKey, receiveKey = exitToClient, clientToExit
sendDirection, receiveDirection = 1, 0
}
sendAEAD, err := newGCM(sendKey)
send, err := newGCM(sendKey)
if err != nil {
return nil, err
}
receiveAEAD, err := newGCM(receiveKey)
recv, err := newGCM(receiveKey)
if err != nil {
return nil, err
}
// --- Diagnostic dumps ---------------------------------------------------
//
// [KEYDUMP] digest is emitted from debug level 2 (-dd): SHA-256
// prefixes of the context and of the derived keys, so two peers can
// compare derivations without revealing keys. It carries no hash of
// the secret itself: that would let anyone with the log test guesses
// without paying for scrypt.
//
// The detailed dump (secret hex, master, directional keys, send/recv
// keys) is printed only when --sensitive is set.
utils.Debugf("[KEYDUMP] digest side=%s contextSHA256=%s masterSHA256=%s c2eSHA256=%s e2cSHA256=%s",
side,
// without paying for scrypt. The detailed dump is --sensitive only.
utils.Debugf("[KEYDUMP] digest side=%s context=%q contextSHA256=%s masterSHA256=%s c2eSHA256=%s e2cSHA256=%s",
st.side, context,
utils.Sha256Short([]byte(context)),
utils.Sha256Short(master),
utils.Sha256Short(clientToExit),
@@ -115,30 +239,185 @@ func NewEncryptedTransport(inner Transport, secret, context string, exitNode boo
)
if utils.Sensitive() {
utils.Debugf("[KEYDUMP] ============================================================")
utils.Debugf("[KEYDUMP] side=%s", side)
utils.Debugf("[KEYDUMP] secretLen=%d", len(secret))
utils.Debugf("[KEYDUMP] secretSHA256=%s", utils.Sha256Hex([]byte(secret)))
utils.Debugf("[KEYDUMP] secretHex(first 32)=%s", hex.EncodeToString([]byte(secret)[:minInt(len(secret), 32)]))
utils.Debugf("[KEYDUMP] side=%s", st.side)
utils.Debugf("[KEYDUMP] secretLen=%d", len(st.secret))
utils.Debugf("[KEYDUMP] secretSHA256=%s", utils.Sha256Hex([]byte(st.secret)))
utils.Debugf("[KEYDUMP] secretHex(first 32)=%s", hex.EncodeToString([]byte(st.secret)[:minInt(len(st.secret), 32)]))
utils.Debugf("[KEYDUMP] context=%q", context)
utils.Debugf("[KEYDUMP] contextSHA256=%s", utils.Sha256Hex([]byte(context)))
utils.Debugf("[KEYDUMP] salt=%s", hex.EncodeToString(salt[:]))
utils.Debugf("[KEYDUMP] master=%s", hex.EncodeToString(master))
utils.Debugf("[KEYDUMP] client->exit=%s", hex.EncodeToString(clientToExit))
utils.Debugf("[KEYDUMP] exit->client=%s", hex.EncodeToString(exitToClient))
utils.Debugf("[KEYDUMP] sendDir=%d recvDir=%d", sendDirection, receiveDirection)
utils.Debugf("[KEYDUMP] sendDir=%d recvDir=%d", st.sendDir, st.recvDir)
utils.Debugf("[KEYDUMP] sendKey=%s", hex.EncodeToString(sendKey))
utils.Debugf("[KEYDUMP] recvKey=%s", hex.EncodeToString(receiveKey))
utils.Debugf("[KEYDUMP] ============================================================")
}
return &contextKeys{context: context, send: send, recv: recv}, nil
}
return &EncryptedTransport{
Transport: inner,
sendAEAD: sendAEAD,
receiveAEAD: receiveAEAD,
sendDirection: sendDirection,
recvDirection: receiveDirection,
seen: make(map[string]struct{}),
}, nil
// deriveMore derives the pending candidates in the background, one at a
// time (each scrypt run takes 32 MiB).
func (st *keyStore) deriveMore() {
st.mu.Lock()
if st.deriving || len(st.pending) == 0 {
st.mu.Unlock()
return
}
st.deriving = true
st.mu.Unlock()
utils.SafeGo("crypto.deriveAlternates", func() {
defer func() {
st.mu.Lock()
st.deriving = false
st.mu.Unlock()
}()
for {
st.mu.Lock()
if len(st.pending) == 0 {
st.mu.Unlock()
return
}
c := st.pending[0]
st.pending = st.pending[1:]
st.mu.Unlock()
k, err := st.derive(c)
if err != nil {
utils.Debugf("[CRYPTO] derive alternate context %q: %v", c, err)
continue
}
st.mu.Lock()
st.keys = append(st.keys, k)
n := len(st.keys)
st.mu.Unlock()
utils.Debugf("[CRYPTO] side=%s alternate KDF context #%d ready: %q", st.side, n-1, c)
}
})
}
// sendKeys picks the keys to send under. A client that has not heard the
// peer under any context moves to the next candidate every
// contextRotateEvery; an exit answers under the context the client last
// used.
func (r *keyRing) sendKeys() *contextKeys {
keys, pending := r.snapshot()
r.mu.Lock()
defer r.mu.Unlock()
now := time.Now()
if !r.exit {
if r.locked && now.Sub(r.heard) > contextUnlockAfter {
r.locked = false
r.started, r.rotated = time.Time{}, time.Time{}
utils.Debugf("[CRYPTO] side=%s peer silent for %v: will try the other KDF contexts again", r.side, contextUnlockAfter)
}
if !r.locked && (len(keys) > 1 || pending) {
if r.started.IsZero() {
r.started, r.rotated = now, now
} else if now.Sub(r.rotated) >= contextRotateEvery && now.Sub(r.started) >= contextFirstRotate {
r.rotated = now
if pending {
go r.deriveMore()
}
if len(keys) > 1 {
prev := r.send
r.send = (r.send + 1) % len(keys)
if utils.Throttled("crypto.rotate."+r.side, 10*time.Second) {
utils.Infof("[CRYPTO] no answer from the peer under KDF context %q for %v; trying %q",
keys[prev].context, contextRotateEvery, keys[r.send].context)
}
}
}
}
}
return keys[r.send]
}
// open tries every derived context, the one in use first. It reports the
// plaintext and the index of the context that opened the packet.
func (r *keyRing) open(nonce, ciphertext, header []byte) ([]byte, int, error) {
keys, pending := r.snapshot()
r.mu.Lock()
first := r.send
r.mu.Unlock()
plaintext, err := keys[first].recv.Open(nil, nonce, ciphertext, header)
if err == nil {
return plaintext, first, nil
}
for i, k := range keys {
if i == first {
continue
}
if p, e := k.recv.Open(nil, nonce, ciphertext, header); e == nil {
return p, i, nil
}
}
if pending {
r.deriveMore()
}
return nil, -1, err
}
// accepted records that the peer spoke under keys[idx], and tells a
// client's other pipeline.
func (r *keyRing) accepted(idx int) {
r.mu.Lock()
siblings := r.siblings
r.mu.Unlock()
r.adopt(idx, true)
for _, s := range siblings {
s.adopt(idx, false)
}
}
func (r *keyRing) adopt(idx int, heard bool) {
ctx := r.key(idx).context
primary := r.key(0).context
r.mu.Lock()
defer r.mu.Unlock()
if heard {
r.heard = time.Now()
r.failures = 0
}
if idx == r.send && (r.locked || r.exit) {
return
}
prev := r.key(r.send).context
r.send = idx
r.locked = true
if !heard {
return
}
switch {
case prev == ctx:
utils.Debugf("[CRYPTO] side=%s peer confirmed KDF context %q", r.side, ctx)
case idx == 0:
utils.Debugf("[CRYPTO] side=%s peer is back on the primary KDF context %q", r.side, ctx)
case utils.Throttled("crypto.adopt."+r.side, 10*time.Second):
utils.Infof("[CRYPTO] peer derives its keys from KDF context %q, not %q as this side does: switched to it (both sides should be updated to the same core)",
ctx, primary)
}
}
// failed counts a packet no candidate context could open and explains the
// likely cause now and then.
func (r *keyRing) failed() {
keys, pending := r.snapshot()
r.mu.Lock()
r.failures++
n := r.failures
heard := !r.heard.IsZero()
r.mu.Unlock()
if n < 3 || pending || !utils.Throttled("crypto.fail."+r.side, 30*time.Second) {
return
}
if heard {
utils.Infof("[CRYPTO] %d packets from the peer failed authentication after it had worked: another device with a different key, or old data replayed on the carrier", n)
return
}
utils.Infof("[CRYPTO] %d packets from the peer failed authentication under all %d KDF contexts tried (primary %q, sha256 %s): the encryption key differs from the peer's",
n, len(keys), keys[0].context, utils.Sha256Short([]byte(keys[0].context)))
}
func deriveDirectionalKey(master []byte, label string) []byte {
@@ -160,20 +439,22 @@ func newGCM(key []byte) (cipher.AEAD, error) {
}
func (e *EncryptedTransport) Send(data []byte) error {
header := []byte{encryptedMagic[0], encryptedMagic[1], encryptedMagic[2], encryptedVersion, e.sendDirection}
nonce := make([]byte, e.sendAEAD.NonceSize())
r := e.ring
keys := r.sendKeys()
header := []byte{encryptedMagic[0], encryptedMagic[1], encryptedMagic[2], encryptedVersion, r.sendDir}
nonce := make([]byte, keys.send.NonceSize())
if _, err := rand.Read(nonce); err != nil {
utils.Debugf("[CRYPTO] Send: rand nonce failed: %v", err)
e.sendErr.Add(1)
return fmt.Errorf("create packet nonce: %w", err)
}
packet := make([]byte, 0, len(header)+len(nonce)+len(data)+e.sendAEAD.Overhead())
packet := make([]byte, 0, len(header)+len(nonce)+len(data)+keys.send.Overhead())
packet = append(packet, header...)
packet = append(packet, nonce...)
packet = e.sendAEAD.Seal(packet, nonce, data, header)
packet = keys.send.Seal(packet, nonce, data, header)
utils.Debugf("[CRYPTO] Send #%d dir=%d plaintext=%d ciphertext=%d nonce=%s",
e.sendOK.Load()+1, e.sendDirection, len(data), len(packet), hex.EncodeToString(nonce))
utils.Debugf("[CRYPTO] Send #%d dir=%d plaintext=%d ciphertext=%d nonce=%s ctx=%s",
e.sendOK.Load()+1, r.sendDir, len(data), len(packet), hex.EncodeToString(nonce), utils.Sha256Short([]byte(keys.context)))
// Plaintext frames can carry control messages with cookie jars, so
// they are dumped only with --sensitive; ciphertext is what the
// carrier sees anyway.
@@ -195,13 +476,15 @@ func (e *EncryptedTransport) Send(data []byte) error {
}
func (e *EncryptedTransport) Receive(callback func([]byte)) {
r := e.ring
e.Transport.Receive(func(packet []byte) {
utils.Debugf("[CRYPTO] Recv raw %d ciphertext bytes", len(packet))
if utils.IsVerbose() {
utils.Debugf("[CRYPTO] Recv raw hexdump:\n%s", hex.Dump(packet))
}
minLen := encryptedHeader + e.receiveAEAD.NonceSize() + e.receiveAEAD.Overhead()
const nonceSize, overhead = 12, 16 // AES-GCM
minLen := encryptedHeader + nonceSize + overhead
if len(packet) < minLen {
e.recvBadLen.Add(1)
utils.Debugf("[CRYPTO] Recv DROP: too short (%d < %d)", len(packet), minLen)
@@ -211,8 +494,8 @@ func (e *EncryptedTransport) Receive(callback func([]byte)) {
if header[0] != encryptedMagic[0] || header[1] != encryptedMagic[1] ||
header[2] != encryptedMagic[2] {
e.recvBadHdr.Add(1)
utils.Debugf("[CRYPTO] Recv DROP: bad magic %x (want %x)",
header[:3], encryptedMagic[:])
utils.Debugf("[CRYPTO] Recv DROP: bad magic %x (want %x)%s",
header[:3], encryptedMagic[:], frameHint(packet))
return
}
if header[3] != encryptedVersion {
@@ -221,17 +504,17 @@ func (e *EncryptedTransport) Receive(callback func([]byte)) {
header[3], encryptedVersion)
return
}
if header[4] != e.recvDirection {
if header[4] != r.recvDir {
e.recvBadHdr.Add(1)
utils.Debugf("[CRYPTO] Recv DROP: wrong direction %d (want %d)",
header[4], e.recvDirection)
utils.Debugf("[CRYPTO] Recv DROP: wrong direction %d (want %d): a packet of this side's own role (echo, or both peers configured as %s)",
header[4], r.recvDir, map[bool]string{true: "exit", false: "client"}[r.exit])
return
}
nonceEnd := encryptedHeader + e.receiveAEAD.NonceSize()
nonceEnd := encryptedHeader + nonceSize
nonce := packet[encryptedHeader:nonceEnd]
utils.Debugf("[CRYPTO] Recv #%d dir=%d nonce=%s cipherLen=%d -> decrypting",
e.recvOK.Load()+e.recvFail.Load()+1, header[4], hex.EncodeToString(nonce), len(packet)-nonceEnd)
plaintext, err := e.receiveAEAD.Open(nil, nonce, packet[nonceEnd:], header)
plaintext, idx, err := r.open(nonce, packet[nonceEnd:], header)
if err != nil {
e.recvFail.Add(1)
utils.Debugf("[CRYPTO] Recv DECRYPT FAIL dir=%d nonce=%s err=%v (recvFail=%d recvOK=%d)",
@@ -239,6 +522,7 @@ func (e *EncryptedTransport) Receive(callback func([]byte)) {
if utils.IsVerbose() {
utils.Debugf("[CRYPTO] failed ciphertext hexdump:\n%s", hex.Dump(packet))
}
r.failed()
return
}
if !e.rememberNonce(nonce) {
@@ -247,9 +531,10 @@ func (e *EncryptedTransport) Receive(callback func([]byte)) {
hex.EncodeToString(nonce), e.recvReplay.Load())
return
}
r.accepted(idx)
e.recvOK.Add(1)
utils.Debugf("[CRYPTO] Recv DECRYPT OK #%d dir=%d plaintext=%d bytes nonce=%s",
e.recvOK.Load(), header[4], len(plaintext), hex.EncodeToString(nonce))
utils.Debugf("[CRYPTO] Recv DECRYPT OK #%d dir=%d plaintext=%d bytes nonce=%s ctx#%d",
e.recvOK.Load(), header[4], len(plaintext), hex.EncodeToString(nonce), idx)
if utils.IsVerbose() && utils.Sensitive() {
utils.Debugf("[CRYPTO] Recv plaintext hexdump:\n%s", hex.Dump(plaintext))
}
@@ -257,6 +542,25 @@ func (e *EncryptedTransport) Receive(callback func([]byte)) {
})
}
// frameHint names what a frame that is not an encrypted record looks like,
// for the drop log: most often the peer runs the other layering (classic vs
// Session) or has no encryption at all.
func frameHint(p []byte) string {
if len(p) == 0 {
return ""
}
switch p[0] {
case batchFormatVersion:
return " - a batch-v2 frame: the peer runs classic mode (codec outside encryption)"
case 0x00, CompressionMarker:
return " - a legacy per-packet frame: the peer runs classic mode with --codec=legacy"
}
if p[0]>>4 == 4 {
return " - a bare IPv4 packet: the peer runs without encryption"
}
return ""
}
func (e *EncryptedTransport) rememberNonce(nonce []byte) bool {
key := string(nonce)
e.seenMu.Lock()
+115
View File
@@ -0,0 +1,115 @@
package transport
import (
"sync"
"time"
"openflux/utils"
)
// frameDemux splits one carrier between the two layerings OpenFlux has
// (see PROTOCOL_NEGOTIATION.md):
//
// Session carrier <- AES-GCM record ("OFX...") <- batch-v2 <- envelopes
// classic carrier <- codec frame (0x02 / 0x00 / 0x1F) <- AES-GCM <- IPv4
//
// The first byte tells them apart, so a Session and its classic fallback
// share a carrier without either seeing the other's frames. Only the
// Session side starts and stops the carrier.
type frameDemux struct {
raw Transport
name string
once sync.Once
mu sync.RWMutex
sessionCb func([]byte)
classicCb func([]byte)
}
func newFrameDemux(raw Transport, name string) *frameDemux {
return &frameDemux{raw: raw, name: name}
}
func (d *frameDemux) attach() {
d.once.Do(func() { d.raw.Receive(d.dispatch) })
}
func (d *frameDemux) dispatch(p []byte) {
if len(p) == 0 {
return
}
d.mu.RLock()
sessionCb, classicCb := d.sessionCb, d.classicCb
d.mu.RUnlock()
if p[0] == encryptedMagic[0] {
if sessionCb != nil {
sessionCb(p)
}
return
}
if classicCb != nil {
classicCb(p)
return
}
if len(p) == 1 && p[0] == 0x00 {
return // a carrier keepalive (Volga), not a peer frame
}
if utils.Throttled("demux.classic."+d.name, 30*time.Second) {
utils.Infof("[SESSION] %q: the peer sends classic-mode frames (first byte 0x%02x)%s, but this side serves Session peers only "+
"(--negotiate, --transports, .conf transports, a wizard node): update the peer to a build on this core, give it a Session "+
"profile, or run this side classic (--transport=X with a key), which serves both",
d.name, p[0], classicFrameKind(p[0]))
}
}
func classicFrameKind(b byte) string {
switch b {
case batchFormatVersion:
return " - batch-v2"
case 0x00, CompressionMarker:
return " - legacy per-packet"
}
if b>>4 == 4 {
return " - unencrypted IPv4"
}
return ""
}
func (d *frameDemux) side(session bool) *demuxSide {
return &demuxSide{d: d, session: session}
}
// demuxSide is one layering's view of the carrier.
type demuxSide struct {
d *frameDemux
session bool
}
func (s *demuxSide) Start() error {
if s.session {
return s.d.raw.Start()
}
return nil
}
func (s *demuxSide) Stop() error {
if s.session {
return s.d.raw.Stop()
}
return nil
}
func (s *demuxSide) Send(p []byte) error { return s.d.raw.Send(p) }
func (s *demuxSide) IsConnected() bool { return s.d.raw.IsConnected() }
func (s *demuxSide) Stats() TransportStats { return s.d.raw.Stats() }
func (s *demuxSide) Receive(cb func([]byte)) {
s.d.mu.Lock()
if s.session {
s.d.sessionCb = cb
} else {
s.d.classicCb = cb
}
s.d.mu.Unlock()
s.d.attach()
}
+106
View File
@@ -0,0 +1,106 @@
package transport
import "sort"
// ContextPlaceholder is the --url default and the encryption context of a
// channel none of whose carriers has a document URL.
const ContextPlaceholder = "http://#"
// maxContextCandidates bounds how many contexts a peer tries: each one costs
// an scrypt derivation (32 MiB, tens of milliseconds) the first time it is
// needed.
const maxContextCandidates = 8
// ContextSource is one carrier as far as the encryption context cares.
type ContextSource struct {
Type string
URL string
Priority int
}
// contextURL reports whether a carrier's URL can name the channel. A
// cupsonline "URL" is the room list its exit creates at start, so the exit
// cannot know it beforehand; direct carries host:port, which differs between
// the two sides (0.0.0.0:port on the exit, the public address on the
// client); oneme has no URL.
func contextURL(s ContextSource) bool {
switch s.Type {
case "cupsonline", "direct", "oneme":
return false
}
return s.URL != "" && s.URL != ContextPlaceholder
}
// KDFContexts is the one rule every OpenFlux peer uses to pick the context
// its encryption keys are derived from (the scrypt salt); both peers must
// arrive at the same string. It replaces the copies that had drifted apart
// (CLI, Android bridge, Desktop, iOS):
//
// explicit --session-context / the context an openflux:// link carries
// --url unless it is a cupsonline room list
// transports URL of the highest-priority carrier that names the
// channel (see contextURL); equal priorities keep the first
// fallback ContextPlaceholder
//
// alternates are the contexts other or older builds derive for the same
// setup, most likely first: the placeholder, --url taken literally (older
// classic cupsonline peers used the room list), every carrier URL, and the
// transport names (third-party panels and an older fork used them when no
// URL was set). A receiver that cannot authenticate a packet under the
// primary context tries these before dropping it; see EncryptedTransport.
func KDFContexts(explicit, globalURL string, specs []ContextSource) (primary string, alternates []string) {
cups := false
for _, s := range specs {
if s.Type == "cupsonline" && s.URL != "" && s.URL == globalURL {
cups = true
}
}
if len(specs) == 1 && specs[0].Type == "cupsonline" {
cups = true
}
switch {
case explicit != "":
primary = explicit
case globalURL != "" && globalURL != ContextPlaceholder && !cups:
primary = globalURL
default:
best := -1
for i, s := range specs {
if !contextURL(s) {
continue
}
if best < 0 || s.Priority > specs[best].Priority {
best = i
}
}
if best >= 0 {
primary = specs[best].URL
} else {
primary = ContextPlaceholder
}
}
seen := map[string]bool{primary: true}
add := func(c string) {
if c == "" || seen[c] || len(alternates) >= maxContextCandidates-1 {
return
}
seen[c] = true
alternates = append(alternates, c)
}
add(ContextPlaceholder)
if globalURL != ContextPlaceholder {
add(globalURL)
}
byPriority := append([]ContextSource(nil), specs...)
sort.SliceStable(byPriority, func(i, j int) bool { return byPriority[i].Priority > byPriority[j].Priority })
for _, s := range byPriority {
if s.Type != "direct" && s.Type != "oneme" {
add(s.URL)
}
}
for _, s := range byPriority {
add(s.Type)
}
return primary, alternates
}
+16 -1
View File
@@ -56,9 +56,15 @@ type DocSession struct {
func (s *DocSession) safeWrite(messageType int, data []byte) error {
s.writeMu.Lock()
defer s.writeMu.Unlock()
// A write into a half-open connection (NAT dropped it, the network
// changed) would otherwise block until the kernel gives up, minutes.
_ = s.Conn.SetWriteDeadline(time.Now().Add(docWriteTimeout))
return s.Conn.WriteMessage(messageType, data)
}
// docWriteTimeout bounds one WebSocket write to the document.
const docWriteTimeout = 20 * time.Second
type MailruDocsTransport struct {
*transport.BaseTransport
@@ -163,6 +169,9 @@ func (t *MailruDocsTransport) connectToDoc(attempt int) {
info, err := t.fetchDocInfo(t.weblink)
if err != nil {
utils.Debugf("[M-DOCS] fetchDocInfo failed: %v", err)
if utils.Throttled("m-docs.fetch", time.Minute) {
utils.Infof("[M-DOCS] cannot open the document: %v; retrying", err)
}
t.scheduleReconnect(attempt)
return
}
@@ -267,6 +276,9 @@ func (t *MailruDocsTransport) connectToDoc(attempt int) {
_, message, err := conn.ReadMessage()
if err != nil {
utils.Debugf("[M-DOCS] Read error: %v", err)
if utils.Throttled("m-docs.drop", time.Minute) {
utils.Infof("[M-DOCS] connection to the document dropped: %v; reconnecting", err)
}
t.SetConnected(false)
conn.Close()
@@ -343,8 +355,11 @@ func (t *MailruDocsTransport) keepAliveLoop() {
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)
utils.Debugf("[M-DOCS] Keep-alive failed, closing the connection to reconnect: %v", err)
t.SetConnected(false)
// Close it so the reader, which may sit in ReadMessage on
// a half-open socket forever, errors out and reconnects.
_ = session.Conn.Close()
}
}
}
+74 -13
View File
@@ -10,6 +10,8 @@ package manager
import (
"errors"
"fmt"
"strconv"
"strings"
"sync"
"time"
@@ -34,6 +36,7 @@ type CookieProvider interface {
type Entry struct {
Name string
Type string
URL string // document URL, "" when the carrier has none
Priority int
Raw transport.Transport
Provider CookieProvider // nil if the transport does not carry cookies
@@ -120,6 +123,16 @@ func (m *Manager) Add(name, typ string, raw transport.Transport, priority int, p
return nil
}
// SetURL records the document URL of an attached transport, which cookie
// messages carry so the peer can match carriers it names differently.
func (m *Manager) SetURL(name, url string) {
m.mu.Lock()
if e, ok := m.entries[name]; ok {
e.URL = url
}
m.mu.Unlock()
}
// Remove detaches a transport from the Session and stops it.
func (m *Manager) Remove(name string) error {
m.mu.Lock()
@@ -299,20 +312,64 @@ func shareableCookies(jar map[string]string) map[string]string {
return out
}
// cookieTransport resolves the transport a cookie message refers to: the
// named one, or for peers that predate named messages, the
// highest-priority transport that carries cookies.
func (m *Manager) cookieTransport(name string) string {
// cookieTransport resolves the transport a cookie message refers to. The
// two sides do not always name carriers alike (the apps name them after the
// type, "mailru", "mailru-2"; a .conf or a panel names sections freely), so
// after the name itself it tries the document URL the message carries,
// then the type the name starts with when this side has one carrier of it.
// Peers that predate named messages mean the highest-priority transport
// that carries cookies.
func (m *Manager) cookieTransport(name, doc string) string {
m.mu.RLock()
defer m.mu.RUnlock()
if name != "" {
if name == "" {
for _, n := range m.order {
if m.entries[n].Provider != nil {
return n
}
}
return ""
}
if _, ok := m.entries[name]; ok {
return name
}
for _, n := range m.order {
if m.entries[n].Provider != nil {
return n
if doc != "" {
for _, n := range m.order {
if m.entries[n].URL == doc {
utils.Debugf("[MANAGER] peer's carrier %q is this side's %q (same document)", name, n)
return n
}
}
}
typ := name
if i := strings.LastIndex(name, "-"); i > 0 {
if _, err := strconv.Atoi(name[i+1:]); err == nil {
typ = name[:i]
}
}
var match []string
for _, n := range m.order {
if m.entries[n].Type == typ {
match = append(match, n)
}
}
if len(match) == 1 {
utils.Debugf("[MANAGER] peer's carrier %q is this side's %q (only %s)", name, match[0], typ)
return match[0]
}
if utils.Throttled("manager.name."+name, time.Minute) {
utils.Infof("[MANAGER] the peer refers to carrier %q, which this side cannot match (carriers: %v): the two configs name carriers differently", name, m.order)
}
return ""
}
// entryURL is the document URL of an attached transport.
func (m *Manager) entryURL(name string) string {
m.mu.RLock()
defer m.mu.RUnlock()
if e, ok := m.entries[name]; ok {
return e.URL
}
return ""
}
@@ -324,7 +381,7 @@ func (m *Manager) handleCookies(sub control.Subtype, payload []byte) {
utils.Debugf("[MANAGER] bad cookies payload: %v", err)
return
}
name := m.cookieTransport(cp.Transport)
name := m.cookieTransport(cp.Transport, cp.Doc)
if name == "" {
return
}
@@ -339,7 +396,7 @@ func (m *Manager) handleCookies(sub control.Subtype, payload []byte) {
return
}
jar = shareableCookies(jar)
body, _ := (&control.CookiesPayload{Transport: name, Jar: jar, Reason: "requested"}).Encode()
body, _ := (&control.CookiesPayload{Transport: name, Doc: m.entryURL(name), Jar: jar, Reason: "requested"}).Encode()
_ = m.SendControl(control.SubtypeCookiesResponse, body)
case control.SubtypeCookiesResponse, control.SubtypeCookiesOffer:
if len(cp.Jar) == 0 {
@@ -394,8 +451,12 @@ func (m *Manager) DispatchControl(sub control.Subtype, payload []byte) {
return
}
// The exit's side of that transport is stuck: stop routing through
// it here as well, the cookies that unstick it included.
m.session.MarkStalled(req.Transport)
// it here as well, the cookies that unstick it included. The report
// keeps the exit's name: the cookies go back under it.
if local := m.cookieTransport(req.Transport, req.Doc); local != "" {
m.session.MarkStalled(local)
}
utils.Infof("[MANAGER] the exit's carrier %q needs a check in a browser (%s): %s", req.Transport, req.Reason, req.URL)
m.mu.RLock()
cb := m.remoteAuth
m.mu.RUnlock()
@@ -552,7 +613,7 @@ func (m *Manager) forwardAuth(name, url, reason string) {
m.authSent[name] = now
m.mu.Unlock()
body, _ := (&control.AuthRequiredPayload{Transport: name, URL: url, Reason: reason}).Encode()
body, _ := (&control.AuthRequiredPayload{Transport: name, URL: url, Reason: reason, Doc: m.entryURL(name)}).Encode()
if err := m.SendControl(control.SubtypeAuthRequired, body); err != nil {
// No client yet: let the transport's next report try again.
utils.Debugf("[MANAGER] forward AuthRequired (%s): %v", name, err)
+419 -25
View File
@@ -64,8 +64,43 @@ type Session struct {
cntCtrlRecv atomic.Uint64
cntDecodeErr atomic.Uint64
cntUnknownKind atomic.Uint64
// Classic fallback, see SetClassic.
classic ClassicMode
classicCodec string
classicLink *transportLink // exit: the carrier a classic client was last heard on
classicSeen bool // client: the exit answered in classic mode
classicSince time.Time // client: when data started going out classic
alternates []string // KDF contexts the peer may derive instead, see KDFContexts
cntClassicSent atomic.Uint64
cntClassicRecv atomic.Uint64
cntClassicDrop atomic.Uint64
}
// ClassicMode is how a Session treats peers of the classic (pre-Session)
// layering.
type ClassicMode int
const (
// ClassicOff: Session peers only (--negotiate, and every exit that was
// configured as a Session). Classic frames are dropped with a log line.
ClassicOff ClassicMode = iota
// ClassicFallback (client, one carrier): until the exit answers the
// Session handshake, IPv4 goes out in the classic layering, so an exit
// that predates Session, or runs classic, still works; the client keeps
// offering the handshake and switches to the Session once answered.
ClassicFallback
// ClassicAccept (exit configured classic): the exit also serves
// classic clients, as long as no Session client is active. Session
// clients get a Session, so an updated client never runs classic.
ClassicAccept
)
// classicWait is how long a client with classic fallback waits for the
// Session handshake before letting data go out classic.
const classicWait = 3 * time.Second
type candidatePeer struct {
sender [32]byte
local [32]byte
@@ -80,15 +115,95 @@ const (
type transportLink struct {
name string
raw Transport
demux *frameDemux
encrypted *EncryptedTransport
batched *BatchedTransport
priority int
// The classic pipeline on the same carrier, nil unless SetClassic:
// classicEnc(classicCodec(classic side of demux)).
classicEnc *EncryptedTransport
classicCodec *CodecTransport
classicHeard time.Time
started bool
lastHeard time.Time
dead bool
}
// SetClassic lets this Session also speak the classic layering: a client
// falls back to it while the exit does not answer the handshake
// (ClassicFallback), an exit serves classic clients (ClassicAccept). codec is
// the preferred classic framing (CodecBatched or CodecLegacy); the other one
// is accepted too and tried when the peer stays silent. Call before
// AddTransport.
func (s *Session) SetClassic(codec string) {
s.mu.Lock()
defer s.mu.Unlock()
if s.exit {
s.classic = ClassicAccept
} else {
s.classic = ClassicFallback
}
s.classicCodec = codec
utils.Debugf("[SESSION] classic mode: %s (preferred codec %s)", s.classic, codecName(codec != CodecLegacy))
}
func (m ClassicMode) String() string {
switch m {
case ClassicFallback:
return "fallback"
case ClassicAccept:
return "accept"
}
return "off"
}
// SetAlternateContexts gives every carrier, present and future, the KDF
// contexts the peer may derive its keys from instead of this side's (see
// KDFContexts and EncryptedTransport).
func (s *Session) SetAlternateContexts(contexts []string) {
s.mu.Lock()
s.alternates = append(s.alternates, contexts...)
links := make([]*transportLink, 0, len(s.links))
for _, l := range s.links {
links = append(links, l)
}
s.mu.Unlock()
for _, l := range links {
l.encrypted.SetAlternateContexts(contexts)
}
}
// newLink builds a carrier's pipelines: the Session's
// batched(encrypted(session side)) and, with classic mode on, the classic
// encrypted(codec(classic side)) sharing its keys.
func (s *Session) newLink(name string, raw Transport, secret, context string, priority int) (*transportLink, error) {
d := newFrameDemux(raw, name)
enc, err := NewEncryptedTransport(d.side(true), secret, context, s.exit)
if err != nil {
return nil, fmt.Errorf("session: wrap %q: %w", name, err)
}
s.mu.Lock()
alternates := append([]string(nil), s.alternates...)
classic, codec := s.classic, s.classicCodec
s.mu.Unlock()
enc.SetAlternateContexts(alternates)
link := &transportLink{
name: name,
raw: raw,
demux: d,
encrypted: enc,
batched: NewBatchedTransport(enc),
priority: priority,
}
if classic != ClassicOff {
link.classicCodec = NewCodecTransport(d.side(false), codec, !s.exit)
link.classicEnc = enc.SharingKeys(link.classicCodec)
}
return link, nil
}
func NewSession(p PeerParameters, exit bool) (*Session, error) {
if !validParameters(p) {
return nil, errors.New("session requires IPv4/TCP and a packet limit from 1280 to 65000")
@@ -146,18 +261,9 @@ func (s *Session) AddTransport(name string, raw Transport, secret, context strin
}
s.mu.Unlock()
enc, err := NewEncryptedTransport(raw, secret, context, s.exit)
link, err := s.newLink(name, raw, secret, context, priority)
if err != nil {
return fmt.Errorf("session: wrap %q: %w", name, err)
}
bat := NewBatchedTransport(enc)
link := &transportLink{
name: name,
raw: raw,
encrypted: enc,
batched: bat,
priority: priority,
return err
}
s.mu.Lock()
@@ -220,18 +326,28 @@ func (s *Session) Start() error {
timeout := s.handshakeTimeout
local := s.local
order := append([]string(nil), s.order...)
if s.classic == ClassicFallback && len(links) != 1 {
// A classic exit has exactly one carrier; several can only mean a
// Session exit.
utils.Debugf("[SESSION] classic fallback off: %d carriers (a classic exit has one)", len(links))
s.classic = ClassicOff
for _, l := range links {
l.classicEnc, l.classicCodec = nil, nil
}
}
classic := s.classic
s.mu.Unlock()
role := "client"
if exit {
role = "exit"
}
utils.Debugf("[SESSION] Start role=%s timeout=%v local=%s transports=%v",
role, timeout, shortID(local), order)
utils.Debugf("[SESSION] Start role=%s timeout=%v local=%s transports=%v classic=%s",
role, timeout, shortID(local), order, classic)
for _, link := range links {
if err := s.startLink(link); err != nil {
utils.Debugf("[SESSION] transport %q start: %v; retrying in background", link.name, err)
utils.Infof("[SESSION] carrier %q failed to start: %v; retrying in the background", link.name, err)
s.superviseLink(link)
} else {
utils.Debugf("[SESSION] transport %q started (priority=%d)", link.name, link.priority)
@@ -247,6 +363,26 @@ func (s *Session) Start() error {
s.wg.Add(1)
go s.helloLoop()
if classic == ClassicFallback {
wait := min(classicWait, timeout)
if err := s.waitReady(wait); err == nil {
s.logHandshake()
return nil
}
s.mu.Lock()
stopped := s.stopped
if !stopped && !s.ready {
s.classicSince = time.Now()
}
s.mu.Unlock()
if stopped {
return errors.New("session: stopped")
}
utils.Infof("[SESSION] no Session handshake from the exit within %v on %q: sending in classic mode meanwhile; "+
"switching to the Session as soon as the exit answers", wait, order[0])
return nil
}
utils.Debugf("[SESSION] client: waiting for handshake (timeout=%v)", timeout)
if err := s.waitReady(timeout); err != nil {
utils.Debugf("[SESSION] handshake FAILED after %v: %v", timeout, err)
@@ -255,6 +391,11 @@ func (s *Session) Start() error {
return fmt.Errorf("session: handshake failed: %w", err)
}
s.logHandshake()
return nil
}
func (s *Session) logHandshake() {
s.mu.Lock()
peer := s.peer
remote := s.remote
@@ -262,7 +403,6 @@ func (s *Session) Start() error {
utils.Debugf("[SESSION] handshake OK: peer=%s caps=0x%x maxPacket=%d",
shortID(peer), remote.Capabilities, remote.MaxPacketSize)
s.dumpDiagnostics("handshake-success")
return nil
}
func (s *Session) dumpDiagnostics(why string) {
@@ -279,11 +419,51 @@ func (s *Session) dumpDiagnostics(why string) {
name, l.started, l.dead, time.Since(l.lastHeard).Round(time.Millisecond), l.raw.IsConnected())
if l.encrypted != nil {
so, se, ro, rf, rr, bh, bl := l.encrypted.CryptoStats()
utils.Debugf("[SESSION-DIAG] crypto link=%q sendOK=%d sendErr=%d recvOK=%d recvFail=%d recvReplay=%d badHdr=%d badLen=%d",
name, so, se, ro, rf, rr, bh, bl)
utils.Debugf("[SESSION-DIAG] crypto link=%q sendOK=%d sendErr=%d recvOK=%d recvFail=%d recvReplay=%d badHdr=%d badLen=%d ctx=%q",
name, so, se, ro, rf, rr, bh, bl, l.encrypted.Context())
}
if l.classicCodec != nil {
utils.Debugf("[SESSION-DIAG] classic link=%q codec=%s heard=%v", name, l.classicCodec.Current(), !l.classicHeard.IsZero())
}
}
utils.Debugf("[SESSION-DIAG] classic mode=%s sent=%d recv=%d drop=%d", s.classic, s.cntClassicSent.Load(), s.cntClassicRecv.Load(), s.cntClassicDrop.Load())
s.mu.Unlock()
if why != "handshake-success" {
utils.Infof("[SESSION] handshake not complete (%s): %s", why, s.diagnose())
}
}
// diagnose names the likeliest reason the exit has not answered the
// handshake, from what did arrive on the carriers.
func (s *Session) diagnose() string {
s.mu.Lock()
var recv, ok, fail uint64
connected := false
for _, l := range s.links {
recv += l.raw.Stats().PacketsRecv
connected = connected || l.raw.IsConnected()
if l.encrypted != nil {
_, _, ro, rf, _, _, _ := l.encrypted.CryptoStats()
ok += ro
fail += rf
}
}
classicHeard := s.classicSeen
s.mu.Unlock()
hellos := s.cntHelloRecv.Load()
switch {
case !connected:
return "no carrier is connected on this side (document unreachable, captcha, or network)"
case classicHeard:
return "the exit answers in classic mode only (an old or classic exit); traffic flows classic"
case recv == 0:
return "nothing arrived from the exit on any carrier: it is not running, uses another document/room, or is on another carrier"
case ok == 0 && fail > 0:
return "packets arrived but none decrypted under any KDF context: the encryption key differs from the exit's"
case ok > 0 && hellos == 0:
return "packets decrypted but none was a Session hello: the exit runs classic mode"
}
return fmt.Sprintf("%d frames arrived, %d decrypted, %d hellos; see [SESSION-DIAG] at -dd", recv, ok, hellos)
}
func (s *Session) startLink(link *transportLink) error {
@@ -291,6 +471,13 @@ func (s *Session) startLink(link *transportLink) error {
return err
}
link.batched.Receive(func(p []byte) { s.receive(link, p) })
if link.classicCodec != nil {
if err := link.classicCodec.Start(); err != nil {
utils.Debugf("[SESSION] %q classic pipeline: %v", link.name, err)
} else {
link.classicEnc.Receive(func(p []byte) { s.receiveClassic(link, p) })
}
}
s.mu.Lock()
stopped := s.stopped
@@ -300,8 +487,7 @@ func (s *Session) startLink(link *transportLink) error {
}
s.mu.Unlock()
if stopped {
_ = link.batched.Stop()
_ = link.raw.Stop()
link.stop()
return errors.New("session stopped")
}
// A carrier that comes up in an established session (e.g. once a check
@@ -334,10 +520,13 @@ func (s *Session) superviseLink(link *transportLink) {
attempt++
err := s.startLink(link)
if err == nil {
utils.Debugf("[SESSION] transport %q up after %d retries", link.name, attempt)
utils.Infof("[SESSION] carrier %q up after %d retries", link.name, attempt)
return
}
utils.Debugf("[SESSION] transport %q start retry #%d: %v", link.name, attempt, err)
if utils.Throttled("session.restart."+link.name, 2*time.Minute) {
utils.Infof("[SESSION] carrier %q still not up after %d retries: %v", link.name, attempt, err)
}
if delay *= 2; delay > s.restartMax {
delay = s.restartMax
}
@@ -345,22 +534,42 @@ func (s *Session) superviseLink(link *transportLink) {
}()
}
// helloBackoff is when the hello pace drops from helloInterval to
// helloSlow; an exit proven classic gets one every helloClassic.
const (
helloBackoff = 20 * time.Second
helloSlow = 2 * time.Second
helloClassic = 10 * time.Second
)
func (s *Session) helloLoop() {
defer s.wg.Done()
tick := time.NewTicker(s.helloInterval)
defer tick.Stop()
attempt := 0
started := time.Now()
var lastSent time.Time
for {
s.mu.Lock()
ready := s.ready
pace := s.helloInterval
if time.Since(started) > helloBackoff {
pace = max(pace, helloSlow)
}
if s.classicSeen {
pace = max(pace, helloClassic)
}
var names []string
if !ready {
if !ready && time.Since(lastSent) >= pace {
for _, name := range s.order {
if s.links[name].started {
names = append(names, name)
}
}
}
if ready {
started = time.Now()
}
local := s.local
peer := s.peer
s.mu.Unlock()
@@ -371,11 +580,18 @@ func (s *Session) helloLoop() {
attempt, names, shortID(local), shortID(peer))
}
}
if len(names) > 0 {
lastSent = time.Now()
}
for _, name := range names {
if err := s.helloVia(name); err != nil {
utils.Debugf("[SESSION] hello via %q: %v", name, err)
}
}
if attempt == 80 && !ready {
attempt++
s.dumpDiagnostics("handshake-slow")
}
select {
case <-s.done:
return
@@ -397,7 +613,7 @@ func (s *Session) keepaliveLoop() {
s.mu.Lock()
if !s.exit && s.ready && s.peerKeepalive && s.peerSilentLocked() {
s.resetLocked()
utils.Debugf("[SESSION] peer silent on every transport; handshaking again")
utils.Infof("[SESSION] exit silent on every carrier for %v: handshaking again", s.linkTimeout)
}
var quiet []*transportLink
if s.ready {
@@ -446,7 +662,7 @@ func (s *Session) waitReady(timeout time.Duration) error {
tick := time.NewTicker(20 * time.Millisecond)
defer tick.Stop()
lastLog := time.Now()
for !s.IsConnected() {
for !s.handshakeDone() {
select {
case <-s.done:
return errors.New("session stopped")
@@ -480,24 +696,117 @@ func (s *Session) Stop() error {
}
s.mu.Unlock()
for _, l := range links {
_ = l.batched.Stop()
_ = l.raw.Stop()
l.stop()
}
})
s.wg.Wait()
return nil
}
// stop takes down the carrier once: the batched wrapper stops it through
// the encryption layer and the demux, the classic pipeline only its own
// queue.
func (l *transportLink) stop() {
if l.classicCodec != nil {
_ = l.classicCodec.Stop()
}
_ = l.batched.Stop()
}
func (s *Session) IsConnected() bool {
s.mu.Lock()
ready := s.ready && !s.stopped
classic := s.classicConnectedLocked()
s.mu.Unlock()
if classic {
return true
}
if !ready {
return false
}
return s.anyLive()
}
// handshakeDone reports whether the Session handshake completed and a
// carrier reaches the peer (IsConnected also counts classic mode).
func (s *Session) handshakeDone() bool {
s.mu.Lock()
ready := s.ready && !s.stopped
s.mu.Unlock()
return ready && s.anyLive()
}
// classicConnectedLocked: a classic-fallback client counts as connected
// while its carrier is (what classic mode always reported); an exit while
// a classic client was heard lately. Caller holds s.mu.
func (s *Session) classicConnectedLocked() bool {
if s.stopped || s.ready {
return false
}
switch s.classic {
case ClassicFallback:
l := s.classicTargetLocked()
return l != nil && s.connectedLocked(l)
case ClassicAccept:
l := s.classicLink
return l != nil && time.Since(l.classicHeard) < s.linkTimeout
}
return false
}
// classicTargetLocked is the carrier classic IPv4 goes out on, or nil.
// Caller holds s.mu.
func (s *Session) classicTargetLocked() *transportLink {
switch s.classic {
case ClassicFallback:
if len(s.order) == 1 {
if l := s.links[s.order[0]]; l.started && l.classicEnc != nil {
return l
}
}
case ClassicAccept:
return s.classicLink
}
return nil
}
// sessionActiveLocked reports whether the Session client was heard
// recently enough that a classic client must not take the exit's replies
// from it: a live client sends a keepalive at least every
// keepaliveInterval, so half that again covers one lost. A client that
// went away yields to a classic one after that, not after linkTimeout.
// Caller holds s.mu.
func (s *Session) sessionActiveLocked() bool {
if !s.ready {
return false
}
window := s.keepaliveInterval * 3 / 2
for _, l := range s.links {
if s.connectedLocked(l) && time.Since(l.lastHeard) < window {
return true
}
}
return false
}
// Mode names what IPv4 currently goes out as: "session", "classic" or ""
// (nothing yet).
func (s *Session) Mode() string {
s.mu.Lock()
defer s.mu.Unlock()
switch {
case s.stopped:
return ""
case s.exit && s.classicLink != nil && !s.sessionActiveLocked():
return "classic"
case s.ready:
return "session"
case s.classicTargetLocked() != nil:
return "classic"
}
return ""
}
func (s *Session) anyLive() bool {
s.mu.Lock()
defer s.mu.Unlock()
@@ -520,6 +829,11 @@ func (s *Session) heardLocked(l *transportLink) bool {
func (s *Session) ActiveTransport() string {
s.mu.Lock()
defer s.mu.Unlock()
if s.classicConnectedLocked() {
if l := s.classicTargetLocked(); l != nil {
return l.name
}
}
if !s.ready || s.stopped {
return ""
}
@@ -534,6 +848,11 @@ func (s *Session) ActiveTransport() string {
func (s *Session) LiveTransports() []string {
s.mu.Lock()
defer s.mu.Unlock()
if s.classicConnectedLocked() {
if l := s.classicTargetLocked(); l != nil {
return []string{l.name}
}
}
if !s.ready || s.stopped {
return nil
}
@@ -617,6 +936,14 @@ func (s *Session) helloVia(name string) error {
func (s *Session) Send(p []byte) error {
s.mu.Lock()
if !s.stopped && s.classic != ClassicOff {
// A client whose exit has not answered the handshake, or an exit
// whose current client is a classic one: classic layering.
if l := s.classicTargetLocked(); l != nil && (!s.ready || (s.exit && !s.sessionActiveLocked())) {
s.mu.Unlock()
return s.sendClassic(l, p)
}
}
if !s.ready || s.stopped {
s.mu.Unlock()
return ErrNegotiationPending
@@ -772,6 +1099,67 @@ func permittedPacket(p []byte, limits PeerParameters) error {
return nil
}
// ---- classic layering ----
// sendClassic sends one IPv4 packet in the classic layering on link.
func (s *Session) sendClassic(link *transportLink, p []byte) error {
if len(p) < 20 || p[0]>>4 != 4 {
return errors.New("session: classic mode carries IPv4 packets only")
}
n := s.cntClassicSent.Add(1)
if n == 1 || n%500 == 0 {
utils.Debugf("[SESSION] classic send #%d via %q size=%d codec=%s ctx=%s",
n, link.name, len(p), link.classicCodec.Current(), utils.Sha256Short([]byte(link.classicEnc.Context())))
}
return link.classicEnc.Send(p)
}
// receiveClassic takes one decrypted IPv4 packet of the classic layering.
func (s *Session) receiveClassic(link *transportLink, p []byte) {
if len(p) < 20 || p[0]>>4 != 4 {
s.cntClassicDrop.Add(1)
hint := ""
if len(p) > 0 && p[0] == 0xFF {
hint = " (a control frame of the iOS fork's own protocol, which this core does not speak; update the app to one built on this core)"
}
utils.Debugf("[SESSION] classic frame from %q is not IPv4 (%d bytes)%s", link.name, len(p), hint)
return
}
now := time.Now()
s.mu.Lock()
if s.stopped {
s.mu.Unlock()
return
}
if s.exit {
if s.sessionActiveLocked() {
s.cntClassicDrop.Add(1)
s.mu.Unlock()
if utils.Throttled("session.classic.busy", 30*time.Second) {
utils.Infof("[SESSION] classic packets on %q ignored: a Session client is active (one client per exit; another device uses the same key?)", link.name)
}
return
}
if s.classicLink != link {
utils.Infof("[SESSION] classic client on %q (it does not speak the Session handshake): serving it in classic mode; update the client to get a Session", link.name)
}
s.classicLink = link
} else if !s.classicSeen {
s.classicSeen = true
utils.Infof("[SESSION] the exit answers in classic mode on %q (it predates Session or runs classic): staying classic, still offering the handshake", link.name)
}
link.classicHeard = now
cb := s.dataCallback
s.mu.Unlock()
n := s.cntClassicRecv.Add(1)
if n == 1 || n%500 == 0 {
utils.Debugf("[SESSION] classic recv #%d from %q size=%d", n, link.name, len(p))
}
if cb != nil {
cb(append([]byte(nil), p...))
}
}
// ---- receive ----
func (s *Session) receive(link *transportLink, p []byte) {
@@ -892,6 +1280,9 @@ func (s *Session) receiveHello(link *transportLink, env *control.Envelope) {
s.mu.Unlock()
s.cntHelloAccept.Add(1)
if echo && !wasReady && !s.exit && (s.classicSeen || !s.classicSince.IsZero()) {
utils.Infof("[SESSION] the exit answered the Session handshake on %q: switching from classic to the Session", link.name)
}
if echo {
utils.Debugf("[SESSION] hello #%d from %q ACCEPT: peer=%s -> ready (accept=%d)",
n, link.name, shortID(peer), s.cntHelloAccept.Load())
@@ -930,6 +1321,9 @@ func (s *Session) offerReplacementLocked(link *transportLink, sender [32]byte, p
names := append([]string(nil), s.order...)
s.mu.Unlock()
utils.Debugf("[SESSION] peer REPLACED by %s after fresh challenge echo", shortID(sender))
if s.exit {
utils.Infof("[SESSION] a new client took over the session on %q: the previous one restarted, or two devices use the same key (the exit serves one client at a time)", link.name)
}
for _, name := range names {
_ = s.helloVia(name)
}
+10 -15
View File
@@ -36,28 +36,23 @@ func (s *Session) AddTransportPostStart(name string, raw Transport, secret, cont
}
s.mu.Unlock()
enc, err := NewEncryptedTransport(raw, secret, context, s.exit)
link, err := s.newLink(name, raw, secret, context, priority)
if err != nil {
return fmt.Errorf("session: wrap %q: %w", name, err)
return err
}
bat := NewBatchedTransport(enc)
// Post-start carriers are Session-only: classic fallback is for a
// single-carrier client, and an exit adds these at a Session client's
// request.
link.classicEnc, link.classicCodec = nil, nil
// bat.Start starts raw through the encryption layer.
if err := bat.Start(); err != nil {
// batched.Start starts raw through the encryption layer and the demux.
if err := link.batched.Start(); err != nil {
return fmt.Errorf("session: transport %q start: %w", name, err)
}
link := &transportLink{
name: name,
raw: raw,
encrypted: enc,
batched: bat,
priority: priority,
started: true,
}
link.started = true
// Same receive path as the bootstrap transports: everything that
// arrives on this link goes through Session.receive.
bat.Receive(func(p []byte) { s.receive(link, p) })
link.batched.Receive(func(p []byte) { s.receive(link, p) })
s.mu.Lock()
s.links[name] = link
+21 -23
View File
@@ -31,8 +31,10 @@ const (
"(KHTML, like Gecko) Chrome/153.0.0.0 Mobile Safari/537.36"
boardsSocketHostDefault = "socket33.boards.yandex.ru"
// Engine.io heartbeat. Сервер шлёт pingInterval=25000, pingTimeout=30000.
// Мы пингуем сами каждые 20с, чтобы NAT не рвал idle-соединение.
// Heartbeat. Сервер шлёт engine.io ping "2" (pingInterval=25000,
// pingTimeout=30000), мы отвечаем "3". Сами шлём только heartbeat в
// namespace dashboard каждые 20с, чтобы NAT не рвал idle-соединение;
// клиентский "2" сервер EIO=4 считает ошибкой и закрывает сокет.
boardsPingInterval = 20 * time.Second
// Дедлайн чтения в основном цикле. С запасом над boardsPingInterval.
@@ -458,10 +460,20 @@ func (t *BoardsTransport) connectLoop(info boardsInfo) {
return
default:
}
if err := t.connectAndServe(info); err != nil {
utils.Debugf("[BOARDS] ws error: %v", err)
began := time.Now()
err := t.connectAndServe(info)
lasted := time.Since(began)
if err != nil {
utils.Debugf("[BOARDS] ws error after %v: %v", lasted.Round(time.Second), err)
if utils.Throttled("boards.drop", time.Minute) {
utils.Infof("[BOARDS] connection to the board dropped after %v: %v; reconnecting", lasted.Round(time.Second), err)
}
}
t.SetConnected(false)
if lasted > time.Minute {
// A session that held is not part of a failure streak.
attempt = 0
}
select {
case <-t.done:
return
@@ -547,9 +559,13 @@ func (t *BoardsTransport) connectAndServe(info boardsInfo) error {
t.SetConnected(true)
utils.SafeGo("boards.writer", func() { t.writerLoop(sess) })
// No client-side engine.io ping: in EIO=4 the server pings ("2") and the
// client answers ("3", see handleMessage). A client "2" is an invalid
// heartbeat direction to an engine.io v4 server, which then closes the
// socket, so the old pingLoop dropped the board every
// boardsPingInterval. The dashboard heartbeat below keeps it busy.
kaStop := make(chan struct{})
utils.SafeGo("boards.keepalive", func() { t.keepAliveLoop(sess, kaStop) })
utils.SafeGo("boards.ping", func() { t.pingLoop(sess, kaStop) })
defer close(kaStop)
for {
@@ -789,24 +805,6 @@ func (t *BoardsTransport) keepAliveLoop(sess *boardsSession, stop chan struct{})
}
}
func (t *BoardsTransport) pingLoop(sess *boardsSession, stop chan struct{}) {
tick := time.NewTicker(boardsPingInterval)
defer tick.Stop()
for {
select {
case <-stop:
return
case <-t.done:
return
case <-tick.C:
if err := sess.writeRaw("2"); err != nil {
utils.Debugf("[BOARDS] engine.io ping: %v", err)
return
}
}
}
}
// ---- приём ----
func (t *BoardsTransport) handleMessage(sess *boardsSession, raw []byte) {
+36 -4
View File
@@ -52,6 +52,11 @@ type VolgaConfig struct {
// MaxSessionAge rotates the WebSocket (and its authorization) even
// while it looks healthy; 0 disables rotation.
MaxSessionAge time.Duration
// WebSocket buffers. Large ones help a server; a phone's VPN process
// (iOS caps it at 50 MB) cannot afford them.
WSReadBufferSize int
WSWriteBufferSize int
}
func DefaultVolgaConfig() VolgaConfig {
@@ -79,16 +84,37 @@ func DefaultVolgaConfig() VolgaConfig {
WSReadTimeout: 60 * time.Second,
KeepAliveInterval: 10 * time.Second,
MaxSessionAge: 30 * time.Minute,
WSReadBufferSize: 4 << 20,
WSWriteBufferSize: 4 << 20,
}
}
// SlimVolgaConfig is the memory-constrained profile for phones, above all
// the iOS Network Extension (50 MB for the whole process): a small relay
// worker pool and queue, smaller batches and WebSocket buffers. The wire
// format is the same, so it talks to a node on the default profile.
func SlimVolgaConfig() VolgaConfig {
c := DefaultVolgaConfig()
c.MaxIdleConnsPerHost = 8
c.MaxIdleConns = 16
c.WorkerCount = 4
c.QueueSize = 4096
c.BatchMaxBytes = 256 * 1024
c.WSReadBufferSize = 128 << 10
c.WSWriteBufferSize = 128 << 10
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(`<script[^>]*id="client-config"[^>]*>(.*?)</script>`)
var (
b64BufPool = sync.Pool{
New: func() interface{} { return make([]byte, 0, 16*1024*1024) },
// base64Encode grows a buffer for a bigger batch; a 16 MiB default
// kept that much per pooled buffer alive for every small packet.
New: func() interface{} { return make([]byte, 0, 256*1024) },
}
jsonBufPool = sync.Pool{
New: func() interface{} { return bytes.NewBuffer(make([]byte, 0, 128*1024)) },
@@ -945,8 +971,8 @@ func (w *wsListener) connect() error {
dialer := websocket.Dialer{
NetDialContext: netbind.DialContext,
HandshakeTimeout: w.config.WSHandshakeTimeout,
ReadBufferSize: 4 << 20,
WriteBufferSize: 4 << 20,
ReadBufferSize: w.config.WSReadBufferSize,
WriteBufferSize: w.config.WSWriteBufferSize,
}
conn, _, err := dialer.Dial(wsURL, header)
@@ -1141,11 +1167,17 @@ type YandexVolgaTransport struct {
}
func NewYandexVolgaTransport(docURL string, cfg transport.TransportConfig) *YandexVolgaTransport {
return NewYandexVolgaTransportWithConfig(docURL, cfg, DefaultVolgaConfig())
}
// NewYandexVolgaTransportWithConfig is NewYandexVolgaTransport with a
// resource profile, e.g. SlimVolgaConfig on a phone.
func NewYandexVolgaTransportWithConfig(docURL string, cfg transport.TransportConfig, volga VolgaConfig) *YandexVolgaTransport {
jar, _ := cookiejar.New(nil)
return &YandexVolgaTransport{
BaseTransport: transport.NewBaseTransport(cfg),
docURL: docURL,
config: DefaultVolgaConfig(),
config: volga,
stats: &VolgaStats{},
cookieJar: jar,
keepAliveStop: make(chan struct{}),
+16 -1
View File
@@ -66,9 +66,15 @@ type DocSession struct {
func (s *DocSession) safeWrite(messageType int, data []byte) error {
s.writeMu.Lock()
defer s.writeMu.Unlock()
// A write into a half-open connection (NAT dropped it, the network
// changed) would otherwise block until the kernel gives up, minutes.
_ = s.Conn.SetWriteDeadline(time.Now().Add(docWriteTimeout))
return s.Conn.WriteMessage(messageType, data)
}
// docWriteTimeout bounds one WebSocket write to the document.
const docWriteTimeout = 20 * time.Second
type YandexDocsTransport struct {
*transport.BaseTransport
@@ -192,6 +198,9 @@ func (t *YandexDocsTransport) connectToDoc(attempt int) {
return
}
utils.Debugf("[YDOCS] fetchDocInfo failed: %v", err)
if utils.Throttled("ydocs.fetch", time.Minute) {
utils.Infof("[YDOCS] cannot open the document: %v; retrying", err)
}
t.scheduleReconnect(attempt)
return
}
@@ -264,6 +273,9 @@ func (t *YandexDocsTransport) connectToDoc(attempt int) {
_, message, err := conn.ReadMessage()
if err != nil {
utils.Debugf("[YDOCS] Read error: %v", err)
if utils.Throttled("ydocs.drop", time.Minute) {
utils.Infof("[YDOCS] connection to the document dropped: %v; reconnecting", err)
}
t.SetConnected(false)
conn.Close()
// If the session was healthy for a while, treat the next
@@ -348,8 +360,11 @@ func (t *YandexDocsTransport) keepAliveLoop() {
if session != nil && session.Conn != nil {
if err := session.safeWrite(websocket.TextMessage, []byte(keepAliveMsg)); err != nil {
utils.Debugf("[YDOCS] Keep-alive failed: %v", err)
utils.Debugf("[YDOCS] Keep-alive failed, closing the connection to reconnect: %v", err)
t.SetConnected(false)
// Close it so the reader, which may sit in ReadMessage on
// a half-open socket forever, errors out and reconnects.
_ = session.Conn.Close()
}
}
}
+7 -2
View File
@@ -31,7 +31,12 @@ func newVolgaTransport(docURL string, cfg transport.TransportConfig) (transport.
// It is the single place that knows every transport package. main.go passes
// it into manager.New, and manager calls it whenever the peer asks the exit
// to bring up an additional transport at runtime.
func transportFactory(baseCfg transport.TransportConfig) manager.Factory {
//
// isExit is this process's role: a cupsonline client must never create
// rooms of its own. (It used to be built as an exit everywhere, so a
// Session client with no or dead rooms created four new ones and waited in
// them, where the exit never came.)
func transportFactory(baseCfg transport.TransportConfig, isExit bool) manager.Factory {
return func(cfg *control.TransportConfig) (transport.Transport, error) {
if cfg == nil {
return nil, fmt.Errorf("factory: nil config")
@@ -46,7 +51,7 @@ func transportFactory(baseCfg transport.TransportConfig) manager.Factory {
case "mailru":
return mailru.NewMailruDocsTransport(cfg.URL, baseCfg), nil
case "cupsonline":
return cupsonline.NewCupsonlineTransport(cfg.URL, baseCfg, false), nil
return cupsonline.NewCupsonlineTransport(cfg.URL, baseCfg, !isExit), nil
case "oneme":
token, _ := cfg.Params["token"].(string)
uidStr, _ := cfg.Params["uid"].(string)
+7 -3
View File
@@ -65,14 +65,14 @@ func buildTransportSpecs(specs []transportSpec, urls map[string]string, extra ma
// makeRawTransport builds a raw transport from a spec without the Manager's
// factory (bootstrap path). The Manager's factory is only used for transports
// added later via SubtypeTransportStart.
func makeRawTransport(spec transportSpec, baseCfg transport.TransportConfig) (transport.Transport, error) {
func makeRawTransport(spec transportSpec, baseCfg transport.TransportConfig, isExit bool) (transport.Transport, error) {
cfg := &control.TransportConfig{
Name: spec.Name,
Type: spec.Type,
URL: spec.URL,
Params: spec.Params,
}
return transportFactory(baseCfg)(cfg)
return transportFactory(baseCfg, isExit)(cfg)
}
// registerBootstrapTransports wires every spec into the manager, and also
@@ -80,8 +80,9 @@ func makeRawTransport(spec transportSpec, baseCfg transport.TransportConfig) (tr
// can use any of them. Transports that learn their client address only when
// running go into rooms by spec name, for --share.
func registerBootstrapTransports(m *manager.Manager, specs []transportSpec, baseCfg transport.TransportConfig, secret, ctx string, rooms map[string]roomLister) error {
isExit := m.Session().IsExit()
for _, spec := range specs {
raw, err := makeRawTransport(spec, baseCfg)
raw, err := makeRawTransport(spec, baseCfg, isExit)
if err != nil {
return fmt.Errorf("%s: %w", spec.Name, err)
}
@@ -101,6 +102,9 @@ func registerBootstrapTransports(m *manager.Manager, specs []transportSpec, base
if err := m.Add(spec.Name, spec.Type, raw, spec.Priority, provider); err != nil {
return fmt.Errorf("manager add %s: %w", spec.Name, err)
}
if spec.URL != transport.ContextPlaceholder {
m.SetURL(spec.Name, spec.URL)
}
}
return nil
}
+29 -1
View File
@@ -9,6 +9,7 @@ import (
"os"
"sync"
"sync/atomic"
"time"
)
// Debug levels. Each level prints everything the ones below it do.
@@ -169,8 +170,35 @@ func emit(message string) {
// Infof always logs, regardless of debug level. Used for user-facing status
// lines (e.g. cups room open/close) that must be visible without --debug.
//
// With a log sink set (an embedding app: the Android and iOS bridges) the
// line goes there, so the app's log screen shows the same status lines as
// the CLI's output; otherwise to the standard log.
func Infof(format string, args ...interface{}) {
log.Output(2, fmt.Sprintf(format, args...))
message := fmt.Sprintf(format, args...)
logSinkMu.RLock()
sink := logSink
logSinkMu.RUnlock()
if sink != nil {
sink(message)
return
}
log.Output(2, message)
}
// throttle remembers when each Throttled key last fired.
var throttle sync.Map // key -> time.Time
// Throttled reports whether a message identified by key may be logged now:
// true at most once per every. For warnings that would otherwise repeat on
// every packet (a key mismatch drops each one).
func Throttled(key string, every time.Duration) bool {
now := time.Now()
if last, ok := throttle.Load(key); ok && now.Sub(last.(time.Time)) < every {
return false
}
throttle.Store(key, now)
return true
}
// SafeGo runs fn in a new goroutine, recovering from any panic so a crash in
+15
View File
@@ -0,0 +1,15 @@
package utils
import "unicode/utf16"
// MinSecretChars is the shortest shared secret any peer accepts.
const MinSecretChars = 16
// SecretChars is the length of a secret in characters as every OpenFlux
// client counts them: UTF-16 code units, the way Kotlin and Java measure
// String.length. Counting bytes instead (as the core once did) let the core
// accept a 10-letter Cyrillic secret (20 bytes) that Desktop and Android
// reject, so the same link worked on one device and not on another.
func SecretChars(s string) int {
return len(utf16.Encode([]rune(s)))
}