Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Changelog

## Unreleased

### Added

- Direct VPS stream transport over `/stream`, with existing Apps Script and
direct POST modes preserved as fallback paths.
- Binary direct batch mode for direct VPS transports.
- Server-side `max_sessions` limit.
- Graceful server shutdown on SIGINT/SIGTERM.
- Stream reconnect grace period before server-side sessions are aborted.
- Fuzz entry points for frame and batch decoding.
- Architecture documentation in `docs/ARCHITECTURE.md`.

### Changed

- Client shutdown now logs whether session cleanup completed before timeout.
- Server request bodies are rejected early when `Content-Length` exceeds the
configured maximum.
- Hot idle timers use reusable timers in the client worker and server long-poll
wait paths.
- Closed tunnel sessions now surface `io.ErrClosedPipe` through the SOCKS
adapter instead of silently accepting writes.

### Fixed

- Server request size is independent from the response pre-encode budget.
- Direct stream routing uses the same parallel SYN handling as POST routing.
- DNS cache dials multiple resolved addresses and promotes the last successful
address.
- Server session routing re-checks ownership before delivering frames.
59 changes: 53 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ This is the free Google-side piece that hides your traffic.
3. Delete the default code and paste everything from [`apps_script/Code.gs`](apps_script/Code.gs).
4. Change this line to your VPS IP:
```javascript
const VPS_URL = 'http://YOUR.VPS.IP:8443/tunnel';
const RELAY_URLS = ['http://YOUR.VPS.IP:8443/tunnel'];
```
5. Click **Deploy → New deployment** → set type to **Web app**.
6. Set **Execute as:** Me and **Who has access:** Anyone.
Expand Down Expand Up @@ -261,6 +261,7 @@ Paste this (adjust the path if your binary is in a different location):
[Unit]
Description=GooseRelayVPN exit server
After=network.target
Wants=network-online.target

[Service]
Type=simple
Expand All @@ -270,6 +271,16 @@ Restart=always
RestartSec=3
StandardOutput=journal
StandardError=journal
LimitNOFILE=1048576
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=full
ProtectHome=read-only
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
LockPersonality=yes

[Install]
WantedBy=multi-user.target
Expand Down Expand Up @@ -426,6 +437,24 @@ What the client does for you automatically:

---

## Direct Stream Mode

For the lowest latency direct-to-VPS path, add `direct_stream_urls` to `client_config.json` and keep `transport_mode` at `auto`:

```json
{
"transport_mode": "auto",
"direct_stream_urls": ["wss://YOUR.VPS.DOMAIN:8443/stream"],
"script_keys": ["APPS_SCRIPT_FALLBACK_DEPLOYMENT_ID"]
}
```

`auto` tries the WebSocket `/stream` endpoint first, then direct binary POST via `relay_urls` if configured, then Apps Script. Use `direct_stream`, `direct_post`, or `apps_script` to force one path. Apps Script cannot carry WebSockets; it remains the compatibility fallback.

Optional stream knobs are `stream_connect_timeout_ms` (default `5000`), `stream_ping_interval_ms` (default `20000`), and `stream_reconnect_backoff_ms` (default `1000`). `auto_tune` can be set to `true` to let the client adjust `poll_idle_sleep_ms` inside fixed caps using observed TTFB; it never changes crypto, wire compatibility, connect timeouts, or payload limits.

---

## Configuration

### Client (`client_config.json`)
Expand All @@ -435,7 +464,7 @@ What the client does for you automatically:
| `socks_host` | `127.0.0.1` | Host/IP for the local SOCKS5 listener. Set to `0.0.0.0` for LAN sharing. |
| `socks_port` | `1080` | Port for the local SOCKS5 listener. |
| `google_host` | `216.239.38.120` | Google edge IP/host to dial (port is fixed to `443`). |
| `sni` | `www.google.com` | SNI presented during the TLS handshake. Accepts a single string or an array — `["www.google.com", "mail.google.com", "accounts.google.com"]` — where each SNI host gets its own connection and throttle bucket, which can multiply available bandwidth in regions that rate-limit per domain name. |
| `sni` | `["www.google.com", "mail.google.com", "accounts.google.com"]` | SNI presented during the TLS handshake. Accepts a single string or an array. Each SNI host gets its own connection and throttle bucket, which can multiply available bandwidth in regions that rate-limit per domain name. |
| `script_keys` | — | Array of Apps Script deployments. Each entry can be a bare Deployment ID string or an object `{ "id": "...", "account": "..." }` labeling the Google account it's deployed under. **The `account` label is load-bearing**: the client groups deployments by account and runs 4 poll workers per *account bucket* (more if you raise `idle_slots_per_bucket`), matching Apps Script's per-account concurrency cap. Bare strings (or unlabeled objects) all collapse into one anonymous bucket — fine if every deployment is under one Google account, but if they're under multiple accounts, label them or you lose the parallelism. See [Increase capacity with multiple deployments](#increase-capacity-with-multiple-deployments). |
| `tunnel_key` | — | 64-char hex AES-256 key. Must match the server byte-for-byte. |
| `socks_user` | *(optional)* | SOCKS5 username (RFC 1929). When set, clients must authenticate or the connection is rejected. Must be paired with `socks_pass` — set both or neither. |
Expand All @@ -451,15 +480,18 @@ What the client does for you automatically:
| `server_port` | `8443` | Port where the exit server listens. Must be reachable from Google's network. |
| `tunnel_key` | — | 64-char hex AES-256 key. Must match the client. |
| `upstream_proxy` | *(optional)* | Route all outbound connections through a local SOCKS5 proxy. Useful when your VPS datacenter IP is blocked by certain sites. Set to `socks5://127.0.0.1:40000` to use Cloudflare WARP (DNS is resolved by the proxy, so target sites see the Cloudflare IP instead of your VPS IP). Leave empty or omit to dial directly. |
| `auto_tune` | `false` | When `true`, the server adjusts only `active_drain_window_ms` and the coalesce windows inside fixed safety caps based on downstream queue wait. It does not change `long_poll_window_ms`, dial timeouts, session limits, or body-size limits. |
| `upstream_dial_timeout_ms` | `15000` | Exit-side TCP dial timeout for new upstream connections. Latency mode defaults this to `8000`; lower values fail dead CDN edges faster, higher values tolerate unusually slow networks. |
| `debug_timing` | `false` | When `true`, logs per-session DNS and TCP dial latency so you can pinpoint where time is going. |
| `max_request_body_bytes` | `12582912` | VPS HTTP request-body cap. Keep this above the client `max_request_bytes_pre_encode` because Apps Script text mode base64-expands request batches. |

---

## Updating the Apps Script forwarder

If you change `Code.gs` — for example to point at a new VPS IP — you must create a **new deployment** in the Apps Script editor (Deploy → **New deployment**, not just "Manage deployments"). Saving alone does nothing; the live `/exec` URL serves the published version. After redeploying, update `script_keys` in `client_config.json`.

The current `Code.gs` also tracks per-deployment invocation counts and exposes them via `doGet`, along with forwarder/protocol metadata used by the client's pre-flight check. If you have an older deployment, redeploying once enables the `script=N` field in the client's periodic `[stats]` line and avoids version-mismatch warnings.
The current `Code.gs` exposes forwarder/protocol metadata via `doGet` for the client's pre-flight check. It can also expose per-deployment invocation counts when `ENABLE_INVOCATION_COUNTING` is set to `true`; counting is disabled by default because writing Apps Script properties on every tunnel request adds latency.

---

Expand All @@ -480,7 +512,7 @@ Key invariants:
- **Apps Script never sees plaintext.** The script is a ~30-line forwarder; the AES key lives only on your machine and the VPS.
- **DNS travels through the tunnel.** The SOCKS5 server uses a no-op resolver; use `socks5h://` so DNS is resolved at the exit, not locally.
- **Long-poll, full-duplex.** The VPS holds each request open for up to 8s waiting for downstream bytes; the client runs **4 concurrent poll workers per labeled `account` bucket** in `script_keys` (default; scales further with `idle_slots_per_bucket`) — so 1 account = 4 workers, 2 accounts = 8 workers, 3 accounts = 12 workers, regardless of how many deployment IDs each account has. The bucket model exists because Apps Script's per-second concurrency cap is per-account; scaling workers by deployment count instead caused users with multiple IDs under one account to see Apps Script HTML error pages mid-session. Downstream frames are coalesced in a small (~25 ms) window so streaming workloads send fewer, larger HTTP responses.
- **Health-aware multi-deployment.** When `script_keys` lists more than one deployment, the client picks endpoints in round-robin and exponentially blacklists any that misbehave; one same-poll retry is attempted on a fresh deployment so transient failures don't drop traffic.
- **Health-aware multi-deployment.** When `script_keys` lists more than one deployment, the client picks endpoints by health, RTT, and quota pressure. TX batches retry across every configured endpoint before giving up, so one exhausted deployment cannot discard data while another deployment is still healthy.

### Wire format

Expand Down Expand Up @@ -532,7 +564,7 @@ GooseRelayVPN/
| Pre-flight fails: `Apps Script cannot reach your VPS` | Port 8443 on your VPS is not reachable. Run `sudo ufw allow 8443/tcp` on the VPS and check your cloud provider's firewall rules. |
| Log says `relay returned non-batch payload` | Apps Script returned an HTML page instead of an encrypted batch. Three common causes: (1) the deployment in `script_keys` isn't live, or **Who has access** is not set to `Anyone` — re-deploy (Deploy → **New deployment**) and update `script_keys`; (2) the deployment was added to an existing Apps Script project alongside other files — create a **new** project with only `Code.gs` in it, then deploy from there; (3) you have multiple deployments under the same Google account and are hitting that account's per-second concurrency cap — label `script_keys` entries with their `account` so the client throttles per-account (see [Increase capacity with multiple deployments](#increase-capacity-with-multiple-deployments)). |
| Log says `relay returned HTTP 404 via …` | The Deployment ID in your config doesn't match a live `/exec`. Re-deploy and update the config. |
| Log says `relay returned HTTP 500 via …` | Apps Script can't reach `VPS_URL`. Check the server address in `Code.gs`, confirm the VPS is up, and confirm inbound TCP/8443 is open. `curl http://your.vps.ip:8443/healthz` should return 200. |
| Log says `relay returned HTTP 500 via …` | Apps Script can't reach a `RELAY_URLS` entry. Check the server address in `Code.gs`, confirm the VPS is up, and confirm inbound TCP/8443 is open. `curl http://your.vps.ip:8443/healthz` should return 200. |
| Log says `relay request failed via …: timeout` | Fronted connection to Google is failing. Try a different `google_host` — any 216.239.x.120 served by Google works. |
| Browser hangs on every request | Make sure your browser extension uses SOCKS5 with **DNS through proxy** enabled (not plain SOCKS5). In Firefox, check **Proxy DNS when using SOCKS v5**. |
| `[exit] dial X: ... timeout` on the VPS server logs | The target host blocks datacenter IPs, or your VPS has no outbound connectivity for that port. |
Expand All @@ -541,14 +573,22 @@ GooseRelayVPN/
| One deployment hits quota mid-session | If `script_keys` has more than one entry, the client automatically blacklists the failing one for a few seconds and keeps going on the others. With only one entry, browsing stops until the quota resets (~10:30 AM Iran time / midnight Pacific). |
| Mismatched AES keys | Symptom: client logs no errors but no traffic flows; VPS logs no `dial ...` lines. Confirm `tunnel_key` is byte-identical in both configs. |

To collect a shareable support bundle without exposing tunnel secrets, run:

```bash
./goose-client -config client_config.json -dump-diag
```

The generated `goose-diagnostics-*.zip` includes runtime, goroutine, heap, and redacted client-config data. Use `-diag-output path/to/file.zip` if you want a specific output path.

---

## Security Tips

- **Never share `client_config.json` or `server_config.json`** — the AES key is in there and a leaked key means anyone can tunnel through your VPS.
- **Generate a fresh key with `openssl rand -hex 32`** for every deployment. Don't reuse keys across hosts.
- **AES-GCM is the only authentication.** There's no password, no rate-limiting, no per-user accounting. Treat the key like a server-admin password.
- **Apps Script logs every `doPost` invocation** in Google's dashboard (count and duration only — Apps Script never sees plaintext).
- **Apps Script logs executions in Google's dashboard** (count and duration only — Apps Script never sees plaintext). The optional `ENABLE_INVOCATION_COUNTING` counter is off by default for latency.
- **Keep `socks_host` on the client at `127.0.0.1`** unless you specifically want LAN sharing.
- **Each Apps Script deployment is rate-limited to ~20,000 calls/day** on free Google accounts.

Expand All @@ -563,6 +603,13 @@ The `bench/` directory contains an end-to-end harness that spins up real `goose-
```bash
# Build the binaries and run the full benchmark suite
bash bench/bench.sh

# Guard the frame/batch hot path against allocation or throughput regressions
go test -bench 'Benchmark(Frame|EncodeBatch|DecodeBatch|SealOpen)' -benchmem ./internal/frame

# Compare direct POST and direct WebSocket stream latency
bash bench/bench.sh --smoke --scenario ttfb_p50_p95 --transport direct_post
bash bench/bench.sh --smoke --scenario ttfb_p50_p95 --transport direct_stream
```

The harness compares your working tree against the committed baseline in `bench/baselines/` and prints a side-by-side table. Regressions above the noise floor fail the script with exit code 1. Include the output in your PR description.
Expand Down
4 changes: 2 additions & 2 deletions README_FA.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ cp server_config.example.json server_config.json
3. کد پیش‌فرض را حذف کنید و همه محتوای [`apps_script/Code.gs`](apps_script/Code.gs) را جایگزین کنید.
4. این خط را با IP VPS خودتان جایگزین کنید:
```javascript
const VPS_URL = 'http://YOUR.VPS.IP:8443/tunnel';
const RELAY_URLS = ['http://YOUR.VPS.IP:8443/tunnel'];
```
5. روی **Deploy → New deployment** کلیک کنید و نوع را **Web app** بگذارید.
6. **Execute as:** Me و **Who has access:** Anyone را انتخاب کنید.
Expand Down Expand Up @@ -532,7 +532,7 @@ GooseRelayVPN/
| Pre-flight fails: `Apps Script cannot reach your VPS` | پورت 8443 روی VPS قابل دسترسی نیست. `sudo ufw allow 8443/tcp` را اجرا کنید و فایروال ارائه‌دهنده ابری را هم بررسی کنید. |
| Log says `relay returned non-batch payload` | Apps Script به جای batch رمزشده، HTML برگردانده. سه علت رایج: (۱) deployment داخل `script_keys` زنده نیست یا **Who has access** روی `Anyone` نیست — دوباره deploy کنید (Deploy → **New deployment**) و `script_keys` را به‌روزرسانی کنید؛ (۲) deployment کنار فایل‌های دیگر در یک پروژه Apps Script موجود اضافه شده — یک پروژه **جدید** با فقط `Code.gs` بسازید و از آنجا deploy کنید؛ (۳) چند deployment زیر یک اکانت گوگل دارید و به per-second concurrency cap همان اکانت می‌خورید — entryهای `script_keys` را با `account` برچسب بزنید تا کلاینت per-account throttle کند (به [افزایش ظرفیت با چند deployment](#افزایش-ظرفیت-با-چند-deployment-پیشنهاد-میشود) مراجعه کنید). |
| Log says `relay returned HTTP 404 via …` | Deployment ID در کانفیگ شما با `/exec` زنده‌ای مطابقت ندارد. دوباره deploy کنید و کانفیگ را به‌روزرسانی کنید. |
| Log says `relay returned HTTP 500 via …` | Apps Script نمی‌تواند به `VPS_URL` برسد. آدرس سرور در `Code.gs` را چک کنید، مطمئن شوید VPS بالا است و TCP/8443 ورودی باز است. `curl http://your.vps.ip:8443/healthz` باید 200 برگرداند. |
| Log says `relay returned HTTP 500 via …` | Apps Script نمی‌تواند به یکی از آدرس‌های `RELAY_URLS` برسد. آدرس سرور در `Code.gs` را چک کنید، مطمئن شوید VPS بالا است و TCP/8443 ورودی باز است. `curl http://your.vps.ip:8443/healthz` باید 200 برگرداند. |
| Log says `relay request failed via …: timeout` | اتصال fronted به گوگل fail می‌شود. یک `google_host` دیگر امتحان کنید — هر 216.239.x.120 که گوگل سرویس می‌دهد کار می‌کند. |
| Browser hangs on every request | مطمئن شوید افزونه مرورگر روی SOCKS5 با **DNS through proxy** تنظیم شده است (نه SOCKS5 معمولی). در Firefox گزینه **Proxy DNS when using SOCKS v5** را فعال کنید. |
| `[exit] dial X: ... timeout` در لاگ VPS | مقصد، IPهای دیتاسنتر را بلاک می‌کند یا VPS شما برای آن پورت اتصال خروجی ندارد. |
Expand Down
60 changes: 47 additions & 13 deletions apps_script/Code.gs
Original file line number Diff line number Diff line change
Expand Up @@ -5,27 +5,61 @@
// and never sees plaintext or holds the key.
//
// Wire: client POSTs base64(encrypted batch). We forward the bytes verbatim
// to RELAY_URL and return its response body verbatim.
// to one of RELAY_URLS and return its response body verbatim.
//
// Replace RELAY_URL with your VPS address before deploying.
// Replace RELAY_URLS with your VPS address(es) before deploying.

const RELAY_URL = 'http://YOUR.VPS.IP:8443/tunnel';
const RELAY_URLS = [
// Replace YOUR_SERVER_PORT with server_config.json's server_port.
// The dist/server_config.json used for the current test listens on 5443.
'http://YOUR.VPS.IP:YOUR_SERVER_PORT/tunnel',
];
const FORWARDER_VERSION = 1;
const PROTOCOL_VERSION = 1;
const ENABLE_INVOCATION_COUNTING = false;
const GAS_RELAY_LOOP_RE = /^https?:\/\/script\.google\.com\/macros\//i;

function doPost(e) {
bumpInvocationCount_();
for (let i = 0; i < RELAY_URLS.length; i++) {
if (GAS_RELAY_LOOP_RE.test(RELAY_URLS[i])) {
return ContentService
.createTextOutput('relay_loop_detected: RELAY_URLS must point to your VPS /tunnel endpoint, not Apps Script')
.setMimeType(ContentService.MimeType.TEXT);
}
}
if (ENABLE_INVOCATION_COUNTING) {
bumpInvocationCount_();
}
const payload = (e && e.postData && e.postData.contents) || '';
const resp = UrlFetchApp.fetch(RELAY_URL, {
method: 'post',
contentType: 'text/plain',
payload: payload,
muteHttpExceptions: true,
followRedirects: false,
deadline: 30, // seconds; long-poll window is kept at 8s for Apps Script stability
});
let lastText = '';
for (let i = 0; i < RELAY_URLS.length; i++) {
try {
const resp = UrlFetchApp.fetch(RELAY_URLS[i], {
method: 'post',
contentType: 'text/plain',
payload: payload,
muteHttpExceptions: true,
followRedirects: false,
});
const status = resp.getResponseCode();
const text = resp.getContentText();
lastText = text;
if (status === 200) {
return ContentService
.createTextOutput(text)
.setMimeType(ContentService.MimeType.TEXT);
}
lastText = JSON.stringify({
e: 'upstream_status',
status: status,
body: text.slice(0, 1024),
});
} catch (err) {
lastText = String(err);
}
}
return ContentService
.createTextOutput(resp.getContentText())
.createTextOutput(lastText)
.setMimeType(ContentService.MimeType.TEXT);
}

Expand Down
Loading