Skip to content

proxyless: search the resolver × wire space for a strategy that reaches the destination - #14

Merged
myleshorton merged 1 commit into
mainfrom
fisk/proxyless-search
Jul 29, 2026
Merged

proxyless: search the resolver × wire space for a strategy that reaches the destination#14
myleshorton merged 1 commit into
mainfrom
fisk/proxyless-search

Conversation

@myleshorton

@myleshorton myleshorton commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

New flint-proxyless crate — the Rust counterpart to the Outline SDK smart dialer's proxyless path (x/smart, findDNS × findTLS): reach the real destination with no proxy and no exit hop, by finding a (resolver, wire) pairing the local network does not block.

Chunk 2 of the proxyless port. #13 made the DNS and shaping axes orthogonal; this consumes them as a genuine cartesian product.

The certificate is the oracle

This is the load-bearing idea, so it is worth stating before the mechanics.

A censor can block a connection, and can answer a plaintext DNS query with anything it likes. What it cannot do is produce a valid certificate for the destination. So the test for "did this strategy work" is not did DNS answer or did TCP connect — it is did a TLS handshake to the resolved address complete against a verified chain and hostname.

Every dial in this crate, probe and destination alike, uses CertVerification::Roots. An unverified dial would destroy the property the whole design rests on. Note this is deliberately stricter than the legacy resolver dial, which still inherits CertVerification::None (design §11, the follow-up from #13).

That oracle is also what admits the poisonable resolver kinds here even though default_pool() excludes them: a forged answer cannot survive the handshake, so a poisoned resolver loses the race rather than silently winning it.

flowchart TB
    S["Space — resolvers × wires"] --> E["enumerate wire-major<br/>all resolvers on no-op plan first"]
    E --> R["race_windowed, window = 4"]
    R --> P["probe candidate"]

    subgraph ORACLE["probe — the oracle"]
        direction TB
        P1["resolve domain via Kind + WirePlan<br/>flint_dns::resolve_one_shaped"]
        P2["dial resolved IP with the same WirePlan"]
        P3["verify chain + hostname<br/>CertVerification::Roots"]
        P1 --> P2 --> P3
    end

    P --> ORACLE
    ORACLE -->|"all test domains pass"| W["winning Strategy"]
    ORACLE -.->|"any domain fails"| X["candidate abandoned"]
    W --> C["StrategyCache — resolver name + wire index<br/>re-verified on next use, never trusted"]
    W --> D["dial host:port — verified destination stream"]
Loading

Search behaviour, and why

Wire-major enumeration. Every resolver against the no-op plan first, then against each shaping plan in turn. That settles "is any resolver reachable with no shaping at all" before paying for shaping — the same instinct as the smart dialer resolving DNS before searching TLS transports.

A window of 4. Each probe is a real DNS lookup plus a real TLS handshake. Firing dozens at once would be slow, wasteful, and conspicuous on exactly the networks that are watching. race_windowed refills as each finishes, so the common case (an early candidate works) never touches the tail. There is a test asserting peak in-flight never exceeds the window.

All test domains, not any. One domain might be reachable by accident while the network is still hostile to the rest, so a partial pass must not win. An empty domain list is rejected outright rather than trivially "succeeding" against everything — a search with no oracle proves nothing.

A cached winner is re-verified, not trusted. What worked yesterday may be blocked today. On a cache hit the strategy is re-probed; if that fails the entry is forgotten and the full search runs, so a strategy the censor has since caught cannot pin the client to a dead path. StrategyCache stores a (resolver name, wire index) pair — both plain values a caller can persist with whatever encoding it already uses, and the stable resolver name survives pool reordering or a signed pool update. A stale entry resolves to None rather than to the wrong strategy.

Testability

The verification step is injectable (find_with / find_cached_with), so the search logic — enumeration order, the all-domains rule, concurrency bounds, failure reporting, cache hit/miss/self-heal — is unit-tested with no network and no TLS engine. That matters here because flint_dial::dial needs the boring feature, which the default CI job does not enable; without injection none of this would be covered by cargo test --workspace.

The real end-to-end path is a #[ignore]d live test (live_find_reaches_real_domains), matching the existing convention in flint-dns.

Scope, stated plainly

No exit hop means traffic leaves the user's own address for the real destination. This defeats blocking, not observation — an on-path censor still sees which host is being contacted, it just cannot classify or cut the TLS — and it does nothing against IP-level blackholing. It is a reachability tool, not an anonymity one, and must not silently stand in for a proxy path a user believes is carrying their traffic. This is in the crate docs and design §6.2 so it cannot be missed by a future caller.

Verification

  • cargo fmt --all -- --check — clean
  • cargo clippy --workspace --all-targets -- -D warnings — clean
  • cargo test --workspace — clean; 12 flint-proxyless tests (1 live-ignored)
  • cargo doc -p flint-proxylesszero warnings
  • cargo check -p flint-proxyless --features boring — clean (the live path's engine wiring)

Next

Chunk 3 is disorder/TTL as a new shaping axis, and the spark-side transport plus Kindling::with_proxyless for config fetches follow. The §11 resolver-verification gap from #13 remains the highest-value separate follow-up — this crate does not depend on it being fixed, since it verifies its own dials, but the legacy DoH path still does not.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BufGsK81otiqihvrZdfoJX

Summary by CodeRabbit

  • New Features

    • Added proxyless connectivity that searches DNS resolver and TLS connection strategies.
    • Verifies candidate connections across all configured test domains before selecting a strategy.
    • Added bounded parallel probing to find working strategies efficiently.
    • Added per-network caching with automatic validation and recovery from stale entries.
  • Documentation

    • Documented proxyless strategy selection, verification, caching, and limitations.

…es the destination

Chunk 2 of the outline-sdk proxyless port. New `flint-proxyless` crate: the Rust
counterpart to the Outline smart dialer's proxyless path (`x/smart`,
findDNS × findTLS) — reach the real destination with no proxy and no exit hop by
finding a `(resolver, wire)` pairing the local network does not block.

Chunk 1 made the two axes orthogonal; this consumes them as a genuine cartesian
product. A `Strategy` is one point in it, a `Space` declares the candidates, and
`find` enumerates them **wire-major** — every resolver against the no-op plan
first, then against each shaping plan — so "is any resolver reachable with no
shaping at all" is settled before paying for shaping, mirroring the smart dialer
resolving DNS before it searches TLS transports. Candidates race through
`race_windowed` with a window of 4: each probe is a real lookup plus a real
handshake, and firing dozens at once would be slow, wasteful, and conspicuous
exactly where someone is watching.

The certificate is the oracle. A censor can block a connection, and can answer a
plaintext query with anything it likes, but cannot produce a valid certificate for
the destination. So success is not "DNS answered" or "TCP connected" but *a TLS
handshake to the resolved address completing against a verified chain and
hostname*. Every dial here — probe and destination alike — uses
`CertVerification::Roots`; an unverified dial would destroy the property the
design rests on. Note this is deliberately stricter than the legacy resolver dial,
which still inherits `CertVerification::None` (design §11).

That oracle is what admits the poisonable resolver kinds here even though
`default_pool()` excludes them: a forged answer cannot survive the handshake, so a
poisoned resolver loses the race instead of silently winning it.

Two properties worth stating because they are easy to get wrong:

- A candidate must reach **all** test domains, not any, so one accidentally-open
  path cannot vouch for a hostile network. An empty domain list is rejected rather
  than trivially "succeeding" against everything.
- A cached winner is **re-verified, not trusted** — what worked yesterday may be
  blocked today — so a caught strategy self-heals into a fresh search instead of
  pinning the client to a dead path. `StrategyCache` stores a
  `(resolver name, wire index)` pair, both plain values a caller can persist with
  any encoding, and the stable resolver name survives pool reordering or a signed
  update; a stale entry resolves to None rather than the wrong strategy.

The verification step is injectable (`find_with` / `find_cached_with`), so the
search logic — enumeration order, the all-domains rule, concurrency, failure
reporting — is unit-tested with no network and no TLS engine. The real end-to-end
path is a `#[ignore]`d live test, since dialing needs the `boring` feature.

Scope stated in the crate docs and §6.2: no exit hop means traffic leaves the
user's own address for the real destination. This defeats blocking, not
observation, and does nothing against IP blackholing — a reachability tool, not an
anonymity one, and not a silent stand-in for a proxy path.

Gate: cargo fmt --all --check, cargo clippy --workspace --all-targets -D warnings,
cargo test --workspace (12 flint-proxyless tests, 1 live-ignored), cargo doc with
zero warnings, and cargo check -p flint-proxyless --features boring all clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BufGsK81otiqihvrZdfoJX
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds the flint-proxyless crate, which searches resolver and TLS wire-plan combinations using bounded, certificate-verified probing. It requires all test domains to succeed and caches winners per network with stale-entry recovery.

Changes

Proxyless reachability

Layer / File(s) Summary
Strategy model and crate setup
Cargo.toml, crates/flint-proxyless/Cargo.toml, crates/flint-proxyless/src/lib.rs
Registers the new crate and defines Strategy, Space, FindError, candidate enumeration, and cache identifiers.
Concurrent search and verified dialing
crates/flint-proxyless/src/lib.rs, docs/design.md
Searches resolver/wire combinations with bounded concurrency, requires every test domain to succeed, and performs certificate-verified DNS/TLS connections.
Cached winner lifecycle
crates/flint-proxyless/src/cache.rs, crates/flint-proxyless/src/lib.rs
Stores winners per network, reconstructs strategies from resolver names and wire indices, invalidates stale entries, and tests cache behavior.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant find_cached_with
  participant StrategyCache
  participant probe
  participant DNSResolver
  participant TLSDial
  Caller->>find_cached_with: request strategy for network
  find_cached_with->>StrategyCache: read cached winner
  find_cached_with->>probe: verify candidate across domains
  probe->>DNSResolver: resolve domain through strategy
  DNSResolver-->>probe: resolved address
  probe->>TLSDial: establish verified TLS
  TLSDial-->>probe: handshake result
  probe-->>find_cached_with: candidate success or failure
  find_cached_with-->>Caller: verified Strategy
Loading

Possibly related PRs

  • getlantern/flint#13: Adds the resolver and wire-plan functionality consumed by the proxyless search and verified dialing flow.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: a proxyless search over resolver × wire combinations to find a destination-reaching strategy.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fisk/proxyless-search

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (4)
crates/flint-proxyless/src/lib.rs (3)

341-355: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

resolve_first bakes in PROBE_PORT even for callers that discard it.

dial() (Line 337) only uses .ip() from the result and substitutes its own port, so the PROBE_PORT-tagged SocketAddr built here is thrown away in that path. Returning IpAddr instead (letting probe() attach PROBE_PORT itself) would make the helper's contract match both call sites without the discarded intermediate value.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/flint-proxyless/src/lib.rs` around lines 341 - 355, The resolve_first
helper should return the resolved IpAddr rather than constructing a SocketAddr
with PROBE_PORT. Update its return type and result construction, then adjust
callers such as dial() and probe() so each attaches its own required port while
preserving the existing address resolution and error behavior.

99-162: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Duplicate index-decomposition logic between strategy() and entry_for().

Both methods independently re-derive the same index % n (resolver) / index / n (wire) wire-major mapping. Since entry_for's output is later fed straight back through Entry::resolve (cache.rs) to reconstruct a Strategy, any future drift between the two formulas would silently mismatch cached winners to the wrong candidate rather than erroring.

♻️ Suggested consolidation
 impl Space {
+    /// Decompose `index` into `(resolver_index, wire_index)`, or `None` if out of range.
+    fn decompose(&self, index: usize) -> Option<(usize, usize)> {
+        let n = self.resolvers.len();
+        if n == 0 || index >= self.len() {
+            return None;
+        }
+        Some((index % n, index / n))
+    }
+
     pub fn strategy(&self, index: usize) -> Option<Strategy> {
-        let n = self.resolvers.len();
-        if n == 0 || index >= self.len() {
-            return None;
-        }
+        let (r, w) = self.decompose(index)?;
         Some(Strategy {
-            resolver: self.resolvers[index % n].clone(),
-            wire: self.wires[index / n].clone(),
+            resolver: self.resolvers[r].clone(),
+            wire: self.wires[w].clone(),
         })
     }

     pub fn entry_for(&self, index: usize) -> Option<cache::Entry> {
-        let n = self.resolvers.len();
-        if n == 0 || index >= self.len() {
-            return None;
-        }
+        let (r, w) = self.decompose(index)?;
         Some(cache::Entry {
-            resolver: self.resolvers[index % n].name.clone(),
-            wire: index / n,
+            resolver: self.resolvers[r].name.clone(),
+            wire: w,
         })
     }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/flint-proxyless/src/lib.rs` around lines 99 - 162, Consolidate the
wire-major index decomposition used by Space::strategy and Space::entry_for into
a single shared helper or canonical path. Reuse that logic in both methods so
the resolver index and wire index remain identical, while preserving the
existing out-of-range behavior and returned Strategy/cache::Entry values.

211-231: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Per-candidate failure detail is discarded on total failure.

race_windowed returns the accumulated Vec<io::Error> on failure, but search throws it away and reports only a count (FindError::AllFailed { tried }). For a tool whose whole point is diagnosing why a network blocks reachability, surfacing at least the last/representative error (DNS failure vs. handshake failure vs. cert mismatch) would materially help callers and operators distinguish censorship signatures from local misconfiguration.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/flint-proxyless/src/lib.rs` around lines 211 - 231, Update search’s
race_windowed error mapping to retain and expose a representative per-candidate
io::Error, such as the last accumulated error, in FindError::AllFailed instead
of discarding the Vec<io::Error>. Extend or use the existing FindError
representation while preserving the tried count and successful search behavior.
crates/flint-proxyless/Cargo.toml (1)

17-21: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Make the runtime dependency used or remove it.

flint-proxyless declares tokio with rt/time in production dependencies, but the crate only uses tokio inside #[cfg(test)]; the #[async_trait] impl depends on executor/runtime features from the calling crate, not this crate’s direct usage. Either gate a needed runtime feature behind a feature flag or remove the [dependencies] entry to avoid forcing rt/time on downstream crates.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/flint-proxyless/Cargo.toml` around lines 17 - 21, Remove the unused
tokio entry from the flint-proxyless [dependencies] section, since tokio is only
referenced by cfg(test) code. Preserve the test-only tokio usage through the
crate’s existing dev-dependency configuration or add the appropriate dev
dependency if needed, without imposing runtime features on downstream users.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@crates/flint-proxyless/Cargo.toml`:
- Around line 17-21: Remove the unused tokio entry from the flint-proxyless
[dependencies] section, since tokio is only referenced by cfg(test) code.
Preserve the test-only tokio usage through the crate’s existing dev-dependency
configuration or add the appropriate dev dependency if needed, without imposing
runtime features on downstream users.

In `@crates/flint-proxyless/src/lib.rs`:
- Around line 341-355: The resolve_first helper should return the resolved
IpAddr rather than constructing a SocketAddr with PROBE_PORT. Update its return
type and result construction, then adjust callers such as dial() and probe() so
each attaches its own required port while preserving the existing address
resolution and error behavior.
- Around line 99-162: Consolidate the wire-major index decomposition used by
Space::strategy and Space::entry_for into a single shared helper or canonical
path. Reuse that logic in both methods so the resolver index and wire index
remain identical, while preserving the existing out-of-range behavior and
returned Strategy/cache::Entry values.
- Around line 211-231: Update search’s race_windowed error mapping to retain and
expose a representative per-candidate io::Error, such as the last accumulated
error, in FindError::AllFailed instead of discarding the Vec<io::Error>. Extend
or use the existing FindError representation while preserving the tried count
and successful search behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 02b0c8c8-44d4-4771-bc2a-58e0b76474d7

📥 Commits

Reviewing files that changed from the base of the PR and between 4bd130f and ac879d0.

📒 Files selected for processing (5)
  • Cargo.toml
  • crates/flint-proxyless/Cargo.toml
  • crates/flint-proxyless/src/cache.rs
  • crates/flint-proxyless/src/lib.rs
  • docs/design.md

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant