Bundle
dsh-cftunnel
Cloudflare Tunnel plugin for DeepSeek Harness: expose the local web GUI through a quick / token / API tunnel behind a token-gated loopback proxy, with optional Cloudflare Access email OTP.
- Source
- weicy98
- stars
- 1 stars
- License
- MIT
- Updated
- Updated 5 days ago
Readme
# dsh-cftunnel
[English](README.md) | [中文](README.zh.md)
A DeepSeek Harness bundle plugin that runs a [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) to expose the local web GUI (`http://127.0.0.1:3080`) on the public internet.
**Install the plugin, open Settings, type your Cloudflare info, click Connect.** `cloudflared` is downloaded and managed by the plugin itself — no manual install, no package manager, no restart.
## Quick start
1. **Install** — one command:
```sh
dsh plugin --profile web add git+https://github.com/weicy98/dsh-cftunnel
```
2. **Restart `dsh web`**, open **Settings → Cloudflare Tunnel**.
3. **Pick a mode, fill in your Cloudflare info, click Connect.** Copy the access URL (it embeds a token on first use) and share it.
> The first Connect downloads `cloudflared` (~40 MB) once — on slow networks this can take several minutes; the Settings panel shows a progress bar while it downloads.
## Modes
Pick the mode that matches what you have:
| Mode | Inputs | URL | Use when |
| --- | --- | --- | --- |
| Quick tunnel | none | random `https://<name>.trycloudflare.com` | you want a URL right now, no Cloudflare account setup |
| Tunnel token | tunnel token + hostname | `https://<hostname>` | you already have a tunnel from the Zero Trust dashboard |
| API token | API token + tunnel name + hostname | `https://<hostname>` | you want the plugin to create the tunnel, DNS, and (optionally) email OTP for you |
## How it works, end to end
1. The Settings section shows a mode picker (Quick / Tunnel token / API token) and a **Connect** button.
2. On Connect the plugin first **self-provisions `cloudflared`** — if `$DSH_HOME/cloudflared/cloudflared` is missing it downloads the official GitHub release asset for your platform, extracts it, and caches it.
3. It starts the tunnel:
- **Quick**: `cloudflared tunnel --url http://127.0.0.1:<proxyPort>` → random `https://<name>.trycloudflare.com`.
- **Token**: `cloudflared tunnel run` with a remotely-managed token (routing/ingress configured in the Zero Trust dashboard).
- **API**: create the tunnel + ingress + DNS CNAME through the Cloudflare HTTP API, then `cloudflared tunnel run` with the returned token (no `cert.pem`, no local config.yml).
4. All tunnel traffic flows through a **loopback reverse proxy** (`127.0.0.1:<proxyPort>`) that rewrites the Host header to loopback and drops Origin, so the harness trust fence accepts every request — privileged methods included. Access-token verification (default on) and optional Cloudflare Access email OTP gate the proxy.
5. **Secrets** (API token, tunnel token, proxy access token) live in the harness credential store (`~/.dsh/.credentials.yaml`); **non-secret preferences** (mode, hostname, tunnel name, account/zone ids, email-OTP settings) live in a `cloudflare-tunnel` settings namespace (`~/.dsh/settings.yaml`) and prefill the form on the next open.
## Install
From this checkout's directory:
```sh
dsh plugin --profile web add file:.
```
Or from a git / npm source:
```sh
dsh plugin --profile web add git+https://github.com/weicy98/dsh-cftunnel
dsh plugin --profile web add dsh-cftunnel
```
`dsh plugin` runs pnpm in the profile directory, then reconciles `dsh.profile.bundles` — because this package declares `dsh.bundle.patch`, it is appended to the bundle stack automatically. Restart `dsh web` and open **Settings → Cloudflare Tunnel**.
> Reinstalling after editing the source: `dsh plugin --profile web remove dsh-cftunnel && dsh plugin --profile web add file:.` — pnpm treats `file:` dependencies as immutable, so remove + add (or a version bump) is the reliable refresh path.
## Configuration
Deployment-level options are set through the profile's own `cordis.patch.yml` (`~/.dsh/profiles/web/cordis.patch.yml`) or a `--patch` overlay. Everything else is a Settings-panel field and persists in the settings namespace.
```yaml
- id: cloudflare-tunnel
config:
cloudflaredPath: 'cloudflared' # explicit binary path (default: auto-download to $DSH_HOME/cloudflared)
port: 3080 # local harness port to proxy
downloadMirror: '' # optional ghproxy-style prefix, e.g. 'https://ghproxy.example/'
proxyPort: 3091 # loopback port of the reverse proxy
proxyAuth: 'token' # 'token' (default) | 'none'
proxyToken: '' # optional fixed access token; empty = auto-generate and persist
accessPolicy: 'none' # 'none' | 'emailOtp'
accessEmailDomains: [] # e.g. ['yourcompany.com'] — restrict email OTP delivery
accessAccountId: '' # optional explicit Cloudflare account id
accessZoneId: '' # optional explicit Cloudflare zone id
```
Notes:
- `downloadMirror` controls how the one-time `cloudflared` download happens. Empty (the default) downloads through the GitHub API → CDN — authoritative, but slow on some networks (measured ~80 KB/s, so ~8 minutes for the 40 MB binary). Set a mirror prefix to speed it up; the plugin then downloads from `<mirror>https://github.com/cloudflare/cloudflared/releases/latest/download/<asset>`. A mirror is a third party you are trusting to serve an unmodified binary.
- `accessAccountId` / `accessZoneId` are also fillable in the Settings panel; setting them there avoids needing `Account Settings:Read` / `Zone:Read` API-token permissions.
- `proxyAuth: 'none'` disables the access-token gate (the UI confirms before turning it off).
## Security model (important)
All tunnel traffic goes through a **loopback reverse proxy** (`127.0.0.1:<proxyPort>`) that rewrites requests to look loopback (Host → `127.0.0.1:<port>`, Origin dropped). The harness trust fence therefore accepts them fully — **privileged methods included** — so a remote visitor gets the complete local experience.
The proxy binds `127.0.0.1` only; its sole entry point is `cloudflared` (Cloudflare edge). Two independent gates sit in front of it:
- **Access token (default ON, `proxyAuth: 'token'`)**: every request must carry the token — sent once as `?token=…` (the proxy then plants an `HttpOnly; Secure` cookie), or as the cookie afterwards. The UI shows the ready-to-share access URL and can toggle the gate live.
- **Cloudflare Access email OTP (optional, `accessPolicy: 'emailOtp'`)**: edge-level email verification before the request even reaches the proxy.
Recommendation: expose the public URL with **both** gates on. Disabling both means anyone holding the URL has full control of the harness (settings, credentials, directory picker) — only acceptable on a trusted network.
Local use (`http://127.0.0.1:3080`) bypasses the proxy entirely and is unaffected.
## Access control (email OTP)
Set `accessPolicy: 'emailOtp'` (or tick the checkbox in the panel) to gate the tunnel behind [Cloudflare Access One-time PIN](https://developers.cloudflare.com/cloudflare-one/integrations/identity-providers/one-time-pin/). On connect, the plugin uses the same API token to create a self-hosted Access application bound to the hostname plus an allow policy:
- **No whitelist**: any visitor enters an email, receives a 6-digit PIN, and only gets through after entering it.
- **Whitelist with full emails** (`user@example.com`): exact-email rules (Access "Emails").
- **Whitelist with domains** (`yourcompany.com`): email-domain rules (Access "Emails ending in").
Requirements:
- **API-token mode only** (quick and token tunnels cannot be gated automatically — their hostname isn't programmatically managed by this plugin).
- The API token additionally needs **`Access: Organizations, Identity Providers, and Groups:Edit`**, and (unless `accessAccountId` is set) **`Account Settings:Read`** to resolve the account id.
## API token permissions
API-token mode needs (see also the hint inside the Settings panel):
| Scope | Permission | Used for |
| --- | --- | --- |
| Account | `Cloudflare Tunnel — Write` | create tunnel, set ingress |
| Zone | `DNS — Edit` | create the CNAME record |
| Account | `Account Settings — Read` | resolve account id (skip by filling Account ID) |
| Zone | `Zone — Read` | resolve zone id (skip by filling Zone ID) |
| Account | `Access: Organizations, Identity Providers, and Groups — Write` | email OTP only |
## Prerequisites
- A profile with the web surface (`dsh web` / `--profile web`).
- **Tunnel token mode**: a tunnel token from the Zero Trust dashboard, with a public hostname routing to `http://127.0.0.1:3091` (the loopback proxy port — routing to `3080` bypasses the access-token gate).
- **API token mode**: an API token with the permissions above, plus a hostname whose DNS zone is on that account.
- Outbound network access to `github.com` on first connect (only to fetch `cloudflared`; it is then cached).
## Development
```sh
pnpm install # dsh-settings (dev/peer), schemastery
pnpm test # node --test (cfapi, cloudflared, host lifecycle, and proxy suites)
```
## Architecture
| Concern | Where | Mechanism |
| --- | --- | --- |
| Provision `cloudflared` | `lib/cloudflared.js` | GitHub release download + `tar` extract + cache under `$DSH_HOME/cloudflared` |
| Run/manage tunnel | `lib/index.js` | `node:child_process` spawn + `ctx.effect` teardown |
| Cloudflare API | `lib/cfapi.js` | `cfd_tunnel` create + `configurations` ingress + `dns_records` CNAME + Access app/policy |
| Loopback proxy | `lib/proxy.js` | Host rewrite to loopback + Origin drop + access-token gate (cookie / `?token=`) + WS forward — zero dependencies (`node:http` + `node:net`, no http-proxy / DEP0060) |
| Dependencies | `package.json` | `schemastery` as a leaf dependency; host seams (`dsh-settings`) as peerDependencies; `engines.node >= 20.3` |
| Secrets | `lib/index.js` | `ctx.credentials` (`CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_TUNNEL_TOKEN`, `CLOUDFLARE_TUNNEL_PROXY_TOKEN`) |
| Preferences | `lib/index.js` | `ctx.settings` namespace `cloudflare-tunnel` |
| Client→Host RPC | both halves | `ctx.connection.rpc.handle('/cloudflare-tunnel', …)` / `ctx.get('connection').rpc.call(…)` |
| Settings UI | `lib/client.js` | `ctx.slots.inject('settings.section', …)` + locale dictionaries |
| Bundle wiring | `cordis.patch.yml` + `package.json` `dsh` | one insert row, auto-added to `dsh.profile.bundles` |
RPC endpoints on `/cloudflare-tunnel`: `status`, `start`, `stop`, `setProxyAuth`. `start` accepts an `AbortSignal`, so cancelling from the UI aborts in-flight downloads and API setup.
## Limitations
- Web-profile only (the node half waits for `connection`, `credentials`, `settings`, and `webServer`; in headless/TUI it stays dormant).
- Process-local: the running tunnel does not survive a harness restart, but the cached binary, the stored secrets and preferences do. The tunnel, ingress, and DNS records persist on Cloudflare.
- The API token is used for Cloudflare API setup calls (tunnel/ingress/DNS/Access); the running `cloudflared` authenticates with the tunnel token, not the API token.
Install
dsh plugin --profile web add github:weicy98/dsh-cftunnel
Profile: web
With the hub plugin installed, ask your agent to install it by name — it resolves the same plan shown here.
dsh plugin --profile web add github:stvlynn/dsh.fish#path:packages/dsh-plugin-hub
install dsh-cftunnel from the hub
- This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.