Files
Asger Weirsøe 3f170293f5
release / release (push) Successful in 1m19s
rotate egress over a larger WG pool, with graceful drain and live status
Keep the N always-on tunnel slots fixed but let each slot's WireGuard config
rotate through a larger pool, so a 10-concurrent provider cap (e.g. Proton) can
still cycle 50-100 profiles.

- lxc/rotate.sh + weircon-rotate.{service,timer}: round-robin one slot at a
  time through wg-pool/, repointing a symlink and restarting only that slot.
- service: proxyManager tracks per-slot in-flight + drain/undrain state; a
  localhost admin server (WEIRCON_ADMIN_LISTEN) lets rotate.sh drain a slot
  before teardown and warm it back in after, so no request is routed to a
  tunnel mid-rotation. Slots self-heal if undrain never arrives.
- GET /status: poll-friendly JSON of per-slot egress IP/state plus inferred
  next-rotation slot + ETA, fed by a background egress-IP prober.
- docs + env examples for all new knobs.
2026-06-01 10:58:51 +02:00

253 lines
17 KiB
Markdown
Raw Permalink 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 50100 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 ~12 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.
### 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 15 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.