Files
weircon-random-proxy/README.md
T
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

17 KiB
Raw Blame History

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).

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:

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:

curl -H "X-WEIRCON-RANDOM-IP: $API_KEY" \
     "https://random-proxy.example.com/?url=https://api.ipify.org"

Pin a specific tunnel:

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 /.

curl -H "X-WEIRCON-RANDOM-IP: $API_KEY" https://random-proxy.example.com/status
{
  "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:

cd service && make build      # → ./weircon-random-proxy

Deploy

Dev machine:

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.