A transparent proxy that routes traffic through upstream proxies based on hostname and IP matching. Supports HTTP, HTTPS, WebSocket, WSS, and SOCKS5 without terminating TLS.
-
Transparent proxying — HTTP, HTTPS, WebSocket, and WSS
-
SOCKS5 inbound — accepts SOCKS5 clients on the same port, auto-detected alongside HTTP
-
No TLS termination — extracts SNI from the ClientHello for routing, then tunnels raw bytes
-
HTTP CONNECT — works as an explicit proxy for clients that support it
-
Flexible routing — exact hostnames, domain suffixes, glob patterns, and CIDR subnets
-
Upstream proxy support — HTTP CONNECT, SOCKS5, and WireGuard upstreams
-
Connection pooling — reuses upstream connections across HTTP requests
-
HTTP keep-alive — serves multiple requests per client connection
-
Hot reload — config changes (including included files) are detected and applied without restart
-
Config includes — split config across multiple files with glob support
-
Live log streaming — stream colored logs from a running server with level filtering
-
Health checks — TCP health checks for all upstreams via the status command
-
Internal pages — link dashboards at
pages.subspace.puband statistics atstats.subspace.pub -
Statistics — live metrics, upstream health, and historical charts with persistent SQLite storage
-
Styled error pages — DNS failures and connection errors show helpful error pages instead of bare 502s
brew install davidolrik/tap/subspaceTo run subspace as a background service that starts automatically at login:
brew services start subspaceRequires Go 1.26 or later.
go install go.olrik.dev/subspace@latestOr clone and build:
git clone https://github.com/davidolrik/subspace.git
cd subspace
go build -o subspace .Create a config file at ~/.config/subspace/config.kdl:
listen "127.0.0.1:8118"
upstream "corporate" {
type "http"
address "proxy.corp.com:3128"
username "user"
password "pass"
}
upstream "tunnel" {
type "socks5"
address "socks.example.com:1080"
}
route ".corp.internal" via="corporate"
route "specific.host.com" via="tunnel"Start the proxy:
subspace serveUse it:
# HTTP proxy
curl -x http://localhost:8118 http://example.com
# HTTPS (via CONNECT)
curl -x http://localhost:8118 https://example.com
# SOCKS5 (same port)
curl --socks5-hostname localhost:8118 https://example.com
git -c http.proxy=socks5h://localhost:8118 clone https://github.com/org/repoSubspace uses KDL for configuration. The default config path is ~/.config/subspace/config.kdl (respects $XDG_CONFIG_HOME).
The address to listen on. The plain form binds a single port:
listen "127.0.0.1:8118"listen may also take a block, and may be repeated to bind multiple
ports. Each listener can carry per-port options:
listen "127.0.0.1:8118"
listen "127.0.0.1:8119" {
private true // suppress per-domain stats for this port
label "incognito" // cosmetic name used in logs and dashboards
}private suppresses successful per-domain and per-route writes for
every connection accepted on the listener. Total connections, protocol
breakdowns and per-upstream byte counts still record so the rollups
stay accurate. Point an "incognito" browser profile at the private
port (e.g. HTTPS_PROXY=http://127.0.0.1:8119) and successful browsing
won't appear in the per-domain or per-route history.
Failures are recorded either way. A failed connection didn't
transfer any payload, and the failure already surfaces in subspace logs regardless — the dashboard isn't a new disclosure surface.
Suppressing failures from the per-domain view would just hide
diagnostic data the operator needs to triage broken upstreams. So
private affects success/bytes only; failures always attribute to
domain and route.
This only hides traffic from subspace's own statistics. SNI is still visible on the wire to anyone who can observe your network. If that's your threat model, use a VPN or Tor — not a stats-tier "incognito" flag.
If something leaked into the per-domain stats that shouldn't have
(e.g. you forgot to switch your tool to the private port), use
subspace stats purge <domain> to scrub it after the fact.
Changing the listener set requires a restart; other settings still hot-reload.
Path to the Unix control socket used by status and logs commands. Defaults to ~/.config/subspace/control.sock.
control_socket "/tmp/subspace.sock"Defines a named upstream proxy. Supported types: http (HTTP CONNECT), socks5, and wireguard.
upstream "corporate" {
type "http"
address "proxy.corp.com:3128"
username "user" // optional
password "secret" // optional
}
upstream "tunnel" {
type "socks5"
address "127.0.0.1:1080"
}
upstream "home" {
type "wireguard"
endpoint "vpn.example.com:51820"
private-key "base64-encoded-private-key"
public-key "base64-encoded-peer-public-key"
address "10.0.0.2/32"
dns "1.1.1.1" // optional
}Routes traffic matching a pattern through a named upstream. Rules are evaluated in order and the last match wins.
// Exact hostname
route "specific.host.com" via="myproxy"
// Domain suffix — matches any subdomain of example.com
route ".example.com" via="myproxy"
// Glob pattern — matches IP ranges
route "192.168.*.*" via="lan-proxy"
// CIDR subnet — matches IP addresses in the network
route "10.0.0.0/8" via="internal"
// Direct — bypass all upstreams for this pattern
route "bypass.corp.com" via="direct"
// Blackhole — drop traffic for this pattern (refuses with HTTP 451 / SOCKS5 0x02)
route ".ads.example.com" via="blackhole"
route "*.telemetry.com" via="blackhole"
// Fallback to blackhole — if the work proxy is down, refuse rather than leak directly
route ".risky.example" via="corporate" fallback="blackhole"
// Private route — connections still flow, but the per-domain and per-route
// stats tables don't record them. Per-upstream and protocol rollups still count.
route ".bank.example.com" via="direct" private=truePattern types:
| Pattern | Example | Matches |
|---|---|---|
| Exact | "example.com" |
example.com only |
| Domain suffix | ".example.com" |
foo.example.com, bar.baz.example.com |
| Glob | "192.168.*.*" |
192.168.1.1, 192.168.0.255 |
| CIDR | "10.0.0.0/8" |
any IP in the 10.0.0.0/8 subnet (IPv4 and IPv6) |
Three built-in pseudo-upstreams need no upstream block:
-
direct— bypass any broader matching upstream and connect straight to the target. -
blackhole— drop the traffic and tell the operator about it. HTTP/CONNECT/WebSocket clients receive a syntheticHTTP/1.1 451 Unavailable For Legal Reasons, SOCKS5 clients get reply byte0x02(connection not allowed by ruleset), and TLS pass-through connections are closed. Drops are tracked per-route, per-domain and per-upstream insubspace statusand the statistics dashboard so you can see how much traffic was prevented from leaving. -
ignore(aliasignored) — drop the traffic quietly. The connection is closed with no protocol-level response: no 451 page, no SOCKS5 reply byte. Only theignoreupstream's total counter is bumped — no per-domain or per-route stats. Typical use is as a fallback for traffic the operator doesn't care about when the intended upstream is unreachable:// When the corporate VPN is down, silently drop git fetches instead of // leaking them to direct or surfacing 502s in the dashboard. route ".git.corp.example" via="corporate" fallback="ignore"
Unmatched traffic always connects directly.
Includes other config files, resolved relative to the file containing the include statement. Supports glob patterns.
include "upstreams/*.kdl"
include "routes/corporate.kdl"Nested includes are supported. Circular includes are detected and rejected. Glob patterns that match no files are silently ignored; exact paths that don't exist produce an error.
New files added to an already-included directory are picked up automatically on the next config change.
Defines an internal page served at pages.subspace.pub/{name}/. The page name is derived from the filename by default, or set explicitly with name=. An optional alias= adds a second path. p.subspace.pub is a shorthand for pages.subspace.pub.
page "dev.kdl"
page "ops.kdl"
page "my-page.kdl" name="internal" alias="int"This creates pages at pages.subspace.pub/dev/, pages.subspace.pub/ops/, and pages.subspace.pub/internal/ (also p.subspace.pub/int/). Each page is configured in its own KDL file:
title "Development"
footer "Acme Corp"
list "Repositories" {
link "GitHub" url="https://github.com/org" icon="si-github" description="Source code"
link "CI/CD" url="https://ci.example.com" icon="fa-rocket"
}Icons are embedded in the binary (no external requests). Use si-* for Simple Icons and fa-* for Font Awesome.
All configured pages and the statistics page appear in a shared navigation menu.
- Statistics — always available at
stats.subspace.pub(orstatistics.subspace.pub). Shows live metrics (connections, active, upstream health), and historical charts (connections over time, traffic by upstream, protocol breakdown). Statistics are persisted to a SQLite database and retained for one year with automatic downsampling. Top-N windowed deltas are computed as the sum of positive per-nameLAGdifferences, with a 2h pre-window lookback to seed the first sample — counter resets (daemon restarts) inside a window are skipped without distorting the totals. - Fallback — when subspace is not running, an external redirect server sends visitors to the documentation site at
https://subspace.pub/. - Error pages — DNS failures and connection errors show styled error pages instead of bare HTTP 502 responses.
The statistics page is always available at stats.subspace.pub regardless of page configuration. p.subspace.pub is a shorthand for pages.subspace.pub.
All config files (main and included) are watched for changes. When any file is modified, Subspace re-parses the entire config tree, validates it, and applies the new routing if valid. Invalid configs are rejected with a warning and the current routing stays active.
Settings that require a restart: listen, control_socket.
Starts the proxy server.
subspace serve
subspace serve --config /path/to/config.kdlShows health and status of upstream proxies. Performs a TCP health check against each upstream.
subspace status # colored terminal output
subspace status -J # raw JSON outputShows which route and upstream would handle a given URL.
$ subspace resolve https://app.corp.internal/apiStreams logs from a running server.
subspace logs # last 10 info+ lines
subspace logs -N 50 # last 50 lines
subspace logs -L error # only errors
subspace logs -L debug -F # all levels, follow live| Flag | Description | Default |
|---|---|---|
-N, --lines |
Number of historical lines | 10 |
-L, --level |
Minimum level: debug, info, warn, error |
info |
-F, --follow |
Stream live output after history | false |
Remove every historical sample for a domain from the per-domain stats table. Useful when something landed in stats that should have been browsed through a private listener.
subspace stats purge tracker.example.com
subspace stats purge tracker.example.com -J # raw JSON outputOnly the per-domain table is touched — route, upstream, protocol, and connection rollups are intentionally left intact, since route patterns aren't 1:1 with a single host and the rollups don't identify the domain in the first place. The live in-memory counter is cleared too, so the dashboard stops showing the host immediately.
Prints the version and exits.
subspace versionWhen a connection arrives, Subspace peeks at the first byte to classify the protocol:
0x05(SOCKS5) — Performs the SOCKS5 handshake to extract the target hostname and port. Routes through the same upstream selection as HTTP CONNECT, then relays bidirectionally.0x16(TLS) — Parses the ClientHello to extract the SNI hostname, reads the full record length for reliable extraction. Routes based on SNI, then tunnels the raw TLS bytes without decryption.- HTTP CONNECT — Responds with
200 Connection Establishedand relays bytes bidirectionally. - Plain HTTP — Reads the
Hostheader for routing. DetectsUpgrade: websocketfor WebSocket upgrade. Supports HTTP/1.1 keep-alive for multiple requests per connection.
All protocols share the same routing rules and upstream configuration. SOCKS5 clients (git, ssh, curl) can use the same port as HTTP proxy clients.
HTTP requests reuse upstream connections when possible. After a response is fully read, the upstream connection is returned to a per-host pool instead of being closed. The next request to the same upstream and target address gets the pooled connection, avoiding a fresh TCP + proxy handshake.
Pool defaults: 4 idle connections per host, 90 second idle timeout. The pool is drained on config reload.
CONNECT and TLS connections use bidirectional relay and are not pooled.
For tunneled connections (TLS, CONNECT, WebSocket), Subspace unwraps the buffered reader before entering the relay loop. This allows the kernel to use splice/sendfile for zero-copy data transfer between sockets.
Subspace clears proxy-related environment variables (HTTP_PROXY, HTTPS_PROXY, NO_PROXY, ALL_PROXY) on startup. Routing is controlled exclusively by the config file.
Respects $XDG_CONFIG_HOME for the default config directory. Respects $NO_COLOR to disable colored output.
MIT