|
| 1 | +# Smart routing — learned, class-aware exit selection |
| 2 | + |
| 3 | +Date: 2026-08-11. Status: owner-directed design, pending owner review of this document. |
| 4 | +Research basis: probes/multi-exit audit (live telemetry, both testers), DPI feasibility |
| 5 | +study (incl. a local nDPI compile spike), gopacket lineage investigation, DWLC-vs-bandit |
| 6 | +literature review (19 cited sources), platform constraints research, and a build-seams |
| 7 | +audit of sdk/connect/android/apple/windows. Owner decisions incorporated: auto-learn + |
| 8 | +manual overrides; separate ndpi library file; 5 traffic classes; learning on by default |
| 9 | +with opt-out, reset, 90-day default retention settable to unlimited; every heavy piece |
| 10 | +optional per platform with legacy compatibility. |
| 11 | + |
| 12 | +## 1. Goal and principles |
| 13 | + |
| 14 | +Evolve exit selection from evidence-based-but-uniform to class-aware and self-improving: |
| 15 | +bandwidth-, latency-, and program-aware placement that trains locally and gets better the |
| 16 | +longer it runs — without touching what already works. |
| 17 | + |
| 18 | +- **Learning optimizes; verdicts protect.** The existing safety machinery (probes |
| 19 | + qualify, traffic convicts, quarantine, shared-fate holds, verdict budget) remains the |
| 20 | + SOLE removal authority. The learner ranks healthy exits only; it can never keep traffic |
| 21 | + on an exit the evidence has condemned, never bypass the verdict budget. |
| 22 | +- **Deterministic first, learner thin.** Our telemetry shows exits degrade to conviction |
| 23 | + within minutes and slots turn over ~14 min on average — the regime where bandit regret |
| 24 | + is worst and fresh measurement pays most. So placement is a deterministic score over |
| 25 | + live metrics; the learner is a small additive bonus, shipped dark until logs prove it. |
| 26 | +- **Everything heavy is optional.** cgo classification and per-app attribution are |
| 27 | + platform-gated capability seams (nil interface = today's behavior, cost one branch). |
| 28 | + Pure-Go layers ship everywhere. Zero-value-off on every new knob; old clients interop |
| 29 | + with the same servers by construction (client-local feature, no protocol change). |
| 30 | + |
| 31 | +## 2. Classification — what is this flow |
| 32 | + |
| 33 | +Five classes: latency-sensitive, streaming, bulk, browsing, background. |
| 34 | + |
| 35 | +Precedence: manual per-app override > nDPI verdict > exe/app default > port+SNI heuristic. |
| 36 | + |
| 37 | +- **nDPI (full tier)**: shipped as a SEPARATE library file (ndpi.dll / libndpi.so / |
| 38 | + libndpi.dylib) — clean LGPL-3.0 separation, and file-presence doubles as the runtime |
| 39 | + full/light switch on every platform. Our own thin binding (~12 externs, opaque pointers |
| 40 | + only, 2-3 one-line C shims; no struct mirroring — that is what killed every prior Go |
| 41 | + binding) against a PINNED nDPI release tag, in a new package in the sdk/cgo module |
| 42 | + (separate Go module — gomobile builds cannot see it by construction). |
| 43 | +- **Budget**: first 8 packets per flow, both directions, then classify-or-giveup and free |
| 44 | + (1.3 KB/flow while under classification, ≤512 flows concurrently, queue 1024). One |
| 45 | + dedicated worker goroutine owning one detection module (~14 MB once, ~1-2 µs/packet |
| 46 | + measured). Hot path pays one nil-check when idle; when classifying, a mandatory bounded |
| 47 | + copy (≤1600 B) — payload slices alias pooled buffers, queueing a live slice is |
| 48 | + use-after-recycle (review-blocking invariant). Queue full → drop the job, count it, |
| 49 | + flow keeps its heuristic class. nDPI is never called from the pump thread. |
| 50 | +- **Light tier** (no library present / iOS): pure-Go classifier using connect's existing |
| 51 | + SNI sniffer, DNS reverse index, and ports. Same 5 classes, lower confidence. |
| 52 | +- **App attribution**: implement the existing FlowOwnerLookup seam per platform |
| 53 | + (async-with-default — it must never run synchronously inside the pump's C→Go call): |
| 54 | + Windows service = TCP/UDP owner table → exe; Linux = sock_diag netlink → /proc; |
| 55 | + Android = getConnectionOwnerUid (API 29+, once per flow + 5-tuple cache; measure |
| 56 | + binder cost under flow storms before finalizing); macOS = none at launch (needs a |
| 57 | + future companion filter extension); iOS = none (no API exists). |
| 58 | +- **gopacket**: NOT a production dependency (stays test-only per the policy comment in |
| 59 | + connect). Housekeeping: retarget the 6 test files from github.com/google/gopacket |
| 60 | + v1.1.19 (2020 code; carries unfixed remotely-triggerable decoder panics, e.g. |
| 61 | + CVE-2026-65819-class bugs never advisoried there) to github.com/gopacket/gopacket |
| 62 | + ≥ v1.7.1 (all High decoder-panic advisories fixed + regression-tested). If its TLS |
| 63 | + ClientHello decode is ever used outside tests: feed exact-capacity slices or a copy — |
| 64 | + its parser re-slices data[:cap(data)]. QUIC classification comes from nDPI, not |
| 65 | + gopacket (no lineage has a QUIC layer). |
| 66 | + |
| 67 | +## 3. Placement — deterministic scored selection |
| 68 | + |
| 69 | +At the existing single placement site (one shared code path on all platforms; sticky |
| 70 | +affinity continues to govern already-placed flows): |
| 71 | + |
| 72 | +- Composite score per (class, exit) from: probe RTT, goodput EWMA, stall/receive |
| 73 | + evidence, jitter; per-class metric weights (latency-sensitive weights RTT/jitter, |
| 74 | + streaming weights sustained throughput + stability, bulk weights throughput, etc.). |
| 75 | +- **Incumbent hysteresis**: a challenger must beat the incumbent by >10% composite score |
| 76 | + (Fortinet-style A = R/(1+L/100)) — kills score-noise churn. |
| 77 | +- **N-of-M demotion**: rank demotion requires 2-of-3 consecutive bad intervals, never one |
| 78 | + sample (Cisco EAAR multiplier idiom). Convictions are unaffected — they are |
| 79 | + evidence-based, not score-based. |
| 80 | +- **Anti-herding tie-break**: when the top exits are within the hysteresis margin, place |
| 81 | + the new flow on the LESS-LOADED one (power-of-two-choices) — directly fixes the |
| 82 | + measured 37-flows-on-one-exit accretion. |
| 83 | +- Score reacts within one metric interval (1-5 s); failover remains the verdict layer's |
| 84 | + job and is faster still. |
| 85 | + |
| 86 | +## 4. Learning — thin, dark-first, background-funded |
| 87 | + |
| 88 | +- **Per-(class, exit) Sliding-Window UCB** (disjoint tables, ~5-min window; explicitly |
| 89 | + NOT LinUCB — at 5 classes × ≤18 exits, feature models add complexity without coverage). |
| 90 | + The UCB optimism bonus is ADDITIVE to the deterministic score and keeps under-sampled |
| 91 | + exits measured. |
| 92 | +- **Exploration budget**: exploration placements only ever use background-class flows, |
| 93 | + hard-capped at ≤1-in-8 background placements, suppressed during shared-fate holds and |
| 94 | + above the flow soft cap. Interactive traffic never pays the tuition. |
| 95 | +- **Dark launch**: the learner logs its would-be choices (counterfactuals) with zero |
| 96 | + behavioral effect first; it goes live only when logged uplift is positive (Phase 0 |
| 97 | + reward instrumentation defines the metric: class-normalized goodput + stall-free time, |
| 98 | + counting only backlogged flows to avoid demand confounding). |
| 99 | +- **Cold providers / probation**: optimism-under-uncertainty replaces an ad-hoc probation |
| 100 | + flag — cold or bench-returned exits carry a decaying uncertainty bonus and earn rank |
| 101 | + through background traffic. The same prior biases recruitment ranking in the 2x |
| 102 | + evaluation pool. |
| 103 | +- **Persistence**: coarse per-provider-IDENTITY priors only (score EWMA, conviction |
| 104 | + count, last-seen; TTL per §7) — never raw UCB windows or posteriors (stale-on-load at |
| 105 | + our churn, and a poisoning surface for adversarial providers). |
| 106 | + |
| 107 | +## 5. Upgrades to the existing machinery (from the same research) |
| 108 | + |
| 109 | +1. **Quarantine flap damping** (observed: same exits benched 4-5x, bench migrate always |
| 110 | + movable=0): RFC 2439-style escalation — re-conviction bench 60 s → 120 s → 240 s with |
| 111 | + penalty half-life ~10-15 min; release-on-receive-progress preserved. |
| 112 | +2. **Re-entry ramp**: a released exit returns at reduced score weight and ramps to full |
| 113 | + eligibility (fast to leave, slow to return) — implemented as a temporary score |
| 114 | + penalty, not a state-machine change. |
| 115 | +3. **Removal census fix rides along** (task #51): self-closing channels emit a |
| 116 | + reason=channel-closed removal line where exitLost fires. |
| 117 | +4. Shared-fate holds and the verdict budget are independently validated by the same |
| 118 | + literature (rate-limited reaction to shared signals) — unchanged. |
| 119 | + |
| 120 | +## 6. Platform tiers and guards |
| 121 | + |
| 122 | +| Platform | nDPI | Attribution | Pure-Go scoring+learner | Gate | |
| 123 | +|---|---|---|---|---| |
| 124 | +| Windows | Full — ndpi.dll, LoadLibrary | Full (service, owner table) | On | DLL presence + settings; nil seam | |
| 125 | +| Linux | Full — libndpi.so, dlopen | Full (sock_diag) | On | .so presence + settings; nil seam | |
| 126 | +| macOS | Full — libndpi.dylib in app bundle, dlopen from the NE extension (macOS NE has NO memory limit, unlike iOS) | Off at launch (needs future filter extension; packet tunnel cannot attribute) | On | build tag + presence; nil seam | |
| 127 | +| Android | Optional — per-ABI libndpi.so in the APP's jniLibs (the .aar cannot carry it), lazy dlopen; absent → light | getConnectionOwnerUid (API ≥29) | On | dlopen probe → nil seam; API-level check | |
| 128 | +| iOS | **None** — LGPL-3.0 vs sealed/signed bundles is effectively prohibited (FSF position; VLC precedent), and ~14 MB vs the ~50 MB NE jetsam cap is marginal anyway | None (no API in a packet tunnel; NEAppRule is MDM-only) | On (light classifier) | cgo package invisible to gomobile by module boundary; nothing to exclude | |
| 129 | + |
| 130 | +Verified guard mechanics (build-seams audit): |
| 131 | +- sdk/cgo is a separate Go module (`sdk/cgo/go.mod`); gomobile binds package |
| 132 | + `github.com/urnetwork/sdk` only — a new `sdk/cgo/ndpi` package is invisible to Android |
| 133 | + and Apple builds by construction. No build tags needed for anything under sdk/cgo. |
| 134 | +- The classifier/attributor seams copy the FlowOwnerLookup pattern exactly: nil-by-default |
| 135 | + field, gomobile-safe basic types, atomic pointer, one-branch nil cost on the egress |
| 136 | + path, re-applied on every multi rebuild. |
| 137 | +- The tier knob follows the ReliabilitySettings pattern: zero value = off/legacy; runtime |
| 138 | + swappable; logged in the session settings banner. |
| 139 | +- **Trap to respect**: the Android aar build greps exported sources against an allowlist — |
| 140 | + any new setter/type in package sdk must use gomobile-exportable basic types (as |
| 141 | + FlowOwnerLookup does) or the Android build fails. |
| 142 | +- **Second-DLL touch list** (Windows packaging, all name files explicitly and must change |
| 143 | + in lockstep): sdk/cgo/Makefile build_windows recipes (note: `; \` chaining means a |
| 144 | + failed sub-build does not fail make — add explicit failure), build-sdk.ps1, the CI |
| 145 | + "Verify the SDK actually produced DLLs" step (extend or a missing ndpi.dll ships |
| 146 | + silently), fetch-deps.ps1, App/Service .vcxproj copy steps, installer Package.wxs, |
| 147 | + package-portable.ps1 $required list. Runtime LoadLibrary avoids a second .def/.lib. |
| 148 | +- Apple/Android CI: provably zero changes (apple fork has no CI; android CI never |
| 149 | + references sdk/cgo and gomobile cannot compile it). |
| 150 | + |
| 151 | +## 7. Persistence, privacy, controls |
| 152 | + |
| 153 | +- New dot-file(s) under the existing per-network-space `.by` dir (precedent: |
| 154 | + `.doh_server_scores`), keyed by provider identity. Follows automatically on every |
| 155 | + platform via the app-supplied storage root. |
| 156 | +- **On by default; opt-out toggle; retention default 90 days, user-settable to unlimited; |
| 157 | + reset button.** Reset = delete the dot-file (and Logout already wipes the whole `.by` |
| 158 | + dir — existing hook). All data is local only, never uploaded. |
| 159 | +- Advanced Mode panel: per-app table (learned class + override), tier switch |
| 160 | + (full/light/off), retention setting, reset, and a live "why this exit" explainer for |
| 161 | + the current placements (score components + any UCB bonus). |
| 162 | + |
| 163 | +## 8. Phasing (each phase independently shippable) |
| 164 | + |
| 165 | +- **Phase 0 — gates, no behavior change**: (a) prove the nDPI ARM64 windows cross-build |
| 166 | + (its configure hard-rejects aarch64-mingw — small vendored Makefile over src/lib + |
| 167 | + third_party with pregenerated ndpi_define.h; amd64 uses upstream's own proven autotools |
| 168 | + cross); (b) reward instrumentation logging per-(class, exit) outcomes to answer "do |
| 169 | + per-class rankings actually diverge" (the go/no-go for Phase 3); (c) fix or exclude the |
| 170 | + dead DNS-through-exit probe metric before any score consumes it. |
| 171 | +- **Phase 1 — deterministic scorer** (+hysteresis, N-of-M, anti-herding tie-break, |
| 172 | + quarantine damping+ramp, recruitment prior, priors dot-file). Pure Go, all platforms. |
| 173 | + gopacket test retarget rides along. Windows FlowOwnerLookup service implementation |
| 174 | + (async) lands here — exe-class placement works with zero DPI. |
| 175 | +- **Phase 2 — classification**: nDPI binding + worker + 5-class mapping on Windows |
| 176 | + (separate ndpi.dll); light classifier everywhere; per-class scoring live. |
| 177 | +- **Phase 3 — learner**: SW-UCB dark → live on proven uplift. Android optional .so + |
| 178 | + UID attribution when the Android fork next takes a feature train. |
| 179 | +- **Phase 4 — declined by default**: LinUCB/TS upgrade only if context cardinality grows |
| 180 | + far beyond 5 classes; macOS attribution extension as its own future project. |
| 181 | + |
| 182 | +## 9. Testing |
| 183 | + |
| 184 | +- Scorer/hysteresis/tie-break/damping: table-driven with deterministic goldens (SW-UCB is |
| 185 | + deterministic given the window — golden-testable; another reason not TS). |
| 186 | +- Binding: compile-time sizeof assert against the pinned nDPI tag; the spike harness kept |
| 187 | + as a per-upgrade smoke test; classification correctness via captured-flow fixtures. |
| 188 | +- Seams: nil-classifier/nil-attributor paths pinned (mobile equivalence); aar validate |
| 189 | + allowlist check in CI already enforces exportability. |
| 190 | +- Live checkpoint greps: classifier verdict lines, placement "because" lines, dark-launch |
| 191 | + counterfactual log, exploration budget counter never exceeding 1-in-8. |
| 192 | + |
| 193 | +## 10. Out of scope |
| 194 | + |
| 195 | +Split tunneling (separate feature, blocked on driver attestation signing — task #23/#24); |
| 196 | +iOS nDPI (revisit only with an ntop commercial license — one email if ever wanted); |
| 197 | +macOS per-app attribution extension; server-side changes of any kind (none needed). |
| 198 | + |
| 199 | +## 11. Known risks / honest caveats |
| 200 | + |
| 201 | +ARM64 nDPI build is the one unproven leg (Phase 0 gates everything on it). The iOS 50 MB |
| 202 | +figure is Apple-forum-stated, not contractual. Android attribution binder cost under flow |
| 203 | +storms is unmeasured (Phase 3 pre-task). macOS App Store review of a dlopened LGPL dylib |
| 204 | +is untested — direct distribution is the safe path. nDPI ABI churns every minor release — |
| 205 | +survivable only via the opaque-pointer + pinned-tag discipline; every prior Go binding |
| 206 | +died of struct mirroring. Sticky-affinity flow-cap exceedance (owner 37, friend 1324 vs |
| 207 | +cap 16) still lacks per-placement attribution to confirm/refute the known TOCTOU race — |
| 208 | +Phase 0 instrumentation covers it (ties to task S2/#15). |
0 commit comments