# TUNNEL


> 0.2: tunnel traffic goes to **127.0.0.1:21730 only**. Port 21731 is the
> owner-only config UI, requires a private terminal token, and rejects forwarded
> headers/foreign origins. Do not expose or proxy it. The config token must
> never be entered as the remote MCP bearer token or shared with ChatGPT.

How to expose a JUJU server to a remote MCP client (ChatGPT, Claude, an
Inspector on another machine) over HTTPS.

JUJU binds **only** `127.0.0.1:21730` — see `httpServer.listen(port, '127.0.0.1', …)`
in [`src/server/appServer.ts`](../src/server/appServer.ts). It never touches the
network edge; making that loopback listener reachable is *your* job. This is the
runbook for doing it with a Cloudflare **named tunnel**. Any equivalent that
terminates TLS and reverse-proxies **all paths** to `127.0.0.1:21730` (an
nginx/Caddy box, an SSH tunnel) works the same way.

## 1. Responsibility boundary

JUJU owns the process on your machine. You own the edge.

**What JUJU does:** opens the loopback listener on `127.0.0.1:<port>` (default
`21730`); serves the MCP resource (`/mcp`), the OAuth authorization server,
discovery metadata, the owner-consent pages, and `/healthz` **all on the same
origin** (your `--public-url`); trusts `X-Forwarded-Proto`/`-For` **only from the
loopback hop** (`app.set('trust proxy', 'loopback')`) while pinning the OAuth
**issuer to your configured origin** — never deriving it from a `Host` or
`X-Forwarded-*` header; and validates inbound `Host`/`Origin` against loopback
plus your configured hostname.

**What you do:** create the tunnel, the DNS record, and the TLS-terminated
hostname; route **all paths** of that hostname to `127.0.0.1:<port>`; run the
tunnel daemon on the **same machine** as JUJU; manage the firewall, any Access
policy, and service supervision.

**JUJU will NEVER:**

- Create, register, or run a Cloudflare tunnel (or any tunnel).
- Create or edit DNS records.
- Request, store, or read a Cloudflare API token or account credential.
- Open a firewall port, bind `0.0.0.0`, or bind anything but loopback.
- Install a launch agent / systemd unit for `cloudflared`.
- Kill or reconfigure whatever holds its port (it exits `EADDRINUSE`; you resolve it or pass `--port`).

Without a public URL, `juju start` still binds loopback but runs **LOCKED**:
`/mcp` returns `401 setup_required` and every tool call is refused. Configure the
URL with `juju init --public-url …` first.

## 2. Route ALL paths, not just `/mcp`

The most common mistake is forwarding only `/mcp`. OAuth then fails because the
client cannot reach discovery, authorization, token, or consent routes. Every
path below lives on the **same public origin** and must be reachable through the
tunnel (see §8.2 of
[`JUJU_IMPLEMENTATION_SPEC.md`](../JUJU_IMPLEMENTATION_SPEC.md) and the route
registrations in [`src/server/appServer.ts`](../src/server/appServer.ts)):

| Path | Purpose |
|---|---|
| `/mcp` | Protected MCP resource (Bearer-authenticated). |
| `/.well-known/oauth-protected-resource/mcp` | Metadata the client reads after a `401`. |
| `/.well-known/oauth-authorization-server` | OAuth/OIDC discovery (issuer, endpoints). |
| `/auth` | Authorization endpoint. |
| `/token`, `/token/revocation` | Token + revocation endpoints. |
| `/jwks` | Signing keys the client uses to verify tokens. |
| `/interaction/*` | Owner-consent pages + provider callback resume. |
| `/healthz` | Minimal public liveness probe. |

The `401` from `/mcp` carries a `WWW-Authenticate` header pointing at the
`/.well-known/oauth-protected-resource/mcp` URL; if that 404s because you
forwarded only `/mcp`, the flow dead-ends. Route the hostname, not a prefix.

### Example ingress ([`examples/cloudflared.yml`](../examples/cloudflared.yml))

```yaml
tunnel: <YOUR_TUNNEL_ID>
credentials-file: /home/you/.cloudflared/<YOUR_TUNNEL_ID>.json

ingress:
  - hostname: juju.example.com
    service: http://127.0.0.1:21730
  # Final catch-all (required by cloudflared).
  - service: http_status:404
```

- `tunnel:` / `credentials-file:` — the named tunnel's UUID and per-tunnel
  credential JSON that `cloudflared tunnel create` prints/writes (§3); Cloudflare
  artifacts you manage, never read by JUJU.
- `ingress:` — an **ordered** list, first match wins.
- `hostname: … / service: http://127.0.0.1:21730` — send **every** request for
  that host to JUJU's loopback listener. There is **no `path:` key**, so all
  paths match. Do not add `path: /mcp`.
- `service: http_status:404` — the mandatory final catch-all; anything matching
  no hostname gets a 404 instead of being proxied.

Two things **not** to add:

- **No `originRequest.httpHostHeader` override.** cloudflared forwards the
  original `Host` (`juju.example.com`) by default, and JUJU validates it against
  your configured hostname (`hostHeaderValidation`). Rewriting it to `localhost`
  makes JUJU reject the request.
- **No second `service:` to any other local port** — no Inspector, dev server,
  or debug endpoint. JUJU exposes exactly one origin (§6).

## 3. Named tunnel setup (real `cloudflared` commands)

These run on the **same machine as JUJU**, as you — not JUJU. Install
`cloudflared` from Cloudflare's packages first.

```bash
# 1. One-time browser login; writes ~/.cloudflared/cert.pem (account cert).
cloudflared tunnel login
# 2. Create the tunnel. Prints a UUID; writes ~/.cloudflared/<UUID>.json (the credentials-file).
cloudflared tunnel create juju
# 3. Create the DNS route (a proxied CNAME) for your hostname.
cloudflared tunnel route dns juju juju.example.com
# 4. Write the config (§2) at ~/.cloudflared/config.yml with <YOUR_TUNNEL_ID> + credentials-file from step 2.
$EDITOR ~/.cloudflared/config.yml
# 5. Run it (foreground). Keep this process up alongside JUJU.
cloudflared tunnel run juju
```

Then start JUJU on the same box:

```bash
juju init --public-url https://juju.example.com   # writes the global profile
cd /Users/you/projects/my-app
juju trust --mode edit
juju start                                         # binds 127.0.0.1:21730
```

Both processes must be up for the public URL to answer; to run the tunnel
unattended, install it as a managed service (`cloudflared service install`),
outside JUJU. The `--public-url` origin's host is added to JUJU's `Host`/`Origin`
allowlist, so `https://juju.example.com` in `init` must equal the `hostname:` in
your ingress **exactly**.

## 4. Why a named tunnel + stable hostname is the baseline

Cloudflare **Quick Tunnels** (`cloudflared tunnel --url …`, the throwaway
`*.trycloudflare.com` hostnames) are **not** the recommended baseline, for two
reasons grounded in §2.4 of the spec:

1. **SSE limitation.** Quick Tunnels do not reliably carry streaming HTTP/SSE,
   which MCP transports use. A named tunnel with a stable hostname does.
2. **Hostname persistence for OAuth callbacks.** OAuth ties the issuer, the
   registered client redirect URI, and existing grants to a **fixed origin**. A
   Quick Tunnel hands you a new random hostname each run, invalidating your
   callback and forcing re-registration; a named tunnel's hostname is stable, so
   `juju auth add-client --redirect-uri …` and issued grants survive restarts.

A Quick Tunnel (or a JSON-only, non-streaming smoke test) is at most a
**diagnostic** — useful to prove basic reachability when run against your
**real** client. It is **not** the production or OAuth acceptance baseline; never
record a Quick-Tunnel smoke test as OAuth acceptance.

## 5. Cloudflare Access caveat

You may put Cloudflare **Access** in front of the hostname, but know what it is
and is not:

- The Access **browser-login page is not the MCP OAuth flow.** It is a separate
  Cloudflare identity gate. A client negotiating OAuth against JUJU's own
  `/.well-known` + `/auth` + `/token` endpoints will not drive an Access login
  page, and its OAuth callback has nothing to do with Access.
- Access **service tokens** require `CF-Access-Client-Id` /
  `CF-Access-Client-Secret` **headers** a ChatGPT-style MCP client cannot send.
  Gating the origin behind a service token simply blocks the client.

If you use Access, design a policy that lets JUJU's OAuth endpoints through
end-to-end and test the full flow with your actual client. **Do not "fix" an
Access conflict by disabling JUJU's auth** — a public URL with JUJU auth off
exposes an unauthenticated code-execution surface to the internet. Keep it on.

## 6. Health & verification

With the tunnel up and JUJU running with a public URL, verify over HTTPS from any
machine:

```bash
# Liveness. authConfigured must be true (false => JUJU is LOCKED; run juju init).
curl -fsS https://juju.example.com/healthz
# {"status":"ok","name":"juju","version":"0.x.y","authConfigured":true}
# Protected-resource metadata (what the client fetches after a 401).
curl -fsS https://juju.example.com/.well-known/oauth-protected-resource/mcp
# OAuth authorization-server discovery (issuer + endpoints, all on this origin).
curl -fsS https://juju.example.com/.well-known/oauth-authorization-server
# The MCP resource: expect 401 + WWW-Authenticate until OAuth completes (a 401 here is success).
curl -i https://juju.example.com/mcp
```

If `/healthz` shows `"authConfigured":false`, the server is LOCKED — run `juju
init --public-url …` and restart. If discovery URLs 404 while `/healthz` answers,
you forwarded only `/mcp` — fix the ingress (§2).

**Do not expose anything else through the tunnel.** The ingress has exactly one
`service:` line, to `127.0.0.1:21730`, plus the 404 catch-all. Never route an
MCP Inspector, a debug port, or an admin socket — JUJU keeps owner administration
on a private local IPC socket precisely so it never rides the public origin.

## 7. Local no-domain fixture (TEST-ONLY, plain HTTP)

To exercise the full OAuth + MCP flow with **no Cloudflare account and no
domain**, use the bundled
[`examples/reverse-proxy-fixture.mjs`](../examples/reverse-proxy-fixture.mjs). It
stands in for your tunnel, forwarding **all paths** to JUJU and injecting
`X-Forwarded-Proto` / `X-Forwarded-Host` as a real tunnel hop does.

> **TEST-ONLY. Plain HTTP, no TLS, loopback only.** For local development and
> integration tests — never a deployment path or OAuth acceptance evidence.

```bash
# 1. Point JUJU's public URL at the fixture (http:// origins need the opt-in flag — LOCAL TESTS ONLY).
juju init --public-url http://127.0.0.1:8788 --dangerously-allow-insecure-http
# 2. Start JUJU (binds 127.0.0.1:21730).
juju start
# 3. In another shell, run the fixture: listen on 8788, forward to 21730.
node examples/reverse-proxy-fixture.mjs 8788 21730
# 4. Verify through the fixture, exactly as through a real tunnel.
curl -fsS http://127.0.0.1:8788/healthz
curl -fsS http://127.0.0.1:8788/.well-known/oauth-protected-resource/mcp
curl -i    http://127.0.0.1:8788/mcp        # 401 until OAuth — expected
```

Because the issuer host is `127.0.0.1`, JUJU's loopback `Host` allowlist accepts
the forwarded request with no extra config. For a real deployment, drop the
fixture and the `--dangerously-allow-insecure-http` flag and re-`init` with
`https://`.

## 8. Running multiple projects at once

For sequential work, use Local Config to switch the active project on the same
MCP endpoint; see the [workspace guide](../README.md#quickstart). No second
tunnel is needed. The client still needs explicit access to the new path.

Simultaneous servers are an advanced setup. Each needs its own public hostname,
MCP port, private config port and isolated state directory. Do not use 21731 as
a second MCP port: it belongs to the first server's private config listener.

| Profile | MCP port (tunnel target) | Private config port (never tunneled) |
|---|---|---|
| A | 21730 | 21731 |
| B | 21732 | 21733 |

Before starting, prepare two owner-only config directories outside the
workspaces. In each directory's `config.json`, set `configPort` to the private
port in the table. `--port` changes only the MCP port, not the config port.
The paths below are examples of separately prepared profiles. Do not overwrite
a populated config file with a minimal example.

```bash
# Project A, profile configPort: 21731
cd /Users/you/projects/app-a
JUJU_CONFIG_DIR="$HOME/.config/juju-a" juju start --port 21730 --public-url https://juju-a.example.com

# Project B, profile configPort: 21733
cd /Users/you/projects/app-b
JUJU_CONFIG_DIR="$HOME/.config/juju-b" juju start --port 21732 --public-url https://juju-b.example.com
```

Separate `JUJU_CONFIG_DIR` values keep keys, grants and saved settings
independent. Use the same profile environment for that server's `juju auth`,
`status`, `trust` and `stop` commands. Register clients and grant workspace
access independently for each profile.

Route the hostnames to their MCP ports, forwarding all paths so OAuth and
metadata discovery reach the same server:

```yaml
ingress:
  - hostname: juju-a.example.com
    service: http://127.0.0.1:21730
  - hostname: juju-b.example.com
    service: http://127.0.0.1:21732
  - service: http_status:404
```

Never route either private config port through the tunnel. Each server checks
its configured Host and OAuth issuer/audience. The simultaneous two-profile
deployment with a real tunnel is **MANUAL_NOT_RUN**; the default single-server
release gate does not establish that result. Port conflicts fail explicitly
without killing another process or silently choosing a new port.
