Initial commit
release / release (push) Successful in 1m15s

This commit is contained in:
Asger Weirsøe
2026-05-27 15:02:44 +02:00
commit 8652fcfbba
18 changed files with 1359 additions and 0 deletions
+54
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
wg-configs/*.conf
*.env
service/weircon-random-proxy
+176
View File
@@ -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.
+9
View File
@@ -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
+14
View File
@@ -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"
+95
View File
@@ -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"
+87
View File
@@ -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
+88
View File
@@ -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).
+31
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
[Unit]
Description=All weircon-random-proxy tunnels
StopWhenUnneeded=no
[Install]
WantedBy=multi-user.target
+17
View File
@@ -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
+35
View File
@@ -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;
+19
View File
@@ -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)
+5
View File
@@ -0,0 +1,5 @@
module weircon.dk/random-proxy
go 1.22
require golang.org/x/net v0.30.0
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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>
View File