Initial commit
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
wg-configs/*.conf
|
||||
*.env
|
||||
service/weircon-random-proxy
|
||||
@@ -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://<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.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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/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/<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.
|
||||
@@ -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
|
||||
@@ -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 <id 0..N>}"
|
||||
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"
|
||||
@@ -0,0 +1,95 @@
|
||||
#!/bin/bash
|
||||
# Bringer én tunnel-namespace op.
|
||||
# - opretter (eller genbruger) bridge br-weircon i hoved-netns
|
||||
# - opretter netns proxy<id>
|
||||
# - laver veth-par mellem hoved-netns og proxy<id>, 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 <id 0..N>}"
|
||||
|
||||
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"
|
||||
@@ -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 <<EOF
|
||||
|
||||
Setup done.
|
||||
|
||||
Configs til stede: $([[ $missing -eq 0 ]] && echo "alle 10 OK" || echo "MANGLER — push dem ind før du starter")
|
||||
|
||||
Start stakken:
|
||||
systemctl enable --now weircon-proxies.target
|
||||
systemctl enable --now weircon-proxy@{0..9}.service
|
||||
systemctl enable --now weircon-fetch.service
|
||||
|
||||
Verificér:
|
||||
systemctl status 'weircon-proxy@*'
|
||||
curl http://127.0.0.1:8080/health
|
||||
curl -H 'X-Weircon-Random-Ip-Redirect: https://api.ipify.org' http://127.0.0.1:8080/
|
||||
|
||||
Egress-IP tjek (skal vise N distinkte upstream-IPs):
|
||||
for i in 0 1 2 3 4 5 6 7 8 9; do
|
||||
curl -sS -H "X-Weircon-Proxy-Id: \$i" -H "X-Weircon-Random-Ip-Redirect: https://api.ipify.org" http://127.0.0.1:8080/
|
||||
echo " ← proxy\$i"
|
||||
done
|
||||
EOF
|
||||
@@ -0,0 +1,88 @@
|
||||
# Proxmox host setup (one-time)
|
||||
|
||||
Alt herfra køres på Proxmox-hosten som root. Når LXC'en er oppe, skip videre til `setup-container.sh`.
|
||||
|
||||
## 1. Opret unprivileged container
|
||||
|
||||
Brug Debian 12 (eller nyere). Tilpas `200` (CTID), storage og bridge til din opsætning.
|
||||
|
||||
```sh
|
||||
pveam download local debian-12-standard_12.7-1_amd64.tar.zst # hvis ikke allerede til stede
|
||||
|
||||
pct create 200 local:vztmpl/debian-12-standard_12.7-1_amd64.tar.zst \
|
||||
--hostname weircon-random-proxy \
|
||||
--unprivileged 1 \
|
||||
--features nesting=1,keyctl=1 \
|
||||
--memory 1024 \
|
||||
--swap 512 \
|
||||
--cores 2 \
|
||||
--rootfs local-lvm:8 \
|
||||
--net0 name=eth0,bridge=vmbr0,ip=dhcp \
|
||||
--onboot 1
|
||||
```
|
||||
|
||||
> **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).
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
[Unit]
|
||||
Description=All weircon-random-proxy tunnels
|
||||
StopWhenUnneeded=no
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
@@ -0,0 +1,5 @@
|
||||
module weircon.dk/random-proxy
|
||||
|
||||
go 1.22
|
||||
|
||||
require golang.org/x/net v0.30.0
|
||||
@@ -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=
|
||||
+300
@@ -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)
|
||||
}
|
||||
}
|
||||
+418
@@ -0,0 +1,418 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>weircon-random-proxy · API tester</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f7f7f8;
|
||||
--panel: #ffffff;
|
||||
--text: #1f2328;
|
||||
--muted: #656d76;
|
||||
--border: #d0d7de;
|
||||
--accent: #0969da;
|
||||
--ok: #1a7f37;
|
||||
--warn: #9a6700;
|
||||
--err: #cf222e;
|
||||
--code-bg: #f1f3f5;
|
||||
--pill-bg: #ddf4ff;
|
||||
--mono: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #0d1117;
|
||||
--panel: #161b22;
|
||||
--text: #e6edf3;
|
||||
--muted: #8b949e;
|
||||
--border: #30363d;
|
||||
--accent: #58a6ff;
|
||||
--ok: #3fb950;
|
||||
--warn: #d29922;
|
||||
--err: #f85149;
|
||||
--code-bg: #1e242b;
|
||||
--pill-bg: #133156;
|
||||
}
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.wrap { max-width: 920px; margin: 0 auto; padding: 24px; }
|
||||
header { margin-bottom: 32px; }
|
||||
h1 { margin: 0 0 4px; font-size: 24px; letter-spacing: -0.01em; }
|
||||
h1 .tag { font-family: var(--mono); font-size: 13px; color: var(--muted); margin-left: 8px; font-weight: 400; }
|
||||
header p { margin: 0; color: var(--muted); }
|
||||
section { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 20px; margin-bottom: 16px; }
|
||||
section h2 { margin: 0 0 4px; font-size: 18px; }
|
||||
section h2 .method { font-family: var(--mono); font-size: 12px; padding: 2px 8px; border-radius: 4px; background: var(--pill-bg); color: var(--accent); margin-right: 8px; vertical-align: 1px; }
|
||||
section h2 code { font-size: 16px; color: var(--text); background: transparent; padding: 0; }
|
||||
section > p { margin: 0 0 12px; color: var(--muted); font-size: 14px; }
|
||||
label { display: block; font-size: 13px; color: var(--muted); margin-bottom: 4px; }
|
||||
input, select, textarea {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
}
|
||||
input:focus, select:focus, textarea:focus { outline: 2px solid var(--accent); outline-offset: -1px; border-color: transparent; }
|
||||
textarea { font-family: var(--mono); resize: vertical; min-height: 80px; }
|
||||
.grid { display: grid; grid-template-columns: 2fr 1fr 1fr; gap: 12px; margin-bottom: 12px; }
|
||||
@media (max-width: 600px) { .grid { grid-template-columns: 1fr; } }
|
||||
.field { margin-bottom: 12px; }
|
||||
button {
|
||||
padding: 8px 16px;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover { filter: brightness(0.92); }
|
||||
button.secondary { background: var(--panel); color: var(--text); border: 1px solid var(--border); }
|
||||
button.secondary:hover { background: var(--code-bg); }
|
||||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
pre, code { font-family: var(--mono); }
|
||||
pre {
|
||||
background: var(--code-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
overflow: auto;
|
||||
font-size: 12px;
|
||||
max-height: 360px;
|
||||
margin: 0;
|
||||
}
|
||||
code { background: var(--code-bg); padding: 1px 4px; border-radius: 3px; font-size: 0.92em; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 13px; margin-bottom: 12px; }
|
||||
th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border); vertical-align: top; }
|
||||
th { font-weight: 600; color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
td code { font-size: 12px; }
|
||||
.status-pill { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 12px; font-family: var(--mono); font-weight: 600; }
|
||||
.status-2xx { background: rgba(63, 185, 80, 0.15); color: var(--ok); }
|
||||
.status-3xx { background: rgba(210, 153, 34, 0.15); color: var(--warn); }
|
||||
.status-4xx, .status-5xx { background: rgba(248, 81, 73, 0.15); color: var(--err); }
|
||||
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
.row > * { flex: 0 0 auto; }
|
||||
details summary { cursor: pointer; color: var(--muted); font-size: 13px; padding: 4px 0; }
|
||||
details summary:hover { color: var(--text); }
|
||||
.muted { color: var(--muted); font-size: 13px; }
|
||||
.tunnel-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 8px; margin-top: 12px; }
|
||||
.tunnel { padding: 10px 12px; background: var(--code-bg); border: 1px solid var(--border); border-radius: 6px; font-family: var(--mono); font-size: 12px; }
|
||||
.tunnel .id { color: var(--muted); font-size: 11px; }
|
||||
.tunnel .ip { font-size: 13px; font-weight: 600; margin-top: 2px; }
|
||||
.tunnel.err { border-color: var(--err); }
|
||||
.tunnel.err .ip { color: var(--err); }
|
||||
.tunnel.loading .ip { color: var(--muted); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
|
||||
<header>
|
||||
<h1>weircon-random-proxy <span class="tag" id="version-tag">api tester</span></h1>
|
||||
<p>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.</p>
|
||||
</header>
|
||||
|
||||
<section id="config-section">
|
||||
<h2>Configuration</h2>
|
||||
<p>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.</p>
|
||||
<div class="grid">
|
||||
<div>
|
||||
<label for="apikey">X-WEIRCON-RANDOM-IP (API key)</label>
|
||||
<input id="apikey" type="password" autocomplete="off" placeholder="optional">
|
||||
</div>
|
||||
<div>
|
||||
<label for="base-url">Service base URL</label>
|
||||
<input id="base-url" type="url" placeholder="(this origin)">
|
||||
</div>
|
||||
<div style="display: flex; align-items: end;">
|
||||
<button class="secondary" onclick="forgetCreds()">Forget</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2><span class="method">GET</span><code>/health</code></h2>
|
||||
<p>Service health and the number of upstream tunnels configured.</p>
|
||||
<div class="row">
|
||||
<button onclick="callHealth()">Try it</button>
|
||||
<span id="health-status" class="muted"></span>
|
||||
</div>
|
||||
<pre id="health-body" style="margin-top: 8px; display: none;"></pre>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2><span class="method">ANY</span><code>/</code></h2>
|
||||
<p>Fetch a target URL through one of the configured tunnels. Status, headers, and body of the upstream response are streamed back to you.</p>
|
||||
|
||||
<h3 style="margin: 16px 0 8px; font-size: 14px;">Parameters</h3>
|
||||
<table>
|
||||
<thead><tr><th>Source</th><th>Name</th><th>Required</th><th>Description</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>header</td><td><code>X-WEIRCON-RANDOM-IP-REDIRECT</code></td><td>one of</td><td>Target URL to fetch. Takes precedence over <code>?url=</code>.</td></tr>
|
||||
<tr><td>query</td><td><code>?url=</code></td><td>one of</td><td>Alternative way to pass the target URL.</td></tr>
|
||||
<tr><td>header</td><td><code>X-WEIRCON-PROXY-ID</code></td><td>no</td><td>Pin a specific tunnel (0..N-1). Default: random.</td></tr>
|
||||
<tr><td>header</td><td><code>X-WEIRCON-FORWARD-METHOD</code></td><td>no</td><td>Override the HTTP method sent to the target.</td></tr>
|
||||
<tr><td>body</td><td><em>passthrough</em></td><td>no</td><td>Forwarded as-is for POST/PUT/PATCH.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3 style="margin: 16px 0 8px; font-size: 14px;">Try it out</h3>
|
||||
<div class="grid">
|
||||
<div>
|
||||
<label for="target">Target URL</label>
|
||||
<input id="target" type="url" value="https://api.ipify.org" placeholder="https://example.com">
|
||||
</div>
|
||||
<div>
|
||||
<label for="method">Method</label>
|
||||
<select id="method">
|
||||
<option>GET</option><option>POST</option><option>PUT</option><option>PATCH</option><option>DELETE</option><option>HEAD</option><option>OPTIONS</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="proxy-id">Proxy ID</label>
|
||||
<input id="proxy-id" type="number" min="0" placeholder="random">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field" id="body-field" style="display: none;">
|
||||
<label for="body">Request body</label>
|
||||
<textarea id="body" placeholder='{"hello":"world"}'></textarea>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button onclick="callFetch()">Send</button>
|
||||
<button class="secondary" onclick="showCurl()">Show cURL</button>
|
||||
<span id="fetch-status" class="muted"></span>
|
||||
</div>
|
||||
|
||||
<details id="curl-details" style="margin-top: 12px;">
|
||||
<summary>cURL equivalent</summary>
|
||||
<pre id="curl-out"></pre>
|
||||
</details>
|
||||
|
||||
<div id="response-block" style="display: none; margin-top: 16px;">
|
||||
<div class="row" style="margin-bottom: 8px;">
|
||||
<span id="resp-status" class="status-pill"></span>
|
||||
<span id="resp-egress" class="muted"></span>
|
||||
<span id="resp-latency" class="muted"></span>
|
||||
</div>
|
||||
<details open>
|
||||
<summary>Body</summary>
|
||||
<pre id="resp-body"></pre>
|
||||
</details>
|
||||
<details>
|
||||
<summary>Response headers</summary>
|
||||
<pre id="resp-headers"></pre>
|
||||
</details>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Tunnel health check</h2>
|
||||
<p>Sends a request to each configured tunnel and reports the egress IP. A healthy stack returns N distinct IPs.</p>
|
||||
<div class="row">
|
||||
<input id="probe-url" type="url" value="https://api.ipify.org" style="max-width: 320px;">
|
||||
<button onclick="probeAll()">Probe all tunnels</button>
|
||||
</div>
|
||||
<div id="tunnel-grid" class="tunnel-grid"></div>
|
||||
</section>
|
||||
|
||||
<p class="muted" style="text-align: center; margin-top: 24px;">
|
||||
weircon-random-proxy · <a href="/health" style="color: var(--accent);">/health</a>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const HEADER_AUTH = "X-Weircon-Random-Ip";
|
||||
const HEADER_REDIRECT = "X-Weircon-Random-Ip-Redirect";
|
||||
const HEADER_PROXY_ID = "X-Weircon-Proxy-Id";
|
||||
const HEADER_METHOD = "X-Weircon-Forward-Method";
|
||||
const RESP_HEADER_EGRESS = "x-weircon-egress-proxy";
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
function loadCreds() {
|
||||
$("apikey").value = localStorage.getItem("weircon_apikey") || "";
|
||||
$("base-url").value = localStorage.getItem("weircon_base_url") || "";
|
||||
}
|
||||
function saveCreds() {
|
||||
localStorage.setItem("weircon_apikey", $("apikey").value);
|
||||
localStorage.setItem("weircon_base_url", $("base-url").value);
|
||||
}
|
||||
function forgetCreds() {
|
||||
localStorage.removeItem("weircon_apikey");
|
||||
localStorage.removeItem("weircon_base_url");
|
||||
loadCreds();
|
||||
}
|
||||
function baseURL() {
|
||||
return ($("base-url").value || window.location.origin).replace(/\/$/, "");
|
||||
}
|
||||
function authHeaders() {
|
||||
const h = {};
|
||||
const k = $("apikey").value;
|
||||
if (k) h[HEADER_AUTH] = k;
|
||||
return h;
|
||||
}
|
||||
|
||||
async function callHealth() {
|
||||
saveCreds();
|
||||
$("health-status").textContent = "calling…";
|
||||
$("health-body").style.display = "none";
|
||||
try {
|
||||
const r = await fetch(baseURL() + "/health", { headers: authHeaders() });
|
||||
const text = await r.text();
|
||||
$("health-status").textContent = r.ok ? `HTTP ${r.status}` : `HTTP ${r.status} (failed)`;
|
||||
$("health-body").textContent = tryPrettyJSON(text);
|
||||
$("health-body").style.display = "block";
|
||||
try {
|
||||
const j = JSON.parse(text);
|
||||
if (j.proxies) renderTunnelGrid(j.proxies);
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
$("health-status").textContent = "network error: " + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function callFetch() {
|
||||
saveCreds();
|
||||
const target = $("target").value.trim();
|
||||
if (!target) { alert("target URL required"); return; }
|
||||
const method = $("method").value;
|
||||
const proxyId = $("proxy-id").value.trim();
|
||||
const body = $("body").value;
|
||||
|
||||
const headers = { ...authHeaders(), [HEADER_REDIRECT]: target };
|
||||
if (proxyId !== "") headers[HEADER_PROXY_ID] = proxyId;
|
||||
|
||||
const init = { method, headers };
|
||||
if (["POST", "PUT", "PATCH"].includes(method) && body) init.body = body;
|
||||
|
||||
$("fetch-status").textContent = "calling…";
|
||||
$("response-block").style.display = "none";
|
||||
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
const r = await fetch(baseURL() + "/", init);
|
||||
const t1 = performance.now();
|
||||
const text = await r.text();
|
||||
showResponse(r, text, Math.round(t1 - t0));
|
||||
$("fetch-status").textContent = "";
|
||||
} catch (e) {
|
||||
$("fetch-status").textContent = "network error: " + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
function showResponse(r, body, latencyMs) {
|
||||
$("response-block").style.display = "block";
|
||||
const sp = $("resp-status");
|
||||
sp.textContent = `${r.status} ${r.statusText}`;
|
||||
sp.className = "status-pill status-" + (Math.floor(r.status / 100)) + "xx";
|
||||
|
||||
const egress = r.headers.get(RESP_HEADER_EGRESS);
|
||||
$("resp-egress").textContent = egress !== null ? `via proxy${egress}` : "";
|
||||
$("resp-latency").textContent = `${latencyMs} ms`;
|
||||
|
||||
$("resp-body").textContent = tryPrettyJSON(body);
|
||||
const headerLines = [];
|
||||
r.headers.forEach((v, k) => headerLines.push(`${k}: ${v}`));
|
||||
$("resp-headers").textContent = headerLines.join("\n");
|
||||
}
|
||||
|
||||
function tryPrettyJSON(s) {
|
||||
try { return JSON.stringify(JSON.parse(s), null, 2); } catch (_) { return s; }
|
||||
}
|
||||
|
||||
function showCurl() {
|
||||
saveCreds();
|
||||
const target = $("target").value.trim();
|
||||
const method = $("method").value;
|
||||
const proxyId = $("proxy-id").value.trim();
|
||||
const body = $("body").value;
|
||||
const k = $("apikey").value;
|
||||
|
||||
let cmd = `curl -X ${method} \\\n "${baseURL()}/"`;
|
||||
if (k) cmd += ` \\\n -H '${HEADER_AUTH}: ${k}'`;
|
||||
cmd += ` \\\n -H '${HEADER_REDIRECT}: ${target}'`;
|
||||
if (proxyId !== "") cmd += ` \\\n -H '${HEADER_PROXY_ID}: ${proxyId}'`;
|
||||
if (["POST", "PUT", "PATCH"].includes(method) && body) {
|
||||
cmd += ` \\\n --data-raw '${body.replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
$("curl-out").textContent = cmd;
|
||||
$("curl-details").open = true;
|
||||
}
|
||||
|
||||
async function renderTunnelGrid(count) {
|
||||
const grid = $("tunnel-grid");
|
||||
grid.innerHTML = "";
|
||||
for (let i = 0; i < count; i++) {
|
||||
const el = document.createElement("div");
|
||||
el.className = "tunnel";
|
||||
el.id = `tunnel-${i}`;
|
||||
el.innerHTML = `<div class="id">proxy${i}</div><div class="ip">—</div>`;
|
||||
grid.appendChild(el);
|
||||
}
|
||||
}
|
||||
|
||||
async function probeAll() {
|
||||
const probeURL = $("probe-url").value.trim();
|
||||
if (!probeURL) { alert("probe URL required"); return; }
|
||||
|
||||
// ensure grid is rendered (calls /health if needed)
|
||||
if ($("tunnel-grid").children.length === 0) {
|
||||
try {
|
||||
const r = await fetch(baseURL() + "/health", { headers: authHeaders() });
|
||||
const j = await r.json();
|
||||
renderTunnelGrid(j.proxies);
|
||||
} catch (e) {
|
||||
alert("failed to read /health: " + e.message);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const cells = [...$("tunnel-grid").children];
|
||||
cells.forEach(c => { c.className = "tunnel loading"; c.querySelector(".ip").textContent = "…"; });
|
||||
|
||||
await Promise.all(cells.map(async (cell, i) => {
|
||||
const headers = { ...authHeaders(), [HEADER_REDIRECT]: probeURL, [HEADER_PROXY_ID]: String(i) };
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
const r = await fetch(baseURL() + "/", { headers });
|
||||
const text = (await r.text()).trim();
|
||||
const ms = Math.round(performance.now() - t0);
|
||||
if (r.ok) {
|
||||
cell.className = "tunnel";
|
||||
cell.querySelector(".ip").textContent = text.slice(0, 64);
|
||||
cell.querySelector(".id").textContent = `proxy${i} · ${ms}ms`;
|
||||
} else {
|
||||
cell.className = "tunnel err";
|
||||
cell.querySelector(".ip").textContent = `HTTP ${r.status}`;
|
||||
}
|
||||
} catch (e) {
|
||||
cell.className = "tunnel err";
|
||||
cell.querySelector(".ip").textContent = "error";
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
$("method").addEventListener("change", () => {
|
||||
const m = $("method").value;
|
||||
$("body-field").style.display = ["POST", "PUT", "PATCH"].includes(m) ? "block" : "none";
|
||||
});
|
||||
|
||||
loadCreds();
|
||||
// auto-load /health to populate tunnel grid on first paint
|
||||
callHealth();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user