-
Notifications
You must be signed in to change notification settings - Fork 3
protocol/meek: domain-fronted meek outbound (draft) #265
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
16 commits
Select commit
Hold shift + click to select a range
a1b4abb
protocol/meek: domain-fronted meek outbound
myleshorton 5d1682b
protocol/meek + cmd/meek-server: domain-fronted server
myleshorton 1386542
cmd/meek-server: add end-to-end SOCKS5 smoke test + deployment notes
myleshorton c467035
protocol/meek: SetReadDeadline now unblocks a parked Read
myleshorton fe2ab1b
meek: address PR review — security hardening + cleanups
myleshorton 4a33249
meek: drop "Tor pluggable transport" framing in package doc
myleshorton 3c81128
meek: address second review pass — destination routing + buffer-cap c…
myleshorton 5e3ae35
meek: fix SOCKS5 CONNECT over the polling Conn (byte-wise reads desyn…
myleshorton 92610cc
meek: retriable polls (seq/ack) + larger negotiated poll body
myleshorton 06c6dc9
cmd/meek-server: add deploy.sh (build, ship, swap, verify, rollback)
myleshorton f3fbae8
meek: address Copilot review — hex reply codes, RSV check, bytewise h…
myleshorton f1cbf6b
meek: address second Copilot round (over-cap response + deploy.sh har…
myleshorton 263f4b5
Merge pull request #282 from getlantern/fisk/meek-socks5-fix
myleshorton 73c35a3
Merge remote-tracking branch 'origin/main' into fisk/meek-outbound
myleshorton 446085a
meek: address Copilot review on #265 (EOF propagation, net.Error dead…
myleshorton 67937cc
meek: address CodeRabbit review on #265 (timeouts, EOF/session harden…
myleshorton File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,145 @@ | ||
| #!/usr/bin/env bash | ||
| # Deploy meek-server to its fronted origin host. | ||
| # | ||
| # Builds linux/amd64 from THIS checkout, ships it, verifies the transfer by | ||
| # sha256, swaps it in atomically (keeping a timestamped backup), restarts the | ||
| # service, and verifies /healthz — rolling back automatically if it doesn't come | ||
| # back. Finally runs the end-to-end SOCKS5 smoke test (best-effort). | ||
| # | ||
| # MEEK_HOST is REQUIRED (no default) so an env-less run can't silently deploy to a | ||
| # live origin. The host's service layout isn't pinned in-repo, so the rest is | ||
| # overridable via env (defaults below). Confirm these match the host before the | ||
| # first real run; use --dry-run to preview. | ||
| # | ||
| # MEEK_HOST=139.162.181.47 (required) MEEK_SSH_USER=root MEEK_SSH_KEY=~/.ssh/id | ||
| # MEEK_REMOTE_BIN=/usr/local/bin/meek-server MEEK_SERVICE=meek-server | ||
| # MEEK_RESTART_CMD="systemctl restart $MEEK_SERVICE" | ||
| # MEEK_STATUS_CMD="systemctl is-active $MEEK_SERVICE" | ||
| # MEEK_HEALTHZ_URL=https://meek.getiantem.org/healthz | ||
| # MEEK_SSH_STRICT=accept-new (set to "yes" for strict host-key checking) | ||
| # | ||
| # Usage: cmd/meek-server/deploy.sh [-n|--dry-run] [-h|--help] | ||
| set -euo pipefail | ||
|
|
||
| HOST="${MEEK_HOST:-}" | ||
| SSH_USER="${MEEK_SSH_USER:-root}" | ||
| SSH_KEY="${MEEK_SSH_KEY:-}" | ||
| REMOTE_BIN="${MEEK_REMOTE_BIN:-/usr/local/bin/meek-server}" | ||
| SERVICE="${MEEK_SERVICE:-meek-server}" | ||
| RESTART_CMD="${MEEK_RESTART_CMD:-systemctl restart $SERVICE}" | ||
| STATUS_CMD="${MEEK_STATUS_CMD:-systemctl is-active $SERVICE}" | ||
| # No default: a fixed prod URL here would verify (and gate rollback on) the wrong | ||
| # host for staging/canary deploys. Unset → the HTTP health check is skipped and we | ||
| # rely on the service-status check after restart. | ||
| HEALTHZ_URL="${MEEK_HEALTHZ_URL:-}" | ||
|
|
||
| DRY_RUN=0 | ||
| case "${1:-}" in | ||
| -n|--dry-run) DRY_RUN=1 ;; | ||
| -h|--help) sed -n '2,21p' "$0"; exit 0 ;; | ||
| "") ;; | ||
| *) echo "unknown arg: $1 (try --help)" >&2; exit 2 ;; | ||
| esac | ||
|
|
||
| # Required (checked after --help/-h so those don't need it): never default to a live host. | ||
| : "${HOST:?required — set MEEK_HOST to the target origin (e.g. 139.162.181.47); refusing to default to a live host}" | ||
|
|
||
| cd "$(dirname "$0")/../.." # repo root | ||
|
|
||
| # accept-new (TOFU) by default so a first deploy to operator-owned infra doesn't | ||
| # require pre-seeding known_hosts; set MEEK_SSH_STRICT=yes for strict checking. | ||
| SSH_STRICT="${MEEK_SSH_STRICT:-accept-new}" | ||
| SSH_OPTS=(-o "StrictHostKeyChecking=$SSH_STRICT" -o ConnectTimeout=15) | ||
| [ -n "$SSH_KEY" ] && SSH_OPTS+=(-i "$SSH_KEY") | ||
| ssh_h() { ssh "${SSH_OPTS[@]}" "${SSH_USER}@${HOST}" "$@"; } | ||
| say() { printf '\n=== %s ===\n' "$*"; } | ||
|
|
||
| # Hash locally with whichever tool exists: sha256sum (most Linux) or shasum -a 256 | ||
| # (macOS, some others). The remote always has sha256sum. | ||
| sha256_local() { | ||
| if command -v sha256sum >/dev/null 2>&1; then | ||
| sha256sum "$1" | awk '{print $1}' | ||
| else | ||
| shasum -a 256 "$1" | awk '{print $1}' | ||
| fi | ||
| } | ||
|
|
||
| say "build meek-server (linux/amd64) from $(git rev-parse --short HEAD 2>/dev/null || echo '?')" | ||
| TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT | ||
| GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o "$TMP/meek-server" ./cmd/meek-server | ||
| LSHA=$(sha256_local "$TMP/meek-server") | ||
| echo "built $(wc -c <"$TMP/meek-server") bytes sha256=$LSHA" | ||
|
|
||
| if [ "$DRY_RUN" = 1 ]; then | ||
| cat <<PLAN | ||
|
|
||
| [dry-run] would deploy to ${SSH_USER}@${HOST}: | ||
| scp -> /tmp/meek-server.new (then verify sha256 == $LSHA) | ||
| cp $REMOTE_BIN -> ${REMOTE_BIN}.bak.<ts> (backup, if present) | ||
| install /tmp/meek-server.new -> $REMOTE_BIN | ||
| run $RESTART_CMD ; check $STATUS_CMD | ||
| verify $HEALTHZ_URL returns "ok" (else roll back) | ||
| smoke test: cmd/meek-server/smoketest/socks5.sh | ||
| PLAN | ||
| exit 0 | ||
| fi | ||
|
|
||
| say "ship to ${SSH_USER}@${HOST}" | ||
| scp "${SSH_OPTS[@]}" "$TMP/meek-server" "${SSH_USER}@${HOST}:/tmp/meek-server.new" | ||
|
|
||
| say "verify transfer (sha256)" | ||
| RSHA=$(ssh_h "sha256sum /tmp/meek-server.new | awk '{print \$1}'") | ||
| if [ "$RSHA" != "$LSHA" ]; then | ||
| echo "sha256 mismatch: local=$LSHA remote=$RSHA — aborting, nothing swapped" >&2 | ||
| ssh_h "rm -f /tmp/meek-server.new" || true | ||
| exit 1 | ||
| fi | ||
| echo "verified on host" | ||
|
|
||
| say "backup + atomic swap + restart" | ||
| BAK="${REMOTE_BIN}.bak.$(date -u +%Y%m%dT%H%M%SZ)" | ||
| ssh_h bash -s <<REMOTE | ||
| set -euo pipefail | ||
| if [ -f "$REMOTE_BIN" ]; then cp -a "$REMOTE_BIN" "$BAK"; echo "backed up -> $BAK"; fi | ||
| install -m 0755 /tmp/meek-server.new "$REMOTE_BIN" | ||
| rm -f /tmp/meek-server.new | ||
| $RESTART_CMD | ||
| sleep 2 | ||
| echo -n "service: "; $STATUS_CMD || true | ||
| REMOTE | ||
|
|
||
| if [ -z "$HEALTHZ_URL" ]; then | ||
| say "verify /healthz (skipped — set MEEK_HEALTHZ_URL for an HTTP health gate)" | ||
| echo "relying on the post-restart service-status check above" | ||
| else | ||
| say "verify /healthz" | ||
| ok=0 | ||
| for _ in $(seq 1 10); do | ||
| if curl -fsS --max-time 10 "$HEALTHZ_URL" 2>/dev/null | grep -q "ok"; then ok=1; break; fi | ||
| sleep 2 | ||
| done | ||
| if [ "$ok" != 1 ]; then | ||
| echo "healthz did not return ok — ROLLING BACK to $BAK" >&2 | ||
| ssh_h bash -s <<REMOTE || true | ||
| set -euo pipefail | ||
| if [ -f "$BAK" ]; then install -m 0755 "$BAK" "$REMOTE_BIN"; $RESTART_CMD; echo "rolled back"; else echo "no backup present"; fi | ||
| REMOTE | ||
| exit 1 | ||
| fi | ||
| echo "healthz ok" | ||
| fi | ||
|
|
||
| say "end-to-end smoke test (best-effort)" | ||
| if [ -x cmd/meek-server/smoketest/socks5.sh ]; then | ||
| if bash cmd/meek-server/smoketest/socks5.sh; then | ||
| echo "smoke test PASSED" | ||
| else | ||
| echo "NOTE: smoke test did not pass — often httpbin.org being down, not the deploy." | ||
| echo " re-run against a reliable target, e.g. edit TARGET_HOST to example.com." | ||
| fi | ||
| else | ||
| echo "(socks5.sh not found/executable — skipped)" | ||
| fi | ||
|
|
||
| echo | ||
| echo "deploy complete: $REMOTE_BIN @ $HOST (sha256 $LSHA) backup: $BAK" |
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,122 @@ | ||
| // Command meek-server is a domain-frontable HTTP endpoint that | ||
| // terminates the meek-v1 protocol and forwards each session's bytes to | ||
| // a configured TCP upstream (typically a SOCKS5 inbound on localhost). | ||
| // | ||
| // Deployment: runs behind a CDN (Akamai DSA, CloudFront alt-domain | ||
| // distribution) that terminates TLS and forwards plain HTTP to the | ||
| // server's --listen address. The CDN's inner Host header carries the | ||
| // client's session over the fronted connection. | ||
| // | ||
| // Example, running on Linode behind nginx-as-TLS-terminator on :443 | ||
| // with a sing-box SOCKS5 inbound on localhost:1080: | ||
| // | ||
| // meek-server -listen :8080 -upstream 127.0.0.1:1080 | ||
| package main | ||
|
|
||
| import ( | ||
| "context" | ||
| "errors" | ||
| "flag" | ||
| "fmt" | ||
| "log/slog" | ||
| "net/http" | ||
| "os" | ||
| "os/signal" | ||
| "syscall" | ||
| "time" | ||
|
|
||
| "github.com/getlantern/lantern-box/protocol/meek" | ||
| ) | ||
|
|
||
| func main() { | ||
| if err := run(); err != nil { | ||
| fmt.Fprintf(os.Stderr, "meek-server: %v\n", err) | ||
| os.Exit(1) | ||
| } | ||
| } | ||
|
|
||
| func run() error { | ||
| listen := flag.String("listen", ":8080", "address to listen on") | ||
| upstream := flag.String("upstream", "", "upstream TCP address (e.g. 127.0.0.1:1080)") | ||
| path := flag.String("path", "/", "URL path the server handles (other paths get 404)") | ||
| maxBody := flag.Int("max-body", 256*1024, "max request/response body bytes per poll (matches the client's 256 KiB default)") | ||
| holdoff := flag.Duration("holdoff", 50*time.Millisecond, "how long to wait for upstream bytes before responding") | ||
| idleTimeout := flag.Duration("idle-timeout", 5*time.Minute, "session idle reap threshold") | ||
| authToken := flag.String("auth-token", "", "shared secret required in the X-Meek-Auth header; empty disables auth (open relay — only for local testing)") | ||
| debug := flag.Bool("debug", false, "verbose logging") | ||
| flag.Parse() | ||
|
|
||
| if *upstream == "" { | ||
| return errors.New("-upstream is required") | ||
| } | ||
|
|
||
| logLevel := slog.LevelInfo | ||
| if *debug { | ||
| logLevel = slog.LevelDebug | ||
| } | ||
| logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: logLevel})) | ||
|
|
||
| if *authToken == "" { | ||
| logger.Warn("meek-server: -auth-token not set; running as an OPEN RELAY into the upstream (fine for local testing, never in production)") | ||
| } | ||
|
|
||
| srv, err := meek.NewServer(meek.ServerConfig{ | ||
| Upstream: *upstream, | ||
| MaxBodyBytes: *maxBody, | ||
| ResponseHoldoff: *holdoff, | ||
| SessionIdleTimeout: *idleTimeout, | ||
| AuthToken: *authToken, | ||
| Logger: logger, | ||
| }) | ||
| if err != nil { | ||
| return fmt.Errorf("create server: %w", err) | ||
| } | ||
| defer srv.Close() | ||
|
|
||
| mux := http.NewServeMux() | ||
| mux.Handle(*path, srv) | ||
| mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { | ||
| fmt.Fprintf(w, "ok sessions=%d\n", srv.SessionCount()) | ||
| }) | ||
|
|
||
| httpServer := &http.Server{ | ||
| Addr: *listen, | ||
| Handler: mux, | ||
| // Bound slow/abusive clients. Generous vs meek's poll model: each POST | ||
| // sends its body then gets a response within ResponseHoldoff, so these | ||
| // only kill genuinely-stuck connections, not normal polls. | ||
| ReadHeaderTimeout: 10 * time.Second, | ||
| ReadTimeout: 60 * time.Second, | ||
| WriteTimeout: 60 * time.Second, | ||
| IdleTimeout: 120 * time.Second, | ||
| } | ||
|
myleshorton marked this conversation as resolved.
|
||
|
|
||
| errCh := make(chan error, 1) | ||
| go func() { | ||
| logger.Info("meek-server starting", | ||
| slog.String("listen", *listen), | ||
| slog.String("upstream", *upstream), | ||
| slog.String("path", *path), | ||
| ) | ||
| if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { | ||
| errCh <- err | ||
| } | ||
| close(errCh) | ||
| }() | ||
|
|
||
| sigCh := make(chan os.Signal, 1) | ||
| signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) | ||
|
|
||
| select { | ||
| case err := <-errCh: | ||
| if err != nil { | ||
| return fmt.Errorf("listen: %w", err) | ||
| } | ||
| case sig := <-sigCh: | ||
| logger.Info("meek-server shutting down", slog.String("signal", sig.String())) | ||
| ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | ||
| defer cancel() | ||
| _ = httpServer.Shutdown(ctx) | ||
| } | ||
| return nil | ||
| } | ||
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| # meek-server smoke tests | ||
|
|
||
| End-to-end validation that a deployed meek-server behind a CDN actually | ||
| shuttles bytes from a fronted HTTPS client all the way to the public | ||
| internet via the upstream proxy. | ||
|
|
||
| ## Reference deployment (verified 2026-05-23) | ||
|
|
||
| ```text | ||
| outer SNI = a248.e.akamai.net (or any Akamai-fronted host) | ||
| inner Host = meek.dsa.akamai.getiantem.org | ||
| client ──────HTTPS─────► Akamai DSA ──────HTTP/HTTPS─────► Linode :443 | ||
| │ | ||
| Caddy (TLS terminator, | ||
| LE cert for meek.getiantem.org) | ||
| │ HTTP | ||
| ▼ | ||
| meek-server :8080 | ||
| (-upstream 127.0.0.1:1080) | ||
| │ TCP | ||
| ▼ | ||
| microsocks :1080 | ||
| (SOCKS5, direct outbound) | ||
| │ | ||
| ▼ | ||
| public internet | ||
| ``` | ||
|
|
||
| Akamai property: `meek.dsa.akamai.getiantem.org`, edge hostname | ||
| `meek.dsa.akamai.getiantem.org.edgesuite.net` (Shared Cert / Standard | ||
| TLS — auto-covered by the edgesuite wildcard at the SNI layer used by | ||
| fronted clients). | ||
|
|
||
| Cloudflare DNS on `getiantem.org`: | ||
| - `meek.dsa.akamai` CNAME → `meek.dsa.akamai.getiantem.org.edgesuite.net` (DNS-only) | ||
| - `meek` A → 139.162.181.47 (origin direct, for Caddy's LE challenge and | ||
| for Akamai's origin connection) | ||
|
|
||
| ## socks5.sh | ||
|
|
||
| Sequential SOCKS5 handshake + HTTP `GET /ip` against `httpbin.org:80` | ||
| via the proxy. A successful run prints the origin IP httpbin observed | ||
| — it should be the Linode's public IP, confirming the request actually | ||
| exited the box. | ||
|
|
||
| Run from anywhere with network access: | ||
|
|
||
| ```bash | ||
| ./socks5.sh | ||
| ``` | ||
|
|
||
| Override the front or inner host for a different deployment: | ||
|
|
||
| ```bash | ||
| FRONT_HOST=a248.e.akamai.net \ | ||
| INNER_HOST=meek.dsa.akamai.getiantem.org \ | ||
| ./socks5.sh | ||
| ``` | ||
|
|
||
| ### How it works | ||
|
|
||
| microsocks requires strict SOCKS5 request-response, so the script | ||
| does the dance in three phases through the meek tunnel: | ||
|
|
||
| 1. **Method-select**: POST 3 bytes (`05 01 00`) → expect `05 00` | ||
| 2. **CONNECT**: POST `05 01 00 03 <len> httpbin.org <port>` → expect `05 00 00 01 ...` | ||
| 3. **HTTP**: POST `GET /ip HTTP/1.0\r\n...` → drain the HTTP response | ||
|
|
||
| Each phase is one or more `POST /` calls with the same `X-Session-Id` | ||
| header so meek-server routes them to the same upstream TCP connection. | ||
| Follow-up empty POSTs are used to drain bytes that the upstream wrote | ||
| while the script was building the next request. | ||
|
|
||
| ### What a successful run looks like | ||
|
|
||
| ```console | ||
| ✅ End-to-end SUCCESS: "origin": "139.162.181.47" | ||
| The request traversed: curl → Akamai → Caddy → meek-server → microsocks → httpbin.org | ||
| ``` |
Oops, something went wrong.
Oops, something went wrong.
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.
Uh oh!
There was an error while loading. Please reload this page.