Initial commit

This commit is contained in:
Asger Weirsøe
2026-05-27 20:08:04 +02:00
commit 38fd417b49
18 changed files with 1359 additions and 0 deletions
+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>