One endpoint in front of all your APIs — matched by path, rewritten, and sent out each on its own link.
/openai/* → api.openai.com/v1/* via jp-proxy (HTTP)
/deepseek/* → api.deepseek.com/* direct
/gemini/* → generativelanguage.googleapis.com/* via us-socks (SOCKS5)
Point one hostname at PipeRouter and fan it out to as many upstreams as you like — each reached over its own network path. It matches every request by path prefix, rewrites it onto the target, and forwards it transparently. It never reads your bodies or touches your API keys; callers send credentials straight through to the upstream.
Configure it in one YAML file or from the built-in web console, and reload live — no restarts.
Request ─▶ Match prefix ─▶ Rewrite path ─▶ Pick transport ─▶ Forward ─▶ Stream response back
| 🎯 Prefix routing | Longest-prefix match on segment boundaries — /openai matches /openai/models, never /openai2. Order in the file never matters. |
| ✂️ Path rewrite | Strip or keep the matched prefix, join with the target path. Query strings and percent-encoding (%2F, spaces) pass through byte-for-byte. |
| 🔀 Per-route egress | Send each route out direct, through an http proxy (CONNECT for HTTPS), or over socks5 — with pooled, reused connections. |
| 🌊 Streaming first | SSE and WebSocket just work. Bodies stream both ways, never buffered — gigabyte uploads and hour-long event streams are fine. |
| ♻️ Live reload | Edit the YAML or use the console; changes apply atomically, mid-flight requests and open streams never drop. Bad config? It keeps serving the last good one. |
| 🖥️ Built-in console | A Vue 3 web UI — dashboard, route/transport editors, live logs and diagnostics — served straight from the binary (dark & light). |
| 📦 Single binary | One ~8 MB Go binary with the frontend embedded. Drop it on a box and run. No Node, no nginx, no sidecars. |
| 🔒 Transparent | Upstream status codes pass through unchanged; hop-by-hop headers handled per spec; no injected headers, no retries, no surprises. |
The data plane is allocation-light and never buffers a body. On an Apple M1 against a local upstream:
- sub-millisecond overhead (~47 µs median added latency per request)
- ~2.4 GB/s streamed throughput
- 256 MiB request bodies forwarded with an essentially flat heap — streamed, not buffered
- 200+ concurrent SSE streams held open with no goroutine growth
Reproduce with go test -bench . ./test/integration.
Build (needs Go ≥ 1.26 and Node.js) — or grab a release binary:
git clone https://github.com/kites262/piperouter
cd piperouter
make build # → dist/piperouter (web UI embedded)Write piperouter.yaml:
version: v0.3
server:
proxy:
listen: ":8080"
admin:
listen: "127.0.0.1:9090" # admin API + web UI, loopback only
transports:
- name: jp-proxy
type: http
url: http://127.0.0.1:7890
routes:
- name: openai
prefix: /openai
options:
target: https://api.openai.com/v1
strip_prefix: true
transport: jp-proxy # this route goes out through the HTTP proxy
- name: github
prefix: /github
options:
target: https://api.github.com # transport defaults to built-in "direct"Run it, then hit it:
./dist/piperouter serve --config piperouter.yaml
# → https://api.github.com/repos/golang/go (direct)
curl http://127.0.0.1:8080/github/repos/golang/go
# → https://api.openai.com/v1/models (through jp-proxy);
# your Authorization header is forwarded untouched
curl http://127.0.0.1:8080/openai/models -H "Authorization: Bearer $OPENAI_API_KEY"Open the console at http://127.0.0.1:9090.
A fully-commented config lives at
configs/example.yaml; every field, default and validation rule is documented indocs/configuration.md.
A single-page Vue 3 console, dark-first with a light theme (toggle in the sidebar — your choice is remembered, first visit follows your OS).
| Module | What it does |
|---|---|
| Dashboard | Service status, uptime, request totals, error rate, P95 latency, route summaries and recent errors — health at a glance. |
| Routes | List, create, edit, enable/disable, delete and test routes. The editor validates live and previews the final URL mapping before you save. |
| Route detail | The pipeline (prefix → rewrite → transport → target) plus per-route counts, error rate, P50/P95/P99 latency and recent requests. |
| Transports | Manage HTTP/SOCKS5 proxies and see which routes use each. |
| Logs | The recent-request log, filterable by route and status class. Bodies and sensitive headers are never recorded. |
| Diagnostics | Send a test request through a real route or transport and watch every stage: resolution, URL, connection, TLS, status, timing. |
| Settings | Listeners, TLS, log level and log-buffer capacity. |
Everything the UI does goes through the JSON API under /api/v1
(spec: api/openapi.yaml) — script the same operations with curl.
Matching is path-prefix only, on segment boundaries, longest prefix wins:
Routes: /api /api/openai /api/openai/v1
GET /api/openai/v1/models → matched by /api/openai/v1
GET /api/openai/models → matched by /api/openai
GET /apiv2/... → 404 (not a segment boundary)
Rewrite joins the target path with the remaining request path, preserving query and encoding exactly:
prefix /openai target https://api.example.com/v1 strip_prefix: true
/openai/chat/completions?stream=true → https://api.example.com/v1/chat/completions?stream=true
strip_prefix: false
/openai/chat → https://api.example.com/v1/openai/chat
The Host header is set to the target host. Connection failures map to 502, header timeouts to
504; a client that hangs up mid-request gets no response written. Full error table in
docs/configuration.md.
Route — one prefix → backend mapping (type: proxy or static):
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
name |
string | yes | — | unique, [A-Za-z0-9][A-Za-z0-9._-]{0,63} |
enabled |
bool | no | true |
disabled routes never match |
type |
enum | no | proxy |
proxy reverse-proxies to a URL; static serves one local file |
prefix |
string | yes | — | path prefix; starts with /, unique; non-root must not end with / |
target |
string | yes | — | proxy: absolute http/https URL; static: path to a file (absolute, or relative to the config file's directory) |
strip_prefix |
bool | no | true |
proxy only: remove the matched prefix before joining the target path |
transport |
string | no | direct |
proxy only: outbound transport to use |
Transport — one outbound link:
| Field | Type | Required | Description |
|---|---|---|---|
name |
string | yes | unique (direct is the reserved built-in) |
type |
enum | yes | http or socks5 |
url |
string | yes | http://host:port / socks5://host:port; credentials not supported |
The built-in direct transport always exists and must not be declared. Global sections cover
listeners, optional proxy TLS, log level and outbound timeouts.
The admin API and web console have no authentication. Treat the admin plane as privileged.
- The admin listener defaults to
127.0.0.1:9090— loopback only, where it also enforces a loopbackHostcheck to blunt DNS-rebinding. Keep it that way on any shared or internet-facing host; binding a non-loopback address logs a prominent warning. - To reach the console remotely, front it with something you trust: an SSH tunnel
(
ssh -N -L 9090:127.0.0.1:9090 you@server), a VPN, or an authenticating reverse proxy such as Caddy. Seedocs/deployment.md. - The admin API never emits CORS headers and rejects cross-origin mutating requests.
- Logs and the console never record bodies, query strings, or sensitive headers (
Authorization,Cookie, …). Proxy/target URLs with embedded credentials are rejected.
PipeRouter does not do ACME. For public HTTPS, terminate TLS in Caddy (recommended) or point
server.proxy.tls at existing certificate files.
Client ──HTTPS──▶ Caddy ──HTTP──▶ PipeRouter ──▶ upstreams
A distroless Docker image (~17 MB), a docker-compose + Caddy example and a systemd unit live in
deploy/; the full walkthrough is in docs/deployment.md.
piperouter [serve] [flags] run the proxy (default command)
piperouter validate [flags] validate a configuration file and exit
piperouter version print version information
Flag (for serve) |
Description |
|---|---|
--config <path> |
configuration file (default piperouter.yaml) |
--proxy-listen <addr> |
override server.proxy.listen (runtime only, not persisted) |
--admin-listen <addr> |
override server.admin.listen (runtime only, not persisted) |
--disable-admin |
disable the admin API and web console |
--disable-web |
disable the web console (admin API stays on) |
--log-level <level> |
debug | info | warn | error |
CLI flags take precedence over the file and are never written back.
make build # full release: Vite → internal/webui/dist → go test -race → dist/piperouter
make frontend # Vite-build the WebUI straight into the Go embed directory
make test # ensure-embed + go test -race ./...
make run # backend only (run `make frontend` first for a current UI)
make generate # regenerate the OpenAPI TypeScript client from api/openapi.yamlThe WebUI is embedded with a single pipeline — no content-hash patching, no
manual copy step. internal/webui/dist/ is fully gitignored; make ensure-embed
(seeded by make test / make backend) only adds a throwaway .gitkeep when
no UI has been built yet so //go:embed always has a file:
web/ ── vite build ──▶ internal/webui/dist/ ── go:embed ──▶ binary
(assets/app.js + app.css, stable names)
Frontend work — run the backend and the Vite dev server side by side (the dev server proxies /api
to 127.0.0.1:9090):
make run # terminal 1 — backend
cd web && npm install && npm run dev # terminal 2 — web UI with HMRcmd/piperouter/ CLI entry point (serve | validate | version)
internal/ Go packages: proxy, router, transport, config, runtime,
metrics, logging, diagnostics, api, webui, app
web/ Vue 3 + TypeScript + Vite + Tailwind web UI
api/openapi.yaml Admin API contract (the frontend client is generated from it)
configs/ deploy/ docs/ test/
Released under the MIT License.