commit 8652fcfbbad290eb1f10f4c8e044f14d3e15e1af Author: Asger Weirsøe Date: Wed May 27 15:02:44 2026 +0200 Initial commit diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..7dff5cc --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,54 @@ +name: release + +on: + push: + tags: + - "v*" + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.22" + cache-dependency-path: service/go.sum + + - name: Vet + working-directory: service + run: go vet ./... + + - name: Build static linux/amd64 binary + working-directory: service + env: + CGO_ENABLED: "0" + GOOS: linux + GOARCH: amd64 + run: | + go build -trimpath -ldflags="-s -w" -o weircon-random-proxy . + ls -lh weircon-random-proxy + + - name: Stage release bundle + run: | + VERSION="${{ github.ref_name }}" + STAGE="weircon-random-proxy-${VERSION}-linux-amd64" + mkdir -p "dist/${STAGE}" + cp service/weircon-random-proxy "dist/${STAGE}/" + cp -r lxc npm README.md "dist/${STAGE}/" + tar -C dist -czf "dist/${STAGE}.tar.gz" "${STAGE}" + sha256sum "dist/${STAGE}.tar.gz" "dist/${STAGE}/weircon-random-proxy" > dist/SHA256SUMS + ls -lh dist/ + + - name: Upload release asset + uses: https://gitea.com/actions/gitea-release-action@v1 + with: + token: ${{ secrets.GITEA_TOKEN }} + server_url: ${{ github.server_url }} + tag_name: ${{ github.ref_name }} + name: ${{ github.ref_name }} + files: | + dist/weircon-random-proxy-${{ github.ref_name }}-linux-amd64.tar.gz + dist/weircon-random-proxy-${{ github.ref_name }}-linux-amd64/weircon-random-proxy + dist/SHA256SUMS diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..044caea --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +wg-configs/*.conf +*.env +service/weircon-random-proxy diff --git a/README.md b/README.md new file mode 100644 index 0000000..18a3897 --- /dev/null +++ b/README.md @@ -0,0 +1,176 @@ +# 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://: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.conf` and the rest is identical. + +## 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: ` so the caller can see which tunnel was used. + +## 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` with wg0 + veth + bridge). | +| `lxc/netns-down.sh` | Tears it back down (invoked by systemd ExecStopPost). | +| `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-fetch.service` | The fetch service itself. Hardened (DynamicUser, no capabilities). | +| `lxc/fetch.env.example` | Default env. Copy to `/etc/weircon-random-proxy/fetch.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. | + +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/.conf`. +3. `pct push` binary, scripts, units, and your WireGuard configs into the container. +4. `pct enter && 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. diff --git a/lxc/fetch.env.example b/lxc/fetch.env.example new file mode 100644 index 0000000..3b1db60 --- /dev/null +++ b/lxc/fetch.env.example @@ -0,0 +1,9 @@ +# Lytte-adresse. 0.0.0.0 så NPM kan ramme den udefra (LAN/Tailscale). +WEIRCON_LISTEN=0.0.0.0:8080 + +# 10 tunneler — hver netns har sin egen IP på den interne br-weircon, port 1080. +# Hvis du øger antal tunneler i netns-up.sh / weircon-proxy@.service, så udvid her tilsvarende. +WEIRCON_PROXY_ADDRS=10.99.0.10:1080,10.99.0.11:1080,10.99.0.12:1080,10.99.0.13:1080,10.99.0.14:1080,10.99.0.15:1080,10.99.0.16:1080,10.99.0.17:1080,10.99.0.18:1080,10.99.0.19:1080 + +# Maks tid pr. upstream-fetch (sekunder). +WEIRCON_REQUEST_TIMEOUT_SEC=30 diff --git a/lxc/netns-down.sh b/lxc/netns-down.sh new file mode 100644 index 0000000..591b59a --- /dev/null +++ b/lxc/netns-down.sh @@ -0,0 +1,14 @@ +#!/bin/bash +# River én tunnel-namespace ned. Bridge'n (br-weircon) bliver bevidst stående +# for at undgå race med andre tunneler der starter samtidig. +set -euo pipefail + +ID="${1:?usage: $0 }" +NS="proxy${ID}" +VETH_MAIN="vp${ID}a" + +ip link delete "$VETH_MAIN" 2>/dev/null || true +ip netns delete "$NS" 2>/dev/null || true +rm -rf "/etc/netns/${NS}" + +echo "netns $NS revet ned" diff --git a/lxc/netns-up.sh b/lxc/netns-up.sh new file mode 100644 index 0000000..49a8ac4 --- /dev/null +++ b/lxc/netns-up.sh @@ -0,0 +1,95 @@ +#!/bin/bash +# Bringer én tunnel-namespace op. +# - opretter (eller genbruger) bridge br-weircon i hoved-netns +# - opretter netns proxy +# - laver veth-par mellem hoved-netns og proxy, attacher hoved-siden til bridge +# - flytter et frisk wg0-interface ind i ns'en og applikerer WG-config +# - sætter default-route via wg0 inde i ns'en +# Idempotent: river først evt. tidligere state for samme id ned. +set -euo pipefail + +ID="${1:?usage: $0 }" + +NS="proxy${ID}" +WG="wg0" # interfacenavn inde i ns'en +VETH_MAIN="vp${ID}a" +VETH_NS="vp${ID}b" +BRIDGE="br-weircon" +BRIDGE_IP="10.99.0.1" +BRIDGE_CIDR="${BRIDGE_IP}/24" +NS_IP="10.99.0.$((10 + ID))" +NS_CIDR="${NS_IP}/24" +CONF="/etc/weircon-random-proxy/wg/proxy${ID}.conf" +RESOLV_DIR="/etc/netns/${NS}" + +if [[ ! -f "$CONF" ]]; then + echo "missing wg config at $CONF" >&2 + exit 1 +fi + +# --- 1. river eventuel tidligere state ned -------------------------------- +ip link delete "$VETH_MAIN" 2>/dev/null || true +ip netns delete "$NS" 2>/dev/null || true +rm -rf "$RESOLV_DIR" + +# --- 2. bridge (delt af alle tunneler) ------------------------------------ +if ! ip link show "$BRIDGE" >/dev/null 2>&1; then + ip link add "$BRIDGE" type bridge + ip addr add "$BRIDGE_CIDR" dev "$BRIDGE" + ip link set "$BRIDGE" up +fi + +# --- 3. per-netns resolver fra DNS-feltet i WG-conf'en -------------------- +mkdir -p "$RESOLV_DIR" +: > "${RESOLV_DIR}/resolv.conf" +DNS_LINE=$(awk -F'=' '/^[[:space:]]*DNS[[:space:]]*=/ {print $2; exit}' "$CONF" || true) +if [[ -n "${DNS_LINE:-}" ]]; then + IFS=',' read -ra dns_arr <<< "$DNS_LINE" + for d in "${dns_arr[@]}"; do + d="${d// /}" + [[ -n "$d" ]] && echo "nameserver $d" >> "${RESOLV_DIR}/resolv.conf" + done +fi + +# --- 4. ns + loopback ------------------------------------------------------ +ip netns add "$NS" +ip -n "$NS" link set lo up + +# --- 5. veth-par, hoved-siden til bridge ---------------------------------- +ip link add "$VETH_MAIN" type veth peer name "$VETH_NS" +ip link set "$VETH_MAIN" master "$BRIDGE" +ip link set "$VETH_MAIN" up +ip link set "$VETH_NS" netns "$NS" +ip -n "$NS" addr add "$NS_CIDR" dev "$VETH_NS" +ip -n "$NS" link set "$VETH_NS" up + +# --- 6. WG-interface: opret + konfigurer i hoved-netns, flyt så ind ------- +# Endpoint-hostname (hvis WG-config'en bruger ét) skal resolves *inden* +# interfacet flyttes — netns'en har kun upstream-DNS, som kun virker når +# tunnelen allerede er oppe. Klassisk chicken-and-egg. +ip link add "$WG" type wireguard + +TMPCONF=$(mktemp) +trap 'rm -f "$TMPCONF"' EXIT +wg-quick strip "$CONF" > "$TMPCONF" +wg setconf "$WG" "$TMPCONF" # resolves via hoved-netns DNS + +ip link set "$WG" netns "$NS" + +# Interface-adresser fra [Interface] Address = ... +while read -r ADDR; do + [[ -z "$ADDR" ]] && continue + ip -n "$NS" addr add "$ADDR" dev "$WG" +done < <(awk -F'=' ' + /^[[:space:]]*Address[[:space:]]*=/ { + gsub(/[[:space:]]/, "", $2); + n = split($2, a, ","); + for (i = 1; i <= n; i++) print a[i]; + }' "$CONF") + +ip -n "$NS" link set "$WG" up + +# --- 7. default route via wg (egress til internettet) -------------------- +ip -n "$NS" route add default dev "$WG" + +echo "netns $NS oppe; socks5 vil lytte på ${NS_IP}:1080" diff --git a/lxc/setup-container.sh b/lxc/setup-container.sh new file mode 100644 index 0000000..7a0b946 --- /dev/null +++ b/lxc/setup-container.sh @@ -0,0 +1,87 @@ +#!/bin/bash +# Køres som root inde i LXC'en. Idempotent. +set -euo pipefail + +if [[ "$EUID" -ne 0 ]]; then + echo "must be run as root" >&2 + exit 1 +fi + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +# 1. APT deps +export DEBIAN_FRONTEND=noninteractive +apt-get update +apt-get install -y --no-install-recommends \ + wireguard-tools \ + iproute2 \ + iptables \ + ca-certificates \ + git \ + build-essential \ + procps + +# 2. Build + install microsocks (lille, ingen runtime deps) +if ! command -v microsocks >/dev/null 2>&1; then + tmp=$(mktemp -d) + git clone --depth=1 https://github.com/rofl0r/microsocks.git "$tmp" + make -C "$tmp" + install -Dm755 "$tmp/microsocks" /usr/local/bin/microsocks + rm -rf "$tmp" +fi + +# 3. Install fetch-service binary (forventes pushet ind af host-scriptet) +if [[ ! -x /usr/local/bin/weircon-random-proxy ]]; then + echo "ERR: /usr/local/bin/weircon-random-proxy mangler — push den fra host'en først" >&2 + exit 1 +fi + +# 4. Helper-scripts +install -Dm755 "$SCRIPT_DIR/netns-up.sh" /usr/local/sbin/weircon-netns-up +install -Dm755 "$SCRIPT_DIR/netns-down.sh" /usr/local/sbin/weircon-netns-down + +# 5. systemd units +install -Dm644 "$SCRIPT_DIR/weircon-proxies.target" /etc/systemd/system/weircon-proxies.target +install -Dm644 "$SCRIPT_DIR/weircon-proxy@.service" /etc/systemd/system/weircon-proxy@.service +install -Dm644 "$SCRIPT_DIR/weircon-fetch.service" /etc/systemd/system/weircon-fetch.service + +# 6. Config-dir + default env +mkdir -p /etc/weircon-random-proxy/wg +chmod 700 /etc/weircon-random-proxy/wg +if [[ ! -f /etc/weircon-random-proxy/fetch.env ]]; then + install -m640 "$SCRIPT_DIR/fetch.env.example" /etc/weircon-random-proxy/fetch.env +fi + +systemctl daemon-reload + +# 7. Tjek at WG-configs er på plads +missing=0 +for i in 0 1 2 3 4 5 6 7 8 9; do + if [[ ! -f /etc/weircon-random-proxy/wg/proxy${i}.conf ]]; then + echo "WARN: /etc/weircon-random-proxy/wg/proxy${i}.conf mangler" + missing=1 + fi +done + +cat < **Hvorfor `nesting=1`**: WireGuard inde i en netns inde i en unprivileged LXC kræver at flere user-/mount-namespaces kan stables. +> **Hvorfor ikke `nesting=0`**: så afviser kernen `ip netns add` i containeren. + +## 2. TUN passthrough + +Tilføj til `/etc/pve/lxc/200.conf`: + +``` +lxc.cgroup2.devices.allow: c 10:200 rwm +lxc.mount.entry: /dev/net/tun dev/net/tun none bind,create=file +``` + +Kernel-WireGuard bruger som regel en wireguard-netdev og kræver strengt taget ikke `/dev/net/tun` — men `wg-quick` og enkelte VPN-værktøjer rører ved den, og det skader ikke at have den tilgængelig. + +Tjek at WireGuard-modulet er loaded på *hosten*: + +```sh +modprobe wireguard +lsmod | grep wireguard +echo wireguard >> /etc/modules-load.d/wireguard.conf +``` + +## 3. Start container + indlæs filer + +```sh +pct start 200 + +# Byg fetch-service binæren på din dev-maskine (Arch eller hvor): +# cd random_proxy/service && make build +# Push den + setup-scriptet ind: + +pct push 200 ./service/weircon-random-proxy /usr/local/bin/weircon-random-proxy --perms 0755 +pct push 200 ./lxc/setup-container.sh /root/setup-container.sh --perms 0755 +pct push 200 ./lxc/netns-up.sh /root/netns-up.sh --perms 0755 +pct push 200 ./lxc/netns-down.sh /root/netns-down.sh --perms 0755 +pct push 200 ./lxc/systemd/weircon-proxies.target /root/weircon-proxies.target +pct push 200 ./lxc/systemd/weircon-proxy@.service /root/weircon-proxy@.service +pct push 200 ./lxc/systemd/weircon-fetch.service /root/weircon-fetch.service +pct push 200 ./lxc/fetch.env.example /root/fetch.env.example + +# WireGuard configs (én pr. tunnel) +for i in 0 1 2 3 4 5 6 7 8 9; do + pct push 200 ./wg-configs/proxy${i}.conf /etc/weircon-random-proxy/wg/proxy${i}.conf --perms 0600 +done + +# Hop ind og kør resten +pct enter 200 +bash /root/setup-container.sh +``` + +## 4. Health check fra hosten + +Hvis LXC'ens IP er fx 10.0.0.50: + +```sh +curl http://10.0.0.50:8080/health +# {"ok":true,"proxies":10} +``` + +## 5. Peg NPM på containeren + +I NginxProxyManager: +- Forward Hostname / IP: `10.0.0.50` +- Forward Port: `8080` +- Tab "Advanced": indsæt indholdet af `npm/advanced.conf` (husk at udskifte placeholder API-key). diff --git a/lxc/systemd/weircon-fetch.service b/lxc/systemd/weircon-fetch.service new file mode 100644 index 0000000..5642f0c --- /dev/null +++ b/lxc/systemd/weircon-fetch.service @@ -0,0 +1,31 @@ +[Unit] +Description=weircon-random-proxy fetch service +After=network-online.target weircon-proxies.target +Wants=network-online.target weircon-proxies.target + +[Service] +Type=simple +EnvironmentFile=/etc/weircon-random-proxy/fetch.env +ExecStart=/usr/local/bin/weircon-random-proxy +Restart=on-failure +RestartSec=3 +# Service skal kun lytte i hoved-netns og må ikke køre som root. +DynamicUser=yes +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +RestrictNamespaces=yes +RestrictRealtime=yes +LockPersonality=yes +MemoryDenyWriteExecute=yes +SystemCallFilter=@system-service +SystemCallErrorNumber=EPERM +CapabilityBoundingSet= +AmbientCapabilities= + +[Install] +WantedBy=multi-user.target diff --git a/lxc/systemd/weircon-proxies.target b/lxc/systemd/weircon-proxies.target new file mode 100644 index 0000000..74415fa --- /dev/null +++ b/lxc/systemd/weircon-proxies.target @@ -0,0 +1,6 @@ +[Unit] +Description=All weircon-random-proxy tunnels +StopWhenUnneeded=no + +[Install] +WantedBy=multi-user.target diff --git a/lxc/systemd/weircon-proxy@.service b/lxc/systemd/weircon-proxy@.service new file mode 100644 index 0000000..ad4b4c4 --- /dev/null +++ b/lxc/systemd/weircon-proxy@.service @@ -0,0 +1,17 @@ +[Unit] +Description=weircon-random-proxy tunnel %i (Proton WG + microsocks in netns proxy%i) +After=network-online.target +Wants=network-online.target +PartOf=weircon-proxies.target + +[Service] +Type=simple +ExecStartPre=/usr/local/sbin/weircon-netns-up %i +ExecStart=/usr/sbin/ip netns exec proxy%i /usr/local/bin/microsocks -i 0.0.0.0 -p 1080 +ExecStopPost=-/usr/local/sbin/weircon-netns-down %i +Restart=on-failure +RestartSec=5 +TimeoutStartSec=30 + +[Install] +WantedBy=weircon-proxies.target diff --git a/npm/advanced.conf b/npm/advanced.conf new file mode 100644 index 0000000..c806355 --- /dev/null +++ b/npm/advanced.conf @@ -0,0 +1,35 @@ +# Paste into NginxProxyManager → your proxy host → "Advanced" tab. +# +# Forward Hostname/IP and Forward Port are set normally via the UI (point at +# the LXC's internal IP, port 8080). This snippet only adds header-based auth. + +# 1. API key. Replace with a long random value (>= 32 chars). +# Generate one with: openssl rand -hex 32 +set $weircon_api_key "REPLACE_WITH_A_LONG_RANDOM_API_KEY"; + +# 2. Auth gate. Pass if either (a) the API key matches, or (b) it's a +# request for the built-in /ui tester page (line below — comment it out +# if you want the UI itself to require the key too). +set $weircon_auth_ok 0; +if ($http_x_weircon_random_ip = $weircon_api_key) { set $weircon_auth_ok 1; } +if ($request_uri ~* "^/ui($|\?|/)") { set $weircon_auth_ok 1; } + +if ($weircon_auth_ok = 0) { + return 401; +} + +# 3. Strip the auth header before forwarding — backend should never see it. +proxy_set_header X-Weircon-Random-Ip ""; + +# 4. Generous timeouts: upstream fetches can be slow. +proxy_connect_timeout 15s; +proxy_send_timeout 60s; +proxy_read_timeout 60s; + +# 5. No buffering — let the body stream through. +proxy_buffering off; +proxy_request_buffering off; +proxy_http_version 1.1; + +# 6. Higher body cap in case you ever POST something heavy. +client_max_body_size 32m; diff --git a/service/Makefile b/service/Makefile new file mode 100644 index 0000000..76dcbfc --- /dev/null +++ b/service/Makefile @@ -0,0 +1,19 @@ +BINARY := weircon-random-proxy +PREFIX ?= /usr/local + +.PHONY: build run tidy clean install + +build: + CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o $(BINARY) . + +run: + go run . + +tidy: + go mod tidy + +clean: + rm -f $(BINARY) + +install: build + install -Dm755 $(BINARY) $(DESTDIR)$(PREFIX)/bin/$(BINARY) diff --git a/service/go.mod b/service/go.mod new file mode 100644 index 0000000..ab4b86f --- /dev/null +++ b/service/go.mod @@ -0,0 +1,5 @@ +module weircon.dk/random-proxy + +go 1.22 + +require golang.org/x/net v0.30.0 diff --git a/service/go.sum b/service/go.sum new file mode 100644 index 0000000..b338806 --- /dev/null +++ b/service/go.sum @@ -0,0 +1,2 @@ +golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= +golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= diff --git a/service/main.go b/service/main.go new file mode 100644 index 0000000..048049a --- /dev/null +++ b/service/main.go @@ -0,0 +1,300 @@ +// weircon-random-proxy — HTTP fetch service that forwards client requests +// through one of N upstream SOCKS5 endpoints (each typically backing a +// distinct WireGuard tunnel). +// +// Intended to sit behind a reverse proxy (e.g. NginxProxyManager) that +// validates an API key header and strips it before forwarding. +package main + +import ( + _ "embed" + "context" + "errors" + "fmt" + "log" + "math/rand/v2" + "net" + "net/http" + "net/http/httputil" + "net/url" + "os" + "strconv" + "strings" + "time" + + "golang.org/x/net/proxy" +) + +//go:embed ui.html +var uiHTML []byte + +type Config struct { + Listen string + ProxyAddrs []string + Timeout time.Duration + UIEnabled bool +} + +func loadConfig() (Config, error) { + cfg := Config{ + Listen: envStr("WEIRCON_LISTEN", ":8080"), + Timeout: time.Duration(envInt("WEIRCON_REQUEST_TIMEOUT_SEC", 30)) * time.Second, + UIEnabled: envBool("WEIRCON_UI_ENABLED", true), + } + if raw := envStr("WEIRCON_PROXY_ADDRS", ""); raw != "" { + for _, a := range strings.Split(raw, ",") { + a = strings.TrimSpace(a) + if a == "" { + continue + } + cfg.ProxyAddrs = append(cfg.ProxyAddrs, a) + } + } else { + host := envStr("WEIRCON_PROXY_HOST", "127.0.0.1") + base := envInt("WEIRCON_PROXY_BASE_PORT", 25400) + count := envInt("WEIRCON_PROXY_COUNT", 10) + for i := 0; i < count; i++ { + cfg.ProxyAddrs = append(cfg.ProxyAddrs, fmt.Sprintf("%s:%d", host, base+i)) + } + } + if len(cfg.ProxyAddrs) == 0 { + return cfg, errors.New("no proxy addrs configured") + } + for i, a := range cfg.ProxyAddrs { + if _, _, err := net.SplitHostPort(a); err != nil { + return cfg, fmt.Errorf("invalid proxy addr [%d] %q: %w", i, a, err) + } + } + return cfg, nil +} + +func envStr(k, def string) string { + if v, ok := os.LookupEnv(k); ok && v != "" { + return v + } + return def +} + +func envInt(k string, def int) int { + if v, ok := os.LookupEnv(k); ok && v != "" { + if n, err := strconv.Atoi(v); err == nil { + return n + } + } + return def +} + +func envBool(k string, def bool) bool { + v, ok := os.LookupEnv(k) + if !ok || v == "" { + return def + } + switch strings.ToLower(v) { + case "1", "true", "yes", "on": + return true + case "0", "false", "no", "off": + return false + } + return def +} + +// buildTransports creates one *http.Transport per backend SOCKS5 tunnel so +// each egress maintains its own connection pool. +func buildTransports(cfg Config) ([]*http.Transport, error) { + pool := make([]*http.Transport, len(cfg.ProxyAddrs)) + for i, addr := range cfg.ProxyAddrs { + base := &net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second} + d, err := proxy.SOCKS5("tcp", addr, nil, base) + if err != nil { + return nil, fmt.Errorf("socks5 dialer proxy%d: %w", i, err) + } + ctxDialer, ok := d.(proxy.ContextDialer) + if !ok { + return nil, fmt.Errorf("socks5 dialer proxy%d lacks ContextDialer", i) + } + pool[i] = &http.Transport{ + DialContext: ctxDialer.DialContext, + MaxIdleConns: 100, + MaxIdleConnsPerHost: 10, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 15 * time.Second, + ResponseHeaderTimeout: cfg.Timeout, + ExpectContinueTimeout: 1 * time.Second, + } + } + return pool, nil +} + +type ctxKey struct{} + +type reqCtx struct { + proxyID int + target *url.URL + method string +} + +// poolTransport routes each RoundTrip to the per-tunnel transport carried in +// the request context. +type poolTransport struct{ pool []*http.Transport } + +func (p *poolTransport) RoundTrip(req *http.Request) (*http.Response, error) { + rc, ok := req.Context().Value(ctxKey{}).(reqCtx) + if !ok { + return nil, errors.New("internal: missing reqCtx") + } + return p.pool[rc.proxyID].RoundTrip(req) +} + +var weirconHeaders = []string{ + "X-Weircon-Random-Ip", + "X-Weircon-Random-Ip-Redirect", + "X-Weircon-Proxy-Id", + "X-Weircon-Forward-Method", +} + +func stripWeircon(h http.Header) { + for _, k := range weirconHeaders { + h.Del(k) + } +} + +func pickProxyID(req *http.Request, n int) (int, error) { + raw := req.Header.Get("X-Weircon-Proxy-Id") + if raw == "" { + return rand.IntN(n), nil + } + id, err := strconv.Atoi(raw) + if err != nil { + return 0, fmt.Errorf("X-WEIRCON-PROXY-ID not an int: %w", err) + } + if id < 0 || id >= n { + return 0, fmt.Errorf("X-WEIRCON-PROXY-ID out of range (0-%d)", n-1) + } + return id, nil +} + +func parseTarget(req *http.Request) (*url.URL, error) { + raw := req.Header.Get("X-Weircon-Random-Ip-Redirect") + if raw == "" { + raw = req.URL.Query().Get("url") + } + if raw == "" { + return nil, errors.New("missing target — set X-WEIRCON-RANDOM-IP-REDIRECT or ?url=") + } + u, err := url.Parse(raw) + if err != nil { + return nil, fmt.Errorf("invalid target URL: %w", err) + } + if u.Scheme != "http" && u.Scheme != "https" { + return nil, errors.New("target URL must be http(s)") + } + if u.Host == "" { + return nil, errors.New("target URL missing host") + } + return u, nil +} + +func resolveMethod(req *http.Request) string { + if m := req.Header.Get("X-Weircon-Forward-Method"); m != "" { + return strings.ToUpper(m) + } + return req.Method +} + +func newHandler(cfg Config, pool []*http.Transport) http.Handler { + rp := &httputil.ReverseProxy{ + Rewrite: func(pr *httputil.ProxyRequest) { + rc, ok := pr.In.Context().Value(ctxKey{}).(reqCtx) + if !ok { + return + } + pr.Out.URL = rc.target + pr.Out.Host = rc.target.Host + pr.Out.Method = rc.method + pr.Out.RequestURI = "" + stripWeircon(pr.Out.Header) + }, + Transport: &poolTransport{pool: pool}, + ModifyResponse: func(resp *http.Response) error { + if rc, ok := resp.Request.Context().Value(ctxKey{}).(reqCtx); ok { + resp.Header.Set("X-Weircon-Egress-Proxy", strconv.Itoa(rc.proxyID)) + } + return nil + }, + ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { + id := -1 + if rc, ok := r.Context().Value(ctxKey{}).(reqCtx); ok { + id = rc.proxyID + } + log.Printf("upstream error via proxy%d: %v", id, err) + http.Error(w, fmt.Sprintf("upstream via proxy%d failed: %v", id, err), http.StatusBadGateway) + }, + } + + count := len(pool) + mux := http.NewServeMux() + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"ok":true,"proxies":%d}`, count) + }) + if cfg.UIEnabled { + mux.HandleFunc("/ui", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache") + _, _ = w.Write(uiHTML) + }) + } + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + // Friendly hint if someone GETs / in a browser with no target. + if r.Method == http.MethodGet && r.URL.RawQuery == "" && + r.Header.Get("X-Weircon-Random-Ip-Redirect") == "" && + strings.Contains(r.Header.Get("Accept"), "text/html") && + cfg.UIEnabled { + http.Redirect(w, r, "/ui", http.StatusFound) + return + } + target, err := parseTarget(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + id, err := pickProxyID(r, count) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + ctx := context.WithValue(r.Context(), ctxKey{}, reqCtx{ + proxyID: id, + target: target, + method: resolveMethod(r), + }) + rp.ServeHTTP(w, r.WithContext(ctx)) + }) + return mux +} + +func main() { + cfg, err := loadConfig() + if err != nil { + log.Fatalf("config: %v", err) + } + pool, err := buildTransports(cfg) + if err != nil { + log.Fatalf("transports: %v", err) + } + srv := &http.Server{ + Addr: cfg.Listen, + Handler: newHandler(cfg, pool), + ReadHeaderTimeout: 10 * time.Second, + IdleTimeout: 120 * time.Second, + } + log.Printf("weircon-random-proxy listening on %s (proxies=%d: %s, ui=%t)", + cfg.Listen, len(cfg.ProxyAddrs), strings.Join(cfg.ProxyAddrs, ","), cfg.UIEnabled) + if err := srv.ListenAndServe(); err != nil { + log.Fatalf("server: %v", err) + } +} diff --git a/service/ui.html b/service/ui.html new file mode 100644 index 0000000..587bb49 --- /dev/null +++ b/service/ui.html @@ -0,0 +1,418 @@ + + + + + +weircon-random-proxy · API tester + + + +
+ +
+

weircon-random-proxy api tester

+

HTTP fetch service that forwards a request through one of N upstream WireGuard tunnels. This page lets you exercise the API without writing any code.

+
+ +
+

Configuration

+

If a reverse proxy in front (e.g. NginxProxyManager) validates an API key header, paste it here. Stored in your browser's localStorage. Leave blank when accessing the service directly on LAN.

+
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+

GET/health

+

Service health and the number of upstream tunnels configured.

+
+ + +
+ +
+ +
+

ANY/

+

Fetch a target URL through one of the configured tunnels. Status, headers, and body of the upstream response are streamed back to you.

+ +

Parameters

+ + + + + + + + + +
SourceNameRequiredDescription
headerX-WEIRCON-RANDOM-IP-REDIRECTone ofTarget URL to fetch. Takes precedence over ?url=.
query?url=one ofAlternative way to pass the target URL.
headerX-WEIRCON-PROXY-IDnoPin a specific tunnel (0..N-1). Default: random.
headerX-WEIRCON-FORWARD-METHODnoOverride the HTTP method sent to the target.
bodypassthroughnoForwarded as-is for POST/PUT/PATCH.
+ +

Try it out

+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + + +
+ +
+ cURL equivalent +

+  
+ + +
+ +
+

Tunnel health check

+

Sends a request to each configured tunnel and reports the egress IP. A healthy stack returns N distinct IPs.

+
+ + +
+
+
+ +

+ weircon-random-proxy · /health +

+ +
+ + + + diff --git a/wg-configs/.gitkeep b/wg-configs/.gitkeep new file mode 100644 index 0000000..e69de29