Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
110 changes: 110 additions & 0 deletions cmd/meek-server/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// 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", 64*1024, "max response body bytes")
Comment thread
myleshorton marked this conversation as resolved.
Outdated
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")
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}))

srv, err := meek.NewServer(meek.ServerConfig{
Upstream: *upstream,
MaxBodyBytes: *maxBody,
ResponseHoldoff: *holdoff,
SessionIdleTimeout: *idleTimeout,
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,
ReadHeaderTimeout: 10 * 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)

```
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
```
Comment thread
myleshorton marked this conversation as resolved.
Outdated

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

```
✅ End-to-end SUCCESS: "origin": "139.162.181.47"
The request traversed: curl → Akamai → Caddy → meek-server → microsocks → httpbin.org
```
135 changes: 135 additions & 0 deletions cmd/meek-server/smoketest/socks5.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
#!/bin/bash
# Real end-to-end smoke test: sequential SOCKS5 + HTTP through the meek tunnel.
#
# microsocks requires strict SOCKS5 request-response (no pipelining), so we do:
# POST 1: send method-select (3 bytes) → wait for 2-byte reply
# POST 2: send CONNECT httpbin.org:80 (18 bytes) → wait for 10-byte reply
# POST 3+: send HTTP GET → drain HTTP response
#
# Success criterion: HTTP body contains "origin": "<Linode IP>"

set -euo pipefail

FRONT_HOST="${FRONT_HOST:-a248.e.akamai.net}"
INNER_HOST="${INNER_HOST:-meek.dsa.akamai.getiantem.org}"
TARGET_HOST="httpbin.org"
TARGET_PORT=80

EDGE_IP=$(dig +short "$FRONT_HOST" | grep -E '^[0-9]+\.' | head -1)
[ -z "$EDGE_IP" ] && { echo "couldn't resolve $FRONT_HOST" >&2; exit 1; }
echo "Akamai edge IP: $EDGE_IP"

SID=$(openssl rand -hex 16)
echo "session id: $SID"
POST_URL="https://${FRONT_HOST}/"

# --- meek POST helper ---
# usage: meek_post <out-file> [payload-file]
meek_post() {
local out=$1
local data=${2:-}
local args=(
-sS
--resolve "${FRONT_HOST}:443:${EDGE_IP}"
--http1.1
-X POST
-H "Host: ${INNER_HOST}"
-H "X-Session-Id: ${SID}"
-H "Content-Type: application/octet-stream"
-o "$out"
-w "%{size_download}"
)
if [ -n "$data" ]; then
args+=(--data-binary "@$data")
else
args+=(--data-binary "")
fi
curl "${args[@]}" "$POST_URL"
Comment thread
myleshorton marked this conversation as resolved.
}

# Send <payload-file> then poll empty POSTs until at least <min-bytes> received,
# concatenating each response chunk into <accum-file>.
# usage: send_and_drain <payload-file> <accum-file> <min-bytes> <max-polls>
send_and_drain() {
local payload=$1 accum=$2 minb=$3 maxp=$4
: > "$accum"
local tmp=$(mktemp)
local sz
sz=$(meek_post "$tmp" "$payload")
cat "$tmp" >> "$accum"
local n=$(wc -c < "$accum")
for i in $(seq 1 "$maxp"); do
[ "$n" -ge "$minb" ] && break
sleep 0.3
sz=$(meek_post "$tmp")
cat "$tmp" >> "$accum"
n=$(wc -c < "$accum")
done
rm -f "$tmp"
echo "$n"
}
Comment thread
myleshorton marked this conversation as resolved.
Outdated

# --- Build the three payloads ---
python3 <<PYEOF
import os
target_host = b'${TARGET_HOST}'
target_port = ${TARGET_PORT}

# Method-select: NO_AUTH
open('/tmp/meek-p1-methodsel.bin', 'wb').write(b'\x05\x01\x00')

# CONNECT httpbin.org:80
buf = bytearray(b'\x05\x01\x00\x03')
buf += bytes([len(target_host)]) + target_host
buf += target_port.to_bytes(2, 'big')
open('/tmp/meek-p2-connect.bin', 'wb').write(bytes(buf))

# HTTP request the proxy forwards once CONNECT completes
http = b'GET /ip HTTP/1.0\r\nHost: ' + target_host + b'\r\nConnection: close\r\n\r\n'
open('/tmp/meek-p3-http.bin', 'wb').write(http)
PYEOF
Comment thread
myleshorton marked this conversation as resolved.

echo ""
echo "--- Phase 1: SOCKS5 method-select (NO_AUTH) ---"
n=$(send_and_drain /tmp/meek-p1-methodsel.bin /tmp/meek-r1.bin 2 8)
echo "received ${n} bytes: $(xxd -p /tmp/meek-r1.bin | head -1)"
if ! [ "$(xxd -p /tmp/meek-r1.bin | head -c 4)" = "0500" ]; then
echo "ERROR: expected '0500' (SOCKS5 NO_AUTH accepted), got something else" >&2
exit 1
fi

echo ""
echo "--- Phase 2: SOCKS5 CONNECT $TARGET_HOST:$TARGET_PORT ---"
n=$(send_and_drain /tmp/meek-p2-connect.bin /tmp/meek-r2.bin 10 8)
echo "received ${n} bytes: $(xxd -p /tmp/meek-r2.bin | head -1)"
# SOCKS5 CONNECT reply: 05 00 00 01 ... (success)
if ! [[ "$(xxd -p /tmp/meek-r2.bin | head -c 8)" =~ ^050000 ]]; then
echo "ERROR: SOCKS5 CONNECT reply doesn't start with 05 00 00 (REP=success)" >&2
exit 1
fi
echo "CONNECT succeeded"

echo ""
echo "--- Phase 3: HTTP GET /ip ---"
n=$(send_and_drain /tmp/meek-p3-http.bin /tmp/meek-r3.bin 100 15)
echo "received ${n} bytes of HTTP response"

echo ""
echo "--- HTTP response body ---"
cat /tmp/meek-r3.bin
echo ""
echo "--- success check ---"
if grep -q '"origin"' /tmp/meek-r3.bin; then
origin=$(grep -o '"origin"[^}]*' /tmp/meek-r3.bin)
echo "✅ End-to-end SUCCESS: ${origin}"
echo "The request traversed: curl → Akamai → Caddy → meek-server → microsocks → httpbin.org"
echo "and httpbin reported it saw the Linode's public IP, proving the proxy actually exited the box."
else
echo "❌ HTTP response didn't contain origin field — partial chain failure"
exit 1
fi

echo ""
echo "--- meek-server healthz ---"
curl -sS https://meek.getiantem.org/healthz
echo ""
1 change: 1 addition & 0 deletions constant/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package constant
const (
TypeAmnezia = "amnezia"
TypeALGeneva = "algeneva"
TypeMeek = "meek"
TypeOutline = "outline"
TypeReflex = "reflex"
TypeSamizdat = "samizdat"
Expand Down
37 changes: 37 additions & 0 deletions option/meek.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package option

import "github.com/sagernet/sing-box/option"

// MeekOutboundOptions configures a domain-fronted meek outbound that
// tunnels arbitrary TCP through HTTPS POSTs to a meek server endpoint.
//
// Fronts is the candidate front pool — pairs of (CDN edge IP, outer SNI)
// known to route the inner Host (URL.Host) to the meek server. Radiance
// populates this from the fronted/scanner package's discoveries; without
// at least one front the outbound has nothing to dial.
type MeekOutboundOptions struct {
option.DialerOptions

URL string `json:"url"` // meek server URL (e.g. https://api.iantem.io/meek/)
Comment thread
myleshorton marked this conversation as resolved.
Outdated
Fronts []FrontSpec `json:"fronts"` // candidate fronts
Header MeekHeaders `json:"header,omitempty"` // extra HTTP headers per request

PollIntervalMs int `json:"poll_interval_ms,omitempty"` // default 100
MaxBodyBytes int `json:"max_body_bytes,omitempty"` // default 64 KiB
SessionIDLen int `json:"session_id_len,omitempty"` // default 16
ConnectTimeout string `json:"connect_timeout,omitempty"` // default "15s"
ReadTimeout string `json:"read_timeout,omitempty"` // default "30s"
Comment thread
myleshorton marked this conversation as resolved.
}

// FrontSpec is one (CDN edge IP, outer SNI) pair to dial. Empty SNI
// means send no ServerName extension (Akamai-style); non-empty SNI is
// sent in the ClientHello (CloudFront-style). VerifyHostname is the
// host expected on the cert chain.
type FrontSpec struct {
IPAddress string `json:"ip_address"`
SNI string `json:"sni,omitempty"`
VerifyHostname string `json:"verify_hostname,omitempty"`
}

// MeekHeaders carries fixed-value HTTP headers added to every POST.
type MeekHeaders map[string]string
Loading
Loading