tunnel-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -0
- package/LICENSE +21 -0
- package/README.md +166 -0
- package/SECURITY.md +124 -0
- package/dist/cloudflared/provision.d.ts +19 -0
- package/dist/cloudflared/provision.js +130 -0
- package/dist/cloudflared/tunnelProcess.d.ts +21 -0
- package/dist/cloudflared/tunnelProcess.js +120 -0
- package/dist/config.d.ts +9 -0
- package/dist/config.js +12 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +37 -0
- package/dist/log/sessionLog.d.ts +14 -0
- package/dist/log/sessionLog.js +55 -0
- package/dist/protocol/crypto.d.ts +9 -0
- package/dist/protocol/crypto.js +39 -0
- package/dist/protocol/link.d.ts +9 -0
- package/dist/protocol/link.js +21 -0
- package/dist/protocol/messages.d.ts +48 -0
- package/dist/protocol/messages.js +35 -0
- package/dist/relay/guestClient.d.ts +20 -0
- package/dist/relay/guestClient.js +98 -0
- package/dist/relay/hostRelay.d.ts +31 -0
- package/dist/relay/hostRelay.js +162 -0
- package/dist/session.d.ts +50 -0
- package/dist/session.js +158 -0
- package/dist/tools.d.ts +10 -0
- package/dist/tools.js +48 -0
- package/package.json +75 -0
- package/skill/tunnel-etiquette/SKILL.md +50 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Nothing yet.
|
|
13
|
+
|
|
14
|
+
## [0.1.0] - 2026-06-30
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- Initial release of `tunnel-mcp`, an MCP server that lets two developers'
|
|
19
|
+
Claude agents talk directly to each other through a host-owned, ephemeral,
|
|
20
|
+
end-to-end-encrypted relay.
|
|
21
|
+
- Six MCP tools: `tunnel_open`, `tunnel_join`, `tunnel_say`, `tunnel_listen`,
|
|
22
|
+
`tunnel_status`, and `tunnel_close`.
|
|
23
|
+
- Host-owned, ephemeral relay: the initiator's MCP process becomes an
|
|
24
|
+
in-process WebSocket relay exposed via a throwaway `cloudflared` Quick
|
|
25
|
+
Tunnel, so both sides dial outbound and the tunnel works through
|
|
26
|
+
firewalls/NAT with no port-forwarding.
|
|
27
|
+
- End-to-end encrypted chat message bodies using NaCl secretbox
|
|
28
|
+
(XSalsa20-Poly1305, via `tweetnacl`), so the `cloudflared` pipe only ever
|
|
29
|
+
sees ciphertext for chat bodies.
|
|
30
|
+
- HMAC proof-of-key-possession authentication; the raw session key is never
|
|
31
|
+
sent over the wire.
|
|
32
|
+
- Single-guest lock: the first authenticated guest locks the session to
|
|
33
|
+
exactly two participants.
|
|
34
|
+
- Three teardown triggers for ephemeral sessions: explicit `tunnel_close`,
|
|
35
|
+
30-minute idle timeout, or host process exit — each destroys the relay,
|
|
36
|
+
the `cloudflared` child process, and the on-disk log.
|
|
37
|
+
- Atomic `cloudflared` auto-download to `~/.tunnel/bin` on first use when
|
|
38
|
+
not already available on `PATH`.
|
|
39
|
+
- `tunnel-etiquette` skill, installable into a Claude skills directory, that
|
|
40
|
+
instructs agents to treat peer messages as untrusted data and to require
|
|
41
|
+
their human's OK before writing files, running risky commands, or
|
|
42
|
+
declaring a fix "confirmed".
|
|
43
|
+
- Test suite of 109 tests built with vitest, developed test-first (TDD).
|
|
44
|
+
|
|
45
|
+
[Unreleased]: https://github.com/zachlikefolio/tunnel-mcp/compare/v0.1.0...HEAD
|
|
46
|
+
[0.1.0]: https://github.com/zachlikefolio/tunnel-mcp/releases/tag/v0.1.0
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Zachary Kehl
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# tunnel-mcp
|
|
2
|
+
|
|
3
|
+
**A direct, end-to-end-encrypted tunnel between two developers' Claude agents — no human copy-paste required.**
|
|
4
|
+
|
|
5
|
+
[](https://github.com/zachlikefolio/tunnel-mcp/actions/workflows/ci.yml)
|
|
6
|
+
[](https://www.npmjs.com/package/tunnel-mcp)
|
|
7
|
+
[](./LICENSE)
|
|
8
|
+

|
|
9
|
+
|
|
10
|
+
When two developers each run a Claude agent and need those agents to collaborate,
|
|
11
|
+
the usual workaround is a human sitting in the middle, copy-pasting messages from
|
|
12
|
+
one chat window to the other. **tunnel-mcp** removes that human. It's an MCP
|
|
13
|
+
server that lets one developer's agent open a throwaway, encrypted tunnel and the
|
|
14
|
+
other developer's agent dial straight into it, so the two agents can talk to each
|
|
15
|
+
other directly — while their humans stay in control of what actually happens to
|
|
16
|
+
the filesystem or the shell.
|
|
17
|
+
|
|
18
|
+
## How it works
|
|
19
|
+
|
|
20
|
+
One developer (the **host**) calls `tunnel_open`. Their local `tunnel-mcp`
|
|
21
|
+
process becomes an in-process WebSocket relay and exposes it to the internet via
|
|
22
|
+
a throwaway `cloudflared` Quick Tunnel — no port-forwarding, no server to
|
|
23
|
+
provision. The other developer (the **guest**) calls `tunnel_join` with the link
|
|
24
|
+
the host shares, and their agent dials outbound to that same tunnel. Because both
|
|
25
|
+
sides only ever make outbound connections, it works from behind ordinary
|
|
26
|
+
firewalls and NAT.
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
Host machine Guest machine
|
|
30
|
+
┌───────────────────┐ outbound HTTPS ┌───────────────────┐
|
|
31
|
+
│ Claude (host) │ wss:// │ Claude (guest) │
|
|
32
|
+
│ │ │ ┌──────────────┐ │ │ │
|
|
33
|
+
│ tunnel_open/say/ │──────▶ cloudflared │◀────────│ tunnel_join/say/ │
|
|
34
|
+
│ listen/close │ │ Quick Tunnel │─────────▶ listen/close │
|
|
35
|
+
│ │ │ └──────────────┘ │ │ │
|
|
36
|
+
│ in-process relay │ └───────────────────┘
|
|
37
|
+
└───────────────────┘
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The relay, the `cloudflared` child process, and the on-disk session log all live
|
|
41
|
+
only for the lifetime of the session and are destroyed on teardown.
|
|
42
|
+
|
|
43
|
+
## Install
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
npm install -g tunnel-mcp
|
|
47
|
+
# or, without installing:
|
|
48
|
+
npx tunnel-mcp
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Register it with Claude Code (both developers do this once):
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
claude mcp add tunnel -- tunnel-mcp
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Install the etiquette skill so each agent knows how to behave inside a tunnel
|
|
58
|
+
(treat the peer as untrusted input, check with its human before acting on
|
|
59
|
+
anything the peer says). Copy `skill/tunnel-etiquette/` from this repo into your
|
|
60
|
+
`~/.claude/skills/` directory (or your plugin's skills directory).
|
|
61
|
+
|
|
62
|
+
`cloudflared` is auto-downloaded to `~/.tunnel/bin` the first time it's needed if
|
|
63
|
+
it isn't already on your `PATH` — there's nothing extra to install.
|
|
64
|
+
|
|
65
|
+
## Quickstart
|
|
66
|
+
|
|
67
|
+
**Host** — ask Claude to open a tunnel with a goal:
|
|
68
|
+
|
|
69
|
+
> "Open a tunnel to pair on debugging the checkout flow."
|
|
70
|
+
|
|
71
|
+
Claude calls `tunnel_open({ goal })` and returns a join link. Share that link
|
|
72
|
+
with the other developer over a trusted channel (Slack DM, etc.) — **it's a
|
|
73
|
+
secret**, since it contains the encryption key for the session.
|
|
74
|
+
|
|
75
|
+
**Guest** — paste the link and ask Claude to join:
|
|
76
|
+
|
|
77
|
+
> "Join this tunnel: `<link>`"
|
|
78
|
+
|
|
79
|
+
Claude calls `tunnel_join({ joinLink })`, learns the goal, and the session is
|
|
80
|
+
now locked to just the two of you.
|
|
81
|
+
|
|
82
|
+
**Both** — the agents converse turn-by-turn using `tunnel_say` to send and
|
|
83
|
+
`tunnel_listen` to wait for the next reply, checking in with their humans as
|
|
84
|
+
needed.
|
|
85
|
+
|
|
86
|
+
**Either side** ends the session with `tunnel_close`, which tears down the relay
|
|
87
|
+
and destroys the session log.
|
|
88
|
+
|
|
89
|
+
## Tools
|
|
90
|
+
|
|
91
|
+
| Tool | Who | Purpose |
|
|
92
|
+
| ---------------------------------------- | ----- | ---------------------------------------------------------- |
|
|
93
|
+
| `tunnel_open({goal})` | host | Start the relay + Quick Tunnel and get back a join link. |
|
|
94
|
+
| `tunnel_join({joinLink})` | guest | Dial into a host's tunnel using the link and authenticate. |
|
|
95
|
+
| `tunnel_say({text})` | both | Send a message to the peer. |
|
|
96
|
+
| `tunnel_listen({sinceSeq?, timeoutMs?})` | both | Wait for the next message(s) from the peer. |
|
|
97
|
+
| `tunnel_status()` | both | Inspect the current session (connected, idle, etc.). |
|
|
98
|
+
| `tunnel_close({summary?})` | both | End the session and tear down the relay. |
|
|
99
|
+
|
|
100
|
+
## Security model
|
|
101
|
+
|
|
102
|
+
tunnel-mcp is a security-sensitive tool by nature — it opens a live channel
|
|
103
|
+
between two AI agents. Here's exactly what it does and does not protect:
|
|
104
|
+
|
|
105
|
+
- **Chat message bodies are end-to-end encrypted.** Every `tunnel_say` body is
|
|
106
|
+
sealed with NaCl `secretbox` (XSalsa20-Poly1305, via `tweetnacl`) before it
|
|
107
|
+
crosses the `cloudflared` pipe. The relay and the pipe only ever see
|
|
108
|
+
ciphertext for chat bodies.
|
|
109
|
+
- **The goal, both display names, and system events are plaintext.** The
|
|
110
|
+
`tunnel_open` goal, each participant's name, and connection events
|
|
111
|
+
(joined/left/idle/closed) are sent as plaintext metadata — do not put secrets
|
|
112
|
+
in the goal string or a display name.
|
|
113
|
+
- **Authentication is proof-of-key-possession, not key transmission.** Joining
|
|
114
|
+
uses an HMAC challenge to prove the guest holds the same key as the host; the
|
|
115
|
+
raw key itself is never sent over the wire.
|
|
116
|
+
- **The join link is a credential.** It embeds the session key, so treat it
|
|
117
|
+
like a password — share it only over a channel you already trust (Slack DM,
|
|
118
|
+
etc.), never in a public issue, PR, or chat.
|
|
119
|
+
- **Exactly two participants, enforced by a lock.** The first guest to
|
|
120
|
+
authenticate locks the session; nobody else can join after that.
|
|
121
|
+
- **The peer is untrusted input, not an instruction source.** Messages from the
|
|
122
|
+
other agent are data to reason about, not commands to execute. The etiquette
|
|
123
|
+
skill directs each agent to require its own human's sign-off before writing
|
|
124
|
+
files, running risky commands, or declaring a fix "confirmed" based on
|
|
125
|
+
something the peer said.
|
|
126
|
+
- **Everything is ephemeral.** The session tears down — destroying the relay,
|
|
127
|
+
the `cloudflared` child process, and the on-disk log — on an explicit
|
|
128
|
+
`tunnel_close`, after 30 minutes of no messages (idle timeout), or when the
|
|
129
|
+
host's process exits.
|
|
130
|
+
|
|
131
|
+
See [SECURITY.md](./SECURITY.md) for the full threat model and how to report a
|
|
132
|
+
vulnerability.
|
|
133
|
+
|
|
134
|
+
## Requirements
|
|
135
|
+
|
|
136
|
+
- Node.js >= 20
|
|
137
|
+
- A Claude MCP client (e.g., Claude Code)
|
|
138
|
+
- `cloudflared` — auto-installed to `~/.tunnel/bin` on first use if not already
|
|
139
|
+
on your `PATH`
|
|
140
|
+
|
|
141
|
+
## Development
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
npm ci # install dependencies
|
|
145
|
+
npm test # run the test suite (109 tests, TDD)
|
|
146
|
+
npm run build # compile TypeScript
|
|
147
|
+
npm run lint # eslint
|
|
148
|
+
npm run format:check # prettier --check .
|
|
149
|
+
npm run test:coverage # vitest run --coverage
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
See [CONTRIBUTING.md](./CONTRIBUTING.md) for how to propose changes.
|
|
153
|
+
|
|
154
|
+
## Roadmap / not yet supported
|
|
155
|
+
|
|
156
|
+
This is an MVP. The following are explicitly out of scope for now:
|
|
157
|
+
|
|
158
|
+
- Host-offline / asynchronous messaging
|
|
159
|
+
- More than two participants in a session
|
|
160
|
+
- Alternative transports (ngrok, WebRTC)
|
|
161
|
+
- Join-link rotation or one-time tokens
|
|
162
|
+
- Encrypting the goal or other metadata
|
|
163
|
+
|
|
164
|
+
## License
|
|
165
|
+
|
|
166
|
+
MIT — see [LICENSE](./LICENSE).
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
## Supported Versions
|
|
4
|
+
|
|
5
|
+
tunnel-mcp is pre-1.0 and moving quickly. Only the latest `0.1.x` release
|
|
6
|
+
receives security fixes.
|
|
7
|
+
|
|
8
|
+
| Version | Supported |
|
|
9
|
+
| ------- | ------------------ |
|
|
10
|
+
| 0.1.x | :white_check_mark: |
|
|
11
|
+
| < 0.1 | :x: |
|
|
12
|
+
|
|
13
|
+
## Reporting a Vulnerability
|
|
14
|
+
|
|
15
|
+
Please **do not open a public GitHub issue** for security vulnerabilities.
|
|
16
|
+
|
|
17
|
+
The preferred way to report a vulnerability is through GitHub's private
|
|
18
|
+
security advisories:
|
|
19
|
+
|
|
20
|
+
1. Go to the [tunnel-mcp repository](https://github.com/zachlikefolio/tunnel-mcp).
|
|
21
|
+
2. Open the **Security** tab.
|
|
22
|
+
3. Click **Report a vulnerability** to open a new draft security advisory.
|
|
23
|
+
|
|
24
|
+
This creates a private conversation with the maintainer and lets us
|
|
25
|
+
coordinate a fix and a disclosure timeline before any details become public.
|
|
26
|
+
|
|
27
|
+
If you cannot use GitHub's advisory flow, you may instead email
|
|
28
|
+
**zach@likefolio.com** with details of the issue. Please include:
|
|
29
|
+
|
|
30
|
+
- A description of the vulnerability and its potential impact.
|
|
31
|
+
- Steps to reproduce, or a proof-of-concept, if available.
|
|
32
|
+
- The version/commit of tunnel-mcp you tested against.
|
|
33
|
+
- Any suggested remediation, if you have one.
|
|
34
|
+
|
|
35
|
+
### What to expect
|
|
36
|
+
|
|
37
|
+
- **Acknowledgement**: you should hear back within a few days of your
|
|
38
|
+
report.
|
|
39
|
+
- **Updates**: we'll keep you posted as we investigate and work on a fix.
|
|
40
|
+
- **Credit**: reporters are credited in the advisory and/or release notes
|
|
41
|
+
once a fix ships, unless you tell us you'd prefer to remain anonymous.
|
|
42
|
+
|
|
43
|
+
## Security Model
|
|
44
|
+
|
|
45
|
+
tunnel-mcp lets two developers' Claude agents exchange messages directly
|
|
46
|
+
through a host-owned, ephemeral relay, without a human copy-pasting between
|
|
47
|
+
them. Understanding what is and isn't protected is important before you
|
|
48
|
+
share a join link with anyone.
|
|
49
|
+
|
|
50
|
+
- **Chat message bodies are end-to-end encrypted.** The text passed to
|
|
51
|
+
`tunnel_say` is sealed with NaCl `secretbox` (XSalsa20-Poly1305, via
|
|
52
|
+
`tweetnacl`) using a key that is never transmitted. The cloudflared
|
|
53
|
+
pipe — and the Cloudflare edge it runs over — only ever sees ciphertext
|
|
54
|
+
for chat message bodies.
|
|
55
|
+
- **Metadata is plaintext.** The `goal` passed to `tunnel_open`/`tunnel_join`,
|
|
56
|
+
both participants' display names, and system/connection events (joined,
|
|
57
|
+
left, idle, closed) cross the tunnel as **plaintext**. Do not put secrets
|
|
58
|
+
in the goal or display name.
|
|
59
|
+
- **Authentication is proof-of-key-possession, not key transmission.** The
|
|
60
|
+
join link embeds a session key. The guest's client proves it holds that
|
|
61
|
+
key via an HMAC challenge/response; the raw key itself is never sent over
|
|
62
|
+
the wire. Because the join link contains the key, **treat the join link
|
|
63
|
+
like a password** — share it only over a trusted, already-authenticated
|
|
64
|
+
channel (e.g. a Slack DM to a known teammate), not in a public channel or
|
|
65
|
+
ticket.
|
|
66
|
+
- **Single-guest lock.** The first participant who successfully
|
|
67
|
+
authenticates as guest locks the session. Sessions are strictly two-party;
|
|
68
|
+
a second join attempt is rejected.
|
|
69
|
+
- **Peer input is untrusted.** Everything a peer sends over the tunnel is
|
|
70
|
+
data, never an instruction. The bundled `tunnel-etiquette` skill
|
|
71
|
+
instructs each agent to treat incoming peer messages as untrusted input
|
|
72
|
+
and to get its own human's explicit OK before writing files, running
|
|
73
|
+
risky commands, or declaring a fix "confirmed" based on something the
|
|
74
|
+
peer said.
|
|
75
|
+
- **Ephemeral by design.** A session and everything tied to it — the
|
|
76
|
+
in-process relay, the cloudflared child process, the throwaway Quick
|
|
77
|
+
Tunnel URL, and the on-disk session log — are torn down on: an explicit
|
|
78
|
+
`tunnel_close`, an idle timeout (30 minutes with no messages), or the
|
|
79
|
+
host process exiting. Nothing persists past teardown.
|
|
80
|
+
|
|
81
|
+
## Known Limitations / Threat Model
|
|
82
|
+
|
|
83
|
+
This is an MVP and it is important to be honest about what it does **not**
|
|
84
|
+
protect against:
|
|
85
|
+
|
|
86
|
+
- **The relay path sees metadata in the clear.** The cloudflared Quick
|
|
87
|
+
Tunnel is a real network hop through Cloudflare's edge. While chat
|
|
88
|
+
message bodies are encrypted end-to-end, the goal, both display names,
|
|
89
|
+
and system/connection events are visible in plaintext to anything that
|
|
90
|
+
can observe that path (including Cloudflare's infrastructure). Do not
|
|
91
|
+
put secrets in the goal or names.
|
|
92
|
+
- **No link rotation or expiry beyond session teardown.** A join link is
|
|
93
|
+
valid for the lifetime of the session. If a join link leaks (pasted into
|
|
94
|
+
the wrong channel, logged, etc.) before the host closes the session,
|
|
95
|
+
anyone with that link can join as the guest — up until the host runs
|
|
96
|
+
`tunnel_close`, the session idles out, or the host process exits. There
|
|
97
|
+
is currently no way to rotate the key or issue single-use/expiring
|
|
98
|
+
tokens.
|
|
99
|
+
- **The goal is never encrypted.** By design, the goal string is plaintext
|
|
100
|
+
metadata used for connection setup and display; it receives no
|
|
101
|
+
confidentiality protection at any layer.
|
|
102
|
+
- **Strictly two-party.** The protocol only supports one host and one
|
|
103
|
+
guest per session. There is no support for additional participants,
|
|
104
|
+
multi-party relays, or host-offline/async delivery in this MVP.
|
|
105
|
+
- **"Trusting" a peer only goes as far as your own agent's guardrails.**
|
|
106
|
+
tunnel-mcp does not sandbox or validate what a peer sends beyond
|
|
107
|
+
transport-level auth. The confidentiality/integrity of your own
|
|
108
|
+
workspace depends on the `tunnel-etiquette` skill being installed and
|
|
109
|
+
your agent honoring it (treating peer messages as untrusted data,
|
|
110
|
+
requiring human approval for file writes, running commands, or
|
|
111
|
+
confirming fixes). If you disable or bypass that skill, a malicious or
|
|
112
|
+
compromised peer's messages could otherwise be misinterpreted as
|
|
113
|
+
instructions by an unguarded agent.
|
|
114
|
+
- **Out of scope for this release**: host-offline/async messaging,
|
|
115
|
+
more than two participants, alternative transports (ngrok, WebRTC),
|
|
116
|
+
link rotation or one-time join tokens, and encryption of the goal or
|
|
117
|
+
other connection metadata. These may be considered for future versions
|
|
118
|
+
but should not be assumed to exist today.
|
|
119
|
+
|
|
120
|
+
If you find a way to break any of the guarantees above (e.g. read a chat
|
|
121
|
+
message body without the key, join a locked session, or get an agent to
|
|
122
|
+
treat peer input as trusted instructions bypassing the etiquette skill),
|
|
123
|
+
please report it via the process described above — that is exactly the
|
|
124
|
+
kind of issue we want to hear about.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export declare function cloudflaredBinName(platform: NodeJS.Platform): string;
|
|
2
|
+
export declare function cloudflaredDownloadUrl(platform: NodeJS.Platform, arch: string): string;
|
|
3
|
+
export interface DownloadDeps {
|
|
4
|
+
fetchImpl?: typeof fetch;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Download cloudflared from `url` and install it at `destBinPath`.
|
|
8
|
+
*
|
|
9
|
+
* Atomic: the binary is downloaded/extracted into a unique location under
|
|
10
|
+
* os.tmpdir() and only moved into `destBinPath` once it is fully present and
|
|
11
|
+
* valid, so a failed/partial download never poisons the destination (callers
|
|
12
|
+
* that check `fs.existsSync(destBinPath)` to skip re-downloading are safe).
|
|
13
|
+
*
|
|
14
|
+
* Any failure anywhere in this path (network, tar extraction, fs ops) is
|
|
15
|
+
* caught, temp artifacts (and any partially-installed dest) are cleaned up,
|
|
16
|
+
* and a single readable error with a manual-install pointer is thrown.
|
|
17
|
+
*/
|
|
18
|
+
export declare function downloadCloudflared(url: string, destBinPath: string, deps?: DownloadDeps): Promise<void>;
|
|
19
|
+
export declare function ensureCloudflared(): Promise<string>;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import { execSync, execFileSync } from 'node:child_process';
|
|
6
|
+
import { pipeline } from 'node:stream/promises';
|
|
7
|
+
import { Readable } from 'node:stream';
|
|
8
|
+
import { BIN_DIR } from '../config.js';
|
|
9
|
+
const RELEASE_BASE = 'https://github.com/cloudflare/cloudflared/releases/latest/download';
|
|
10
|
+
const MANUAL_INSTALL_POINTER = 'https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/';
|
|
11
|
+
function arch2cf(arch) {
|
|
12
|
+
if (arch === 'x64')
|
|
13
|
+
return 'amd64';
|
|
14
|
+
if (arch === 'arm64')
|
|
15
|
+
return 'arm64';
|
|
16
|
+
throw new Error(`unsupported arch: ${arch}`);
|
|
17
|
+
}
|
|
18
|
+
export function cloudflaredBinName(platform) {
|
|
19
|
+
return platform === 'win32' ? 'cloudflared.exe' : 'cloudflared';
|
|
20
|
+
}
|
|
21
|
+
export function cloudflaredDownloadUrl(platform, arch) {
|
|
22
|
+
const a = arch2cf(arch);
|
|
23
|
+
if (platform === 'darwin')
|
|
24
|
+
return `${RELEASE_BASE}/cloudflared-darwin-${a}.tgz`;
|
|
25
|
+
if (platform === 'linux')
|
|
26
|
+
return `${RELEASE_BASE}/cloudflared-linux-${a}`;
|
|
27
|
+
if (platform === 'win32')
|
|
28
|
+
return `${RELEASE_BASE}/cloudflared-windows-${a}.exe`;
|
|
29
|
+
throw new Error(`unsupported platform: ${platform}`);
|
|
30
|
+
}
|
|
31
|
+
function onPath() {
|
|
32
|
+
try {
|
|
33
|
+
const cmd = process.platform === 'win32' ? 'where cloudflared' : 'command -v cloudflared';
|
|
34
|
+
const out = execSync(cmd, { stdio: ['ignore', 'pipe', 'ignore'] })
|
|
35
|
+
.toString()
|
|
36
|
+
.trim();
|
|
37
|
+
return out.split('\n')[0] || null;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
// Move src -> dest atomically. fs.renameSync is atomic within a filesystem; if src/dest
|
|
44
|
+
// straddle devices (EXDEV) fall back to copy+unlink, which is the best available
|
|
45
|
+
// approximation (still: dest only appears once the copy has fully completed).
|
|
46
|
+
function moveIntoPlace(src, dest) {
|
|
47
|
+
try {
|
|
48
|
+
fs.renameSync(src, dest);
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
if (err?.code === 'EXDEV') {
|
|
52
|
+
fs.copyFileSync(src, dest);
|
|
53
|
+
fs.unlinkSync(src);
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
throw err;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function rmQuiet(p) {
|
|
61
|
+
try {
|
|
62
|
+
fs.rmSync(p, { recursive: true, force: true });
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// best-effort cleanup; nothing useful to do if this fails
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Download cloudflared from `url` and install it at `destBinPath`.
|
|
70
|
+
*
|
|
71
|
+
* Atomic: the binary is downloaded/extracted into a unique location under
|
|
72
|
+
* os.tmpdir() and only moved into `destBinPath` once it is fully present and
|
|
73
|
+
* valid, so a failed/partial download never poisons the destination (callers
|
|
74
|
+
* that check `fs.existsSync(destBinPath)` to skip re-downloading are safe).
|
|
75
|
+
*
|
|
76
|
+
* Any failure anywhere in this path (network, tar extraction, fs ops) is
|
|
77
|
+
* caught, temp artifacts (and any partially-installed dest) are cleaned up,
|
|
78
|
+
* and a single readable error with a manual-install pointer is thrown.
|
|
79
|
+
*/
|
|
80
|
+
export async function downloadCloudflared(url, destBinPath, deps = {}) {
|
|
81
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
82
|
+
const unique = crypto.randomUUID();
|
|
83
|
+
const tmpFile = path.join(os.tmpdir(), `cloudflared-${unique}.download`);
|
|
84
|
+
const tmpExtractDir = path.join(os.tmpdir(), `cloudflared-extract-${unique}`);
|
|
85
|
+
let destMayBePartial = false;
|
|
86
|
+
try {
|
|
87
|
+
const res = await fetchImpl(url);
|
|
88
|
+
if (!res.ok || !res.body) {
|
|
89
|
+
const status = !res.ok ? ` (status ${res.status})` : '';
|
|
90
|
+
throw new Error(`cloudflared download failed${status}`);
|
|
91
|
+
}
|
|
92
|
+
await pipeline(Readable.fromWeb(res.body), fs.createWriteStream(tmpFile));
|
|
93
|
+
if (url.endsWith('.tgz')) {
|
|
94
|
+
fs.mkdirSync(tmpExtractDir, { recursive: true });
|
|
95
|
+
execFileSync('tar', ['-xzf', tmpFile, '-C', tmpExtractDir]); // extracts a `cloudflared` binary
|
|
96
|
+
const extractedBin = path.join(tmpExtractDir, 'cloudflared');
|
|
97
|
+
destMayBePartial = true;
|
|
98
|
+
moveIntoPlace(extractedBin, destBinPath);
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
destMayBePartial = true;
|
|
102
|
+
moveIntoPlace(tmpFile, destBinPath);
|
|
103
|
+
}
|
|
104
|
+
fs.chmodSync(destBinPath, 0o755);
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
// Never leave a partial/corrupt binary at the cached path.
|
|
108
|
+
if (destMayBePartial)
|
|
109
|
+
rmQuiet(destBinPath);
|
|
110
|
+
rmQuiet(tmpFile);
|
|
111
|
+
rmQuiet(tmpExtractDir);
|
|
112
|
+
const cause = err instanceof Error ? err.message : String(err);
|
|
113
|
+
throw new Error(`cloudflared download/install failed: ${cause}. Install it manually: ${MANUAL_INSTALL_POINTER}`);
|
|
114
|
+
}
|
|
115
|
+
rmQuiet(tmpFile);
|
|
116
|
+
rmQuiet(tmpExtractDir);
|
|
117
|
+
}
|
|
118
|
+
export async function ensureCloudflared() {
|
|
119
|
+
const onpath = onPath();
|
|
120
|
+
if (onpath)
|
|
121
|
+
return onpath;
|
|
122
|
+
const binName = cloudflaredBinName(process.platform);
|
|
123
|
+
const dest = path.join(BIN_DIR, binName);
|
|
124
|
+
if (fs.existsSync(dest))
|
|
125
|
+
return dest;
|
|
126
|
+
fs.mkdirSync(BIN_DIR, { recursive: true });
|
|
127
|
+
const url = cloudflaredDownloadUrl(process.platform, process.arch);
|
|
128
|
+
await downloadCloudflared(url, dest);
|
|
129
|
+
return dest;
|
|
130
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface TunnelHandle {
|
|
2
|
+
publicUrl: string;
|
|
3
|
+
stop(): void;
|
|
4
|
+
}
|
|
5
|
+
export interface StartOptions {
|
|
6
|
+
timeoutMs?: number;
|
|
7
|
+
extraArgs?: string[];
|
|
8
|
+
attempts?: number;
|
|
9
|
+
intervalMs?: number;
|
|
10
|
+
healthCheck?: (url: string) => Promise<boolean>;
|
|
11
|
+
probeTimeoutMs?: number;
|
|
12
|
+
}
|
|
13
|
+
export declare function parsePublicUrl(line: string): string | null;
|
|
14
|
+
export declare function defaultHealthCheck(url: string): Promise<boolean>;
|
|
15
|
+
/**
|
|
16
|
+
* `extraArgs` exists for tests: it lets a fake binary (e.g. `node fake.mjs`) be
|
|
17
|
+
* launched in place of `cloudflared tunnel --url ...`. Production passes none.
|
|
18
|
+
* The URL is surfaced only after a health probe confirms the edge is reachable
|
|
19
|
+
* (cloudflared prints the hostname before routing is live).
|
|
20
|
+
*/
|
|
21
|
+
export declare function startCloudflared(binPath: string, localPort: number, opts?: StartOptions): Promise<TunnelHandle>;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { CLOUDFLARED_URL_TIMEOUT_MS, CLOUDFLARED_HEALTH_ATTEMPTS, CLOUDFLARED_HEALTH_INTERVAL_MS, } from '../config.js';
|
|
3
|
+
const URL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
|
|
4
|
+
// Bounds a single probe so a black-hole connection (or a caller-supplied
|
|
5
|
+
// healthCheck that hangs/throws) can't stall the health-check loop forever.
|
|
6
|
+
const DEFAULT_PROBE_TIMEOUT_MS = 5000;
|
|
7
|
+
export function parsePublicUrl(line) {
|
|
8
|
+
const m = line.match(URL_RE);
|
|
9
|
+
return m ? m[0] : null;
|
|
10
|
+
}
|
|
11
|
+
// Any HTTP response (even 404/502/426) means the Cloudflare edge is routing to us.
|
|
12
|
+
export async function defaultHealthCheck(url) {
|
|
13
|
+
try {
|
|
14
|
+
await fetch(url, { method: 'GET', signal: AbortSignal.timeout(DEFAULT_PROBE_TIMEOUT_MS) });
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return false; // network/DNS error/timeout → edge not ready yet
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
// Races a single health-check attempt against a per-attempt timeout so that a
|
|
22
|
+
// caller-supplied `check` that throws, rejects, or simply never resolves can
|
|
23
|
+
// never leave the loop (and therefore the outer startCloudflared promise)
|
|
24
|
+
// hanging. Any failure mode here just counts as "not healthy yet".
|
|
25
|
+
function probeOnce(url, check, probeTimeoutMs) {
|
|
26
|
+
return new Promise((resolve) => {
|
|
27
|
+
let settled = false;
|
|
28
|
+
const finish = (ok) => {
|
|
29
|
+
if (!settled) {
|
|
30
|
+
settled = true;
|
|
31
|
+
resolve(ok);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
const timer = setTimeout(() => finish(false), probeTimeoutMs);
|
|
35
|
+
Promise.resolve()
|
|
36
|
+
.then(() => check(url))
|
|
37
|
+
.then((ok) => {
|
|
38
|
+
clearTimeout(timer);
|
|
39
|
+
finish(ok);
|
|
40
|
+
})
|
|
41
|
+
.catch(() => {
|
|
42
|
+
clearTimeout(timer);
|
|
43
|
+
finish(false);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
async function waitHealthy(url, attempts, intervalMs, check, probeTimeoutMs) {
|
|
48
|
+
for (let i = 0; i < attempts; i++) {
|
|
49
|
+
if (await probeOnce(url, check, probeTimeoutMs))
|
|
50
|
+
return true;
|
|
51
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
52
|
+
}
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* `extraArgs` exists for tests: it lets a fake binary (e.g. `node fake.mjs`) be
|
|
57
|
+
* launched in place of `cloudflared tunnel --url ...`. Production passes none.
|
|
58
|
+
* The URL is surfaced only after a health probe confirms the edge is reachable
|
|
59
|
+
* (cloudflared prints the hostname before routing is live).
|
|
60
|
+
*/
|
|
61
|
+
export function startCloudflared(binPath, localPort, opts = {}) {
|
|
62
|
+
const args = opts.extraArgs ?? ['tunnel', '--url', `http://localhost:${localPort}`];
|
|
63
|
+
const timeoutMs = opts.timeoutMs ?? CLOUDFLARED_URL_TIMEOUT_MS;
|
|
64
|
+
const attempts = opts.attempts ?? CLOUDFLARED_HEALTH_ATTEMPTS;
|
|
65
|
+
const intervalMs = opts.intervalMs ?? CLOUDFLARED_HEALTH_INTERVAL_MS;
|
|
66
|
+
const check = opts.healthCheck ?? defaultHealthCheck;
|
|
67
|
+
const probeTimeoutMs = opts.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
|
|
68
|
+
return new Promise((resolve, reject) => {
|
|
69
|
+
const child = spawn(binPath, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
70
|
+
let settled = false;
|
|
71
|
+
const stop = () => {
|
|
72
|
+
try {
|
|
73
|
+
child.kill('SIGTERM');
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
/* gone */
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
const fail = (err) => {
|
|
80
|
+
if (!settled) {
|
|
81
|
+
settled = true;
|
|
82
|
+
clearTimeout(timer);
|
|
83
|
+
stop();
|
|
84
|
+
reject(err);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
const timer = setTimeout(() => fail(new Error('cloudflared did not report a URL in time')), timeoutMs);
|
|
88
|
+
const onData = (buf) => {
|
|
89
|
+
for (const line of buf.toString().split('\n')) {
|
|
90
|
+
const url = parsePublicUrl(line);
|
|
91
|
+
if (url && !settled) {
|
|
92
|
+
settled = true;
|
|
93
|
+
clearTimeout(timer);
|
|
94
|
+
waitHealthy(url, attempts, intervalMs, check, probeTimeoutMs)
|
|
95
|
+
.then((ok) => {
|
|
96
|
+
if (ok)
|
|
97
|
+
resolve({ publicUrl: url, stop });
|
|
98
|
+
else {
|
|
99
|
+
stop();
|
|
100
|
+
reject(new Error('cloudflared tunnel never became reachable'));
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
.catch((err) => {
|
|
104
|
+
// Should be unreachable (waitHealthy/probeOnce never reject), but
|
|
105
|
+
// this guarantees the child is never orphaned and the outer
|
|
106
|
+
// promise always settles, even on a future bug or surprise throw.
|
|
107
|
+
stop();
|
|
108
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
109
|
+
reject(new Error(`cloudflared health check failed unexpectedly: ${reason}`));
|
|
110
|
+
});
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
child.stdout?.on('data', onData);
|
|
116
|
+
child.stderr?.on('data', onData);
|
|
117
|
+
child.on('error', (err) => fail(err));
|
|
118
|
+
child.on('exit', (code) => fail(new Error(`cloudflared exited (${code})`)));
|
|
119
|
+
});
|
|
120
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare const TUNNEL_HOME: string;
|
|
2
|
+
export declare const BIN_DIR: string;
|
|
3
|
+
export declare const SESSIONS_DIR: string;
|
|
4
|
+
export declare const DEFAULT_LISTEN_TIMEOUT_MS = 60000;
|
|
5
|
+
export declare const DEFAULT_IDLE_TEARDOWN_MS: number;
|
|
6
|
+
export declare const CLOUDFLARED_URL_TIMEOUT_MS = 30000;
|
|
7
|
+
export declare const CLOUDFLARED_HEALTH_ATTEMPTS = 10;
|
|
8
|
+
export declare const CLOUDFLARED_HEALTH_INTERVAL_MS = 1000;
|
|
9
|
+
export declare const OPEN_RETRY_ATTEMPTS = 3;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
export const TUNNEL_HOME = path.join(os.homedir(), '.tunnel');
|
|
4
|
+
export const BIN_DIR = path.join(TUNNEL_HOME, 'bin');
|
|
5
|
+
export const SESSIONS_DIR = path.join(TUNNEL_HOME, 'sessions');
|
|
6
|
+
export const DEFAULT_LISTEN_TIMEOUT_MS = 60_000;
|
|
7
|
+
export const DEFAULT_IDLE_TEARDOWN_MS = 30 * 60_000;
|
|
8
|
+
// cloudflared startup robustness
|
|
9
|
+
export const CLOUDFLARED_URL_TIMEOUT_MS = 30_000; // wait for the URL line
|
|
10
|
+
export const CLOUDFLARED_HEALTH_ATTEMPTS = 10; // edge-reachability probes
|
|
11
|
+
export const CLOUDFLARED_HEALTH_INTERVAL_MS = 1_000; // delay between probes
|
|
12
|
+
export const OPEN_RETRY_ATTEMPTS = 3; // re-spawn attempts in session.open
|