8000 TOML config file, MCP OAuth and Tailscale exposure by adeelahmad · Pull Request #15 · adeelahmad/Tether · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- TOML config file (`--config`, `$TETHER_CONFIG`, or
`~/.config/tether/config.toml` when present; see `docs/CONFIG.md` and
`tether.example.toml`). Precedence is flag > environment > file > default.
It covers binding, auth (including `[[auth.tokens]]`, folding in the policy
file), allow/deny lists, console capture, the MCP and remote faces, and adds:
`[headers]` (headers Chrome sends from Tether's tabs, and client headers the
proxy strips), `[[cookies]]` seeded into the browser at startup, and
`[chrome].profile` / `--chrome-profile` to launch a specific profile.
- MCP OAuth (`[mcp.oauth]`, `--mcp-oauth`): a built-in OAuth 2.1
authorization server with metadata discovery, dynamic client registration,
PKCE and refresh tokens, so OAuth-only MCP clients can connect. The consent
page asks for a Tether token and the client acts as it; OAuth tokens open
`/mcp` only. `--public-url` sets the issuer.
- `--policy-file` also accepts TOML (`.toml`).
- Tailscale exposure (`[tailscale]`, `--tailscale`, `--tailscale-publish`):
listen on this machine's tailnet address, and optionally publish over HTTPS
with `tailscale serve` (tailnet) or `tailscale funnel` (internet), removed
again on shutdown. Uses the installed tailscale CLI; needs a token.

- Per-token and per-session domain policies (`docs/POLICIES.md`): a
`--policy-file` defines named tokens, each with allow/deny rules, an
`admin` flag and a `scripts` permission; `--deny-domains` adds a global
Expand Down
20 changes: 18 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,10 @@ curl -x "http://tether:$TETHER_TOKEN@my-laptop:8787" --cacert tether-ca.pem http
# or watch and drive it from your phone: http://my-laptop:8787/remote/
```

`tether serve --launch --tailscale` (or `[tailscale] enabled = true` in the
config file) picks the tailnet address for you, and `--tailscale-publish serve`
adds an HTTPS name via `tailscale serve`; see [docs/CONFIG.md](docs/CONFIG.md#tailscale).

Tether refuses to listen beyond loopback without a token. Read
[SECURITY.md](SECURITY.md) before exposing it anywhere.

Expand All @@ -213,25 +217,37 @@ curl -s -H "Authorization: Bearer $TETHER_RESEARCH_TOKEN" -X POST http://127.0.0

## Configuration

Every flag has a `TETHER_*` environment variable; flags win.
Every flag has a `TETHER_*` environment variable, and almost everything can
also go in a TOML config file: `~/.config/tether/config.toml` is read if it
exists, or pass `--config`. Precedence is flag > environment > file > default.
The file also holds extra tokens, headers Chrome sends, cookies to seed, the
Chrome profile to launch and MCP OAuth. Start from
[`tether.example.toml`](tether.example.toml); the full reference is
[docs/CONFIG.md](docs/CONFIG.md).

| Flag | Default | |
|---|---|---|
| `--config` | `~/.config/tether/config.toml` if present | TOML config file — see [docs/CONFIG.md](docs/CONFIG.md) |
| `--addr` | `127.0.0.1:8787` | Listen address |
| `--token` / `--token-file` | — | Bearer token (≥16 chars); required off-loopback |
| `--insecure-allow-public` | `false` | Allow off-loopback with no token (don't) |
| `--chrome` | `auto` | `auto`, a port, `host:port`, `http://…`, `ws://…`, or a `DevToolsActivePort` path |
| `--launch` / `--headless` | `false` | Launch a dedicated Chrome (persistent profile under `--data-dir`) |
| `--chrome-bin`, `--chrome-args`, `--profile-dir` | auto | With `--launch` |
| `--chrome-profile` | last used | With `--launch`: profile inside `--profile-dir`, e.g. `"Profile 1"` |
| `--idle-timeout` | `15m` | Close tabs Tether opened after inactivity (`0` = never) |
| `--timeout` | `60s` | Default per-request timeout |
| `--console-buffer` | `1000` | Console/log entries kept per tab (`0` = no capture) |
| `--allow-domains` | all | Comma-separated navigation allowlist (applies to every face) |
| `--deny-domains` | none | Comma-separated denylist; deny beats allow |
| `--policy-file` | — | JSON file with per-token rules and tokens — see [docs/POLICIES.md](docs/POLICIES.md) |
| `--policy-file` | — | JSON or TOML file with per-token rules and tokens — see [docs/POLICIES.md](docs/POLICIES.md) |
| `--data-dir` | user config dir | Local CA and launched profile |
| `--no-mitm` | `false` | Refuse HTTPS `CONNECT` instead of i 8000 ntercepting |
| `--mcp` | `true` | Serve the MCP face at `/mcp` |
| `--mcp-oauth` | `false` | Let MCP clients sign in with OAuth — see [docs/CONFIG.md](docs/CONFIG.md#mcp-oauth) |
| `--public-url` | from request | Externally visible origin (OAuth issuer) |
| `--tailscale` | `false` | Listen on this machine's tailnet address (keeps the `--addr` port) |
| `--tailscale-publish` | — | `serve` or `funnel`: also publish over HTTPS via the tailscale CLI |
| `--no-remote` | `false` | Don't serve the remote-browser UI at `/remote/` |
| `--remote-quality` | `70` | JPEG quality of the remote UI video (1–100) |
| `--log-level`, `--log-format` | `info`, `text` | `json` for machines; secrets are always redacted |
Expand Down
8 changes: 7 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ within 72 hours and to ship a fix or mitigation for confirmed issues within
| Background tabs; adopted tabs never closed | Tether doesn't steal focus, and only closes tabs it opened. |
| Launched Chrome binds DevTools to `127.0.0.1` | The DevTools port itself is never exposed by Tether. |
| Remote UI login cookie is `HttpOnly`, `SameSite=Strict`, path-scoped and UI-only | Browsers can't send bearer tokens, so `/remote/login` trades the token for an HMAC-of-token cookie that scripts can't read, other sites can't trigger, and the proxy/API/WebDriver never accept. Rotating the token signs every browser out. |
| MCP OAuth is opt-in and scoped | With `[mcp.oauth]`, the consent page requires a Tether token (with a delay on wrong guesses, same-origin posts only, `frame-ancestors 'none'`), PKCE S256 is mandatory, redirect URIs must be https, loopback http, or an app scheme, and OAuth access tokens are accepted on `/mcp` only. Grants are bound to the approving token and die with it; deleting `oauth.key` revokes all. |
| Remote UI pages are locked down | Strict CSP (`script-src 'self'`, `frame-ancestors 'none'`), `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer`; the websocket rejects foreign `Origin`s. `--no-remote` turns the UI off entirely. |

## Recommended exposure: Tailscale
8BD0 Expand All @@ -46,8 +47,13 @@ Then, from any machine on your tailnet:
curl -x "http://tether:$TETHER_TOKEN@my-laptop:8787" --cacert tether-ca.pem https://github.com/
```

Or, in the [config file](docs/CONFIG.md#tailscale), `[tailscale] enabled =
true` does the same, and `publish = "serve"` adds an HTTPS name on your
tailnet via `tailscale serve`.

Use Tailscale ACLs to limit which devices can reach port 8787. Avoid
`tailscale funnel` (that is public internet exposure).
`tailscale funnel` (`publish = "funnel"`) unless you need it: that is public
internet exposure, and Tether refuses either publish mode without a token.

## Threat model

Expand Down
4 changes: 4 additions & 0 deletions cmd/tether/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,12 @@ func serve(args []string, stdout, stderr io.Writer) int {
"browser", a.Browser,
"auth", cfg.HasAuth(),
"ca", a.CAPath,
"config", cfg.ConfigFile,
)
fmt.Fprintf(stdout, "\n Tether %s is serving %s on http://%s\n", version.Version, a.Browser, a.Addr)
if cfg.Tailscale.Publish != "" {
fmt.Fprintf(stdout, " Published: %s (tailscale %s; MCP, WebDriver, API and remote UI)\n", cfg.PublicURL, cfg.Tailscale.Publish)
}
fmt.Fprintf(stdout, " Proxy: curl -x http://%s --cacert <(curl -s http://%s/tether/v1/ca.pem) https://example.com\n", a.Addr, a.Addr)
fmt.Fprintf(stdout, " WebDriver: http://%s (Selenium remote URL)\n", a.Addr)
if cfg.MCP {
Expand Down
220 changes: 220 additions & 0 deletions docs/CONFIG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
# Configuration file

Everything `tether serve` takes on the command line can also live in a TOML
file, along with a few things only the file can express: extra tokens,
browser headers, cookies to seed, and MCP OAuth clients.
[`tether.example.toml`](../tether.example.toml) is a complete, commented
starting point.

## Where Tether looks

1. `--config path/to/tether.toml`
2. `$TETHER_CONFIG`
3. `config.toml` in the per-user Tether directory, if it exists:
- Linux: `~/.config/tether/config.toml` (or `$XDG_CONFIG_HOME/tether/`)
- macOS: `~/Library/Application Support/tether/config.toml`
- Windows: `%AppData%\tether\config.toml`

`--config=` (empty) skips the default file. A file named by `--config` or
`$TETHER_CONFIG` must exist.

## Precedence

```
command-line flag > TETHER_* environment variable > config file > built-in default
```

A setting given in a stronger layer replaces the weaker one entirely, lists
included: `--allow-domains github.com` replaces `[policy].allow`. The
exceptions are things that add up rather than override: tokens from
`--token`, `[auth]`, `[[auth.tokens]]` and `--policy-file` are all accepted
(duplicate names or secrets are an error), and `--policy-file`'s global rules
are merged into the others, as before.

Relative paths in the file are relative to the file; `~/` is your home
directory. Unknown keys are an error, so a typo never silently does nothing.

## Reference

| Key | Flag / env | Default | |
|---|---|---|---|
| **`[server]`** | | | |
| `addr` | `--addr` | `127.0.0.1:8787` | Listen address. Off-loopback needs a token. |
| `insecure_allow_public` | `--insecure-allow-public` | `false` | Listen off-loopback with no token (don't). |
| `public_url` | `--public-url` | from each request | Externally visible origin, e.g. `https://tether.tail1234.ts.net`. The OAuth issuer. |
| `timeout` | `--timeout` | `"60s"` | Default per-request timeout. |
| `idle_timeout` | `--idle-timeout` | `"15m"` | Close tabs Tether opened after inactivity; `"0s"` = never. |
| `data_dir` | `--data-dir` | user config dir | Local CA, launched profile, OAuth key. |
| `mitm` | `--no-mitm` (inverted) | `true` | `false` refuses HTTPS `CONNECT` instead of intercepting it. |
| **`[log]`** | | | |
| `level` | `--log-level` | `"info"` | `debug`, `info`, `warn`, `error`. |
| `format` | `--log-format` | `"text"` | `text` or `json`. |
| **`[auth]`** | | | |
| `token` / `token_env` / `token_file` | `--token` / — / `--token-file` | none | The admin token, at most one source. `token_env` names an environment variable to read it from. |
| `[[auth.tokens]]` | — | none | More tokens, each `name` plus one of `token`/`token_env`/`token_file`, and optional `admin`, `scripts`, `allow`, `deny`. Same shape as a [policy file](POLICIES.md) token. |
| **`[policy]`** | | | |
| `allow` | `--allow-domains` | all | Global navigation allowlist. |
| `deny` | `--deny-domains` | none | Global denylist; deny beats allow. |
| `file` | `--policy-file` | none | An extra JSON or TOML policy file, merged in. |
| **`[headers]`** | | | |
| `extra` | — | none | Table of headers Chrome sends with **every** request from the tabs Tether drives, to every site including third-party resources. Not for secrets. |
| `strip` | — | none | Client request headers the proxy drops before replaying a request in the browser. |
| **`[[cookies]]`** | — | none | Cookies stored in the browser's cookie jar at startup: `name`, `value` or `value_env`, `url` or `domain`, optional `path`, `secure`, `http_only`, `same_site` (`Strict`/`Lax`/`None`), `expires` (a TOML datetime; omit for a session cookie). |
| **`[chrome]`** | | | |
| `endpoint` | `--chrome` | `"auto"` | DevTools endpoint to connect to. |
| `launch` | `--launch` | `false` | Launch a dedicated Chrome instead. |
| `headless` | `--headless` | `false` | With `launch`. |
| `bin` | `--chrome-bin` | auto-detect | With `launch`. |
| `args` | `--chrome-args` | none | With `launch`. A list, so arguments may contain spaces. |
| `user_data_dir` | `--profile-dir` | `<data_dir>/chrome-profile` | With `launch`: Chrome's `--user-data-dir`. |
| `profile` | `--chrome-profile` | Chrome's choice | With `launch`: the profile inside it, e.g. `"Default"` or `"Profile 1"` (`--profile-directory`). |
| **`[console]`** | | | |
| `enabled` | — | `true` | `false` turns console capture off (same as `buffer = 0`). |
| `buffer` | `--console-buffer` | `1000` | Console and browser-log entries kept per tab. |
| **`[mcp]`** | | | |
| `enabled` | `--mcp` | `true` | Serve the MCP face at `/mcp`. |
| **`[mcp.oauth]`** | | | |
| `enabled` | `--mcp-oauth` | `false` | Let MCP clients sign in with OAuth. Needs a token. |
| `access_token_ttl` | — | `"1h"` | |
| `refresh_token_ttl` | — | `"720h"` | |
| `dynamic_registration` | — | `true` | Let clients register themselves (RFC 7591). |
| `[[mcp.oauth.clients]]` | — | none | Pre-registered clients: `id`, `name`, `redirect_uris`, and optionally `secret` or `secret_env` (≥16 characters) for a confidential client. |
| **`[remote]`** | | | |
| `enabled` | `--no-remote` (inverted) | `true` | The remote-browser UI at `/remote/`. |
| `quality` | `--remote-quality` | `70` | JPEG quality of its video, 1–100. |
| **`[tailscale]`** | | | |
| `enabled` | `--tailscale` | `false` | Listen on this machine's tailnet IPv4 address, keeping the port of `addr`. Needs a token. |
| `publish` | `--tailscale-publish` | none | `"serve"` (HTTPS on your tailne 10BC8 t) or `"funnel"` (HTTPS on the public internet), forwarding to Tether. Needs a token. |
| `https_port` | `--tailscale-https-port` | `443` | Port `publish` serves on; funnel allows 443, 8443 or 10000. |
| `bin` | `--tailscale-bin` | `"tailscale"` | The tailscale CLI. |

## Choosing a Chrome profile

Chrome keeps profiles as directories inside one *user data directory*:
`Default`, `Profile 1`, `Profile 2`, … (`chrome://version` shows the one
you are in as "Profile Path"). With `launch = true`, `user_data_dir` picks the
data directory and `profile` picks the profile inside it:

```toml
[chrome]
launch = true
user_data_dir = "~/.config/tether/chrome-profile"
profile = "Profile 1"
```

Recent Chrome versions refuse remote debugging on your everyday data
directory, so point `user_data_dir` at a dedicated one and sign in there once.
When Tether connects to a Chrome you started yourself, it uses whatever
profile that window is running, and `profile` is ignored (with a warning).

## Headers and cookies

```toml
[headers]
extra = { "X-Purpose" = "automation" } # Chrome sends this on every request
strip = ["X-Debug-Token"] # never replay this client header

[[cookies]]
url = "https://example.com"
name = "consent"
value = "yes"
same_site = "Lax"
```

`extra` is applied to every tab Tether attaches to (new tabs and tabs it
adopts) with the DevTools `Network.setExtraHTTPHeaders` command. It goes to
every origin the page loads from, so it is for markers, not credentials.
`[[cookies]]` are written into the browser's cookie jar once, at startup,
exactly as if the site had set them; in a connected (not launched) Chrome that
is your real cookie jar.

## Tailscale

Tailscale is the recommended way to reach Tether from other machines (see
[SECURITY.md](../SECURITY.md#recommended-exposure-tailscale)). Tether drives
the `tailscale` CLI you already have installed and logged in; it embeds no
Tailscale code.

```toml
[auth]
token_env = "TETHER_TOKEN"

[tailscale]
enabled = true # listen on 100.x.y.z:8787 instead of 127.0.0.1:8787
```

That replaces `--addr "$(tailscale ip -4):8787"`. Every device on your
tailnet that your ACLs allow can now use the proxy, WebDriver, MCP and the
remote UI at `http://<machine>:8787`. If the CLI isn't available (for
example in a container on the host network), Tether looks for a tailnet
address on the network interfaces instead.

```toml
[tailscale]
publish = "serve" # https://<machine>.<tailnet>.ts.net -> Tether
```

`publish` runs `tailscale serve --bg` at startup and removes it on shutdown,
giving Tether a real HTTPS name with a valid certificate. It also sets
`public_url` to that name unless you set one, which is what MCP OAuth
clients need. It works with either listener: loopback or the tailnet address.
The HTTPS name carries Tether's own endpoints (MCP, WebDriver, the API and
the remote UI); use the plain tailnet address for the forward proxy.

`publish = "funnel"` does the same with `tailscale funnel`, which puts Tether
**on the public internet**. That is what claude.ai custom connectors need,
since they connect from Anthropic's servers, not your tailnet. Only your
token protects your browser then: use a long random admin token, give agents
narrow `[[auth.tokens]]`, and turn on `[mcp.oauth]` so nobody pastes a token
into a third-party app.

## MCP OAuth

Some MCP clients (claude.ai custom connectors, Claude Desktop's remote
servers, several IDEs) can't send a fixed `Authorization` header; they only
speak the [MCP authorization spec](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization),
which is OAuth 2.1. With `[mcp.oauth] enabled = true` Tether is its own
authorization server:

```toml
[server]
addr = "100.101.102.103:8787" # your tailnet address
public_url = "https://my-laptop.tail1234.ts.net"

[auth]
token_env = "TETHER_TOKEN"

[mcp.oauth]
enabled = true
```

1. The client calls `/mcp`, gets `401` with a `WWW-Authenticate` header
pointing at `/.well-known/oauth-protected-resource/mcp`, and from there
finds `/.well-known/oauth-authorization-server`.
2. It registers itself at `/oauth/register` (unless you turned dynamic
registration off and gave it a pre-registered client).
3. It opens `/oauth/authorize` in your browser. The page asks for a **Tether
token**; the client will act as that token, with its admin, script and
domain permissions. Use a narrow `[[auth.tokens]]` entry for agents.
4. It exchanges the code (PKCE S256 is required) at `/oauth/token` and calls
`/mcp` with the access token, refreshing as needed.

What to know:

- **OAuth tokens open `/mcp` only.** They are not accepted by the proxy,
WebDriver, the control API or the remote UI.
- **Grants follow the token they were approved with.** Remove or change that
token and every grant made with it stops working, access and refresh
tokens alike.
- **Restarts are fine.** Registered clients and tokens are signed with a key
in `<data_dir>/oauth.key` (created on first use, mode 0600); authorization
codes live in memory for a minute. Delete the key file to revoke every
client and grant at once.
- **`public_url`** should be set whenever Tether is reached through a proxy
such as `tailscale serve`/`funnel`, so the issuer and metadata URLs are the
ones the client sees. Without it, Tether uses the request's `Host` and
`X-Forwarded-Proto`.
- Clients on the public internet (claude.ai) need to reach Tether over
HTTPS. `tailscale funnel` does that, but it also puts the consent page and
`/mcp` on the internet: use a long random token and narrow per-agent
tokens.
9 changes: 9 additions & 0 deletions docs/MCP.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ bridge ends the MCP session and Tether closes the tabs that session opened.
`http://127.0.0.1:8787/mcp` and, if you use a token, send
`Authorization: Bearer <token>`.

**Clients that sign in with OAuth** (claude.ai custom connectors, remote
servers in Claude Desktop, IDEs that can't send a fixed header) — enable
`[mcp.oauth]` in the [config file](CONFIG.md#mcp-oauth) (or pass
`--mcp-oauth`), add `http(s)://<tether>/mcp` in the client, and approve the
sign-in page with a Tether token. The client then acts as that token.

## How a session works

Each MCP session works in one **current tab**:
Expand Down Expand Up @@ -153,6 +159,9 @@ is signed in to. In addition to [SECURITY.md](../SECURITY.md):
read or act on where it landed.
- **Tokens:** prefer `TETHER_TOKEN` or `--token-file` over `--token` so the
token does not show up in `ps` or in an MCP config file.
- **OAuth** grants act as the token approved on the consent page, open `/mcp`
only, and die when that token is removed or changed. Approve agents with a
narrow per-agent token rather than the admin one.

## Protocol details

Expand Down
Loading
Loading
0