Files
weircon-random-proxy/README.md
T
agw 06bdd1423f fetch: every outbound request carries one generic browser User-Agent
Stripping forwarding headers kept our IP and chain out of reach, but the
User-Agent went through untouched. Clients that fell back to their own name
("crawlkit-roles", "crawlkit-virk", ...) tied all ten exits together as one
actor, and one missing UA meant Go-http-client. The proxy now overwrites the
UA on every request (WEIRCON_USER_AGENT, default current Firefox) and the
egress prober uses the same string.

Also dropped: From, Sec-Ch-Ua* (would contradict a Firefox UA) and any
X-Weircon-* by prefix. New end-to-end test drives the whole handler and
checks what the target actually receives.

Claude-Session: https://claude.ai/code/session_01AUj1LSMmrowcomkn1ciAgj
2026-09-29 20:14:07 +02:00

263 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# weircon-random-proxy
A small HTTP fetch service that forwards client requests through one of N upstream WireGuard tunnels and returns the response. Designed to run as an unprivileged LXC on Proxmox, with each tunnel isolated in its own network namespace.
Use it whenever you need an HTTP-facing API that returns results from rotating egress IPs — testing geo-restricted endpoints, distributing outbound traffic across providers, hiding a backend's real source IP, building rate-limit-resilient clients, and so on. It is intentionally generic and doesn't care what consumer calls it.
## Architecture
```
Internet
│
▼
┌──────────────────────────────────┐
│ Reverse proxy (e.g. NPM, nginx) │
│ • TLS termination │
│ • Validates X-WEIRCON-RANDOM-IP │
│ (API key) header │
│ • Strips header before forward │
└──────────────┬───────────────────┘
│ http://<lxc-ip>:8080
▼
┌──────────────────────────────────────────────────────┐
│ LXC: weircon-random-proxy (unprivileged + TUN) │
│ │
│ ┌────────────────────────────────────────────────┐ │
│ │ fetch-service (Go, net/http + ReverseProxy) │ │
│ │ • Reads target URL from: │ │
│ │ - query: ?url=https://example.com │ │
│ │ - header: X-WEIRCON-RANDOM-IP-REDIRECT │ │
│ │ • Reads tunnel selection from: │ │
│ │ - header: X-WEIRCON-PROXY-ID (0..N-1) │ │
│ │ - default: random │ │
│ │ • SOCKS5 to 10.99.0.{10+id}:1080 │ │
│ │ • Streams response chunked │ │
│ │ • Optional /ui — built-in API tester │ │
│ └─────────────────┬──────────────────────────────┘ │
│ │ │
│ ┌──────┴──────┐ br-weircon (10.99.0.1/24)│
│ │ bridge │ │
│ └──┬───┬───┬──┘ │
│ veth ────┘ │ └──── veth │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ netns: │ │ netns: │ │ netns: │ …× N │
│ │ proxy0 │ │ proxy1 │ │ proxyN-1│ │
│ │ veth: │ │ veth: │ │ veth: │ │
│ │10.99.0.10│ │10.99.0.11│ │10.99.0.X│ │
│ │ + wg0 │ │ + wg0 │ │ + wg0 │ │
│ │ + micro │ │ + micro │ │ + micro │ │
│ │ socks │ │ socks │ │ socks │ │
│ │ :1080 │ │ :1080 │ │ :1080 │ │
│ └────┬────┘ └────┬────┘ └────┬────┘ │
│ ▼ ▼ ▼ │
│ WireGuard egress (any provider) │
└──────────────────────────────────────────────────────┘
```
Tunnel count is configurable. Default deploy uses 10 tunnels; the WireGuard configs themselves can come from any provider that gives you a standard `[Interface] / [Peer]` config (ProtonVPN, Mullvad, AzireVPN, self-hosted, …). Drop the configs into `/etc/weircon-random-proxy/wg/proxy<N>.conf` and the rest is identical.
### Rotating over more configs than tunnels
A slot (netns `proxy<N>`, fixed IP, fixed SOCKS port) is decoupled from the WireGuard config it carries. `netns-up.sh` just reads `wg/proxy<N>.conf`, and that path can be a **symlink** into a larger pool. So if your provider caps concurrent connections (ProtonVPN allows 10) but you hold 50–100 configs, keep the 10 always-on slots and rotate *which config each slot points at*:
```
/etc/weircon-random-proxy/
wg-pool/ ← all your configs (proton-001.conf … proton-100.conf)
wg/
proxy0.conf -> ../wg-pool/proton-047.conf ← symlink, repointed over time
…
proxy9.conf -> ../wg-pool/proton-088.conf
```
`weircon-rotate.timer` fires `rotate.sh`, which advances **one slot at a time** to the next unused config in the pool and restarts only that slot. The other 9 stay up, so there is always egress; over a full cycle every config gets used. Because `weircon-proxy@.service` tears the old tunnel down before raising the new one, a slot is never doubly-connected — you never exceed the provider's concurrent limit, even mid-rotation.
**Graceful drain.** So a request is never handed to a tunnel that's about to disappear, `rotate.sh` coordinates with the service over a localhost-only admin API before touching a slot:
1. `POST /drain?id=N` — the service stops routing **new** requests to slot N and blocks until that slot's in-flight requests finish (bounded by `WEIRCON_DRAIN_TIMEOUT_SEC`), plus a short `WEIRCON_DRAIN_SETTLE_SEC` quiet period.
2. The symlink is repointed and the tunnel unit restarted.
3. `POST /undrain?id=N` — slot N is returned to service, but held out of the candidate set for `WEIRCON_DRAIN_WARMUP_SEC` so the fresh WireGuard handshake can settle before traffic resumes.
Random picks skip draining/warming slots automatically; a request that *pins* a slot mid-rotation gets `503` + `Retry-After`. If `rotate.sh` ever dies between drain and undrain, the slot self-heals after `WEIRCON_DRAIN_MAX_HOLD_SEC` so the pool can't shrink permanently. The admin server binds `127.0.0.1:8081` by default — keep it off your reverse proxy. `GET /status` returns a per-slot snapshot (in-flight / draining / warming).
```sh
cp proton-*.conf /etc/weircon-random-proxy/wg-pool/ # the pool (not in git)
weircon-rotate init # seed 10 distinct symlinks
systemctl restart weircon-proxy@{0..9}.service
systemctl enable --now weircon-rotate.timer # auto-rotate (default every 5 min)
systemctl edit weircon-rotate.timer # change interval (OnUnitActiveSec)
```
The fetch service is unaware of any of this — it still sees 10 fixed SOCKS endpoints. The only observable change is that the egress IP behind a given `X-WEIRCON-PROXY-ID` drifts over time. (Trade-off: restarting a slot drops requests in flight on that one slot for the ~1–2 s WireGuard handshake; one-at-a-time rotation keeps the blast radius to a single slot.)
## Client API
Fetch a URL through a random tunnel:
```bash
curl -H "X-WEIRCON-RANDOM-IP: $API_KEY" \
-H "X-WEIRCON-RANDOM-IP-REDIRECT: https://api.ipify.org" \
https://random-proxy.example.com/
```
Or with the URL as a query parameter:
```bash
curl -H "X-WEIRCON-RANDOM-IP: $API_KEY" \
"https://random-proxy.example.com/?url=https://api.ipify.org"
```
Pin a specific tunnel:
```bash
curl -H "X-WEIRCON-RANDOM-IP: $API_KEY" \
-H "X-WEIRCON-PROXY-ID: 3" \
"https://random-proxy.example.com/?url=https://api.ipify.org"
```
### Parameters
| Source | Name | Required | Description |
| ----------------- | ----------------------------- | -------- | ------------------------------------------------------------ |
| Reverse-proxy hdr | `X-WEIRCON-RANDOM-IP` | yes* | API key. Validated and stripped by the reverse proxy in front. |
| Header or query | `X-WEIRCON-RANDOM-IP-REDIRECT` / `?url=` | yes | Target URL. Header takes precedence over the query parameter. |
| Header | `X-WEIRCON-PROXY-ID` | no | `0`..`N-1`. Omitted: pick at random. |
| Header | `X-WEIRCON-FORWARD-METHOD` | no | HTTP method sent to the target. Default: same as incoming. |
| Body | _passthrough_ | no | Forwarded as-is on POST/PUT/PATCH. |
*Only when a reverse proxy is in front. Direct LAN access doesn't require it.
### Response
Status, headers, and body of the upstream response are returned directly. The service adds `X-WEIRCON-EGRESS-PROXY: <id>` so the caller can see which tunnel was used.
### What the target sees
Every outbound request is anonymized before it leaves a tunnel, whatever the caller sent:
- **User-Agent is always replaced** with one generic desktop-browser string (`WEIRCON_USER_AGENT`, default a current Firefox). A client that forgets to set a UA, or sets its own name, can't identify you. The background egress prober uses the same UA.
- **Removed:** our control headers (`X-Weircon-*`), every forwarding/client-IP header (`Forwarded`, `Via`, `X-Forwarded-*`, `X-Real-Ip`, `Cf-*`, `True-Client-Ip`, …), `From`, and `Sec-Ch-Ua*` client hints (they would contradict a Firefox UA).
- **Passed through untouched:** `Accept*`, `Cookie`, `Referer`, `Authorization` and the body. What you put there is yours to keep clean.
Bump `WEIRCON_USER_AGENT` every few browser releases; a years-old version is a bot tell of its own.
### Live status (`/status`)
`GET /status` returns a poll-friendly JSON snapshot for scraper-side dashboards — cheap to produce (no network I/O in the request path; egress IPs come from a background prober) and safe to hit every 1–5 s. It's behind the same API-key gate as `/`.
```bash
curl -H "X-WEIRCON-RANDOM-IP: $API_KEY" https://random-proxy.example.com/status
```
```jsonc
{
"now": "2026-06-01T08:47:18Z",
"proxies_total": 10,
"available": 9, // slots currently selectable (not draining/warming)
"proxies": [
{ "id": 0, "state": "live", "inflight": 2, "egress_ip": "185.x.x.10", "reachable": true, "latency_ms": 42, "checked_age_sec": 7 },
{ "id": 1, "state": "draining", "inflight": 0, "egress_ip": "185.x.x.11", "reachable": true, "latency_ms": 51, "checked_age_sec": 9 },
// …
],
"rotation": {
"next_slot": 2, // which slot rotates next (round-robin)
"next_egress_ip": "185.x.x.12", // the egress IP about to be replaced
"eta_sec": 137, // seconds until the next rotation
"interval_sec": 300,
"last_slot": 1, // slot that rotated most recently
"last_rotated_age_sec": 163
}
}
```
Per-slot `state` is `live` (selectable), `draining` (in-flight finishing, about to be torn down), or `warming` (new tunnel up, settling before traffic resumes) — drive your visual "rotating now" indicator off the latter two. The `rotation` block tells you *when* the next rotation lands and *which* egress IP it will replace, so you can show a countdown and flag the outgoing IP ahead of time. The `rotation` fields stay absent until enough rotations have happened to infer the cadence (≈ two timer ticks after boot); egress fields are absent when probing is disabled.
## Built-in API tester (`/ui`)
The fetch service ships with an embedded HTML page at `/ui` that documents the API and lets you exercise every endpoint interactively:
- Enter API key (stored in browser localStorage).
- "Try it out" form for `/` with all parameters as inputs.
- Live cURL equivalent.
- Tunnel health panel — sends a probe to each tunnel and shows egress IP + latency. A healthy stack returns N distinct IPs.
The UI is enabled by default. Set `WEIRCON_UI_ENABLED=false` in `fetch.env` to disable it (the rest of the service is unaffected). The UI is also gated by your reverse proxy's auth check by default — see `npm/advanced.conf` for an optional exception that allows the UI page itself to load without an API key.
## Components
| Path | Purpose |
| ----------------------------------- | ----------------------------------------------------------------------------- |
| `service/main.go` | Go fetch service (static binary, runs in the LXC's main netns). |
| `service/ui.html` | Embedded API tester page served at `/ui`. |
| `service/Makefile` | `make build` → ~6MB static binary; `make install` to `/usr/local/bin`. |
| `lxc/setup-host.md` | Proxmox host: `pct create`, TUN passthrough, push instructions. |
| `lxc/setup-container.sh` | Runs **inside** the LXC: apt deps, microsocks, helper scripts, systemd units. |
| `lxc/netns-up.sh` | Brings one tunnel namespace up (`proxy<N>` with wg0 + veth + bridge). |
| `lxc/netns-down.sh` | Tears it back down (invoked by systemd ExecStopPost). |
| `lxc/rotate.sh` | Rotates one slot to the next pool config; `init` seeds the slots. |
| `lxc/systemd/weircon-proxies.target`| Grouping target for all tunnels. |
| `lxc/systemd/weircon-proxy@.service`| Templated unit. Enable with `weircon-proxy@{0..N-1}.service`. |
| `lxc/systemd/weircon-rotate.service`| Oneshot that runs `rotate.sh` (one slot per invocation). |
| `lxc/systemd/weircon-rotate.timer` | Fires the rotation on an interval (default 5 min; `systemctl edit` to change).|
| `lxc/systemd/weircon-fetch.service` | The fetch service itself. Hardened (DynamicUser, no capabilities). |
| `lxc/fetch.env.example` | Default env. Copy to `/etc/weircon-random-proxy/fetch.env`. |
| `lxc/rotate.env.example` | Rotation env (`SLOTS`). Copy to `/etc/weircon-random-proxy/rotate.env`. |
| `npm/advanced.conf` | Reverse-proxy snippet: header check, strip, forward. |
| `wg-configs/` | Local placeholder. Actual `.conf` files live on the LXC, **not** in git. |
## Service env vars
| Var | Default | Description |
| ------------------------------ | ------------------------ | ----------------------------------------------------------------- |
| `WEIRCON_LISTEN` | `:8080` | `host:port` to bind. |
| `WEIRCON_PROXY_ADDRS` | _(computed)_ | Comma-separated `host:port` list of upstream SOCKS5 endpoints. |
| `WEIRCON_PROXY_HOST` | `127.0.0.1` | Fallback host when `WEIRCON_PROXY_ADDRS` is unset. |
| `WEIRCON_PROXY_BASE_PORT` | `25400` | First port in the fallback range. |
| `WEIRCON_PROXY_COUNT` | `10` | Number of fallback endpoints. |
| `WEIRCON_REQUEST_TIMEOUT_SEC` | `30` | Per-upstream-fetch ceiling. |
| `WEIRCON_UI_ENABLED` | `true` | Toggle the embedded `/ui` page. |
| `WEIRCON_ADMIN_LISTEN` | `127.0.0.1:8081` | Localhost drain/undrain API for rotation. Empty disables it. |
| `WEIRCON_DRAIN_TIMEOUT_SEC` | `25` | Max wait for a slot's in-flight requests to clear during drain. |
| `WEIRCON_DRAIN_SETTLE_SEC` | `2` | Quiet period after in-flight hits zero (the "couple secs before").|
| `WEIRCON_DRAIN_WARMUP_SEC` | `3` | Slot held out of rotation after undrain (the "couple secs after").|
| `WEIRCON_DRAIN_MAX_HOLD_SEC` | `60` | Auto-undrain safety if `rotate.sh` dies between drain and undrain.|
| `WEIRCON_PROBE_URL` | `https://api.ipify.org` | IP-echo URL the prober fetches through each tunnel for `/status`. |
| `WEIRCON_PROBE_INTERVAL_SEC` | `60` | Egress-IP probe cadence. `0` disables probing (`egress_ip` empty).|
| `WEIRCON_PROBE_TIMEOUT_SEC` | `10` | Per-probe ceiling. |
If `WEIRCON_PROXY_ADDRS` is set it wins; otherwise the service computes `WEIRCON_PROXY_HOST:WEIRCON_PROXY_BASE_PORT + i` for `i` in `[0, WEIRCON_PROXY_COUNT)`.
## Service internals
- `net/http/httputil.ReverseProxy` streams response bodies chunked, without buffering.
- One `*http.Transport` per tunnel — connection pools stay separate per egress.
- `golang.org/x/net/proxy.SOCKS5` ContextDialer on each Transport.
- Static `CGO_ENABLED=0` binary, ~6 MB, no runtime dependencies in the LXC.
Build:
```sh
cd service && make build # → ./weircon-random-proxy
```
## Deploy
Dev machine:
```sh
cd service && make build # → ./weircon-random-proxy
```
Proxmox host (see `lxc/setup-host.md` for full detail):
1. `pct create` with `--unprivileged 1 --features nesting=1,keyctl=1` (Debian 12).
2. Add TUN passthrough lines to `/etc/pve/lxc/<ctid>.conf`.
3. `pct push` binary, scripts, units, and your WireGuard configs into the container.
4. `pct enter <ctid> && bash /root/setup-container.sh`.
5. `systemctl enable --now weircon-proxies.target weircon-proxy@{0..9}.service weircon-fetch.service`.
6. Point your reverse proxy at the LXC on port 8080; paste `npm/advanced.conf` into its advanced config with your API key.
7. Open `https://random-proxy.example.com/ui` to verify.
## Releases
Tagged builds (`v*`) trigger `.gitea/workflows/release.yml` which produces a tarball with the binary + LXC scripts + reverse-proxy config bundled for one-shot deployment.