A tunneling relay service that gives each Osaurus agent its own public URL. Each user runs one Osaurus server with multiple agents — each agent has its own secp256k1 identity (address). The user opens a single WebSocket tunnel and registers their agents on it. Public traffic to any agent's subdomain routes through that one tunnel.
[Client A] → https://0xagent1.agent.osaurus.ai/chat ──┐
[Client B] → https://0xagent2.agent.osaurus.ai/chat ──┤→ [Relay] → [1 WebSocket] → [User's Osaurus]
[Client C] → https://0xagent5.agent.osaurus.ai/chat ──┘
- Deno v2+
# Install dependencies
deno install
# Run in development mode (with file watcher)
deno task dev
# Run in production mode
deno task start
# Run tests
deno task test
# Lint
deno task lint
# Format
deno task fmtThe server starts on port 8080 by default. Override with the PORT environment variable.
osaurus-relay/
├── main.ts # Entry point — Deno.serve() HTTP server
├── src/
│ ├── router.ts # HTTP routing: health, stats, tunnel connect, subdomain relay
│ ├── tunnel.ts # WebSocket tunnel lifecycle + keepalive
│ ├── relay.ts # HTTP-to-WS request multiplexing + timeout
│ ├── http.ts # Shared HTTP helpers: JSON responses, CORS, header sanitization
│ ├── auth.ts # secp256k1 signature verification via viem
│ ├── rate_limit.ts # Token bucket rate limiter (per-IP and per-agent)
│ ├── stats.ts # Aggregate analytics counters
│ └── types.ts # All frame/message TypeScript types
├── test/
│ ├── auth_test.ts # Signature verification tests
│ ├── rate_limit_test.ts
│ ├── stats_test.ts # Analytics endpoint + counter tests
│ ├── tunnel_test.ts # Tunnel connect/disconnect/multi-agent tests
│ ├── relay_test.ts # Request forwarding tests
│ └── streaming_test.ts # Streaming response tests
├── Dockerfile # Deno container for Fly.io
├── fly.toml # Fly.io app config
└── deno.json # Deno config, tasks, imports
Health check. Returns 200 OK with:
{ "status": "ok", "tunnels": 42 }Aggregate analytics. Returns 200 OK with:
{
"uptime_seconds": 12345,
"active_tunnels": 3,
"active_agents": 7,
"total_requests_relayed": 1042,
"total_tunnel_connections": 15
}Rate-limited to 10 requests/min per IP.
Opens a WebSocket tunnel. The Osaurus client sends an auth frame as the first message with agent addresses and secp256k1 signatures. On success the relay responds with public URLs for each agent.
Agents can be added or removed mid-session without reconnecting.
Public traffic to an agent's subdomain is relayed through the user's tunnel. The relay injects X-Agent-Address and X-Forwarded-For headers. Infrastructure headers (fly-*, cf-*) and sensitive caller headers (cookie) are stripped before forwarding; authorization is passed through for Osaurus client authentication. The Osaurus instance handles its own authentication — the relay is a transparent proxy.
All agent subdomain responses include Access-Control-Allow-Origin: *. Preflight OPTIONS requests return 204 with appropriate CORS headers.
| Variable | Default | Description |
|---|---|---|
PORT |
8080 |
HTTP server port |
BASE_DOMAIN |
agent.osaurus.ai |
Base domain for agent subdomains |
This section documents the WebSocket protocol for clients connecting a tunnel to the relay.
Open a WebSocket to:
wss://agent.osaurus.ai/tunnel/connect
Authentication uses a challenge-response handshake to prevent signature replay attacks. The relay closes the connection if no auth is received within 10 seconds.
Step 1: Immediately after the WebSocket opens, the relay sends a challenge frame with a single-use 64-character hex nonce:
{ "type": "challenge", "nonce": "a1b2c3...64 hex chars" }Step 2: The client sends an auth frame including the server's nonce:
{
"type": "auth",
"agents": [
{ "address": "0xAgentAddress1...", "signature": "0x..." },
{ "address": "0xAgentAddress2...", "signature": "0x..." }
],
"nonce": "a1b2c3...same nonce from challenge",
"timestamp": 1709136000
}Each agent signs the following message with its own secp256k1 private key using EIP-191 personal_sign:
osaurus-tunnel:<agent-address>:<nonce>:<timestamp>
timestamp is Unix seconds. The relay rejects if it's more than 30 seconds from the server's clock. The nonce must match the one sent in the challenge frame — each nonce is single-use and consumed immediately after verification.
Step 3: If all signatures verify, the relay responds with:
{
"type": "auth_ok",
"agents": [
{ "address": "0xagentaddress1...", "url": "https://0xagentaddress1.agent.osaurus.ai" },
{ "address": "0xagentaddress2...", "url": "https://0xagentaddress2.agent.osaurus.ai" }
]
}On failure the relay sends auth_error and closes the socket:
{ "type": "auth_error", "error": "signature_verification_failed" }
{ "type": "auth_error", "error": "invalid_nonce" }Adding an agent mid-session requires a new challenge-response exchange to get a fresh nonce.
Step 1: Request a challenge:
{ "type": "request_challenge" }Step 2: The relay responds with a new single-use nonce (expires after 30 seconds if unused):
{ "type": "challenge", "nonce": "d4e5f6...64 hex chars" }Step 3: Send the add_agent frame with the nonce:
{ "type": "add_agent", "address": "0xNewAgent...", "signature": "0x...", "nonce": "d4e5f6...same nonce", "timestamp": 1709136030 }The signature covers osaurus-tunnel:<agent-address>:<nonce>:<timestamp>, same as initial auth.
Step 4: Response:
{ "type": "agent_added", "address": "0xnewagent...", "url": "https://0xnewagent.agent.osaurus.ai" }Remove an agent:
{ "type": "remove_agent", "address": "0xAgentToRemove..." }Response:
{ "type": "agent_removed", "address": "0xagenttoremove..." }Maximum 50 agents per tunnel.
When a public HTTP request arrives at an agent's subdomain, the relay forwards it as a request frame:
{
"type": "request",
"id": "req_abc123",
"method": "POST",
"path": "/v1/chat/completions",
"headers": {
"content-type": "application/json",
"x-agent-address": "0xagentaddress1...",
"x-forwarded-for": "203.0.113.1"
},
"body": "{\"message\": \"hello\"}"
}The client must respond with a matching id. There are two response modes:
For non-streaming endpoints, send a single response frame with the complete body:
{
"type": "response",
"id": "req_abc123",
"status": 200,
"headers": { "content-type": "application/json" },
"body": "{\"reply\": \"hi there\"}"
}For streaming endpoints (e.g. SSE), send a stream_start frame to begin the response, followed by any number of stream_chunk frames, and a final stream_end frame:
{ "type": "stream_start", "id": "req_abc123", "status": 200, "headers": { "content-type": "text/event-stream" } }
{ "type": "stream_chunk", "id": "req_abc123", "data": "data: {\"token\": \"Hello\"}\n\n" }
{ "type": "stream_chunk", "id": "req_abc123", "data": "data: {\"token\": \" world\"}\n\n" }
{ "type": "stream_end", "id": "req_abc123" }The relay flushes headers to the HTTP client on stream_start and writes each chunk incrementally. The stream has a 30-second inactivity timeout — if no stream_chunk or stream_end is received within 30 seconds of the last frame, the relay closes the stream.
If no response or stream_start is sent within 30 seconds, the relay returns 504 Gateway Timeout to the caller.
Multiple requests can be in-flight simultaneously over the same WebSocket — the id field is used to match responses to requests. The client chooses per-request whether to use buffered or streaming mode.
The relay sends a ping frame every 30 seconds:
{ "type": "ping", "ts": 1709136000 }The client must respond with:
{ "type": "pong", "ts": 1709136000 }If 3 consecutive pings go unanswered, the relay closes the connection.
The relay may send error frames for protocol violations:
{ "type": "error", "error": "max_agents_reached" }
{ "type": "error", "error": "invalid_signature" }
{ "type": "error", "error": "invalid_nonce" }Callers hitting agent subdomains may receive these relay-level errors:
| Status | Body | Meaning |
|---|---|---|
| 400 | {"error":"invalid_subdomain"} |
Subdomain is not a valid agent address |
| 413 | {"error":"body_too_large"} |
Request body exceeds 10 MB |
| 429 | {"error":"rate_limited"} |
Too many requests to this agent |
| 429 | {"error":"too_many_connections"} |
IP has too many open tunnels (max 10) |
| 502 | {"error":"agent_offline"} |
No active tunnel for this agent |
| 502 | {"error":"tunnel_send_failed"} |
Failed to send request through the tunnel |
| 504 | {"error":"gateway_timeout"} |
Agent didn't respond within 30 seconds |
| Scope | Limit |
|---|---|
| Tunnel connections | 5/min per IP |
| Concurrent tunnels per IP | 10 max |
| Stats endpoint | 10/min per IP |
| Inbound requests | 100/min per agent address |
| Agents per tunnel | 50 max |
| Request body size | 10 MB max (streaming read with early abort) |
The relay is a transparent proxy. It does not authenticate public traffic — that is handled by each user's Osaurus instance using the existing Identity system (secp256k1 signed tokens / osk-v1 access keys).
Relay-level protections:
- IP detection — uses
fly-client-ip(set by Fly.io edge, not spoofable) overx-forwarded-forfor all rate limiting and forwarding - Rate limiting — 100 req/min per agent address, 5 tunnel connects/min per IP, 10 stats req/min per IP
- Concurrent connection limit — max 10 open WebSocket tunnels per IP
- Max body size — 10 MB per request, enforced via streaming read with early abort (prevents memory exhaustion from chunked-encoding attacks that omit
content-length) - Tunnel auth — challenge-response handshake with server-issued single-use nonce + secp256k1 signature with 30-second timestamp window (prevents replay attacks)
- Connection limit — 50 agents per tunnel
- Response header sanitization — hop-by-hop headers (
transfer-encoding,connection,keep-alive,upgrade, etc.) are stripped from response frames before constructing the HTTP response - Request header sanitization — infrastructure headers (
fly-*,cf-*) and sensitive caller headers (cookie,proxy-authorization) are stripped before forwarding to the Osaurus client;authorizationis forwarded since Osaurus clients use bearer tokens for their own authentication - CORS — agent subdomain responses include
Access-Control-Allow-Origin: *; preflightOPTIONSare handled at the router level
fly launch
fly deployThe fly.toml is configured with auto_stop_machines = 'off' and min_machines_running = 1 to keep at least one machine always running — idle shutdown would kill all active WebSocket tunnels.
DNS setup:
*.agent.osaurus.ai. A <fly.io IP>
*.agent.osaurus.ai. AAAA <fly.io IPv6>
Fly.io handles TLS termination with automatic certs for wildcard subdomains.
MIT