> _**HTTP skill · `tunnel` namespace** · ~4,393 tokens_

# `tunnel` — reverse tunnels for HTTP/WS/TCP via container relay

## Purpose

**Mental model: ngrok, but built into every container, with the rest of the platform glued in for free.** Same job — reverse tunnel laptop ↔ container — but the public URL lives on the container's own `*.containers.hoody.icu` host, so it inherits everything the proxy already does:

- **Capability gates** (`proxyPermissionsContainer.*`) apply unchanged — Password / Token / JWT / IP groups gate the tunnel URL the same way they gate any kit URL.
- **Request hooks (MITM)** — wire `proxyHooks.*` rules to inspect, transform, redirect, or block requests before they reach the tunnel; same engine as the rest of the platform.
- **Proxy logs** — every tunnel request is captured by `proxyLogs.*` automatically (status, latency, headers, source IP). No extra setup.
- **Friendly aliases** — point a `<alias>.proxy.hoody.icu` at the tunnel via `POST /api/v1/proxy/aliases` so the public URL hides `containerId`.

Two surfaces:
- **EXPOSE**: publish laptop HTTP/1.1 (+WS) on container's public domain. (ngrok `http`)
- **PULL**: project laptop TCP onto container loopback. (ngrok `tcp` reverse)

Each surface has:
- **Data plane** (open the tunnel) — long-running WebSocket process. Lives in a separate driver (see Quirks).
- **Control plane** (inspect / kill) — short request/response: `GET /api/v1/tunnel/tunnels`, `GET /api/v1/tunnel/sessions`, `GET /api/v1/tunnel/bindings`, `GET /api/v1/tunnel/metrics`, `GET /api/v1/tunnel/health`, `DELETE /api/v1/tunnel/sessions/{session_id}`.

## When to use

- Publish laptop HTTP/WS on `*.containers.hoody.icu`.
- Project laptop TCP onto container `127.0.0.1:<port>`.
- Inspect, scrape metrics, kill sessions.

## When NOT to use

Container-hosted HTTP → `exec`, browser → `browser`, one-shot HTTP → `curl`, edge logs → `proxyLogs`, container↔container TCP not supported.

## Prerequisites

- `hoody-tunnel` kit running; base port reserved.
- EXPOSE URL needs `HOODY_TUNNEL_PUBLIC_URL_PATTERN`.

## Capability URL

→ See `SKILL-HTTP.md § Proxy URLs`.

**Reaching a service you host on a container port** (any port, any namespace):

- `https://{projectId}-{containerId}-http-<port>.{node}.containers.hoody.icu` — proxy speaks HTTP to `localhost:<port>`.
- `https://{projectId}-{containerId}-https-<port>.{node}.containers.hoody.icu` — proxy speaks HTTPS to `localhost:<port>` (target needs TLS).

Edge is always `https://`. No alias, firewall edit, or proxy registration needed; capability-token gates still apply.

## Common workflows

### 1. Inspect

`GET /api/v1/tunnel/tunnels` → sessions, bindings, streams, orphans, FD budget. Drill via `GET /api/v1/tunnel/sessions` / `GET /api/v1/tunnel/bindings`. `GET /api/v1/tunnel/health`; `GET /api/v1/tunnel/metrics` → Prometheus.

### 2. Kill stuck session

1. `GET /api/v1/tunnel/sessions` → `sessionId`.
2. `DELETE /api/v1/tunnel/sessions/{session_id}` with `grace_ms` 0–5000 (default 50). Returns `202`. Live → GOAWAY then close; orphans drop now (parking skipped).
3. Re-list before re-binding.

### 3. MITM / log / gate the tunnel

Tunnel traffic flows through the same proxy as every other kit URL, so:

- **Logs**: `client.proxyLogs.logs.list({ serviceName: 'tunnel' })` returns every request that hit the tunnel — status, latency, source IP, headers — and `client.proxyLogs.logs.streamLogs(...)` for live tail; the generated SDK stream method does **not** expose `serviceName`, so use `list({ serviceName: 'tunnel', ... })` for tunnel-scoped polling. (proxyLogs query params on `list` are `limit` / `offset` / `projectId` / `containerId` / `serviceName` / `level` / `includeRequestBody` / `includeResponseBody` / `last` / `afterId` / `cursor` / `kind` / `method` / `source`; there is no `program` filter.) No agent on the laptop needed.
- **Hooks (MITM)**: register a `proxyHooks` rule scoped to `program: 'tunnel'` (or by alias hostname) to inspect / rewrite / inject / block requests before they reach the WS data plane. Same hook DSL as for any kit. Useful for: stripping a bearer header on the way through, redacting PII, rate-limiting, swapping bodies on the fly, fault-injecting for tests.
- **Gates**: layer `proxyPermissionsContainer.set{Password,Token,Jwt,Ip}Group` on the container; the tunnel URL becomes auth-gated without the laptop ever needing to handle it.

## Quirks & gotchas

- The data-plane (open / pull) is a long-running driver process, not a request/response call. Runtime: Bun 1.3+ or Node 20+ (the listening-server form is Bun-only). The main `tunnel` namespace covers only the read/observability + admin surface (`GET /api/v1/tunnel/tunnels`, `GET /api/v1/tunnel/sessions`, `GET /api/v1/tunnel/bindings`, `GET /api/v1/tunnel/metrics`, `DELETE /api/v1/tunnel/sessions/{session_id}`).
- `BIND_OK.publicUrl=null` without `HOODY_TUNNEL_PUBLIC_URL_PATTERN`.
- `grace_ms` capped at 5000ms; over → `400`.
- Ports `<80` rejected; `80..=1023` need `--allow-privileged-expose`/`--allow-privileged-pull`.
- PULL loopback-only. EXPOSE has atomic takeover (`takeover:true`); old owner gets `RESET(BIND_TAKEOVER)`+`BIND_REVOKED`. PULL takeover → `BIND_ERR(INVALID_KIND)`.
- Idle reaping needs zero streams AND zero bindings. Orphans with parked bindings wait 60s takeover-grace.
- v1 vs v2 subprotocols share `/connect` (`hoody-tunnel.v1` for single-WS sessions, `hoody-tunnel.v2` for multi-WS shard pools); `isV2` on `GET /api/v1/tunnel/sessions` reports the shape. Both subprotocols support graceful resume via `resume.sessionId` in HELLO; `isV2:false` does NOT mean "no resume".
- Multi-WS (v2) drop semantics: dropping the **primary** socket closes the whole session; dropping a **secondary** shard makes the driver close streams pinned to that shard while the kit detaches the shard and the session continues.
- Pre-auth connection cap defaults to `--max-pre-auth-connections=32`; exceeding it closes the socket before HELLO (no explicit close code). HELLO timeout defaults to 5 s.
- **No UDP support.** EXPOSE is HTTP/1.1+WS only; PULL is TCP only.
- `GET /api/v1/tunnel/connect` is the WS-upgrade endpoint of the data plane. The generated SDK exposes it as a plain `http.get` returning an `ApiResponse<unknown>` — that is NOT a working tunnel attach. Use the driver helpers (`tunnelExpose` / `tunnelPull` / `tunnelServe`) re-exported from the main SDK package, which handle the WS subprotocol, HELLO frame, and resume; do not call `GET /api/v1/tunnel/connect` directly.

## Common errors

- `404` on kill — session gone; no retry.
- `403` — SSRF guard; not via Hoody Proxy.
- Upgrade `400` — missing/unsupported subprotocol; WS `1002` — HELLO rejected after upgrade; plain socket close — HELLO timeout or pre-auth cap reached.
- `BIND_ERR` codes: `ALREADY_BOUND` (retry `takeover:true`), `PORT_IN_USE`, `RESERVED_PORT`, `INVALID_HOST`, `PRIVILEGED_PORT`, `BIND_CAP_EXCEEDED`, `INVALID_KIND` (unsupported `(kind, mode)` combo, or `takeover:true` on PULL), `INTERNAL` (server-side, e.g. random-port exhaustion).
- `GOAWAY(IDLE_TIMEOUT)` — no PONG in 60s; reconnect via `resume.sessionId`.
- `503`+`Retry-After:5` at visitor URL — orphan takeover-grace window (set by `expose` driver kill, NOT by admin `DELETE /api/v1/tunnel/sessions/{session_id}` which skips orphan parking).

## Related namespaces

- `proxyLogs` — every tunnel request appears here automatically; filter by `serviceName: 'tunnel'` (there is no `program` filter on the proxy-log API).
- `api` — `proxyHooks.*` (MITM rules), `proxyPermissionsContainer.*` (capability gates), `POST /api/v1/proxy/aliases` (friendly hostnames hiding `containerId`).
- `exec` — for one-off HTTP handlers hosted directly inside the container (no laptop). `curl` — outbound HTTP from the container. `browser` — full headless Chromium. `daemon` — supervise long-running processes.

## Examples

The `tunnel` namespace covers only the **observability + admin** surface — `GET /api/v1/tunnel/health`, `GET /api/v1/tunnel/tunnels`, `GET /api/v1/tunnel/sessions`, `GET /api/v1/tunnel/bindings`, `GET /api/v1/tunnel/metrics`, `DELETE /api/v1/tunnel/sessions/{session_id}`. The data plane (open / pull) is a long-running WebSocket driver that lives in a separate package; it is intentionally out of scope here, so these 7 examples assume *somebody else* (a teammate's tunnel expose/pull session, your CI machine's tunnel session, a test rig) is currently holding the tunnel. You're the operator: inspecting it, scraping metrics, killing it. Set `P`, `C`, `N` (project id, container id, server name) from `GET /api/v1/containers/{id}` first.

Read-only steps were live-attempted against the test container; on this deployment the tunnel kit was 502 (no active driver) at the moment of writing — the kit only serves while a session is connected. Schemas, status codes, response shapes and CLI flags are verified against `generated/openapi.public.json`, `docs/reference/CLI-COMMANDS.md`, and a previously-recorded happy-path run (`scenarios/logs/2026-05-05_22-21-38/tunnel-kit.json`).

### 1. Health probe — kit alive, FD budget not exhausted

**Goal:** before any other call, confirm the tunnel kit is reachable and not saturated. Response includes `pid`, `started`, `userAgent`, `fds` (Unix-only file-descriptor count when available), and `memory.rss`.

```bash
KIT="https://${P}-${C}-tunnel-1.${N}.containers.hoody.icu"
curl -sf "$KIT/api/v1/tunnel/health" | jq '{status, service, started, pid, fds, rss: .memory.rss}'
# {"status":"ok","service":"hoody-tunnel","started":"2026-05-05T22:00:11Z","pid":42,"fds":128,"rss":52428800}
```
If the response is HTML / `Error 502` instead of JSON, the kit base listener isn't reachable through the proxy (kit crashed / not installed / proxy mis-route) — the admin endpoints are designed to stay live independent of any active session. Lack of an active session shows up as `sessions: []`, not 502.

### 2. List every active tunnel — combined sessions + bindings + FD budget

**Goal:** "what's currently tunneling on this container?" One call returns `sessions[]` (each with `peerAddr`, `protocol`, `connectionsGranted`, `activeStreams`, `exposeBindings[]`, `pullBindings[]`), `orphanedSessions` count, `totalStreams`, `totalBindings`, and `fdPermitsAvailable`.

```bash
KIT="https://${P}-${C}-tunnel-1.${N}.containers.hoody.icu"
curl -sf "$KIT/api/v1/tunnel/tunnels" | jq '{
  active: (.sessions | length),
  orphans: .orphanedSessions,
  streams: .totalStreams,
  binds: .totalBindings,
  fdBudget: .fdPermitsAvailable,
  ids: [.sessions[].sessionId]
}'
```
`GET /api/v1/tunnel/tunnels` is the one-shot overview. For per-session detail (peer addr, max-stream cap, v2 flag) drill in via `GET /api/v1/tunnel/sessions` (example #3). Note: `protocol` is per-session and reflects the negotiated control-plane protocol, NOT the upstream — for the "is this an EXPOSE or PULL" answer, look at which of `exposeBindings` / `pullBindings` is non-empty.

### 3. Drill into one session — peer addr, stream load, capacity

**Goal:** you got a `sessionId` from #2; now you want the operator-facing detail (who's connected, how loaded). Returns `peerAddr` (`<ip>:<port>` of the laptop holding the tunnel), `connectionsGranted` (lifetime), `activeStreams` (right now), `maxStreams` (negotiated cap), `isV2` (control-plane protocol), and `bindings[]`.

```bash
KIT="https://${P}-${C}-tunnel-1.${N}.containers.hoody.icu"
SID="S-466ab70d-92e8-49b5-95a9-8c0d585dc2b9"
curl -sf "$KIT/api/v1/tunnel/sessions" \
  | jq --arg s "$SID" '.sessions[] | select(.sessionId==$s) | {
      peer: .peerAddr,
      v2: .isV2,
      load: "\(.activeStreams)/\(.maxStreams)",
      lifetimeConnections: .connectionsGranted,
      binds: [.bindings[] | "\(.kind)/\(.mode):\(.containerPort)#\(.bindId)"]
    }'
```
`activeStreams / maxStreams` is the headroom number — a session sitting at `48/50` is one curl away from `STREAM_LIMIT`. `isV2:false` means the session negotiated the single-WebSocket v1 control plane; resume is still supported via `resume.sessionId` while the orphan is in takeover grace.

### 4. List bindings — which ports are exposed across every session

**Goal:** answer "what container ports are tunnels eating right now?". `GET /api/v1/tunnel/bindings` flattens across one row per active binding — `port`, `kind` (`http` / `tcp`), `mode` (`expose` / `pull`). EXPOSE rows include the owning `sessionId`/`bindId`; current PULL rows report `sessionId: ""` and `bindId: 0`.

```bash
KIT="https://${P}-${C}-tunnel-1.${N}.containers.hoody.icu"
curl -sf "$KIT/api/v1/tunnel/bindings" | jq '
  .bindings | group_by(.mode) | map({mode: .[0].mode, count: length, ports: map(.port)})
'
```
Useful pre-flight check before someone tries to bind another port — `BIND_ERR(PORT_IN_USE)` is one of the most common BIND failures. Also: the wire field is `port` here but `containerPort` inside the per-session `bindings[]` array of #3 — same value, different name (verified against `tunnel_BindingDetail` vs `tunnel_BindingInfo` in the openapi spec).

### 5. Scrape Prometheus metrics — sessions, bindings, FD permits

**Goal:** wire the tunnel kit into your scrape job. Endpoint emits Prometheus text (one of the few endpoints that's not JSON). Three live-verified counters: `hoody_tunnel_sessions_active`, `hoody_tunnel_bindings_active`, `hoody_tunnel_fd_permits_available`.

```bash
KIT="https://${P}-${C}-tunnel-1.${N}.containers.hoody.icu"
curl -sf "$KIT/api/v1/tunnel/metrics" \
  | grep -E '^hoody_tunnel_(sessions_active|bindings_active|fd_permits_available)\b'
# hoody_tunnel_sessions_active 1
# hoody_tunnel_bindings_active 2
# hoody_tunnel_fd_permits_available 1022
```
For a dashboard, register the kit URL as a Prometheus scrape target via `POST /api/v1/proxy/aliases` so the scrape config doesn't carry `containerId`, then gate it with `PUT /api/v1/containers/{id}/proxy/permissions/groups/{groupName}/ip` so only your monitoring VPC can hit `/api/v1/tunnel/metrics`.

### 6. Kill a stuck session (recipe — needs a real session)

**Goal:** a teammate's tunnel expose session is wedged; you want it gone without restarting the kit. `DELETE /api/v1/tunnel/sessions/{session_id}` returns `202` with `{sessionId, status}`. `grace_ms` ∈ [0, 5000] (default 50, anything above 5000 → `400`); the kit sends GOAWAY then waits up to that many ms for in-flight streams before closing. Orphan sessions skip the parking grace window and drop immediately.

⚠ Don't run this in the doc as live verification — it kills whoever's actually connected. Recipe only.

```bash
KIT="https://${P}-${C}-tunnel-1.${N}.containers.hoody.icu"
SID=$(curl -sf "$KIT/api/v1/tunnel/sessions" \
  | jq -r '.sessions[] | select(.peerAddr | startswith("203.0.113.")) | .sessionId' | head -1)
[ -n "$SID" ] || { echo "no matching session"; exit 1; }

# Give in-flight requests 1 second to drain, then close.
curl -sX DELETE "$KIT/api/v1/tunnel/sessions/$SID?grace_ms=1000" | jq .
# {"sessionId":"S-466ab70d-...","status":"closing"}

# Re-list to confirm it's gone (404 on a second kill is expected — see Common errors).
curl -sf "$KIT/api/v1/tunnel/sessions" | jq --arg s "$SID" '.sessions[] | select(.sessionId==$s) | "still here"'
```
After a non-admin driver disconnect, visitors of an orphaned `expose` URL see `503 Retry-After:5` during takeover grace. `DELETE /api/v1/tunnel/sessions/{session_id}` (admin) skips orphan parking and tears bindings down, so do **not** expect that 60 s 503 window from an admin kill — bindings drop immediately. PULL bindings drop instantly in both paths.

### 7. Auto-discover orphans + low-FD alert (monitoring recipe)

**Goal:** one cron-able script that watches both the orphan count (parked bindings whose laptop dropped) and the FD permits remaining; pages on either. `GET /api/v1/tunnel/tunnels` carries both numbers.

```bash
KIT="https://${P}-${C}-tunnel-1.${N}.containers.hoody.icu"
J=$(curl -sf "$KIT/api/v1/tunnel/tunnels")
ORPH=$(echo "$J" | jq '.orphanedSessions')
FDS=$(echo "$J" | jq '.fdPermitsAvailable')

# Threshold: alert if FDs < 64 OR orphans > 0 (orphans hold takeover-grace; can wedge BIND).
if [ "$FDS" -lt 64 ] || [ "$ORPH" -gt 0 ]; then
  echo "ALERT tunnel kit ${C}: orphans=${ORPH} fds=${FDS}"
  # …route to PagerDuty / Slack here…
fi
```
For a continuous live tail of `GOAWAY` / `IDLE_TIMEOUT` / `BIND_REVOKED` events as they happen, attach with the tunnel driver helpers (`TunnelSession` / `tunnelExpose` / `tunnelPull`, re-exported from the main SDK package) to `/api/v1/tunnel/connect` — that's the data-plane driver's surface and lives outside this namespace; do **not** use generated `GET /api/v1/tunnel/connect` for a real attach. The polling recipe above stays inside the request/response admin surface.

## Reference

### `health` (1) — Tunnel control plane (WebSocket + health + management)

| Method | Summary | Params |
|--------|---------|--------|
| `GET /api/v1/tunnel/health` | Kit health |  |

### `tunnel` (6) — Tunnel control plane (WebSocket + health + management)

| Method | Summary | Params |
|--------|---------|--------|
| `GET /api/v1/tunnel/metrics` | Prometheus metrics |  |
| `DELETE /api/v1/tunnel/sessions/{session_id}` | Terminate an active tunnel session | `?grace_ms` |
| `GET /api/v1/tunnel/bindings` | List active bindings across all sessions |  |
| `GET /api/v1/tunnel/sessions` | List active tunnel sessions |  |
| `GET /api/v1/tunnel/tunnels` | List all active tunnels (combined sessions + bindings) |  |
| `GET /api/v1/tunnel/connect` | Tunnel WebSocket control plane |  |

**Param notes:**

- `grace_ms` — GOAWAY drain budget in ms (0-5000, default 50)

