Skip to content

protocol/meek: domain-fronted meek outbound (draft)#265

Draft
myleshorton wants to merge 2 commits into
mainfrom
fisk/meek-outbound
Draft

protocol/meek: domain-fronted meek outbound (draft)#265
myleshorton wants to merge 2 commits into
mainfrom
fisk/meek-outbound

Conversation

@myleshorton
Copy link
Copy Markdown
Contributor

Draft — companion to radiance#488 (fronted/scanner).

Summary

Adds a meek outbound type to lantern-box: a Tor-style pluggable-transport v1 meek client that tunnels arbitrary TCP through chunked HTTPS POSTs to a meek server endpoint. Domain-fronted via per-dial random pick from a configured Fronts list. Session-keyed by a per-Conn random ID in X-Session-Id.

Why

Today our domainfront is a control-plane mechanism only — it routes API calls (config fetch, bandit callbacks) to api.iantem.io through 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's URL and InnerHost are config knobs, not hardcoded; the lantern-cloud side / Linode deployment is tracked separately as getlantern/engineering#3526.

Wire format

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>

---

200 OK
Content-Type: application/octet-stream

<up to MaxBodyBytes of inbound payload (or empty)>

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.goMeekOutboundOptions carries:

    • URL: meek server endpoint (e.g. https://meek.lantern.io/meek/)
    • Fronts []FrontSpec: candidate (IPAddress, SNI, VerifyHostname) tuples; one is picked at random per dial
    • Polling/buffering knobs: PollIntervalMs, MaxBodyBytes, SessionIDLen, ConnectTimeout, ReadTimeout
    • Header for fixed extra HTTP headers per request
    • SNI semantics: empty SNI sends no extension (Akamai-style); non-empty SNI is sent verbatim (CloudFront-style)
  • protocol/meek/client.goDial(ctx, Config) (*Conn, error) produces a net.Conn. Background poll goroutine:

    • Drains writeBuf into the next POST body (capped at MaxBodyBytes)
    • Reads response body into readBuf so callers' Read unblocks
    • Ticks every PollInterval or immediately when Write signals
    • SetReadDeadline / SetWriteDeadline honored
  • protocol/meek/outbound.go — sing-box adapter. Builds an *http.Client whose DialTLSContext:

    • Picks a random FrontSpec from Fronts
    • Dials FrontSpec.IPAddress:443 via the standard sing-box dialer (respects DialerOptions)
    • Sets ServerName = FrontSpec.SNI (or omits the extension if empty)
    • Verifies cert chain against FrontSpec.VerifyHostname
  • Registration: constant.TypeMeek = "meek", plus the standard RegisterOutbound wiring in protocol/register.go. Added to supportedProtocols.

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
    end
Loading

Tests

4 unit tests against an in-process meek echo server:

  • TestConn_RoundTrip: write "hello", read "HELLO" back. Validates end-to-end.
  • TestConn_SessionPersistence: multiple writes hit the same session ID.
  • TestConn_RequiresHTTPClient / TestConn_RequiresURL: config validation.

What's NOT in this PR

  • The meek server itself: deployment on Linode + meek-server Go service is separate (lantern-cloud or fresh repo, TBD).
  • Front-list feed: Fronts comes from radiance/fronted/scanner (radiance#488) but the wiring between them is a follow-up. Today you'd hardcode Fronts in the JSON config.
  • uTLS: the outbound uses stdlib crypto/tls for simplicity. Switching to refraction-networking/utls is a follow-up; the rest of lantern-box already uses it.
  • Per-(ASN, country) bandit aggregation: covered by getlantern/engineering#3525.

Reference

🤖 Generated with Claude Code

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).
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