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/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
+
import { TunnelSession } from './session.js';
|
|
5
|
+
import { registerTools, defaultDisplayName } from './tools.js';
|
|
6
|
+
const session = new TunnelSession();
|
|
7
|
+
const server = new McpServer({ name: 'tunnel', version: '0.1.0' });
|
|
8
|
+
registerTools(server, session, { displayName: defaultDisplayName() });
|
|
9
|
+
let closing = false;
|
|
10
|
+
async function teardown() {
|
|
11
|
+
if (closing)
|
|
12
|
+
return;
|
|
13
|
+
closing = true;
|
|
14
|
+
try {
|
|
15
|
+
await session.close('process exit');
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
/* best effort */
|
|
19
|
+
}
|
|
20
|
+
process.exit(0);
|
|
21
|
+
}
|
|
22
|
+
process.on('SIGINT', teardown);
|
|
23
|
+
process.on('SIGTERM', teardown);
|
|
24
|
+
const transport = new StdioServerTransport();
|
|
25
|
+
// The host holds an HTTP/WS listener + a cloudflared child, so the event loop
|
|
26
|
+
// never drains and `beforeExit` would never fire. Drive teardown off the stdio
|
|
27
|
+
// pipe closing instead, which is how an MCP client actually ends the server.
|
|
28
|
+
transport.onclose = () => {
|
|
29
|
+
void teardown();
|
|
30
|
+
};
|
|
31
|
+
process.stdin.on('end', () => {
|
|
32
|
+
void teardown();
|
|
33
|
+
});
|
|
34
|
+
process.stdin.on('close', () => {
|
|
35
|
+
void teardown();
|
|
36
|
+
});
|
|
37
|
+
await server.connect(transport);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { WireMessage } from '../protocol/messages.js';
|
|
2
|
+
export declare class SessionLog {
|
|
3
|
+
private msgs;
|
|
4
|
+
private seqCounter;
|
|
5
|
+
private filePath;
|
|
6
|
+
private closed;
|
|
7
|
+
constructor(tunnelId: string);
|
|
8
|
+
append(msg: WireMessage): WireMessage;
|
|
9
|
+
record(finalized: WireMessage): void;
|
|
10
|
+
since(sinceSeq: number): WireMessage[];
|
|
11
|
+
all(): WireMessage[];
|
|
12
|
+
get lastSeq(): number;
|
|
13
|
+
delete(): void;
|
|
14
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { SESSIONS_DIR } from '../config.js';
|
|
4
|
+
export class SessionLog {
|
|
5
|
+
msgs = [];
|
|
6
|
+
seqCounter = 0;
|
|
7
|
+
filePath;
|
|
8
|
+
// Set once delete() has run. After that point append()/record() must be
|
|
9
|
+
// no-ops with respect to the file and in-memory store — otherwise a late
|
|
10
|
+
// event (e.g. a guest socket's 'close' handler firing after teardown) can
|
|
11
|
+
// resurrect the .jsonl file moments after delete() removed it, leaving an
|
|
12
|
+
// orphaned log on disk forever.
|
|
13
|
+
closed = false;
|
|
14
|
+
constructor(tunnelId) {
|
|
15
|
+
fs.mkdirSync(SESSIONS_DIR, { recursive: true });
|
|
16
|
+
this.filePath = path.join(SESSIONS_DIR, `${tunnelId}.jsonl`);
|
|
17
|
+
}
|
|
18
|
+
append(msg) {
|
|
19
|
+
if (this.closed) {
|
|
20
|
+
// True no-op: stub a finalized-looking message for the caller without
|
|
21
|
+
// advancing seqCounter, touching msgs, or writing to disk — so a late
|
|
22
|
+
// call after delete() can never recreate the file or extend the log.
|
|
23
|
+
return { ...msg, seq: this.seqCounter, ts: Date.now() };
|
|
24
|
+
}
|
|
25
|
+
const finalized = { ...msg, seq: ++this.seqCounter, ts: Date.now() };
|
|
26
|
+
this.msgs.push(finalized);
|
|
27
|
+
fs.appendFileSync(this.filePath, JSON.stringify(finalized) + '\n');
|
|
28
|
+
return finalized;
|
|
29
|
+
}
|
|
30
|
+
record(finalized) {
|
|
31
|
+
if (this.closed)
|
|
32
|
+
return;
|
|
33
|
+
this.msgs.push(finalized);
|
|
34
|
+
this.seqCounter = Math.max(this.seqCounter, finalized.seq);
|
|
35
|
+
}
|
|
36
|
+
since(sinceSeq) {
|
|
37
|
+
return this.msgs.filter((m) => m.seq > sinceSeq);
|
|
38
|
+
}
|
|
39
|
+
all() {
|
|
40
|
+
return [...this.msgs];
|
|
41
|
+
}
|
|
42
|
+
get lastSeq() {
|
|
43
|
+
return this.seqCounter;
|
|
44
|
+
}
|
|
45
|
+
delete() {
|
|
46
|
+
try {
|
|
47
|
+
fs.rmSync(this.filePath);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
/* already gone */
|
|
51
|
+
}
|
|
52
|
+
this.msgs = [];
|
|
53
|
+
this.closed = true;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type Key = Uint8Array;
|
|
2
|
+
export declare function generateKey(): Key;
|
|
3
|
+
export declare function keyToBase64url(key: Key): string;
|
|
4
|
+
export declare function keyFromBase64url(s: string): Key;
|
|
5
|
+
export declare function seal(plaintext: string, key: Key): string;
|
|
6
|
+
export declare function open(sealed: string, key: Key): string;
|
|
7
|
+
export declare function makeChallenge(): string;
|
|
8
|
+
export declare function respondChallenge(challenge: string, key: Key): string;
|
|
9
|
+
export declare function verifyChallenge(challenge: string, response: string, key: Key): boolean;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import nacl from 'tweetnacl';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
export function generateKey() {
|
|
4
|
+
return nacl.randomBytes(nacl.secretbox.keyLength);
|
|
5
|
+
}
|
|
6
|
+
export function keyToBase64url(key) {
|
|
7
|
+
return Buffer.from(key).toString('base64url');
|
|
8
|
+
}
|
|
9
|
+
export function keyFromBase64url(s) {
|
|
10
|
+
const b = Buffer.from(s, 'base64url');
|
|
11
|
+
if (b.length !== nacl.secretbox.keyLength)
|
|
12
|
+
throw new Error('invalid key length');
|
|
13
|
+
return new Uint8Array(b);
|
|
14
|
+
}
|
|
15
|
+
export function seal(plaintext, key) {
|
|
16
|
+
const nonce = nacl.randomBytes(nacl.secretbox.nonceLength);
|
|
17
|
+
const box = nacl.secretbox(Buffer.from(plaintext, 'utf8'), nonce, key);
|
|
18
|
+
return Buffer.concat([Buffer.from(nonce), Buffer.from(box)]).toString('base64url');
|
|
19
|
+
}
|
|
20
|
+
export function open(sealed, key) {
|
|
21
|
+
const data = Buffer.from(sealed, 'base64url');
|
|
22
|
+
const nonce = new Uint8Array(data.subarray(0, nacl.secretbox.nonceLength));
|
|
23
|
+
const box = new Uint8Array(data.subarray(nacl.secretbox.nonceLength));
|
|
24
|
+
const plain = nacl.secretbox.open(box, nonce, key);
|
|
25
|
+
if (!plain)
|
|
26
|
+
throw new Error('decryption failed');
|
|
27
|
+
return Buffer.from(plain).toString('utf8');
|
|
28
|
+
}
|
|
29
|
+
export function makeChallenge() {
|
|
30
|
+
return crypto.randomBytes(32).toString('base64url');
|
|
31
|
+
}
|
|
32
|
+
export function respondChallenge(challenge, key) {
|
|
33
|
+
return crypto.createHmac('sha256', Buffer.from(key)).update(challenge).digest('base64url');
|
|
34
|
+
}
|
|
35
|
+
export function verifyChallenge(challenge, response, key) {
|
|
36
|
+
const expected = Buffer.from(respondChallenge(challenge, key));
|
|
37
|
+
const got = Buffer.from(response);
|
|
38
|
+
return expected.length === got.length && crypto.timingSafeEqual(expected, got);
|
|
39
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Key } from './crypto.js';
|
|
2
|
+
export interface JoinLink {
|
|
3
|
+
tunnelId: string;
|
|
4
|
+
key: Key;
|
|
5
|
+
wsUrl: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function generateTunnelId(): string;
|
|
8
|
+
export declare function mintLink(publicBaseUrl: string, tunnelId: string, key: Key): string;
|
|
9
|
+
export declare function parseLink(link: string): JoinLink;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { keyToBase64url, keyFromBase64url } from './crypto.js';
|
|
3
|
+
export function generateTunnelId() {
|
|
4
|
+
return crypto.randomBytes(8).toString('hex');
|
|
5
|
+
}
|
|
6
|
+
export function mintLink(publicBaseUrl, tunnelId, key) {
|
|
7
|
+
const wsBase = publicBaseUrl.replace(/^http/, 'ws'); // https->wss, http->ws
|
|
8
|
+
return `${wsBase}/t/${tunnelId}#${keyToBase64url(key)}`;
|
|
9
|
+
}
|
|
10
|
+
export function parseLink(link) {
|
|
11
|
+
const hashIdx = link.indexOf('#');
|
|
12
|
+
if (hashIdx < 0)
|
|
13
|
+
throw new Error('link missing key fragment');
|
|
14
|
+
const urlPart = link.slice(0, hashIdx);
|
|
15
|
+
const keyPart = link.slice(hashIdx + 1);
|
|
16
|
+
const u = new URL(urlPart);
|
|
17
|
+
const m = u.pathname.match(/^\/t\/([0-9a-f]+)$/);
|
|
18
|
+
if (!m)
|
|
19
|
+
throw new Error('link missing tunnel id');
|
|
20
|
+
return { tunnelId: m[1], key: keyFromBase64url(keyPart), wsUrl: urlPart };
|
|
21
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Key } from './crypto.js';
|
|
2
|
+
export type MessageKind = 'chat' | 'system' | 'presence';
|
|
3
|
+
export type Role = 'host' | 'guest';
|
|
4
|
+
export interface WireMessage {
|
|
5
|
+
id: string;
|
|
6
|
+
seq: number;
|
|
7
|
+
from: Role;
|
|
8
|
+
kind: MessageKind;
|
|
9
|
+
body: string;
|
|
10
|
+
ts: number;
|
|
11
|
+
}
|
|
12
|
+
export interface PlainMessage {
|
|
13
|
+
id: string;
|
|
14
|
+
seq: number;
|
|
15
|
+
from: Role;
|
|
16
|
+
kind: MessageKind;
|
|
17
|
+
text: string;
|
|
18
|
+
ts: number;
|
|
19
|
+
}
|
|
20
|
+
export declare function newId(): string;
|
|
21
|
+
export declare function buildChat(from: Role, text: string, key: Key): WireMessage;
|
|
22
|
+
export declare function buildSystem(from: Role, text: string): WireMessage;
|
|
23
|
+
export declare function decrypt(msg: WireMessage, key: Key): PlainMessage;
|
|
24
|
+
export type ControlFrame = {
|
|
25
|
+
t: 'challenge';
|
|
26
|
+
nonce: string;
|
|
27
|
+
} | {
|
|
28
|
+
t: 'auth';
|
|
29
|
+
response: string;
|
|
30
|
+
name: string;
|
|
31
|
+
sinceSeq: number;
|
|
32
|
+
} | {
|
|
33
|
+
t: 'auth_ok';
|
|
34
|
+
goal: string;
|
|
35
|
+
peerName: string;
|
|
36
|
+
backlog: WireMessage[];
|
|
37
|
+
} | {
|
|
38
|
+
t: 'auth_fail';
|
|
39
|
+
reason: string;
|
|
40
|
+
} | {
|
|
41
|
+
t: 'msg';
|
|
42
|
+
msg: WireMessage;
|
|
43
|
+
} | {
|
|
44
|
+
t: 'send';
|
|
45
|
+
msg: WireMessage;
|
|
46
|
+
};
|
|
47
|
+
export declare function encodeFrame(frame: ControlFrame): string;
|
|
48
|
+
export declare function decodeFrame(data: string): ControlFrame;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { seal, open } from './crypto.js';
|
|
3
|
+
export function newId() {
|
|
4
|
+
return crypto.randomBytes(8).toString('hex');
|
|
5
|
+
}
|
|
6
|
+
export function buildChat(from, text, key) {
|
|
7
|
+
return { id: newId(), seq: -1, from, kind: 'chat', body: seal(text, key), ts: 0 };
|
|
8
|
+
}
|
|
9
|
+
export function buildSystem(from, text) {
|
|
10
|
+
return { id: newId(), seq: -1, from, kind: 'system', body: text, ts: 0 };
|
|
11
|
+
}
|
|
12
|
+
// decrypt() must be TOTAL: a malformed/forged peer chat body must never
|
|
13
|
+
// throw here, or one bad message poisons every listen() batch that includes
|
|
14
|
+
// it (the untrusted guest could otherwise deny the host's receive loop).
|
|
15
|
+
export function decrypt(msg, key) {
|
|
16
|
+
let text;
|
|
17
|
+
if (msg.kind === 'chat') {
|
|
18
|
+
try {
|
|
19
|
+
text = open(msg.body, key);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
text = '[unreadable]';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
text = msg.body;
|
|
27
|
+
}
|
|
28
|
+
return { id: msg.id, seq: msg.seq, from: msg.from, kind: msg.kind, text, ts: msg.ts };
|
|
29
|
+
}
|
|
30
|
+
export function encodeFrame(frame) {
|
|
31
|
+
return JSON.stringify(frame);
|
|
32
|
+
}
|
|
33
|
+
export function decodeFrame(data) {
|
|
34
|
+
return JSON.parse(data);
|
|
35
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import { JoinLink } from '../protocol/link.js';
|
|
3
|
+
import { SessionLog } from '../log/sessionLog.js';
|
|
4
|
+
import { WireMessage } from '../protocol/messages.js';
|
|
5
|
+
export declare class GuestClient extends EventEmitter {
|
|
6
|
+
private link;
|
|
7
|
+
private guestName;
|
|
8
|
+
private log;
|
|
9
|
+
private ws?;
|
|
10
|
+
private pending;
|
|
11
|
+
constructor(link: JoinLink, guestName: string, log: SessionLog);
|
|
12
|
+
connect(sinceSeq?: number): Promise<{
|
|
13
|
+
goal: string;
|
|
14
|
+
peerName: string;
|
|
15
|
+
}>;
|
|
16
|
+
private failPending;
|
|
17
|
+
say(msg: WireMessage): Promise<number>;
|
|
18
|
+
get connected(): boolean;
|
|
19
|
+
close(): void;
|
|
20
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import WebSocket from 'ws';
|
|
3
|
+
import { respondChallenge } from '../protocol/crypto.js';
|
|
4
|
+
import { encodeFrame, decodeFrame } from '../protocol/messages.js';
|
|
5
|
+
import { DEFAULT_LISTEN_TIMEOUT_MS } from '../config.js';
|
|
6
|
+
export class GuestClient extends EventEmitter {
|
|
7
|
+
link;
|
|
8
|
+
guestName;
|
|
9
|
+
log;
|
|
10
|
+
ws;
|
|
11
|
+
pending = new Map();
|
|
12
|
+
constructor(link, guestName, log) {
|
|
13
|
+
super();
|
|
14
|
+
this.link = link;
|
|
15
|
+
this.guestName = guestName;
|
|
16
|
+
this.log = log;
|
|
17
|
+
}
|
|
18
|
+
connect(sinceSeq = 0) {
|
|
19
|
+
return new Promise((resolve, reject) => {
|
|
20
|
+
const ws = new WebSocket(this.link.wsUrl);
|
|
21
|
+
this.ws = ws;
|
|
22
|
+
ws.on('message', (data) => {
|
|
23
|
+
let frame;
|
|
24
|
+
try {
|
|
25
|
+
frame = decodeFrame(data.toString());
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (frame.t === 'challenge') {
|
|
31
|
+
ws.send(encodeFrame({
|
|
32
|
+
t: 'auth',
|
|
33
|
+
response: respondChallenge(frame.nonce, this.link.key),
|
|
34
|
+
name: this.guestName,
|
|
35
|
+
sinceSeq,
|
|
36
|
+
}));
|
|
37
|
+
}
|
|
38
|
+
else if (frame.t === 'auth_ok') {
|
|
39
|
+
for (const m of frame.backlog)
|
|
40
|
+
this.log.record(m);
|
|
41
|
+
resolve({ goal: frame.goal, peerName: frame.peerName });
|
|
42
|
+
}
|
|
43
|
+
else if (frame.t === 'auth_fail') {
|
|
44
|
+
reject(new Error(`auth failed: ${frame.reason}`));
|
|
45
|
+
ws.close();
|
|
46
|
+
}
|
|
47
|
+
else if (frame.t === 'msg') {
|
|
48
|
+
this.log.record(frame.msg);
|
|
49
|
+
const waiter = this.pending.get(frame.msg.id);
|
|
50
|
+
if (waiter) {
|
|
51
|
+
this.pending.delete(frame.msg.id);
|
|
52
|
+
waiter.resolve(frame.msg.seq);
|
|
53
|
+
}
|
|
54
|
+
this.emit('message', frame.msg);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
ws.on('close', () => this.failPending(new Error('tunnel disconnected')));
|
|
58
|
+
ws.on('error', (err) => {
|
|
59
|
+
reject(err);
|
|
60
|
+
this.failPending(err);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
// Reject every in-flight say() so a lost echo surfaces as an error, never a hang.
|
|
65
|
+
failPending(err) {
|
|
66
|
+
for (const [, p] of this.pending)
|
|
67
|
+
p.reject(err);
|
|
68
|
+
this.pending.clear();
|
|
69
|
+
}
|
|
70
|
+
say(msg) {
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN)
|
|
73
|
+
return reject(new Error('not connected'));
|
|
74
|
+
const timer = setTimeout(() => {
|
|
75
|
+
if (this.pending.delete(msg.id))
|
|
76
|
+
reject(new Error('timed out waiting for host ack'));
|
|
77
|
+
}, DEFAULT_LISTEN_TIMEOUT_MS);
|
|
78
|
+
this.pending.set(msg.id, {
|
|
79
|
+
resolve: (seq) => {
|
|
80
|
+
clearTimeout(timer);
|
|
81
|
+
resolve(seq);
|
|
82
|
+
},
|
|
83
|
+
reject: (e) => {
|
|
84
|
+
clearTimeout(timer);
|
|
85
|
+
reject(e);
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
this.ws.send(encodeFrame({ t: 'send', msg }));
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
get connected() {
|
|
92
|
+
return !!this.ws && this.ws.readyState === WebSocket.OPEN;
|
|
93
|
+
}
|
|
94
|
+
close() {
|
|
95
|
+
this.failPending(new Error('closed'));
|
|
96
|
+
this.ws?.close();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import { Key } from '../protocol/crypto.js';
|
|
3
|
+
import { SessionLog } from '../log/sessionLog.js';
|
|
4
|
+
import { WireMessage } from '../protocol/messages.js';
|
|
5
|
+
export interface HostRelayOptions {
|
|
6
|
+
tunnelId: string;
|
|
7
|
+
key: Key;
|
|
8
|
+
goal: string;
|
|
9
|
+
hostName: string;
|
|
10
|
+
idleMs?: number;
|
|
11
|
+
}
|
|
12
|
+
export declare class HostRelay extends EventEmitter {
|
|
13
|
+
private opts;
|
|
14
|
+
private log;
|
|
15
|
+
private server;
|
|
16
|
+
private wss;
|
|
17
|
+
private guest?;
|
|
18
|
+
private guestName?;
|
|
19
|
+
private challenges;
|
|
20
|
+
private idleTimer?;
|
|
21
|
+
private tearingDown;
|
|
22
|
+
constructor(opts: HostRelayOptions, log: SessionLog);
|
|
23
|
+
start(): Promise<number>;
|
|
24
|
+
get peerConnected(): boolean;
|
|
25
|
+
submitLocal(msg: WireMessage): WireMessage;
|
|
26
|
+
private resetIdle;
|
|
27
|
+
private submit;
|
|
28
|
+
private broadcast;
|
|
29
|
+
private onConnection;
|
|
30
|
+
close(): Promise<void>;
|
|
31
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
3
|
+
import { WebSocketServer, WebSocket } from 'ws';
|
|
4
|
+
import { makeChallenge, verifyChallenge } from '../protocol/crypto.js';
|
|
5
|
+
import { encodeFrame, decodeFrame, buildSystem, } from '../protocol/messages.js';
|
|
6
|
+
import { DEFAULT_IDLE_TEARDOWN_MS } from '../config.js';
|
|
7
|
+
export class HostRelay extends EventEmitter {
|
|
8
|
+
opts;
|
|
9
|
+
log;
|
|
10
|
+
server;
|
|
11
|
+
wss;
|
|
12
|
+
guest;
|
|
13
|
+
guestName;
|
|
14
|
+
challenges = new WeakMap();
|
|
15
|
+
idleTimer;
|
|
16
|
+
tearingDown = false;
|
|
17
|
+
constructor(opts, log) {
|
|
18
|
+
super();
|
|
19
|
+
this.opts = opts;
|
|
20
|
+
this.log = log;
|
|
21
|
+
this.server = http.createServer();
|
|
22
|
+
this.wss = new WebSocketServer({ server: this.server, path: `/t/${opts.tunnelId}` });
|
|
23
|
+
this.wss.on('connection', (ws) => this.onConnection(ws));
|
|
24
|
+
// A routine socket-level error (e.g. ECONNRESET on a flaky tunnel hop)
|
|
25
|
+
// must never crash the host process.
|
|
26
|
+
this.wss.on('error', (err) => {
|
|
27
|
+
console.error('[tunnel] relay server error:', err);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
start() {
|
|
31
|
+
return new Promise((resolve) => {
|
|
32
|
+
this.server.listen(0, '127.0.0.1', () => {
|
|
33
|
+
this.resetIdle();
|
|
34
|
+
const addr = this.server.address();
|
|
35
|
+
resolve(typeof addr === 'object' && addr ? addr.port : 0);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
get peerConnected() {
|
|
40
|
+
return !!this.guest && this.guest.readyState === WebSocket.OPEN;
|
|
41
|
+
}
|
|
42
|
+
submitLocal(msg) {
|
|
43
|
+
return this.submit(msg);
|
|
44
|
+
}
|
|
45
|
+
// Third teardown trigger: no activity within idleMs → warn, then emit 'idle'.
|
|
46
|
+
resetIdle() {
|
|
47
|
+
if (this.tearingDown)
|
|
48
|
+
return;
|
|
49
|
+
if (this.idleTimer)
|
|
50
|
+
clearTimeout(this.idleTimer);
|
|
51
|
+
this.idleTimer = setTimeout(() => {
|
|
52
|
+
this.tearingDown = true; // stop further reschedules from the warning's submit()
|
|
53
|
+
this.submit(buildSystem('host', 'idle timeout — closing tunnel'));
|
|
54
|
+
this.emit('idle');
|
|
55
|
+
}, this.opts.idleMs ?? DEFAULT_IDLE_TEARDOWN_MS);
|
|
56
|
+
}
|
|
57
|
+
submit(msg) {
|
|
58
|
+
this.resetIdle();
|
|
59
|
+
const finalized = this.log.append(msg);
|
|
60
|
+
this.broadcast({ t: 'msg', msg: finalized });
|
|
61
|
+
this.emit('message', finalized);
|
|
62
|
+
return finalized;
|
|
63
|
+
}
|
|
64
|
+
broadcast(frame) {
|
|
65
|
+
if (this.guest && this.guest.readyState === WebSocket.OPEN) {
|
|
66
|
+
this.guest.send(encodeFrame(frame));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
onConnection(ws) {
|
|
70
|
+
const nonce = makeChallenge();
|
|
71
|
+
this.challenges.set(ws, nonce);
|
|
72
|
+
ws.send(encodeFrame({ t: 'challenge', nonce }));
|
|
73
|
+
ws.on('message', (data) => {
|
|
74
|
+
// A malformed or schema-invalid frame must never crash the process.
|
|
75
|
+
// decodeFrame is a blind `as ControlFrame` cast (no runtime
|
|
76
|
+
// validation), so anything downstream that assumes a field's shape
|
|
77
|
+
// must be defensively checked here, inside the try/catch.
|
|
78
|
+
try {
|
|
79
|
+
let frame;
|
|
80
|
+
try {
|
|
81
|
+
frame = decodeFrame(data.toString());
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (frame.t === 'auth') {
|
|
87
|
+
if (typeof frame.response !== 'string' || typeof frame.name !== 'string') {
|
|
88
|
+
ws.send(encodeFrame({ t: 'auth_fail', reason: 'malformed auth' }));
|
|
89
|
+
ws.close();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const challenge = this.challenges.get(ws);
|
|
93
|
+
if (!challenge || !verifyChallenge(challenge, frame.response, this.opts.key)) {
|
|
94
|
+
ws.send(encodeFrame({ t: 'auth_fail', reason: 'bad key' }));
|
|
95
|
+
ws.close();
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (this.guest && this.guest !== ws && this.guest.readyState === WebSocket.OPEN) {
|
|
99
|
+
ws.send(encodeFrame({ t: 'auth_fail', reason: 'tunnel full' }));
|
|
100
|
+
ws.close();
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
this.guest = ws;
|
|
104
|
+
this.guestName = frame.name;
|
|
105
|
+
const sinceSeq = Number.isFinite(frame.sinceSeq) ? frame.sinceSeq : 0;
|
|
106
|
+
ws.send(encodeFrame({
|
|
107
|
+
t: 'auth_ok',
|
|
108
|
+
goal: this.opts.goal,
|
|
109
|
+
peerName: this.opts.hostName,
|
|
110
|
+
backlog: this.log.since(sinceSeq),
|
|
111
|
+
}));
|
|
112
|
+
this.submit(buildSystem('host', `${frame.name} joined`));
|
|
113
|
+
}
|
|
114
|
+
else if (frame.t === 'send') {
|
|
115
|
+
if (ws !== this.guest)
|
|
116
|
+
return; // only the authenticated guest may send
|
|
117
|
+
const msg = frame.msg;
|
|
118
|
+
if (!msg ||
|
|
119
|
+
typeof msg !== 'object' ||
|
|
120
|
+
typeof msg.id !== 'string' ||
|
|
121
|
+
typeof msg.body !== 'string' ||
|
|
122
|
+
msg.kind !== 'chat' // a guest may only originate chat; system/presence are host-only events
|
|
123
|
+
) {
|
|
124
|
+
return; // ignore malformed or forged send frames
|
|
125
|
+
}
|
|
126
|
+
this.submit({ ...msg, from: 'guest', seq: -1, ts: 0 });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
// Never let a bad frame (or a downstream throw) crash the host
|
|
131
|
+
// process. stderr only — stdout is the MCP stdio channel.
|
|
132
|
+
console.error('[tunnel] relay frame error:', err);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
ws.on('close', () => {
|
|
137
|
+
if (ws === this.guest) {
|
|
138
|
+
this.guest = undefined;
|
|
139
|
+
// During teardown, close() has already terminated this socket and
|
|
140
|
+
// may have already deleted the session log; don't emit a "left"
|
|
141
|
+
// event (and don't let this late callback resurrect the log file —
|
|
142
|
+
// SessionLog.append() is itself a no-op once closed, belt-and-suspenders).
|
|
143
|
+
if (!this.tearingDown)
|
|
144
|
+
this.submit(buildSystem('host', `${this.guestName ?? 'guest'} left`));
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
// A routine socket-level error must never crash the process.
|
|
148
|
+
ws.on('error', (err) => {
|
|
149
|
+
console.error('[tunnel] relay connection error:', err);
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
close() {
|
|
153
|
+
this.tearingDown = true;
|
|
154
|
+
if (this.idleTimer)
|
|
155
|
+
clearTimeout(this.idleTimer);
|
|
156
|
+
return new Promise((resolve) => {
|
|
157
|
+
for (const c of this.wss.clients)
|
|
158
|
+
c.terminate();
|
|
159
|
+
this.wss.close(() => this.server.close(() => resolve()));
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { PlainMessage, Role } from './protocol/messages.js';
|
|
2
|
+
import { TunnelHandle } from './cloudflared/tunnelProcess.js';
|
|
3
|
+
export interface SessionDeps {
|
|
4
|
+
ensureCloudflared: () => Promise<string>;
|
|
5
|
+
startCloudflared: (bin: string, port: number) => Promise<TunnelHandle>;
|
|
6
|
+
idleMs?: number;
|
|
7
|
+
}
|
|
8
|
+
export interface SessionStatus {
|
|
9
|
+
role: Role;
|
|
10
|
+
peerConnected: boolean;
|
|
11
|
+
goal: string;
|
|
12
|
+
lastSeq: number;
|
|
13
|
+
openedAt: number;
|
|
14
|
+
}
|
|
15
|
+
export declare class TunnelSession {
|
|
16
|
+
private deps;
|
|
17
|
+
private role?;
|
|
18
|
+
private key?;
|
|
19
|
+
private tunnelId?;
|
|
20
|
+
private goal;
|
|
21
|
+
private openedAt;
|
|
22
|
+
private log?;
|
|
23
|
+
private source?;
|
|
24
|
+
private relay?;
|
|
25
|
+
private guest?;
|
|
26
|
+
private tunnel?;
|
|
27
|
+
constructor(deps?: SessionDeps);
|
|
28
|
+
get isOpen(): boolean;
|
|
29
|
+
open(goal: string, hostName: string): Promise<{
|
|
30
|
+
tunnelId: string;
|
|
31
|
+
joinLink: string;
|
|
32
|
+
status: string;
|
|
33
|
+
}>;
|
|
34
|
+
join(joinLink: string, guestName: string): Promise<{
|
|
35
|
+
tunnelId: string;
|
|
36
|
+
goal: string;
|
|
37
|
+
peer: string;
|
|
38
|
+
}>;
|
|
39
|
+
say(text: string): Promise<{
|
|
40
|
+
seq: number;
|
|
41
|
+
}>;
|
|
42
|
+
listen(sinceSeq: number, timeoutMs?: number): Promise<{
|
|
43
|
+
messages: PlainMessage[];
|
|
44
|
+
status: SessionStatus;
|
|
45
|
+
}>;
|
|
46
|
+
status(): SessionStatus;
|
|
47
|
+
close(summary?: string): Promise<{
|
|
48
|
+
ok: true;
|
|
49
|
+
}>;
|
|
50
|
+
}
|