📚 中文文档
support web mode
can auto merge conversation
http-relay listens on local HTTP and relays requests in this format:
http://localhost:{port}/https://example.com/path?...
It forwards the request to the target absolute URL in the path and returns the upstream response as-is (status code, headers, body).
Install the latest release binary:
curl -fsSL https://raw.githubusercontent.com/onewesong/http-relay/main/install.sh | shInstall a specific version or install into a user-writable directory:
curl -fsSL https://raw.githubusercontent.com/onewesong/http-relay/main/install.sh | VERSION=v1.2.3 sh
curl -fsSL https://raw.githubusercontent.com/onewesong/http-relay/main/install.sh | BINDIR="$HOME/.local/bin" shBuild from source:
go install github.com/onewesong/http-relay/cmd/http-relay@latest
go install github.com/onewesong/http-relay/cmd/http-relay-auth@latestDocker:
docker run --rm -p 7080:7080 ghcr.io/onewesong/http-relay:latestGitHub Actions image publishing:
- push to
main: publishghcr.io/onewesong/http-relay:edgeandsha-* - push tag like
v1.2.3: publishv1.2.3,1.2,1,latest
- Start service (default
127.0.0.1:7080):
http-relay- Send a request:
curl -i "http://127.0.0.1:7080/https://example.com"Check version:
http-relay versionReverse proxy to a fixed upstream:
http-relay --mode reverse:https://api.example.com
curl -i "http://127.0.0.1:7080/v1/users"The request above is forwarded to https://api.example.com/v1/users.
--mode: target mode, supportsregular(default) andreverse:<url>--config: TOML configuration path; falls back toHTTP_RELAY_CONFIG--listen: listen address, overrides--host/--port--host: listen host (defaults toHOST, then127.0.0.1)--port: listen port (defaults toPORT, then7080)--timeout: upstream request timeout (default:120s)-w/--dump: dump request/response traffic--dump-scope: dump scope, supportsreq,resp,req,resp--mask-auth: mask auth-related request headers in request dump--tui: interactive collapsible TUI; lists each request, arrow keys /j,kto select,enterto expand its headers and body,qto quit (implies dumping req+resp, requires a terminal)--web: serve a live web UI that streams traffic to the browser over SSE; response bodies switch between Preview and Raw, with collapsible JSON, sandboxed HTML, and merged SSE/OpenAI messages; the Conversations view links OpenAI turns by explicit conversation IDs,previous_response_id, or complete message history and links back to source requests (implies dumping req+resp, served on a separate port)--web-listen: listen address for the web UI (default:127.0.0.1:7090)--web-trust-forwarded-headers: trust reverse-proxyX-Forwarded-Proto/X-Forwarded-Host--add-header: add an upstream request header, repeatable--modify-header: set/overwrite an upstream request header, repeatable--script: path to a JavaScript file withonRequest/onResponsehooks that rewrite traffic--script-timeout: per-hook execution timeout (default:200ms)--script-reload: hot-reload mode, supportswatch(default),poll,off
Example:
http-relay --listen 0.0.0.0:9000
http-relay --mode reverse:https://api.example.com --timeout 30sHOST: listen host (default:127.0.0.1)PORT: listen port (default:7080)WIRE_SCOPE: compatibility fallback for--dump-scopeHTTP_RELAY_CONFIG: TOML configuration path, overridden by--configWEB_AUTH_KEY: login key for the Web UI, effective only with--web. Empty or unset keeps the UI public; when set, the page, SSE, and transaction API require login and sessions last 24 hours.WEB_AUTH_JWT_SECRET: overrides the JWT secret in TOML; it does not enable JWT mode by itself.WEB_MAX_TRANSACTIONS_PER_NAMESPACE: maximum retained transactions per namespace, defaults to100and overrides TOML.
Docker Compose example with Web authentication:
services:
http-relay:
image: ghcr.io/onewesong/http-relay:latest
command: ["--listen", "0.0.0.0:7080", "--web", "--web-listen", "0.0.0.0:7090"]
environment:
WEB_AUTH_KEY: "replace-with-a-long-random-secret"
ports:
- "127.0.0.1:7080:7080"
- "127.0.0.1:7090:7090"JWT mode can protect the default view and each namespace independently. Copy config.example.toml, then run http-relay-auth secret to generate a secret. A complete configuration looks like this:
[web]
max_transactions_per_namespace = 100
[web.auth]
mode = "jwt"
secret = "replace-with-http-relay-auth-secret-output"
issuer = "http-relay"
audience = "http-relay-web"
token_ttl = "720h"
max_token_ttl = "2160h"
allow_permanent_tokens = true
admin_enabled = true
default_protected = true
fallback_protected = false
trust_forwarded_headers = false
[web.auth.namespaces]
team-a = true
team-b = true
public-demo = falsemax_transactions_per_namespace applies independently to each namespace; the default view without a namespace is another independent bucket. Only the oldest records in the bucket that exceeds its limit are evicted. It can be overridden temporarily:
WEB_MAX_TRANSACTIONS_PER_NAMESPACE=500 http-relay --config ./config.toml --webEnable the read-only MCP endpoint on the Web listener:
[web.mcp]
enabled = trueThe endpoint is /mcp and accepts the Web JWT as Authorization: Bearer <JWT>. It exposes list_transactions, get_transaction, search_transactions, and analyze_transactions; namespace-scoped tokens remain bound to their JWT namespace while admin tokens may select another namespace.
The secret must be unpadded Base64URL for at least 32 random bytes. Run chmod 600 http-relay.toml when embedding it; using WEB_AUTH_JWT_SECRET is preferable in deployments. JWT mode cannot be combined with WEB_AUTH_KEY.
Start Web mode and create an offline management token:
http-relay --config ./http-relay.toml --web
http-relay-auth issue --config ./http-relay.toml --adminPaste the management token at /login to enter /admin/. The page shows record, last-activity, and SSE-subscriber counts across namespaces and can issue tokens restricted to one non-empty namespace. It cannot issue management tokens; always create those offline with http-relay-auth issue --admin.
Restricted tokens can also be issued and inspected offline:
http-relay-auth issue --config ./http-relay.toml --namespace team-a --ttl 24h
http-relay-auth issue --config ./http-relay.toml --namespace team-a --permanent
printf '%s' "$TOKEN" | http-relay-auth inspect --config ./http-relay.toml ---permanent requires allow_permanent_tokens = true and creates a JWT without exp. Browser cookie cleanup can still require logging in again. The first version has no per-token revocation: rotate the secret and restart to invalidate every old JWT, then create a new management token offline and reissue restricted tokens. Treat management tokens like passwords; never put them in URLs, logs, or shell history.
JWT protects only Web pages, SSE, queries, and Clear operations. It does not authenticate writes to the Relay port. Restrict that port at the network or reverse-proxy layer when exposed beyond localhost.
In regular mode, http-relay rejects path targets that are local, private, link-local, multicast, CGNAT, or resolve to one of those addresses. The same check applies to redirects. This is enabled by default; use --allow-private-targets only for a trusted deployment that deliberately needs internal upstreams.
Docker Compose can mount the full TOML as a Docker Secret:
services:
http-relay:
image: ghcr.io/onewesong/http-relay:latest
command: ["--config", "/run/secrets/http_relay_config", "--listen", "0.0.0.0:7080", "--web", "--web-listen", "0.0.0.0:7090"]
secrets: [http_relay_config]
ports:
- "127.0.0.1:7080:7080"
- "127.0.0.1:7090:7090"
secrets:
http_relay_config:
file: ./http-relay.tomlAlternatively, mount the secret-free template and inject the secret via environment:
environment:
HTTP_RELAY_CONFIG: /etc/http-relay/http-relay.toml
WEB_AUTH_JWT_SECRET: "${WEB_AUTH_JWT_SECRET}"
volumes:
- ./config.example.toml:/etc/http-relay/http-relay.toml:roWhen developing preview plugins, start the local workbench without connecting it to proxy traffic:
go run ./cmd/preview-labIt listens at http://127.0.0.1:8091 by default. The page includes editable JSON, HTML, SSE, OpenAI streaming, text, and binary fixtures with instant Preview/Raw switching. Use -listen to change the address. The lab is development-only and is not included in the production Web UI assets.
When HTTPS is terminated by Nginx, forward these headers and enable trust_forwarded_headers or --web-trust-forwarded-headers only when that proxy is trusted:
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;Regular mode blocks targets resolving to local, private, or otherwise reserved addresses by default. Enable private targets explicitly when required:
[relay]
allow_private_targets = trueThe --allow-private-targets command-line flag remains available and enables the same behavior for a single run.
Enable request/response dump:
http-relay -wMask auth-related headers in request dump:
http-relay -w -mask-authMasked request headers:
Authorization, Proxy-Authorization, Cookie, X-Api-Key, X-Auth-Token.
Use WIRE_SCOPE (effective only when -w is enabled):
req: dump request onlyresp: dump response onlyreq,resp: dump both (default)
Examples:
WIRE_SCOPE=req http-relay -w
WIRE_SCOPE=resp http-relay -w
WIRE_SCOPE=req,resp http-relay -w
http-relay --dump --dump-scope req,respAdd a request header:
http-relay --add-header "X-Debug: 1"Set or overwrite a request header:
http-relay --modify-header "User-Agent: http-relay"Use with reverse proxy mode:
http-relay \
--mode reverse:https://api.example.com \
--add-header "X-Trace-Source: local" \
--modify-header "User-Agent: http-relay"For logic beyond static header rules, point --script at a JavaScript file that
exports one or both hook functions. They run inside an embedded ECMAScript
engine (goja) — no external runtime needed.
http-relay --script ./plugins/examples/relay.example.js// Rewrite the request sent upstream; return an object to short-circuit
// (skip upstream and reply directly).
function onRequest(req) {
req.headers["X-Trace-Id"] = "trace-" + Date.now();
delete req.headers["Cookie"];
// Reroute to a new API version.
if (req.url.indexOf("/api/v1/") >= 0) {
req.url = req.url.replace("/api/v1/", "/api/v2/");
}
// Local mock — never hits upstream.
if (req.url.indexOf("/healthz") >= 0) {
return { status: 200, headers: { "Content-Type": "application/json" }, body: '{"ok":true}' };
}
}
// Rewrite the response returned to the client.
function onResponse(resp, req) {
resp.headers["X-Proxied-By"] = "http-relay";
if (resp.status === 500) {
resp.status = 503;
resp.body = "service temporarily unavailable\n";
}
}Hook object model (mutate in place to take effect):
req.method/req.url/req.host— strings. Rewritingreq.urlreroutes the request; the script has the final say on the target (it overrides--mode).req.headers/resp.headers— plain objects keyed by canonical header name:h["X-Foo"] = "v"— add or overwritedelete h["X-Foo"]— remove the headerh["X-Foo"] = ""— keep the header with an empty value
req.body/resp.body— strings.Content-Lengthis recomputed automatically.resp.status— number.onRequestmayreturn { status, headers, body }to short-circuit.onResponsestill runs on the synthesized response, so it can post-process mocks too.console.log/info/warn/error/debugwrite to stderr (silenced under--tui).req.namespace,req.rewriteProfile, andreq.originalPathare read-only route context strings. They are identical inonRequestandonResponse.
Named rewrite profiles can bind different scripts to different request paths without changing namespace grouping. Configure them in TOML (relative script paths are resolved from the configuration file directory):
[rewrite.profiles.openai]
script = "builtin:rewrite.openai.js"
[rewrite.profiles.mock]
script = "./plugins/examples/rewrite.mock.js"
# timeout/reload omitted: inherit --script-timeout/--script-reloadThen select a profile with a literal @ path segment:
curl "http://127.0.0.1:7080/@openai/https://example.com"
curl "http://127.0.0.1:7080/team-a/@mock/https://example.com/healthz"--script remains the default script for requests without @profile. A named
profile runs alone and is never combined with that default. Unknown profiles
return 404; they cannot reference arbitrary file paths. Profile selection is
available only in regular mode in this version—reverse mode forwards @profile
as an ordinary upstream path segment. Profiles select rewrite behavior only;
they do not authenticate writes to the Relay port.
Behavior notes:
- Both hooks are optional; an absent hook is skipped.
- A hook that throws or exceeds
--script-timeoutreturns500and does not reach upstream. - A script that fails to compile at startup is fatal (the process exits).
--script-reloadcontrols hot-reload:watchreloads on file changes (including editor atomic saves),pollchecks the modification time periodically,offloads once at startup. A reload that fails to compile keeps the previous version serving traffic.- Scripting works in all modes, including
--tuiand--web.
builtin:<file-name> selects a script embedded from plugins/built-in, for
example builtin:rewrite.openai.js. Embedded scripts cannot be hot-reloaded,
and may also be used as the default script with
--script builtin:rewrite.openai.js.
See plugins/examples/relay.example.js for a fuller example.
Scripts can synchronously call explicitly allow-listed APIs through
relay.http.request(options). This capability is disabled by default and is
not a browser fetch, Promise, or async/await implementation:
[rewrite.http]
enabled = true
allowed_origins = ["https://config.example.com"]
timeout = "800ms"
max_timeout = "1s"
max_request_body_bytes = 1048576
max_response_body_bytes = 1048576
max_calls_per_hook = 3
follow_redirects = false
allow_private_networks = false
[rewrite.profiles.external-config]
script = "./plugins/examples/rewrite.external-config.js"
timeout = "1500ms"
reload = "watch"function onRequest(req) {
if (!relay.http.enabled) return;
try {
var response = relay.http.request({
url: "https://config.example.com/v1/features",
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ namespace: req.namespace }),
timeoutMs: 300,
});
if (response.status === 200) {
req.headers["X-Feature"] = JSON.parse(response.body).enabled ? "1" : "0";
}
} catch (error) {
console.warn("external API failed:", error.message);
}
}The result contains status, headers, body, and the final url. HTTP
4xx/5xx responses are returned normally; policy, network, timeout, redirect,
and size-limit failures throw catchable errors. The dedicated client inherits
neither original request credentials nor proxy environment variables. The Hook
timeout covers the complete script execution and must leave enough time for the
external call. Private-network targets and redirects are denied by default;
every target must still exactly match allowed_origins.
Supported proxy env vars:
ALL_PROXY(highest priority)HTTP_PROXY/HTTPS_PROXYNO_PROXY(bypass proxy when matched)
Examples:
HTTPS_PROXY=http://127.0.0.1:7890 http-relay
ALL_PROXY=socks5://127.0.0.1:1080 http-relay
HTTPS_PROXY=http://127.0.0.1:7890 NO_PROXY=example.com http-relayWhen regular-mode private-target protection is enabled (the default), path-selected requests bypass these proxies so the relay itself can connect only to the IPs it validated. Set --allow-private-targets to retain proxy routing for trusted internal targets.
Default regular mode supports these four route shapes:
/{absolute-url}/{namespace}/{absolute-url}/@{profile}/{absolute-url}/{namespace}/@{profile}/{absolute-url}
For example:
http://127.0.0.1:7080/https://example.comhttp://127.0.0.1:7080/http://httpbin.org/post
An optional single-segment namespace can group traffic in the Web UI without changing the upstream target:
curl -i "http://127.0.0.1:7080/team-a/https://example.com"With --web, open http://127.0.0.1:7090/namespace/team-a/ to see only team-a traffic. The root Web URL shows only requests without a namespace. Namespaces may contain letters, digits, dots, underscores, and hyphens, are limited to 64 characters, and must start with a letter or digit. Old Web paths such as /team-a/ are not supported or redirected. Without JWT they only group traffic; JWT mode makes Web reads and Clear operations an authorization boundary. Reverse mode treats the entire path as an upstream path and does not parse namespaces.
Target URL must include http:// or https://.
For example, this is rejected by default even when it appears in the request path as a hostname:
http://127.0.0.1:7080/http://127.0.0.1:8080/
For trusted internal development only, start with:
http-relay --allow-private-targetsreverse:<url> mode joins the incoming path and query onto a fixed upstream:
http-relay --mode reverse:https://api.example.com/base
curl "http://127.0.0.1:7080/v1/users?q=go"The target is https://api.example.com/base/v1/users?q=go.
400: missing or invalid target URL502: upstream connection failure or timeout500: internal server error