Skip to content

Commit 95f4cd7

Browse files
myleshortonclaude
andauthored
flint-fronted: meek polling client + vantage-point scanner (Shir-o-Khorshid CDN-fronting) (#10)
* flint-fronted: meek polling client + vantage-point scanner (Shir-o-Khorshid CDN-fronting) Add a self-bootstrapping, domain-fronted meek POLLING transport. This is distinct from the existing h2-streaming MeekStream (a different wire protocol the deployed meek-server does not speak); only the fronting/TLS/config layer is reused. - meek_poll: MeekPollConn (AsyncRead+AsyncWrite) porting lantern-box protocol/meek PR #282 — discrete POSTs keyed by X-Session-Id + a monotonic server-deduped X-Meek-Seq (lost request/response replays, no gap/dup), 256KB negotiated body (X-Meek-Max-Body), retriable polls. Both h1 and h2 backends. FrontedMeekPollDialer + open_meek_poll glue; app<->poll-task bridged via tokio::io::duplex. - sys_dns: SystemResolver (getaddrinfo) — local/ISP DNS for Akamai-edge discovery. A censor returns truthful, geo-local edge IPs; flint's DoH resolver would bypass exactly the local answers we want. - scanner: vantage-point front discovery — Akamai edge local-DNS resolution + CloudFront & Aliyun/Alibaba prefix sampling, two-stage ranked probe. - socks5: minimal SOCKS5 CONNECT over the tunnel (meek-server upstream is microsocks). - dial_fronts: race scanner-discovered fronts without a Config. 37 hermetic tests pass (incl. multi-poll reassembly + retry-replay-without-gap/dup). Live test (tests/meek_live.rs, run with --features boring --ignored): a real Akamai edge (system-resolved) -> meek over h1 -> the deployed meek-server -> SOCKS5 -> example.com -> 200. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H9beSsYGzUaBhRK5ULmtGr * flint-dial/fronted: surface negotiated ALPN; meek auto-selects h1/h2 The boring Chrome dial offers h2,http/1.1 and the CDN edge picks one, but boxing the SslStream into BoxedTlsStream erased which. Capture it and thread it up so the meek client speaks whatever was negotiated per connection instead of a fixed guess. - flint-dial: new AlpnStream (wraps BoxedTlsStream + the negotiated ALPN) and dial_alpn / dial_over_alpn; dial / dial_over keep their old signatures (thin wrappers that drop the ALPN). dial_boring captures ssl().selected_alpn_protocol() before boxing. - flint-fronted: dial_fronts_alpn (race scanner fronts, keep the ALPN) and open_meek_poll_auto (set MeekPollConfig.http_version = h2 iff the edge negotiated h2, else h1). - meek_live test: drive the full auto path (scan -> dial_fronts_alpn -> open_meek_poll_auto). Live against the deployed server it logs negotiated ALPN "http/1.1" and auto-selects h1; SOCKS5 -> example.com -> 200. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H9beSsYGzUaBhRK5ULmtGr * flint: address review (timeouts, h1 framing/CRLF hardening, drop-abort, honest retry) Review fixes from CodeRabbit + Copilot on the fronted-meek PR: - MeekPollConn aborts its poll task on Drop (was detached, could outlive the conn). - Per-poll request timeout (MeekPollConfig.request_timeout) wraps each roundtrip; an elapsed request is retryable so a stalled response can't block forever. - Retry semantics made honest: removed the no-op Backend::reconnect; retry only in-place when safe (h2: fresh stream + server seq-dedupe), h1 ends the session on failure (single keep-alive connection a failed request leaves desynced). - h1 hardening: reject CR/LF/control chars in inner_host/path (raw request line); require Content-Length on keep-alive and error on over-cap instead of leaving unread bytes; chunked decode now parses the size token before `;extensions`, drains all trailers to the blank line, and keeps draining framing past the cap (stops appending only) so the connection stays aligned. - h2: error on an over-cap response instead of truncating + advancing seq. - SystemResolver: bound getaddrinfo with a 5s timeout (a stuck resolver can't hang the scan). - meek_live: timeout the SOCKS5 handshake + request write too. - cargo fmt (unblocks CI); fix a stale MeekHttpVersion doc (ALPN is now exposed). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H9beSsYGzUaBhRK5ULmtGr * meek_live: assert on the HTTP status line + body marker, not a bare "200" Review (Copilot): "200" could appear anywhere in the bytes. Assert the response starts with "HTTP/1.1 200" and contains "Example Domain". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H9beSsYGzUaBhRK5ULmtGr * flint: review round 3 (stricter host validation, framing checks) - Reject space/non-ASCII (not just CR/LF/control) in the h1 inner_host/path, which would otherwise break the request-target / Host tokenization. - Validate the chunk-terminating CRLF in chunked decode (error on malformed framing instead of silently desyncing the keep-alive connection). - Validate the SOCKS5 reply RSV byte is 0x00. - sys_dns test: bind the resolver to a local before awaiting. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H9beSsYGzUaBhRK5ULmtGr * flint: review round 4 (bound chunk allocation, accurate Front.domain) - read_chunked: reject a chunk whose size exceeds max_body_bytes before allocating, so an attacker-controlled chunk-size line can't force an OOM allocation (the server caps responses at the advertised max-body anyway). - Candidate::to_front sets Front.domain to the edge's verify hostname (e.g. a248.e.akamai.net) rather than duplicating the inner fronted_host. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H9beSsYGzUaBhRK5ULmtGr * flint: review round 5 (chunked over-cap errors; strict inner_host authority) - read_chunked errors when the total exceeds max_body_bytes (matching the non-chunked + h2 paths) instead of silently truncating and advancing the seq. - inner_host is validated as a bare authority — also reject `/ ? # @ \` (not just control/space/non-ASCII), which would break the Host header / request target. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H9beSsYGzUaBhRK5ULmtGr * flint: review round 6 (h2 driver teardown; provider verify hostnames) - H2Backend holds the h2 connection-driver JoinHandle and aborts it on Drop, so the driver (and the TLS stream it holds) can't outlive the backend — matching MeekPollConn's drop semantics. - ScanTargets gains cloudfront_verify_hostname / aliyun_verify_hostname: prefix- sampled (empty-SNI) candidates verify against the edge's cert identity rather than always the inner fronted_host (which only matches for an Akamai endpoint). Defaults preserve current behavior; documented for real CloudFront/Aliyun meek. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H9beSsYGzUaBhRK5ULmtGr * socks5: flush after each write so buffered writers don't deadlock Flush after the method-select greeting and the CONNECT request; a buffered AsyncWrite could otherwise hold the bytes while we block on read_exact. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H9beSsYGzUaBhRK5ULmtGr * flint: review round 8 (hermetic h1 test; normalize system DNS results) - Add a hermetic HTTP/1.1 meek echo test so the h1 backend's Content-Length path has CI coverage (previously only the live test exercised h1). - SystemResolver short-circuits IP literals and sorts+dedups results, matching FlintDnsResolver and avoiding redundant scan/dial attempts. (Skipped the dial() ALPN-Vec micro-alloc nit — negligible, and dial() is not the meek hot path; the race uses dial_alpn.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H9beSsYGzUaBhRK5ULmtGr * flint: fix clippy (derivable_impls) — derive Default for MeekHttpVersion CI runs `cargo clippy --all-targets -- -D warnings`; the manual Default impl tripped clippy::derivable_impls. Replace with #[derive(Default)] + #[default] on H2 (unchanged behavior). Ran the exact CI clippy command locally to confirm clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H9beSsYGzUaBhRK5ULmtGr --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 76c5cd3 commit 95f4cd7

8 files changed

Lines changed: 2102 additions & 9 deletions

File tree

crates/flint-dial/src/engine.rs

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,35 +9,50 @@ use flint_tls::{CertVerification, Profile};
99
use tokio::io::{AsyncRead, AsyncWrite};
1010
use tokio::net::TcpStream;
1111

12-
use crate::{BootstrapStrategy, BoxedTlsStream, TlsEngine};
12+
use crate::{AlpnStream, BootstrapStrategy, BoxedTlsStream, TlsEngine};
1313

1414
/// Execute `strategy`: open a TCP connection to its target, then dial TLS over it ([`dial_over`]).
1515
/// Sets `TCP_NODELAY` when the wire plan asks for it (so each shaped segment leaves as its own packet).
1616
pub async fn dial(strategy: &BootstrapStrategy) -> io::Result<BoxedTlsStream> {
17+
Ok(dial_alpn(strategy).await?.into_inner())
18+
}
19+
20+
/// Like [`dial`], but returns an [`AlpnStream`] that also carries the ALPN protocol the server
21+
/// negotiated, so a consumer can pick its HTTP version per connection.
22+
pub async fn dial_alpn(strategy: &BootstrapStrategy) -> io::Result<AlpnStream> {
1723
let tcp = TcpStream::connect(strategy.target).await?;
1824
if strategy.wire.tcp_nodelay {
1925
let _ = tcp.set_nodelay(true);
2026
}
21-
dial_over(tcp, strategy).await
27+
dial_over_alpn(tcp, strategy).await
2228
}
2329

2430
/// Execute `strategy` over an already-connected byte `stream` (the caller controls how the TCP is
2531
/// opened — e.g. spark injecting socket protection, or a test injecting an in-memory pipe). Applies
2632
/// the wire shaping, then runs the strategy's TLS engine.
2733
pub async fn dial_over<S>(stream: S, strategy: &BootstrapStrategy) -> io::Result<BoxedTlsStream>
34+
where
35+
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
36+
{
37+
Ok(dial_over_alpn(stream, strategy).await?.into_inner())
38+
}
39+
40+
/// Like [`dial_over`], but returns an [`AlpnStream`] carrying the negotiated ALPN protocol.
41+
pub async fn dial_over_alpn<S>(stream: S, strategy: &BootstrapStrategy) -> io::Result<AlpnStream>
2842
where
2943
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
3044
{
3145
match &strategy.engine {
3246
TlsEngine::BoringChrome(profile) => {
33-
dial_boring(
47+
let (stream, alpn) = dial_boring(
3448
stream,
3549
&strategy.sni,
3650
profile,
3751
&strategy.wire,
3852
&strategy.verification,
3953
)
40-
.await
54+
.await?;
55+
Ok(AlpnStream::new(stream, alpn))
4156
}
4257
TlsEngine::Rustls => {
4358
drop(stream);
@@ -70,12 +85,15 @@ async fn dial_boring<S>(
7085
profile: &Profile,
7186
wire: &WirePlan,
7287
verification: &CertVerification,
73-
) -> io::Result<BoxedTlsStream>
88+
) -> io::Result<(BoxedTlsStream, Option<Vec<u8>>)>
7489
where
7590
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
7691
{
7792
let tls = flint_tls::connect_with(shape(stream, wire), sni, profile, verification).await?;
78-
Ok(Box::new(tls))
93+
// Capture the ALPN the edge negotiated (the boring profile offers h2,http/1.1)
94+
// before boxing erases the concrete SslStream.
95+
let alpn = tls.ssl().selected_alpn_protocol().map(|p| p.to_vec());
96+
Ok((Box::new(tls), alpn))
7997
}
8098

8199
#[cfg(not(feature = "boring"))]
@@ -85,7 +103,7 @@ async fn dial_boring<S>(
85103
_profile: &Profile,
86104
_wire: &WirePlan,
87105
_verification: &CertVerification,
88-
) -> io::Result<BoxedTlsStream>
106+
) -> io::Result<(BoxedTlsStream, Option<Vec<u8>>)>
89107
where
90108
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
91109
{

crates/flint-dial/src/lib.rs

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,17 @@
1717
//! explicit `Unsupported` error rather than silently degrading.
1818
#![forbid(unsafe_code)]
1919

20-
use tokio::io::{AsyncRead, AsyncWrite};
20+
use std::io;
21+
use std::pin::Pin;
22+
use std::task::{Context, Poll};
23+
24+
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
2125

2226
mod engine;
2327
mod race;
2428
mod strategy;
2529

26-
pub use engine::{dial, dial_over};
30+
pub use engine::{dial, dial_alpn, dial_over, dial_over_alpn};
2731
pub use flint_tls::CertVerification;
2832
pub use race::{probe_windowed, race, race_windowed, race_with};
2933
pub use strategy::{BootstrapStrategy, TlsEngine};
@@ -36,3 +40,56 @@ pub type BoxedTlsStream = Box<dyn TlsStream>;
3640
pub trait TlsStream: AsyncRead + AsyncWrite + Unpin + Send {}
3741

3842
impl<T: AsyncRead + AsyncWrite + Unpin + Send + ?Sized> TlsStream for T {}
43+
44+
/// A dialed TLS stream that also carries the **ALPN protocol the server
45+
/// negotiated** (e.g. `b"h2"` or `b"http/1.1"`). The boring Chrome engine offers
46+
/// `h2,http/1.1`; the edge picks one, and a consumer (e.g. the meek client) reads
47+
/// it back here to choose its HTTP version per connection rather than guessing.
48+
/// Wraps a [`BoxedTlsStream`] and forwards all I/O to it.
49+
pub struct AlpnStream {
50+
inner: BoxedTlsStream,
51+
alpn: Option<Vec<u8>>,
52+
}
53+
54+
impl AlpnStream {
55+
pub fn new(inner: BoxedTlsStream, alpn: Option<Vec<u8>>) -> Self {
56+
Self { inner, alpn }
57+
}
58+
59+
/// The negotiated ALPN protocol (e.g. `b"h2"`, `b"http/1.1"`), or `None` if
60+
/// none was negotiated.
61+
pub fn alpn(&self) -> Option<&[u8]> {
62+
self.alpn.as_deref()
63+
}
64+
65+
/// Drop the ALPN annotation, yielding the underlying stream.
66+
pub fn into_inner(self) -> BoxedTlsStream {
67+
self.inner
68+
}
69+
}
70+
71+
impl AsyncRead for AlpnStream {
72+
fn poll_read(
73+
mut self: Pin<&mut Self>,
74+
cx: &mut Context<'_>,
75+
buf: &mut ReadBuf<'_>,
76+
) -> Poll<io::Result<()>> {
77+
Pin::new(&mut self.inner).poll_read(cx, buf)
78+
}
79+
}
80+
81+
impl AsyncWrite for AlpnStream {
82+
fn poll_write(
83+
mut self: Pin<&mut Self>,
84+
cx: &mut Context<'_>,
85+
buf: &[u8],
86+
) -> Poll<io::Result<usize>> {
87+
Pin::new(&mut self.inner).poll_write(cx, buf)
88+
}
89+
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
90+
Pin::new(&mut self.inner).poll_flush(cx)
91+
}
92+
fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
93+
Pin::new(&mut self.inner).poll_shutdown(cx)
94+
}
95+
}

crates/flint-fronted/src/lib.rs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,20 @@ pub use flint_transport::{
3232
race_boxed, BoxedConnection, BoxedConnectionTransport, Connection, ConnectionTransport,
3333
RaceError, RaceOptions, TransportConnection,
3434
};
35+
36+
pub mod meek_poll;
37+
pub use meek_poll::{
38+
open_meek_poll, open_meek_poll_auto, FrontedMeekPollDialer, MeekHttpVersion, MeekPollConfig,
39+
MeekPollConn, DEFAULT_MAX_BODY_BYTES,
40+
};
41+
42+
pub mod sys_dns;
43+
pub use sys_dns::SystemResolver;
44+
45+
pub mod scanner;
46+
pub use scanner::{Candidate, ScanResult, ScanTargets};
47+
48+
pub mod socks5;
3549
use http::{Method, Request, StatusCode};
3650
use ring::digest;
3751
use serde::{Deserialize, Serialize};
@@ -1481,6 +1495,36 @@ where
14811495
}
14821496
}
14831497

1498+
/// Race already-materialized fronts (e.g. scanner-discovered) to a working
1499+
/// verified-TLS edge, dialing each with the boring Chrome engine. The scanner /
1500+
/// self-bootstrap path uses this instead of [`FrontPool`] (which needs a
1501+
/// [`Config`]). Requires the `boring` feature at runtime; without it each dial
1502+
/// returns an Unsupported error and the race fails.
1503+
pub async fn dial_fronts(
1504+
host: &str,
1505+
fronts: &[MaterializedFront],
1506+
options: DialOptions,
1507+
) -> Result<FrontedConnection, Error> {
1508+
race_materialized_with(host, fronts, options, |strategy| async move {
1509+
flint_dial::dial(&strategy).await
1510+
})
1511+
.await
1512+
}
1513+
1514+
/// Like [`dial_fronts`], but each dial returns an [`flint_dial::AlpnStream`] so the
1515+
/// caller can read the ALPN the winning edge negotiated (h2 vs http/1.1) — used by
1516+
/// the meek client to auto-select its HTTP version per connection.
1517+
pub async fn dial_fronts_alpn(
1518+
host: &str,
1519+
fronts: &[MaterializedFront],
1520+
options: DialOptions,
1521+
) -> Result<FrontedConnection<flint_dial::AlpnStream>, Error> {
1522+
race_materialized_with(host, fronts, options, |strategy| async move {
1523+
flint_dial::dial_alpn(&strategy).await
1524+
})
1525+
.await
1526+
}
1527+
14841528
struct DialCandidate {
14851529
front: Front,
14861530
addr: SocketAddr,

0 commit comments

Comments
 (0)