Documentation▾
Architecture
Overview
Unclaw is an HTTPS MitM proxy that intercepts, inspects, and forwards HTTPS traffic from agent machines. It runs as a single Node.js process with two listeners:
Internet
|
+-- port 443 (Caddy/TLS) --> localhost:8080 (Dashboard + API)
+-- port 8443 (direct) --> 0.0.0.0:8443 (CONNECT proxy + transparent MitM)
+-- port 51820 (UDP) --> WireGuard tunnel
Connection Methods
Clients connect via one of two methods:
1. WireGuard Tunnel (recommended)
All traffic from the client is routed through a WireGuard VPN tunnel
(AllowedIPs = 0.0.0.0/0). On the server, iptables transparently redirects
port 443 traffic from the WireGuard subnet to the proxy:
Client (10.77.0.x) --> WireGuard tunnel --> Server wg0
--> iptables PREROUTING REDIRECT :443 -> :8443
--> Proxy detects TLS ClientHello, extracts SNI
--> MitM: generate cert, terminate TLS, forward upstream
The client needs no HTTPS_PROXY environment variable. The interception is
completely transparent. The CA certificate is installed system-wide during
onboarding.
Non-443 traffic (HTTP, etc.) passes through the tunnel and is forwarded via NAT masquerade. DNS queries (port 53) are intercepted by the proxy's DNS server for virtual IP resolution (see DNS Interception).
Client identification: by WireGuard tunnel source IP (10.77.0.x).
2. HTTPS_PROXY (explicit proxy)
The client sets HTTPS_PROXY=http://ID:TOKEN@gateway.example.com:8443. Tools that
respect this env var send CONNECT requests to the proxy:
Client --> CONNECT httpbin.org:443 HTTP/1.1
Proxy-Authorization: Basic base64(ID:TOKEN)
--> Proxy responds: 200 Connection Established
--> Client starts TLS inside the tunnel
--> MitM: generate cert, terminate TLS, forward upstream
This works without root access on the client, but only covers tools that
respect HTTPS_PROXY. Per-tool CA certificate configuration is needed
(the join script sets SSL_CERT_FILE, NODE_EXTRA_CA_CERTS, etc.).
Client identification: by Proxy-Authorization header (Basic auth with client ID and token).
3. macOS Network Extension (transparent proxy)
On macOS, unclaw run <cmd> uses a system extension
(NETransparentProxyProvider) to intercept traffic from the wrapped
process tree. The CLI registers the child PID with the NE over XPC
(Mach service group.2H4KBF436B.com.unclaw.app.extension). The NE
walks the PPID chain of each outbound flow to check if it belongs to a
registered process tree, then relays matched flows through a userspace
WireGuard tunnel (boringtun + smoltcp) to the gateway.
unclaw run <cmd>
--> XPC: registerPid(child, agent, cmd)
--> XPC: tunnelActivate(wgConfig)
--> NE intercepts outbound TCP/UDP from child's process tree
--> boringtun encrypts --> UDP to gateway:51820
--> Gateway decrypts, MitM, injects secrets, forwards upstream
The process is sandboxed via sandbox-exec to deny access to local
credentials. No HTTPS_PROXY env var or system-wide CA install is
needed — the NE handles interception transparently at the network
layer.
MitM TLS Interception
The proxy intercepts HTTPS traffic using the "loopback bridge" pattern (from the Avocet proxy):
- Generate a per-host TLS certificate signed by the Unclaw CA (EC P-256, cached in memory with LRU eviction at 256 entries)
- Create an ephemeral
tls.createServer()listening on127.0.0.1:0with the forged certificate - Connect to the ephemeral listener via
net.connect() - Bidirectionally pipe the client connection to the loopback connection (the encrypted TLS data flows through this pipe)
- The TLS server's
secureConnectionevent fires with the decryptedTLSSocket - Read HTTP requests from the decrypted connection, forward upstream
via
fetch(), relay responses back
This avoids node:tls's lack of a way to wrap an arbitrary existing duplex stream as a TLS server.
Protocol Detection
Port 8443 handles both CONNECT and transparent connections on the same listener. The first byte of the connection determines the protocol:
0x16(TLS handshake record) -> transparent mode: extract SNI from ClientHello, identify client by WireGuard IPC(start ofCONNECT ...) -> explicit proxy mode: parse CONNECT request, identify client by Proxy-Authorization header
CA Certificate Management
The CA is auto-generated on first startup using @peculiar/x509 and the
Web Crypto API (no external tools like openssl needed):
- EC P-256 key pair
- Self-signed X.509 certificate (CN=Unclaw CA, 10 year validity)
- Saved to
data/ca/ca-cert.pemanddata/ca/ca-key.pem - Loaded from disk on subsequent starts
Per-host certificates are generated on demand:
- EC P-256 key pair per hostname
- Signed by the CA with SAN extension (DNS or IP type)
- 30-day validity
- Cached in memory (LRU, max 256 entries)
Secret Injection
The proxy replaces placeholder strings in HTTP requests with real secret
values before forwarding upstream. Agents never see or handle real
credentials -- they use placeholders like UNCLAW_PLACEHOLDER_github.
Endpoint Configuration
Endpoint configs live in data/endpoints/*.ts (outside the source tree).
Each file defines target hosts and secrets:
export default {
hosts: ["api.github.com", "github.com"],
secrets: [{
placeholder: "UNCLAW_PLACEHOLDER_github",
file: "/opt/unclaw/data/secrets/github-token",
headers: ["authorization"], // only replace in this header
}],
};
Secret fields:
placeholder: the string agents use in requestsfile: path to the file containing the real secret valueheaders: (optional) restrict replacement to specific header namesbody: (optional) iftrue, also replace in the request body
Secrets are read from disk at startup and on SIGHUP reload.
Replacement Pipeline
For each intercepted HTTP request:
- Look up the endpoint by trusted hostname (from SNI or CONNECT target, never from the HTTP Host header)
- Replace placeholders in allowed headers (respecting the
headersfilter) - Replace placeholders in the body (if
body: true) - Handle Basic auth specially: base64-decode, replace, re-encode
- Update Content-Length if the body was modified
Anti-Exfiltration
Secrets are never injected into headers that could be echoed back in error responses or debug pages:
- Blocked headers:
user-agent,accept,content-type,origin,referer,cache-control,sec-*,openai-organization,anthropic-version, and others - Blocked paths:
/cdn-cgi/*(Cloudflare debug endpoints)
Host Header Security
The proxy enforces that the HTTP Host header matches the trusted hostname
from SNI/CONNECT. This prevents a malicious agent from setting
Host: evil.com to redirect injected secrets to an attacker-controlled
server. The Host header is always overwritten with the trusted hostname.
Hot Reload
Endpoint configs can be reloaded without restarting:
systemctl reload unclaw # sends SIGHUP
This re-reads all data/endpoints/*.ts files and reloads secrets from
disk. Active connections are not interrupted.
IP Binding
Per the spec, leaked credentials should be useless from an unknown IP.
On first request after approval, the client is bound to its external IP
(approvedIp). If a subsequent request comes from a different IP,
approval is auto-revoked and the client goes back to "pending" on the
dashboard.
For WireGuard clients, the real endpoint IP is resolved via
wg show wg0 dump (not the tunnel IP 10.77.0.x). For HTTPS_PROXY
clients, the source IP of the CONNECT request is used.
Re-approving a client clears the IP binding, allowing it to bind to a new IP on next use.
Client Lifecycle
Join: client runs the onboarding script, which:
- Checks server availability
- Chooses connection method (interactive wizard or
--methodflag) - Registers via
POST /api/register(client starts as "pending") - Installs the CA certificate
- Configures WireGuard tunnel or HTTPS_PROXY env vars
Approval: admin approves the client on the dashboard (
POST /api/clients/:id/approve)Active: proxy allows the client's traffic. Each connection is identified (by WireGuard IP or Proxy-Authorization), checked for approval, then MitM'd.
Deny: admin can revoke access (
POST /api/clients/:id/deny). The client remains registered but traffic is rejected.
Client state is persisted in SQLite (data/clients.db). Registrations,
approvals, WireGuard links, and IP bindings survive service restarts.
On startup, WireGuard peers from the database are re-added to the wg0
interface.
API Endpoints
Public (no auth required):
| Method | Path | Description |
|---|---|---|
| GET | /join |
Client onboarding script |
| GET | /api/status |
Health check + WG availability |
| GET | /api/ca.pem |
CA certificate download |
| POST | /api/register |
Register new client |
| POST | /api/setup-wireguard |
Configure WireGuard for client |
Auth (Google OAuth, configurable domain restriction):
| Method | Path | Description |
|---|---|---|
| GET | /auth/login |
Redirect to Google OAuth |
| GET | /auth/callback |
OAuth callback, sets session |
| GET | /auth/logout |
Clear session cookie |
Protected (requires session):
| Method | Path | Description |
|---|---|---|
| GET | / |
Dashboard SPA (index.html) |
| GET | /assets/* |
Dashboard static assets (JS, CSS) |
| GET | /api/me |
Current user email |
| GET | /api/clients |
List all clients |
| POST | /api/clients/:id/profile |
Assign profile to client |
| DELETE | /api/clients/:id/profile |
Remove profile from client |
| DELETE | /api/clients/:id |
Delete client |
| GET | /api/plugins |
List available plugins |
| GET | /api/integrations |
List integrations |
| POST | /api/integrations |
Create integration |
| DELETE | /api/integrations/:id |
Delete integration |
| GET | /api/profiles |
List profiles |
| POST | /api/profiles |
Create profile |
| DELETE | /api/profiles/:id |
Delete profile |
| POST | /api/profiles/:id/integrations |
Add integration to profile |
| DELETE | /api/profiles/:id/integrations/:id |
Remove integration from profile |
| POST | /api/oauth/authorize |
Start OAuth flow for integration |
| POST | /api/oauth/disconnect |
Disconnect OAuth for integration |
| GET | /api/oauth/status/:id |
OAuth connection status |
| GET | /api/requests |
Query request audit log |
WireGuard Network
- Server interface:
wg0, IP10.77.0.1/24, port51820/UDP - Client IPs:
10.77.0.2,.3,.4, ... (assigned sequentially) - Server keypair stored in
data/wg/ - iptables rules (managed by the application, cleaned up on restart):
INPUT -i wg0 -s 10.77.0.0/24 -j ACCEPT(allows DNS + VIP DNAT traffic)PREROUTING -s 10.77.0.0/24 -p tcp --dport 443 -j REDIRECT --to-port 8443PREROUTING -s 10.77.0.0/24 -d 10.78.x.y -p tcp -j DNAT --to 10.77.0.1:<port>(per DNS entry)FORWARD -i wg0 -j ACCEPTFORWARD -o wg0 -m state --state RELATED,ESTABLISHED -j ACCEPTPOSTROUTING -s 10.77.0.0/24 -o enp1s0 -j MASQUERADE
DNS Interception
For protocols without TLS SNI (e.g. SSH), the proxy intercepts DNS queries from WireGuard clients and returns virtual IPs that route traffic to per-hostname TCP listeners.
Flow
- Plugin declares
dnsEntries(config)returning hostnames it wants to intercept (e.g. the GitHub plugin registersgithub.com:22) - On startup, each unique hostname gets a virtual IP from
10.78.0.0/16 - An iptables DNAT rule routes traffic for that VIP to a per-hostname TCP listener on a random port
- A DNS server on
10.77.0.1:53(UDP + TCP) intercepts DNS from WG clients (requiresCAP_NET_BIND_SERVICEon the node binary):- Registered hostnames: return the virtual IP (A record, TTL 30s)
- AAAA queries for registered hostnames: empty response (force IPv4)
- All other queries: forwarded to upstream DNS (8.8.8.8)
- EDNS (OPT records) are parsed and echoed in responses
- When a WG client connects to the virtual IP, the listener:
- Identifies the client by WireGuard tunnel IP
- Verifies the client's profile registered this DNS entry (prevents a client from bypassing DNS and using another profile's VIP directly)
- If TLS (first byte 0x16): performs MitM TLS termination using the loopback bridge pattern (same as the main proxy), then processes the decrypted stream
- Tries plugin protocol handlers for credential injection
- Otherwise pipes to the upstream host bidirectionally
Virtual IP Subnet
10.78.0.0/16 is reserved for DNS-intercepted hostnames. These IPs are
never routed to the internet -- they only exist within the WireGuard
tunnel and are resolved by iptables DNAT rules to local listeners.
Allocations are ephemeral (reset on restart). The 30s DNS TTL ensures clients pick up new mappings quickly.
DNS Transport
Both UDP and TCP are supported. UDP is used for normal queries. TCP is used by clients when responses are large or when the truncation (TC) flag is set. TCP DNS messages are framed with a 2-byte length prefix per RFC 1035.
Request Logging
Every proxied request/response is logged to the SQLite database
at $UNCLAW_DATA/clients.db with a 7-day retention (configurable
via ANALYTICS_RETENTION_DAYS). See
Self-Hosting for details.