Skip to content
Merged
Show file tree
Hide file tree
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 May 19, 2026
5d1682b
protocol/meek + cmd/meek-server: domain-fronted server
myleshorton May 19, 2026
1386542
cmd/meek-server: add end-to-end SOCKS5 smoke test + deployment notes
myleshorton May 23, 2026
c467035
protocol/meek: SetReadDeadline now unblocks a parked Read
myleshorton May 24, 2026
fe2ab1b
meek: address PR review — security hardening + cleanups
myleshorton May 26, 2026
4a33249
meek: drop "Tor pluggable transport" framing in package doc
myleshorton May 26, 2026
3c81128
meek: address second review pass — destination routing + buffer-cap c…
myleshorton May 26, 2026
5e3ae35
meek: fix SOCKS5 CONNECT over the polling Conn (byte-wise reads desyn…
myleshorton Jun 25, 2026
92610cc
meek: retriable polls (seq/ack) + larger negotiated poll body
myleshorton Jun 25, 2026
06c6dc9
cmd/meek-server: add deploy.sh (build, ship, swap, verify, rollback)
myleshorton Jun 25, 2026
f3fbae8
meek: address Copilot review — hex reply codes, RSV check, bytewise h…
myleshorton Jun 26, 2026
f1cbf6b
meek: address second Copilot round (over-cap response + deploy.sh har…
myleshorton Jun 26, 2026
263f4b5
Merge pull request #282 from getlantern/fisk/meek-socks5-fix
myleshorton Jun 26, 2026
73c35a3
Merge remote-tracking branch 'origin/main' into fisk/meek-outbound
myleshorton Jun 26, 2026
446085a
meek: address Copilot review on #265 (EOF propagation, net.Error dead…
myleshorton Jun 26, 2026
67937cc
meek: address CodeRabbit review on #265 (timeouts, EOF/session harden…
myleshorton Jun 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions cmd/meek-server/deploy.sh
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"
122 changes: 122 additions & 0 deletions cmd/meek-server/main.go
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
Comment thread
myleshorton marked this conversation as resolved.

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,
}
Comment thread
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
}
79 changes: 79 additions & 0 deletions cmd/meek-server/smoketest/README.md
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
```
Loading
Loading