protocol/meek: domain-fronted meek outbound (draft)#265
Open
myleshorton wants to merge 7 commits into
Open
Conversation
Adds a first-class meek-style transport (Tor pluggable-transport v1 wire format): chunked TCP-over-HTTPS, session-keyed by a per-Conn random ID in X-Session-Id, polling-based half-duplex. The intended deployment is a separate meek server on a non-API domain (e.g. running on a Linode VPS), reachable through Akamai or CloudFront via inner Host. This keeps user data-plane traffic off api.iantem.io and onto independent infrastructure. Wire shape per request: POST <URL> HTTP/1.1 Host: <inner host> X-Session-Id: <hex session id> Content-Type: application/octet-stream Content-Length: <N> <N bytes of outbound payload> Response body is up to MaxBodyBytes of inbound payload (or empty). The client polls every PollIntervalMs (default 100) so the server can deliver inbound bytes even when the client has nothing to send. Pieces: - option/meek.go: MeekOutboundOptions carries URL + Fronts list + polling/buffering knobs. FrontSpec is (IPAddress, SNI, VerifyHostname) — empty SNI sends no extension (Akamai style), non-empty SNI is sent verbatim (CloudFront style). - protocol/meek/client.go: Conn implementing net.Conn over a polling HTTP client. Goroutine-driven: Write buffers locally + signals the poll loop; Read blocks on inbound buffer; SetReadDeadline honored. - protocol/meek/outbound.go: sing-box adapter. Builds an http.Client whose TLS dialer picks a random front from Fronts per dial, sets ServerName from FrontSpec.SNI, verifies cert chain against VerifyHostname. - Registered in constant/proxy.go and protocol/register.go. Tests cover round-trip echo, session-id persistence across writes, and config validation. Front-list is fed externally — radiance's fronted/scanner produces the working pool per-(ASN, location, time) and supplies it to MeekOutboundOptions.Fronts via config.
Adds the server side of the meek-v1 transport: a plain-HTTP http.Handler that terminates the meek protocol and forwards each session's bytes to a configured TCP upstream. Deploys behind a CDN (Akamai DSA, CloudFront alt-domain) that handles TLS termination. Protocol matches the client in this same package: - POST /<path> with X-Session-Id: <hex> - Request body = bytes for upstream - Response body = up to MaxBodyBytes from upstream - Per-session state keyed by X-Session-Id; idle sessions reaped Design: - One TCP conn per session, dialed lazily on first POST - Background readPump per session drains upstream into a pending buffer; backpressure when buffer exceeds 4x MaxBodyBytes - ResponseHoldoff (default 50ms) bounds the read window per POST so bytes flow back quickly without spinning on empty reads - Session reaper runs every SessionIdleTimeout/2 cmd/meek-server is a thin main wrapper exposing -listen, -upstream, -path, -max-body, -holdoff, -idle-timeout, -debug. Includes a /healthz endpoint that reports SessionCount for monitoring. Tests cover end-to-end echo (real client + real server + real TCP echo upstream), 36 KB bidirectional payloads with chunked transfer, bad-method / missing-session-id rejection, upstream dial failure, and idle session reap. 10 tests total in protocol/meek, all green. Deployment: typically runs alongside a sing-box SOCKS5 inbound on localhost:1080 so the meek tunnel terminates into the existing proxy backend. CDN-side fronting handles TLS termination plus the domain-fronting routing (inner Host = the server's CDN hostname).
Captures the verified reference stack (Akamai DSA → Caddy → meek-server → microsocks → public internet) and a reproducible test that exercises the full chain: SOCKS5 handshake + CONNECT + HTTP GET, returning the origin IP httpbin observed (the Linode's public IP, confirming the request actually exits via the proxy). Test currently passes: ✅ End-to-end SUCCESS: "origin": "139.162.181.47"
readCond.Wait has no native timeout, so a Read parked there only ever woke on data arrival, close, or a fresh SetReadDeadline call — never on the deadline elapsing in real time. Callers setting a future deadline and waiting for it would hang indefinitely. Add a time.AfterFunc that broadcasts on readCond at t. Previous timer is stopped on each SetReadDeadline call (re-arming or clearing) and on Close. Zero t clears without arming. Test asserts a SetReadDeadline(now+100ms) followed by a blocking Read returns errReadDeadline in 50ms–1s.
Contributor
There was a problem hiding this comment.
Pull request overview
Adds a new meek protocol to lantern-box: a Tor-style meek v1 TCP-over-HTTPS transport with a sing-box outbound adapter, plus a reference server/CLI and accompanying unit + smoke tests. This expands domain-fronting from control-plane-only to a potential user data-plane transport.
Changes:
- Register new outbound type
meek(constant.TypeMeek) and expose it viaSupportedProtocols(). - Implement meek client (
net.Conn) + sing-box outbound adapter with per-dial random front selection and cert verification hooks. - Add a meek server implementation and
cmd/meek-serverrunnable, plus unit tests and an end-to-end smoke test script/docs.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 12 comments.
Show a summary per file
| File | Description |
|---|---|
| protocol/register.go | Wires the new meek outbound into protocol registration and the supported protocol list. |
| constant/proxy.go | Adds TypeMeek constant. |
| option/meek.go | Introduces MeekOutboundOptions and front selection specs in config schema. |
| protocol/meek/client.go | Implements meek client connection (polling HTTPS POST loop) as net.Conn. |
| protocol/meek/client_test.go | Unit tests for meek client behavior (round trip, session persistence, deadlines, config validation). |
| protocol/meek/outbound.go | sing-box outbound adapter + HTTP client transport that dials via randomly selected fronts. |
| protocol/meek/server.go | Implements meek-v1 server-side handler with session management and upstream relay. |
| protocol/meek/server_test.go | End-to-end unit tests for server behavior (echo, reaping, bad requests). |
| cmd/meek-server/main.go | Adds runnable meek server command with healthz endpoint and flags. |
| cmd/meek-server/smoketest/socks5.sh | Adds manual end-to-end smoke test script for a deployed fronted setup. |
| cmd/meek-server/smoketest/README.md | Documents reference deployment and smoke test usage. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
outbound.go: - Reject non-https URL in NewOutbound (http:// would bypass the fronted TLS DialTLSContext and the cert pinning, leaking traffic). - Require each front to set verify_hostname or sni; without either, verifyChain runs with an empty DNSName and accepts any trusted cert (no real check). Guarded at config time + at dial time. - Annotate the intentional InsecureSkipVerify with //nolint:gosec and a rationale (custom verification via VerifyPeerCertificate). - Remove unused innerHost field and unused u *url.URL param. client.go: - Bound the write backlog (MaxWriteBufBytes, default 1 MiB): Write blocks with backpressure instead of buffering without bound, so a fast sender on a slow/stalled front can't OOM the process. Wakes on drain, close, or write deadline (SetWriteDeadline now arms a timer like SetReadDeadline). - Apply ExtraHeaders before the protocol-critical ones and skip reserved headers (Host, Content-Type, X-Session-Id) so config can't hijack session keying or framing. server.go: - Add optional AuthToken shared secret (X-Meek-Auth, constant-time compare). Without it the server is an open relay into Upstream; production on a public/fronted hostname MUST set it. Default off preserves local tests. - Replace the readPump sleep-based busy-wait with a sync.Cond (drainCond) signaled by takeLocked/close — no more CPU burn / jitter under backpressure. cmd/meek-server: -auth-token flag + an open-relay warning when unset. option/meek.go: URL example uses meek.dsa.akamai.getiantem.org, not api.iantem.io. smoketest/socks5.sh: per-run mktemp -d instead of fixed /tmp paths (collision/symlink safety). Tests: auth required (403 without/with wrong token), reserved headers not overridable, NewOutbound rejects http scheme + identity-less front.
meek originated in Tor's PT framework but is architecturally distinct from Tor pluggable transports; the polling-over-HTTPS scheme here is the one Psiphon and Lantern use in practice. Reword the package doc to say meek-v1 rather than implying a Tor-PT lineage.
…orrectness - outbound: DialContext now performs a SOCKS5 CONNECT to destination over the tunnel before returning the conn. sing-box treats meek as a terminal outbound and writes the application stream directly; without the CONNECT the SOCKS5 upstream (microsocks) reads the app's first bytes as a malformed handshake and routing fails. - server: reject POST bodies larger than MaxBodyBytes with 413 instead of silently truncating and forwarding a corrupted prefix upstream. - server: readPump only blocks when pending is non-empty, so a single upstream read larger than the cap (possible when MaxBodyBytes*4 < 32 KiB) can't wedge the pump waiting for room that never frees. - client: Write appends in remaining-capacity chunks with backpressure so one large slice can't grow writeBuf past MaxWriteBufBytes. Tests: SOCKS5-connect-over-tunnel chain, oversized-body 413, small-cap delivery (deadlock regression), and large-write backlog cap. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Draft — companion to radiance#488 (fronted/scanner).
Summary
Adds a
meekoutbound type to lantern-box: a meek-v1 client that tunnels arbitrary TCP through chunked HTTPS POSTs to a meek server endpoint. Domain-fronted via per-dial random pick from a configuredFrontslist. Session-keyed by a per-Connrandom ID inX-Session-Id. meek originated in Tor's pluggable-transport framework but is architecturally distinct from Tor PTs; in practice the polling-over-HTTPS scheme implemented here is the one Psiphon and Lantern use.Why
Today our
domainfrontis a control-plane mechanism only — it routes API calls (config fetch, bandit callbacks) toapi.iantem.iothrough Akamai or CloudFront. User traffic still goes through whichever proxy was assigned. If all proxies are blocked, user traffic dies regardless of how well domainfront is working.A meek transport closes that gap: bytes flow
client → wrapped in HTTPS POST → CDN edge → meek server → unwrapped → routed to internet. When normal proxies are down, fronted traffic continues to flow.Server-side topology
Important: the meek server is not intended to live on
api.iantem.io. Plan is a dedicated domain on a Linode VPS (e.g. Frankfurt) — keeps user data-plane traffic off our API infrastructure. The outbound'sURLandInnerHostare config knobs, not hardcoded; the lantern-cloud side / Linode deployment is tracked separately as getlantern/engineering#3526.Wire format
Client polls every
PollIntervalMs(default 100 ms) so the server can deliver queued inbound bytes even when the client has nothing to send.Pieces
option/meek.go—MeekOutboundOptionscarries:URL: meek server endpoint (e.g.https://meek.lantern.io/meek/)Fronts []FrontSpec: candidate(IPAddress, SNI, VerifyHostname)tuples; one is picked at random per dialPollIntervalMs,MaxBodyBytes,SessionIDLen,ConnectTimeout,ReadTimeoutHeaderfor fixed extra HTTP headers per requestprotocol/meek/client.go—Dial(ctx, Config) (*Conn, error)produces anet.Conn. Background poll goroutine:writeBufinto the next POST body (capped atMaxBodyBytes)readBufso callers'ReadunblocksPollIntervalor immediately whenWritesignalsSetReadDeadline/SetWriteDeadlinehonoredprotocol/meek/outbound.go— sing-box adapter. Builds an*http.ClientwhoseDialTLSContext:FrontSpecfromFrontsFrontSpec.IPAddress:443via the standard sing-box dialer (respectsDialerOptions)ServerName = FrontSpec.SNI(or omits the extension if empty)FrontSpec.VerifyHostnameDialContextthen performs a SOCKS5 CONNECT todestinationover the meek tunnel before returning the conn. sing-box treats meek as a terminal outbound and writes the application stream straight into the conn, so the destination has to be conveyed to the server's upstream — a SOCKS5 proxy (microsocks). Without the CONNECT, the upstream would read the app's opening bytes as a malformed SOCKS handshake. This makes the meek server'sUpstreama SOCKS5-server contract (documented onServerConfig.Upstream).Registration:
constant.TypeMeek = "meek", plus the standardRegisterOutboundwiring inprotocol/register.go. Added tosupportedProtocols.Sequence
sequenceDiagram participant App as app participant SB as sing-box participant MK as meek outbound participant Front as CDN edge participant Srv as meek server (Linode) App->>SB: TCP connect to destination SB->>MK: DialContext MK->>MK: Dial(ctx, Config) Note over MK: generate sessionID, start pollLoop MK-->>SB: net.Conn ready SB-->>App: stream open loop application bytes flow App->>SB: Write(bytes) SB->>MK: Write(bytes) Note over MK: buffer, signal pollReady MK->>Front: POST /meek/ Host:meek.lantern.io<br/>X-Session-Id: ...<br/>body=bytes Front->>Srv: route by inner Host Srv-->>Front: response body = upstream bytes Front-->>MK: response body Note over MK: append to readBuf SB-->>App: Read returns end loop on every PollInterval, even when client has nothing MK->>Front: POST (empty body) Front-->>MK: queued inbound bytes endServer side (included in this PR)
protocol/meek/server.go—Server(anhttp.Handler) implementing the meek-v1 server: per-session upstream TCP connection, request body → upstream, upstream bytes → response body, idle-session reaper. OptionalAuthTokenshared secret (X-Meek-Auth, constant-time compared) — when set, unauthenticated requests get 403; without it the server is an open relay into the upstream, so production on a public/fronted hostname must set it.cmd/meek-server— deployable binary wrapping the server (-listen,-upstream,-auth-token,-holdoff,-idle-timeout, …). Warns when-auth-tokenis unset.cmd/meek-server/smoketest/socks5.sh— end-to-end smoke test against the deployed server (SOCKS5 handshake + HTTP GET through the meek tunnel, asserts the proxy egress IP).Security hardening (from review)
httpsURLs (would bypass the fronted TLS dialer) and fronts with no cert identity (verify_hostname/sniboth empty → no real cert check).MaxWriteBufBytes, default 1 MiB) with backpressure, so a fast sender on a slow front can't OOM the process.Host,Content-Type,X-Session-Id) can't be overridden via theheaderconfig.sync.Condinstead of a sleep-based busy-wait under backpressure.Second review pass:
DialContextSOCKS5-CONNECTs todestinationover the tunnel (see theoutbound.gobullet above) — previously the destination was dropped and raw app bytes hit the SOCKS5 upstream.MaxBodyBytesand forwarding a corrupted prefix upstream.pendingis non-empty, so a single upstream read larger thanMaxBodyBytes*4can't deadlock delivery.Writeappends in remaining-capacity chunks, so one large slice can't growwriteBufpastMaxWriteBufBytes.Tests
Unit tests against an in-process meek echo server plus the new hardening:
TestConn_RoundTrip,TestConn_SessionPersistence,TestConn_RequiresHTTPClient/TestConn_RequiresURL— core protocol + config validation.TestConn_SetReadDeadlineUnblocksParkedRead,TestConn_ReservedHeadersNotOverridable— deadline wakeup + reserved-header protection.TestServer_AuthTokenRequired— 403 without/with wrong token, proceeds with the right one.TestNewOutbound_RejectsUnsafeConfig— http scheme + identity-less front rejected.TestServer_SOCKS5ConnectOverTunnel— full client→meek→SOCKS5→destination chain via the sameClientHandshake5the outbound runs.TestServer_RejectsOversizedBody— 413 on a POST overMaxBodyBytes.TestServer_SmallMaxBodyBytesDelivers— read-pump liveness regression with a tiny cap and a 64 KiB upstream burst.TestConn_LargeWriteRespectsBacklogCap— a 1 MiB Write blocks at the cap instead of buffering wholesale.What's NOT in this PR
Frontscomes from radiance/fronted/scanner (radiance#488) but the wiring between them is a follow-up. Today you'd hardcodeFrontsin the JSON config.crypto/tlsfor simplicity. Switching torefraction-networking/utlsis a follow-up; the rest of lantern-box already uses it.Reference
🤖 Generated with Claude Code