@symbo.ls/tunnel 3.14.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/.wrangler/cache/cf.json +1 -0
- package/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite +0 -0
- package/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-shm +0 -0
- package/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-wal +0 -0
- package/.wrangler/state/v3/do/symbols-tunnel-TunnelRoom/3a9e8170dcfddd4d80b5cf81fbbfb44e39e1b52012822f06d44419a77bf5c28b.sqlite +0 -0
- package/.wrangler/state/v3/do/symbols-tunnel-TunnelRoom/3a9e8170dcfddd4d80b5cf81fbbfb44e39e1b52012822f06d44419a77bf5c28b.sqlite-shm +0 -0
- package/.wrangler/state/v3/do/symbols-tunnel-TunnelRoom/3a9e8170dcfddd4d80b5cf81fbbfb44e39e1b52012822f06d44419a77bf5c28b.sqlite-wal +0 -0
- package/.wrangler/state/v3/do/symbols-tunnel-TunnelRoom/bf5eea075153d5d7877e172ef111f3068ecb77c4ecbc4f7c28915912eb3cd0da.sqlite +0 -0
- package/.wrangler/state/v3/do/symbols-tunnel-TunnelRoom/bf5eea075153d5d7877e172ef111f3068ecb77c4ecbc4f7c28915912eb3cd0da.sqlite-shm +0 -0
- package/.wrangler/state/v3/do/symbols-tunnel-TunnelRoom/bf5eea075153d5d7877e172ef111f3068ecb77c4ecbc4f7c28915912eb3cd0da.sqlite-wal +0 -0
- package/.wrangler/state/v3/do/symbols-tunnel-TunnelRoom/metadata.sqlite +0 -0
- package/.wrangler/state/v3/do/symbols-tunnel-TunnelRoom/metadata.sqlite-shm +0 -0
- package/.wrangler/state/v3/do/symbols-tunnel-TunnelRoom/metadata.sqlite-wal +0 -0
- package/README.md +46 -0
- package/bin/client.js +18 -0
- package/bin/server.js +8 -0
- package/dist-worker/README.md +1 -0
- package/dist-worker/index.js +222 -0
- package/dist-worker/index.js.map +8 -0
- package/package.json +25 -0
- package/src/client.js +152 -0
- package/src/protocol.js +24 -0
- package/src/server.js +177 -0
- package/worker/index.js +110 -0
- package/worker/tunnel-room.js +151 -0
- package/wrangler.toml +49 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"httpProtocol":"HTTP/1.1","clientAcceptEncoding":"gzip, deflate, br","requestPriority":"","edgeRequestKeepAliveStatus":1,"requestHeaderNames":{},"clientTcpRtt":7,"clientQuicRtt":0,"colo":"TBS","asn":16010,"asOrganization":"Caucasus Online LLC","country":"GE","isEUCountry":false,"city":"Tbilisi","continent":"AS","region":"Tbilisi","regionCode":"TB","timezone":"Asia/Tbilisi","longitude":"44.83412","latitude":"41.69143","tlsVersion":"TLSv1.3","tlsCipher":"AEAD-AES256-GCM-SHA384","tlsClientRandom":"f1ZBvvH8HtvDoYpg3t6OzwIx8Ks/1zG9HNR394Dfg8c=","tlsClientCiphersSha1":"kXrN3VEKDdzz2cPKTQaKzpxVTxQ=","tlsClientExtensionsSha1":"1eY97BUYYO8vDaTfHQywB1pcNdM=","tlsClientExtensionsSha1Le":"u4wtEMFQBY18l3BzHAvORm+KGRw=","tlsExportedAuthenticator":{"clientHandshake":"9b50ff71340861fedd458ff983032727ef9d477d0c55e523f70ed4a49decc68333024cd0eaf72d0c6ea8dbc04b46aa04","serverHandshake":"f09a1c6c05a4a967ebf1fd6dbbe8b153749e808b0ac8bdea102be1900a5a6b162bd49a2040977bd0fee0d46e9acc315a","clientFinished":"e34c7dcc4aabb8eb057841cc90d5ae810804177894505fbc7858ed21956e29fbb3760113c6b673726c9e9a1de9576216","serverFinished":"77097e6fa8669d551ba1770b0a4a9caf700910f090751e5d1cb2692466221dfb6b7a4062eda8f271725d90c84794dd71"},"tlsClientHelloLength":"1605","tlsClientAuth":{"certPresented":"0","certVerified":"NONE","certRevoked":"0","certIssuerDN":"","certSubjectDN":"","certIssuerDNRFC2253":"","certSubjectDNRFC2253":"","certIssuerDNLegacy":"","certSubjectDNLegacy":"","certSerial":"","certIssuerSerial":"","certSKI":"","certIssuerSKI":"","certFingerprintSHA1":"","certFingerprintSHA256":"","certNotBefore":"","certNotAfter":"","certRFC9440":"","certRFC9440TooLarge":false,"certChainRFC9440":"","certChainRFC9440TooLarge":false},"verifiedBotCategory":"","edgeL4":{"deliveryRate":755939},"botManagement":{"corporateProxy":false,"verifiedBot":false,"jsDetection":{"passed":false},"staticResource":false,"detectionIds":{},"score":99}}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/README.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# @symbo.ls/tunnel
|
|
2
|
+
|
|
3
|
+
Expose a local HTTP server to the internet through `tunnel.symbo.ls`.
|
|
4
|
+
|
|
5
|
+
## Client (CLI)
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
smbls tunnel 1234 # → https://<random>.tunnel.symbo.ls
|
|
9
|
+
smbls tunnel 1234 --subdomain myapp # → https://myapp.tunnel.symbo.ls
|
|
10
|
+
smbls tunnel 1234 --host tunnel.symbo.ls --token $TUNNEL_AUTH_TOKEN
|
|
11
|
+
smbls tunnel 1234 --host localhost:3000 --insecure
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Or standalone:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npx smbls-tunnel 1234 --subdomain myapp
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Server (hosted at tunnel.symbo.ls)
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
PORT=3000 TUNNEL_ROOT_DOMAIN=tunnel.symbo.ls node bin/server.js
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Optional: set `TUNNEL_AUTH_TOKEN` to require a token from clients.
|
|
27
|
+
|
|
28
|
+
### DNS / reverse proxy
|
|
29
|
+
|
|
30
|
+
Point `*.tunnel.symbo.ls` and `tunnel.symbo.ls` at the box running the server.
|
|
31
|
+
Terminate TLS at your reverse proxy (nginx / caddy) and forward both HTTP and
|
|
32
|
+
WebSocket upgrades to the tunnel server's `PORT`.
|
|
33
|
+
|
|
34
|
+
## Protocol
|
|
35
|
+
|
|
36
|
+
Clients open `wss://tunnel.symbo.ls/_tunnel?subdomain=<name>`. Each HTTP request
|
|
37
|
+
to `<sub>.tunnel.symbo.ls` is framed as JSON messages (`request` /
|
|
38
|
+
`request_body` / `request_end`) and replies stream back as `response` /
|
|
39
|
+
`response_body` / `response_end`. Bodies are base64.
|
|
40
|
+
|
|
41
|
+
## Programmatic
|
|
42
|
+
|
|
43
|
+
```js
|
|
44
|
+
import { createTunnelClient } from '@symbo.ls/tunnel/client'
|
|
45
|
+
createTunnelClient({ localPort: 1234, subdomain: 'myapp' })
|
|
46
|
+
```
|
package/bin/client.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createTunnelClient } from '../src/client.js'
|
|
3
|
+
|
|
4
|
+
const args = process.argv.slice(2)
|
|
5
|
+
const opts = { localPort: null }
|
|
6
|
+
for (let i = 0; i < args.length; i++) {
|
|
7
|
+
const a = args[i]
|
|
8
|
+
if (a === '--host') opts.host = args[++i]
|
|
9
|
+
else if (a === '--subdomain' || a === '-s') opts.subdomain = args[++i]
|
|
10
|
+
else if (a === '--token') opts.token = args[++i]
|
|
11
|
+
else if (a === '--insecure') { opts.secure = false; opts.port = 80 }
|
|
12
|
+
else if (/^\d+$/.test(a)) opts.localPort = parseInt(a, 10)
|
|
13
|
+
}
|
|
14
|
+
if (!opts.localPort) {
|
|
15
|
+
console.error('Usage: smbls-tunnel <localPort> [--subdomain name] [--host tunnel.symbo.ls] [--token X] [--insecure]')
|
|
16
|
+
process.exit(1)
|
|
17
|
+
}
|
|
18
|
+
createTunnelClient(opts)
|
package/bin/server.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createTunnelServer } from '../src/server.js'
|
|
3
|
+
|
|
4
|
+
const port = parseInt(process.env.PORT || '3000', 10)
|
|
5
|
+
const { httpServer, rootDomain } = createTunnelServer()
|
|
6
|
+
httpServer.listen(port, () => {
|
|
7
|
+
console.log(`tunnel server listening on :${port} (root: ${rootDomain})`)
|
|
8
|
+
})
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
This folder contains the built output assets for the worker "symbols-tunnel" generated at 2026-04-19T12:26:26.957Z.
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
|
|
3
|
+
|
|
4
|
+
// src/protocol.js
|
|
5
|
+
var MSG = {
|
|
6
|
+
REGISTER: "register",
|
|
7
|
+
REGISTERED: "registered",
|
|
8
|
+
ERROR: "error",
|
|
9
|
+
REQUEST: "request",
|
|
10
|
+
REQUEST_BODY: "request_body",
|
|
11
|
+
REQUEST_END: "request_end",
|
|
12
|
+
RESPONSE: "response",
|
|
13
|
+
RESPONSE_BODY: "response_body",
|
|
14
|
+
RESPONSE_END: "response_end",
|
|
15
|
+
PING: "ping",
|
|
16
|
+
PONG: "pong"
|
|
17
|
+
};
|
|
18
|
+
var randomId = /* @__PURE__ */ __name(() => Math.random().toString(36).slice(2) + Date.now().toString(36), "randomId");
|
|
19
|
+
var randomSubdomain = /* @__PURE__ */ __name(() => {
|
|
20
|
+
const adj = ["calm", "bold", "brisk", "quick", "bright", "soft", "warm", "cool", "keen", "lush"];
|
|
21
|
+
const noun = ["fox", "owl", "wolf", "hawk", "lark", "wren", "seal", "deer", "moth", "cub"];
|
|
22
|
+
const a = adj[Math.floor(Math.random() * adj.length)];
|
|
23
|
+
const n = noun[Math.floor(Math.random() * noun.length)];
|
|
24
|
+
const s = Math.random().toString(36).slice(2, 6);
|
|
25
|
+
return `${a}-${n}-${s}`;
|
|
26
|
+
}, "randomSubdomain");
|
|
27
|
+
|
|
28
|
+
// worker/tunnel-room.js
|
|
29
|
+
var TunnelRoom = class {
|
|
30
|
+
static {
|
|
31
|
+
__name(this, "TunnelRoom");
|
|
32
|
+
}
|
|
33
|
+
constructor(state, env) {
|
|
34
|
+
this.state = state;
|
|
35
|
+
this.env = env;
|
|
36
|
+
this.client = null;
|
|
37
|
+
this.subdomain = null;
|
|
38
|
+
this.root = "tunnel.symbo.ls";
|
|
39
|
+
this.pending = /* @__PURE__ */ new Map();
|
|
40
|
+
}
|
|
41
|
+
async fetch(request) {
|
|
42
|
+
const url = new URL(request.url);
|
|
43
|
+
if (url.pathname === "/_register") {
|
|
44
|
+
return this.register(request, url);
|
|
45
|
+
}
|
|
46
|
+
if (!this.client || this.client.readyState !== 1) {
|
|
47
|
+
return new Response(`No tunnel registered for ${this.subdomain || "this subdomain"}.`, { status: 502 });
|
|
48
|
+
}
|
|
49
|
+
return this.proxyRequest(request);
|
|
50
|
+
}
|
|
51
|
+
async register(request, url) {
|
|
52
|
+
if (request.headers.get("upgrade") !== "websocket") {
|
|
53
|
+
return new Response("expected websocket", { status: 426 });
|
|
54
|
+
}
|
|
55
|
+
this.subdomain = url.searchParams.get("subdomain");
|
|
56
|
+
this.root = url.searchParams.get("root") || this.root;
|
|
57
|
+
if (this.client) {
|
|
58
|
+
try {
|
|
59
|
+
this.client.close(1e3, "replaced");
|
|
60
|
+
} catch {
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const pair = new WebSocketPair();
|
|
64
|
+
const [clientSide, serverSide] = [pair[0], pair[1]];
|
|
65
|
+
serverSide.accept();
|
|
66
|
+
this.client = serverSide;
|
|
67
|
+
serverSide.addEventListener("message", (event) => this.onClientMessage(event));
|
|
68
|
+
const onClose = /* @__PURE__ */ __name(() => {
|
|
69
|
+
this.client = null;
|
|
70
|
+
this.failAllPending("tunnel closed");
|
|
71
|
+
}, "onClose");
|
|
72
|
+
serverSide.addEventListener("close", onClose);
|
|
73
|
+
serverSide.addEventListener("error", onClose);
|
|
74
|
+
serverSide.send(JSON.stringify({
|
|
75
|
+
type: MSG.REGISTERED,
|
|
76
|
+
subdomain: this.subdomain,
|
|
77
|
+
publicUrl: `https://${this.subdomain}.${this.root}`
|
|
78
|
+
}));
|
|
79
|
+
return new Response(null, { status: 101, webSocket: clientSide });
|
|
80
|
+
}
|
|
81
|
+
failAllPending(reason) {
|
|
82
|
+
for (const [, p] of this.pending) {
|
|
83
|
+
if (!p.headSent) {
|
|
84
|
+
p.resolveHead(new Response(reason, { status: 502 }));
|
|
85
|
+
} else {
|
|
86
|
+
try {
|
|
87
|
+
p.controller.error(new Error(reason));
|
|
88
|
+
} catch {
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
this.pending.clear();
|
|
93
|
+
}
|
|
94
|
+
onClientMessage(event) {
|
|
95
|
+
let msg;
|
|
96
|
+
try {
|
|
97
|
+
msg = JSON.parse(typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data));
|
|
98
|
+
} catch {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const entry = msg.id && this.pending.get(msg.id);
|
|
102
|
+
if (!entry) return;
|
|
103
|
+
if (msg.type === MSG.RESPONSE) {
|
|
104
|
+
const stream = new ReadableStream({
|
|
105
|
+
start: /* @__PURE__ */ __name((controller) => {
|
|
106
|
+
entry.controller = controller;
|
|
107
|
+
}, "start")
|
|
108
|
+
});
|
|
109
|
+
entry.headSent = true;
|
|
110
|
+
entry.resolveHead(new Response(stream, {
|
|
111
|
+
status: msg.status || 200,
|
|
112
|
+
headers: sanitizeHeaders(msg.headers || {})
|
|
113
|
+
}));
|
|
114
|
+
} else if (msg.type === MSG.RESPONSE_BODY) {
|
|
115
|
+
if (msg.chunk && entry.controller) {
|
|
116
|
+
entry.controller.enqueue(base64ToBytes(msg.chunk));
|
|
117
|
+
}
|
|
118
|
+
} else if (msg.type === MSG.RESPONSE_END) {
|
|
119
|
+
try {
|
|
120
|
+
entry.controller && entry.controller.close();
|
|
121
|
+
} catch {
|
|
122
|
+
}
|
|
123
|
+
this.pending.delete(msg.id);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
async proxyRequest(request) {
|
|
127
|
+
const id = randomId();
|
|
128
|
+
const url = new URL(request.url);
|
|
129
|
+
const headers = {};
|
|
130
|
+
for (const [k, v] of request.headers) headers[k] = v;
|
|
131
|
+
const bodyBuf = ["GET", "HEAD"].includes(request.method) ? null : new Uint8Array(await request.arrayBuffer());
|
|
132
|
+
const entry = { controller: null, headSent: false };
|
|
133
|
+
const headPromise = new Promise((resolve) => {
|
|
134
|
+
entry.resolveHead = resolve;
|
|
135
|
+
});
|
|
136
|
+
this.pending.set(id, entry);
|
|
137
|
+
this.client.send(JSON.stringify({
|
|
138
|
+
type: MSG.REQUEST,
|
|
139
|
+
id,
|
|
140
|
+
method: request.method,
|
|
141
|
+
url: url.pathname + url.search,
|
|
142
|
+
headers
|
|
143
|
+
}));
|
|
144
|
+
if (bodyBuf && bodyBuf.byteLength) {
|
|
145
|
+
this.client.send(JSON.stringify({ type: MSG.REQUEST_BODY, id, chunk: bytesToBase64(bodyBuf) }));
|
|
146
|
+
}
|
|
147
|
+
this.client.send(JSON.stringify({ type: MSG.REQUEST_END, id }));
|
|
148
|
+
const timeout = new Promise((resolve) => setTimeout(() => {
|
|
149
|
+
if (this.pending.has(id)) {
|
|
150
|
+
this.pending.delete(id);
|
|
151
|
+
resolve(new Response("Tunnel request timed out", { status: 504 }));
|
|
152
|
+
}
|
|
153
|
+
}, 3e4));
|
|
154
|
+
return Promise.race([headPromise, timeout]);
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
var HOP_BY_HOP = /* @__PURE__ */ new Set(["connection", "keep-alive", "transfer-encoding", "upgrade", "proxy-authenticate", "proxy-authorization", "te", "trailer", "content-length"]);
|
|
158
|
+
var sanitizeHeaders = /* @__PURE__ */ __name((h) => {
|
|
159
|
+
const out = {};
|
|
160
|
+
for (const [k, v] of Object.entries(h)) {
|
|
161
|
+
if (!HOP_BY_HOP.has(k.toLowerCase())) out[k] = v;
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
}, "sanitizeHeaders");
|
|
165
|
+
var bytesToBase64 = /* @__PURE__ */ __name((bytes) => {
|
|
166
|
+
let s = "";
|
|
167
|
+
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
|
|
168
|
+
return btoa(s);
|
|
169
|
+
}, "bytesToBase64");
|
|
170
|
+
var base64ToBytes = /* @__PURE__ */ __name((b64) => {
|
|
171
|
+
const s = atob(b64);
|
|
172
|
+
const out = new Uint8Array(s.length);
|
|
173
|
+
for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i);
|
|
174
|
+
return out;
|
|
175
|
+
}, "base64ToBytes");
|
|
176
|
+
|
|
177
|
+
// worker/index.js
|
|
178
|
+
var extractSubdomain = /* @__PURE__ */ __name((host, root) => {
|
|
179
|
+
if (!host) return null;
|
|
180
|
+
host = host.split(":")[0].toLowerCase();
|
|
181
|
+
if (host === root) return null;
|
|
182
|
+
if (!host.endsWith("." + root)) return null;
|
|
183
|
+
return host.slice(0, -("." + root).length);
|
|
184
|
+
}, "extractSubdomain");
|
|
185
|
+
var index_default = {
|
|
186
|
+
async fetch(request, env) {
|
|
187
|
+
const url = new URL(request.url);
|
|
188
|
+
const root = env.TUNNEL_ROOT_DOMAIN || "tunnel.symbo.ls";
|
|
189
|
+
const host = request.headers.get("host") || "";
|
|
190
|
+
if (url.pathname === "/_tunnel" && request.headers.get("upgrade") === "websocket") {
|
|
191
|
+
if (env.TUNNEL_AUTH_TOKEN) {
|
|
192
|
+
const token = url.searchParams.get("token") || request.headers.get("x-tunnel-token");
|
|
193
|
+
if (token !== env.TUNNEL_AUTH_TOKEN) return new Response("unauthorized", { status: 401 });
|
|
194
|
+
}
|
|
195
|
+
const requested = url.searchParams.get("subdomain");
|
|
196
|
+
const sub2 = requested && /^[a-z0-9-]{2,40}$/.test(requested) ? requested : randomSubdomain();
|
|
197
|
+
const id2 = env.TUNNEL_ROOM.idFromName(sub2);
|
|
198
|
+
const stub2 = env.TUNNEL_ROOM.get(id2);
|
|
199
|
+
const rewritten = new Request(`https://internal/_register?subdomain=${sub2}&root=${encodeURIComponent(root)}`, request);
|
|
200
|
+
return stub2.fetch(rewritten);
|
|
201
|
+
}
|
|
202
|
+
const sub = extractSubdomain(host, root);
|
|
203
|
+
if (!sub) {
|
|
204
|
+
if (url.pathname === "/health") {
|
|
205
|
+
return new Response(JSON.stringify({ ok: true }), { headers: { "content-type": "application/json" } });
|
|
206
|
+
}
|
|
207
|
+
return new Response(
|
|
208
|
+
`tunnel.symbo.ls \u2014 run \`smbls tunnel <port>\` to expose a local server.
|
|
209
|
+
`,
|
|
210
|
+
{ headers: { "content-type": "text/plain" } }
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
const id = env.TUNNEL_ROOM.idFromName(sub);
|
|
214
|
+
const stub = env.TUNNEL_ROOM.get(id);
|
|
215
|
+
return stub.fetch(request);
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
export {
|
|
219
|
+
TunnelRoom,
|
|
220
|
+
index_default as default
|
|
221
|
+
};
|
|
222
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/protocol.js", "../worker/tunnel-room.js", "../worker/index.js"],
|
|
4
|
+
"sourceRoot": "dist-worker",
|
|
5
|
+
"sourcesContent": ["export const MSG = {\n REGISTER: 'register',\n REGISTERED: 'registered',\n ERROR: 'error',\n REQUEST: 'request',\n REQUEST_BODY: 'request_body',\n REQUEST_END: 'request_end',\n RESPONSE: 'response',\n RESPONSE_BODY: 'response_body',\n RESPONSE_END: 'response_end',\n PING: 'ping',\n PONG: 'pong'\n}\n\nexport const randomId = () => Math.random().toString(36).slice(2) + Date.now().toString(36)\n\nexport const randomSubdomain = () => {\n const adj = ['calm', 'bold', 'brisk', 'quick', 'bright', 'soft', 'warm', 'cool', 'keen', 'lush']\n const noun = ['fox', 'owl', 'wolf', 'hawk', 'lark', 'wren', 'seal', 'deer', 'moth', 'cub']\n const a = adj[Math.floor(Math.random() * adj.length)]\n const n = noun[Math.floor(Math.random() * noun.length)]\n const s = Math.random().toString(36).slice(2, 6)\n return `${a}-${n}-${s}`\n}\n", "import { MSG, randomId } from '../src/protocol.js'\n\n// One Durable Object per subdomain. Holds the client WebSocket connection\n// and bridges incoming HTTP requests over JSON frames (see ../src/protocol.js).\n\nexport class TunnelRoom {\n constructor (state, env) {\n this.state = state\n this.env = env\n this.client = null // WebSocket accepted from the CLI client\n this.subdomain = null\n this.root = 'tunnel.symbo.ls'\n this.pending = new Map() // reqId -> { controller, resolveHead, rejectHead, headSent }\n }\n\n async fetch (request) {\n const url = new URL(request.url)\n\n if (url.pathname === '/_register') {\n return this.register(request, url)\n }\n\n if (!this.client || this.client.readyState !== 1 /* OPEN */) {\n return new Response(`No tunnel registered for ${this.subdomain || 'this subdomain'}.`, { status: 502 })\n }\n\n return this.proxyRequest(request)\n }\n\n async register (request, url) {\n if (request.headers.get('upgrade') !== 'websocket') {\n return new Response('expected websocket', { status: 426 })\n }\n this.subdomain = url.searchParams.get('subdomain')\n this.root = url.searchParams.get('root') || this.root\n\n // If a previous client is still attached, close it \u2014 a new client wins.\n if (this.client) {\n try { this.client.close(1000, 'replaced') } catch {}\n }\n\n const pair = new WebSocketPair()\n const [clientSide, serverSide] = [pair[0], pair[1]]\n serverSide.accept()\n this.client = serverSide\n\n serverSide.addEventListener('message', (event) => this.onClientMessage(event))\n const onClose = () => { this.client = null; this.failAllPending('tunnel closed') }\n serverSide.addEventListener('close', onClose)\n serverSide.addEventListener('error', onClose)\n\n serverSide.send(JSON.stringify({\n type: MSG.REGISTERED,\n subdomain: this.subdomain,\n publicUrl: `https://${this.subdomain}.${this.root}`\n }))\n\n return new Response(null, { status: 101, webSocket: clientSide })\n }\n\n failAllPending (reason) {\n for (const [, p] of this.pending) {\n if (!p.headSent) {\n p.resolveHead(new Response(reason, { status: 502 }))\n } else {\n try { p.controller.error(new Error(reason)) } catch {}\n }\n }\n this.pending.clear()\n }\n\n onClientMessage (event) {\n let msg\n try { msg = JSON.parse(typeof event.data === 'string' ? event.data : new TextDecoder().decode(event.data)) } catch { return }\n const entry = msg.id && this.pending.get(msg.id)\n if (!entry) return\n\n if (msg.type === MSG.RESPONSE) {\n const stream = new ReadableStream({\n start: (controller) => { entry.controller = controller }\n })\n entry.headSent = true\n entry.resolveHead(new Response(stream, {\n status: msg.status || 200,\n headers: sanitizeHeaders(msg.headers || {})\n }))\n } else if (msg.type === MSG.RESPONSE_BODY) {\n if (msg.chunk && entry.controller) {\n entry.controller.enqueue(base64ToBytes(msg.chunk))\n }\n } else if (msg.type === MSG.RESPONSE_END) {\n try { entry.controller && entry.controller.close() } catch {}\n this.pending.delete(msg.id)\n }\n }\n\n async proxyRequest (request) {\n const id = randomId()\n const url = new URL(request.url)\n const headers = {}\n for (const [k, v] of request.headers) headers[k] = v\n\n const bodyBuf = ['GET', 'HEAD'].includes(request.method) ? null : new Uint8Array(await request.arrayBuffer())\n\n const entry = { controller: null, headSent: false }\n const headPromise = new Promise((resolve) => { entry.resolveHead = resolve })\n this.pending.set(id, entry)\n\n this.client.send(JSON.stringify({\n type: MSG.REQUEST,\n id,\n method: request.method,\n url: url.pathname + url.search,\n headers\n }))\n if (bodyBuf && bodyBuf.byteLength) {\n this.client.send(JSON.stringify({ type: MSG.REQUEST_BODY, id, chunk: bytesToBase64(bodyBuf) }))\n }\n this.client.send(JSON.stringify({ type: MSG.REQUEST_END, id }))\n\n const timeout = new Promise((resolve) => setTimeout(() => {\n if (this.pending.has(id)) {\n this.pending.delete(id)\n resolve(new Response('Tunnel request timed out', { status: 504 }))\n }\n }, 30000))\n\n return Promise.race([headPromise, timeout])\n }\n}\n\nconst HOP_BY_HOP = new Set(['connection', 'keep-alive', 'transfer-encoding', 'upgrade', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'content-length'])\nconst sanitizeHeaders = (h) => {\n const out = {}\n for (const [k, v] of Object.entries(h)) {\n if (!HOP_BY_HOP.has(k.toLowerCase())) out[k] = v\n }\n return out\n}\n\nconst bytesToBase64 = (bytes) => {\n let s = ''\n for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i])\n return btoa(s)\n}\nconst base64ToBytes = (b64) => {\n const s = atob(b64)\n const out = new Uint8Array(s.length)\n for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i)\n return out\n}\n", "// Cloudflare Worker entry for tunnel.symbo.ls.\n// Routes:\n// - `tunnel.symbo.ls/_tunnel?subdomain=X` (WS upgrade) \u2192 TunnelRoom DO named X\n// - `<sub>.tunnel.symbo.ls/*` \u2192 TunnelRoom DO named <sub>\n// - `tunnel.symbo.ls/health` \u2192 JSON ok\n// - `tunnel.symbo.ls/*` \u2192 landing\n\nimport { randomSubdomain } from '../src/protocol.js'\nexport { TunnelRoom } from './tunnel-room.js'\n\nconst extractSubdomain = (host, root) => {\n if (!host) return null\n host = host.split(':')[0].toLowerCase()\n if (host === root) return null\n if (!host.endsWith('.' + root)) return null\n return host.slice(0, -('.' + root).length)\n}\n\nexport default {\n async fetch (request, env) {\n const url = new URL(request.url)\n const root = env.TUNNEL_ROOT_DOMAIN || 'tunnel.symbo.ls'\n const host = request.headers.get('host') || ''\n\n // Client registration WS upgrade.\n if (url.pathname === '/_tunnel' && request.headers.get('upgrade') === 'websocket') {\n if (env.TUNNEL_AUTH_TOKEN) {\n const token = url.searchParams.get('token') || request.headers.get('x-tunnel-token')\n if (token !== env.TUNNEL_AUTH_TOKEN) return new Response('unauthorized', { status: 401 })\n }\n const requested = url.searchParams.get('subdomain')\n const sub = requested && /^[a-z0-9-]{2,40}$/.test(requested) ? requested : randomSubdomain()\n const id = env.TUNNEL_ROOM.idFromName(sub)\n const stub = env.TUNNEL_ROOM.get(id)\n const rewritten = new Request(`https://internal/_register?subdomain=${sub}&root=${encodeURIComponent(root)}`, request)\n return stub.fetch(rewritten)\n }\n\n const sub = extractSubdomain(host, root)\n if (!sub) {\n if (url.pathname === '/health') {\n return new Response(JSON.stringify({ ok: true }), { headers: { 'content-type': 'application/json' } })\n }\n return new Response(\n `tunnel.symbo.ls \u2014 run \\`smbls tunnel <port>\\` to expose a local server.\\n`,\n { headers: { 'content-type': 'text/plain' } }\n )\n }\n\n const id = env.TUNNEL_ROOM.idFromName(sub)\n const stub = env.TUNNEL_ROOM.get(id)\n return stub.fetch(request)\n }\n}\n"],
|
|
6
|
+
"mappings": "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",
|
|
7
|
+
"names": ["sub", "id", "stub"]
|
|
8
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@symbo.ls/tunnel",
|
|
3
|
+
"version": "3.14.0",
|
|
4
|
+
"description": "Expose localhost through tunnel.symbo.ls",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/client.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/client.js",
|
|
9
|
+
"./server": "./src/server.js",
|
|
10
|
+
"./client": "./src/client.js"
|
|
11
|
+
},
|
|
12
|
+
"bin": {
|
|
13
|
+
"smbls-tunnel-server": "bin/server.js",
|
|
14
|
+
"smbls-tunnel": "bin/client.js"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"start": "node bin/server.js"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"ws": "^8.18.0"
|
|
21
|
+
},
|
|
22
|
+
"author": "Symbols",
|
|
23
|
+
"license": "CC-BY-NC-4.0",
|
|
24
|
+
"publishConfig": { "access": "public" }
|
|
25
|
+
}
|
package/src/client.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import WebSocket from 'ws'
|
|
2
|
+
import http from 'node:http'
|
|
3
|
+
import https from 'node:https'
|
|
4
|
+
import { MSG } from './protocol.js'
|
|
5
|
+
|
|
6
|
+
// Connect to a tunnel server and forward requests to a local HTTP server.
|
|
7
|
+
// Returns an EventEmitter-like handle with .close() and `onRegistered` callback.
|
|
8
|
+
|
|
9
|
+
export const createTunnelClient = (options = {}) => {
|
|
10
|
+
const {
|
|
11
|
+
host = process.env.TUNNEL_HOST || 'tunnel.symbo.ls',
|
|
12
|
+
port = 443,
|
|
13
|
+
secure = port === 443,
|
|
14
|
+
localPort,
|
|
15
|
+
localHost = '127.0.0.1',
|
|
16
|
+
subdomain = null,
|
|
17
|
+
token = process.env.TUNNEL_AUTH_TOKEN || null,
|
|
18
|
+
reconnect = true,
|
|
19
|
+
reconnectDelayMs = 2000,
|
|
20
|
+
onRegistered = () => {},
|
|
21
|
+
onError = (err) => console.error('[tunnel] error:', err.message || err),
|
|
22
|
+
onClose = () => {},
|
|
23
|
+
onRequest = () => {},
|
|
24
|
+
onResponse = () => {},
|
|
25
|
+
log = (...a) => console.log('[tunnel]', ...a)
|
|
26
|
+
} = options
|
|
27
|
+
|
|
28
|
+
if (!localPort) throw new Error('createTunnelClient: localPort is required')
|
|
29
|
+
|
|
30
|
+
let ws = null
|
|
31
|
+
let closed = false
|
|
32
|
+
const activeRequests = new Map() // reqId -> { req, chunks }
|
|
33
|
+
|
|
34
|
+
const buildUrl = () => {
|
|
35
|
+
const scheme = secure ? 'wss' : 'ws'
|
|
36
|
+
const params = new URLSearchParams()
|
|
37
|
+
if (subdomain) params.set('subdomain', subdomain)
|
|
38
|
+
const qs = params.toString()
|
|
39
|
+
return `${scheme}://${host}${port && port !== 80 && port !== 443 ? ':' + port : ''}/_tunnel${qs ? '?' + qs : ''}`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const send = (msg) => {
|
|
43
|
+
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const startLocalRequest = (msg) => {
|
|
47
|
+
const startedAt = Date.now()
|
|
48
|
+
onRequest({ id: msg.id, method: msg.method, url: msg.url, headers: msg.headers })
|
|
49
|
+
let bytes = 0
|
|
50
|
+
let status = 0
|
|
51
|
+
const localReq = http.request({
|
|
52
|
+
host: localHost,
|
|
53
|
+
port: localPort,
|
|
54
|
+
method: msg.method,
|
|
55
|
+
path: msg.url,
|
|
56
|
+
headers: { ...msg.headers, host: `${localHost}:${localPort}` }
|
|
57
|
+
}, (localRes) => {
|
|
58
|
+
status = localRes.statusCode
|
|
59
|
+
send({ type: MSG.RESPONSE, id: msg.id, status, headers: localRes.headers })
|
|
60
|
+
localRes.on('data', (chunk) => {
|
|
61
|
+
bytes += chunk.length
|
|
62
|
+
send({ type: MSG.RESPONSE_BODY, id: msg.id, chunk: chunk.toString('base64') })
|
|
63
|
+
})
|
|
64
|
+
localRes.on('end', () => {
|
|
65
|
+
send({ type: MSG.RESPONSE_END, id: msg.id })
|
|
66
|
+
activeRequests.delete(msg.id)
|
|
67
|
+
onResponse({ id: msg.id, method: msg.method, url: msg.url, status, bytes, durationMs: Date.now() - startedAt })
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
localReq.on('error', (err) => {
|
|
71
|
+
send({ type: MSG.RESPONSE, id: msg.id, status: 502, headers: { 'content-type': 'text/plain' } })
|
|
72
|
+
send({ type: MSG.RESPONSE_BODY, id: msg.id, chunk: Buffer.from(`Local server error: ${err.message}`).toString('base64') })
|
|
73
|
+
send({ type: MSG.RESPONSE_END, id: msg.id })
|
|
74
|
+
activeRequests.delete(msg.id)
|
|
75
|
+
onResponse({ id: msg.id, method: msg.method, url: msg.url, status: 502, bytes: 0, durationMs: Date.now() - startedAt, error: err.message })
|
|
76
|
+
})
|
|
77
|
+
activeRequests.set(msg.id, { localReq })
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const connect = () => {
|
|
81
|
+
const url = buildUrl()
|
|
82
|
+
log(`connecting to ${url}`)
|
|
83
|
+
ws = new WebSocket(url, {
|
|
84
|
+
headers: token ? { 'x-tunnel-token': token } : {}
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
let pingTimer = null
|
|
88
|
+
ws.on('open', () => {
|
|
89
|
+
log('connected')
|
|
90
|
+
pingTimer = setInterval(() => {
|
|
91
|
+
if (ws.readyState === WebSocket.OPEN) send({ type: MSG.PING, id: 'keepalive' })
|
|
92
|
+
}, 25000)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
ws.on('message', (raw) => {
|
|
96
|
+
let msg
|
|
97
|
+
try { msg = JSON.parse(raw.toString()) } catch { return }
|
|
98
|
+
|
|
99
|
+
if (msg.type === MSG.REGISTERED) {
|
|
100
|
+
log(`registered → ${msg.publicUrl}`)
|
|
101
|
+
onRegistered(msg)
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
if (msg.type === MSG.ERROR) {
|
|
105
|
+
onError(new Error(msg.message || 'tunnel error'))
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
if (msg.type === MSG.REQUEST) {
|
|
109
|
+
startLocalRequest(msg)
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
const entry = activeRequests.get(msg.id)
|
|
113
|
+
if (!entry) return
|
|
114
|
+
if (msg.type === MSG.REQUEST_BODY) {
|
|
115
|
+
if (msg.chunk) entry.localReq.write(Buffer.from(msg.chunk, 'base64'))
|
|
116
|
+
} else if (msg.type === MSG.REQUEST_END) {
|
|
117
|
+
entry.localReq.end()
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
ws.on('close', () => {
|
|
122
|
+
if (pingTimer) { clearInterval(pingTimer); pingTimer = null }
|
|
123
|
+
onClose()
|
|
124
|
+
if (closed || !reconnect) return
|
|
125
|
+
setTimeout(connect, reconnectDelayMs)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
// Surface the HTTP error body when the WS upgrade is rejected (4xx/5xx).
|
|
129
|
+
// Without this, the user only sees "Unexpected server response: 403" with
|
|
130
|
+
// no indication of what to fix (e.g. missing <owner>-- prefix).
|
|
131
|
+
ws.on('unexpected-response', async (req, res) => {
|
|
132
|
+
let body = ''
|
|
133
|
+
try {
|
|
134
|
+
for await (const chunk of res) body += chunk.toString('utf8')
|
|
135
|
+
} catch { /* ignore read errors — we already have the status */ }
|
|
136
|
+
onError(new Error(`HTTP ${res.statusCode}: ${body.slice(0, 480) || '(empty body)'}`))
|
|
137
|
+
if (pingTimer) { clearInterval(pingTimer); pingTimer = null }
|
|
138
|
+
if (!closed && reconnect) setTimeout(connect, reconnectDelayMs)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
ws.on('error', (err) => onError(err))
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
connect()
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
close: () => {
|
|
148
|
+
closed = true
|
|
149
|
+
if (ws) ws.close()
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
package/src/protocol.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export const MSG = {
|
|
2
|
+
REGISTER: 'register',
|
|
3
|
+
REGISTERED: 'registered',
|
|
4
|
+
ERROR: 'error',
|
|
5
|
+
REQUEST: 'request',
|
|
6
|
+
REQUEST_BODY: 'request_body',
|
|
7
|
+
REQUEST_END: 'request_end',
|
|
8
|
+
RESPONSE: 'response',
|
|
9
|
+
RESPONSE_BODY: 'response_body',
|
|
10
|
+
RESPONSE_END: 'response_end',
|
|
11
|
+
PING: 'ping',
|
|
12
|
+
PONG: 'pong'
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const randomId = () => Math.random().toString(36).slice(2) + Date.now().toString(36)
|
|
16
|
+
|
|
17
|
+
export const randomSubdomain = () => {
|
|
18
|
+
const adj = ['calm', 'bold', 'brisk', 'quick', 'bright', 'soft', 'warm', 'cool', 'keen', 'lush']
|
|
19
|
+
const noun = ['fox', 'owl', 'wolf', 'hawk', 'lark', 'wren', 'seal', 'deer', 'moth', 'cub']
|
|
20
|
+
const a = adj[Math.floor(Math.random() * adj.length)]
|
|
21
|
+
const n = noun[Math.floor(Math.random() * noun.length)]
|
|
22
|
+
const s = Math.random().toString(36).slice(2, 6)
|
|
23
|
+
return `${a}-${n}-${s}`
|
|
24
|
+
}
|
package/src/server.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import http from 'node:http'
|
|
2
|
+
import { WebSocketServer } from 'ws'
|
|
3
|
+
import { URL } from 'node:url'
|
|
4
|
+
import { MSG, randomId, randomSubdomain } from './protocol.js'
|
|
5
|
+
|
|
6
|
+
// Server hosted at tunnel.symbo.ls.
|
|
7
|
+
// - HTTP requests to `<sub>.tunnel.symbo.ls` are proxied over a WebSocket
|
|
8
|
+
// connection held open by the CLI client that owns that subdomain.
|
|
9
|
+
// - Clients register by opening `wss://tunnel.symbo.ls/_tunnel?subdomain=<sub>`.
|
|
10
|
+
|
|
11
|
+
export const createTunnelServer = (options = {}) => {
|
|
12
|
+
const {
|
|
13
|
+
rootDomain = process.env.TUNNEL_ROOT_DOMAIN || 'tunnel.symbo.ls',
|
|
14
|
+
authToken = process.env.TUNNEL_AUTH_TOKEN || null,
|
|
15
|
+
requestTimeoutMs = 30000
|
|
16
|
+
} = options
|
|
17
|
+
|
|
18
|
+
const tunnels = new Map() // subdomain -> { ws, pending: Map<reqId, { res, bodyChunks }> }
|
|
19
|
+
|
|
20
|
+
const send = (ws, msg) => {
|
|
21
|
+
if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(msg))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const extractSubdomain = (hostHeader) => {
|
|
25
|
+
if (!hostHeader) return null
|
|
26
|
+
const host = hostHeader.split(':')[0].toLowerCase()
|
|
27
|
+
if (host === rootDomain) return null
|
|
28
|
+
if (!host.endsWith('.' + rootDomain)) return null
|
|
29
|
+
return host.slice(0, -('.' + rootDomain).length)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const httpServer = http.createServer((req, res) => {
|
|
33
|
+
const sub = extractSubdomain(req.headers.host)
|
|
34
|
+
|
|
35
|
+
if (!sub) {
|
|
36
|
+
if (req.url === '/health') {
|
|
37
|
+
res.writeHead(200, { 'content-type': 'application/json' })
|
|
38
|
+
res.end(JSON.stringify({ ok: true, tunnels: tunnels.size }))
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
res.writeHead(200, { 'content-type': 'text/plain' })
|
|
42
|
+
res.end(`tunnel.symbo.ls — point a CLI client at this host to expose localhost.\nActive tunnels: ${tunnels.size}\n`)
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const tunnel = tunnels.get(sub)
|
|
47
|
+
if (!tunnel) {
|
|
48
|
+
res.writeHead(502, { 'content-type': 'text/plain' })
|
|
49
|
+
res.end(`No tunnel registered for ${sub}.${rootDomain}`)
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const reqId = randomId()
|
|
54
|
+
const pending = { res, timeout: null }
|
|
55
|
+
tunnel.pending.set(reqId, pending)
|
|
56
|
+
|
|
57
|
+
pending.timeout = setTimeout(() => {
|
|
58
|
+
if (!tunnel.pending.has(reqId)) return
|
|
59
|
+
tunnel.pending.delete(reqId)
|
|
60
|
+
if (!res.headersSent) {
|
|
61
|
+
res.writeHead(504, { 'content-type': 'text/plain' })
|
|
62
|
+
res.end('Tunnel request timed out')
|
|
63
|
+
} else {
|
|
64
|
+
res.end()
|
|
65
|
+
}
|
|
66
|
+
}, requestTimeoutMs)
|
|
67
|
+
|
|
68
|
+
send(tunnel.ws, {
|
|
69
|
+
type: MSG.REQUEST,
|
|
70
|
+
id: reqId,
|
|
71
|
+
method: req.method,
|
|
72
|
+
url: req.url,
|
|
73
|
+
headers: req.headers
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
req.on('data', (chunk) => {
|
|
77
|
+
send(tunnel.ws, { type: MSG.REQUEST_BODY, id: reqId, chunk: chunk.toString('base64') })
|
|
78
|
+
})
|
|
79
|
+
req.on('end', () => send(tunnel.ws, { type: MSG.REQUEST_END, id: reqId }))
|
|
80
|
+
req.on('error', () => {
|
|
81
|
+
tunnel.pending.delete(reqId)
|
|
82
|
+
clearTimeout(pending.timeout)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
res.on('close', () => {
|
|
86
|
+
tunnel.pending.delete(reqId)
|
|
87
|
+
clearTimeout(pending.timeout)
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
const wss = new WebSocketServer({ noServer: true })
|
|
92
|
+
|
|
93
|
+
httpServer.on('upgrade', (req, socket, head) => {
|
|
94
|
+
const url = new URL(req.url, `http://${req.headers.host}`)
|
|
95
|
+
if (url.pathname !== '/_tunnel') {
|
|
96
|
+
// Proxy websocket upgrades to subdomain owners.
|
|
97
|
+
const sub = extractSubdomain(req.headers.host)
|
|
98
|
+
const tunnel = sub && tunnels.get(sub)
|
|
99
|
+
if (!tunnel) {
|
|
100
|
+
socket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n')
|
|
101
|
+
socket.destroy()
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
// Minimal: websocket-through-tunnel not implemented; reject for now.
|
|
105
|
+
socket.write('HTTP/1.1 501 Not Implemented\r\n\r\n')
|
|
106
|
+
socket.destroy()
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (authToken) {
|
|
111
|
+
const token = url.searchParams.get('token') || req.headers['x-tunnel-token']
|
|
112
|
+
if (token !== authToken) {
|
|
113
|
+
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n')
|
|
114
|
+
socket.destroy()
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
120
|
+
const requested = url.searchParams.get('subdomain')
|
|
121
|
+
let sub = requested && /^[a-z0-9-]{2,40}$/.test(requested) ? requested : randomSubdomain()
|
|
122
|
+
if (tunnels.has(sub)) sub = `${sub}-${Math.random().toString(36).slice(2, 5)}`
|
|
123
|
+
|
|
124
|
+
const tunnel = { ws, pending: new Map() }
|
|
125
|
+
tunnels.set(sub, tunnel)
|
|
126
|
+
|
|
127
|
+
send(ws, {
|
|
128
|
+
type: MSG.REGISTERED,
|
|
129
|
+
subdomain: sub,
|
|
130
|
+
publicUrl: `https://${sub}.${rootDomain}`
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
ws.on('message', (raw) => {
|
|
134
|
+
let msg
|
|
135
|
+
try { msg = JSON.parse(raw.toString()) } catch { return }
|
|
136
|
+
const pending = msg.id && tunnel.pending.get(msg.id)
|
|
137
|
+
if (!pending) return
|
|
138
|
+
const { res } = pending
|
|
139
|
+
|
|
140
|
+
if (msg.type === MSG.RESPONSE) {
|
|
141
|
+
if (!res.headersSent) res.writeHead(msg.status || 200, msg.headers || {})
|
|
142
|
+
} else if (msg.type === MSG.RESPONSE_BODY) {
|
|
143
|
+
if (msg.chunk) res.write(Buffer.from(msg.chunk, 'base64'))
|
|
144
|
+
} else if (msg.type === MSG.RESPONSE_END) {
|
|
145
|
+
clearTimeout(pending.timeout)
|
|
146
|
+
tunnel.pending.delete(msg.id)
|
|
147
|
+
res.end()
|
|
148
|
+
}
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
const cleanup = () => {
|
|
152
|
+
tunnels.delete(sub)
|
|
153
|
+
for (const [, p] of tunnel.pending) {
|
|
154
|
+
clearTimeout(p.timeout)
|
|
155
|
+
if (!p.res.headersSent) {
|
|
156
|
+
p.res.writeHead(502, { 'content-type': 'text/plain' })
|
|
157
|
+
p.res.end('Tunnel closed')
|
|
158
|
+
} else p.res.end()
|
|
159
|
+
}
|
|
160
|
+
tunnel.pending.clear()
|
|
161
|
+
}
|
|
162
|
+
ws.on('close', cleanup)
|
|
163
|
+
ws.on('error', cleanup)
|
|
164
|
+
})
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
return { httpServer, tunnels, rootDomain }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Allow `node src/server.js` for local testing.
|
|
171
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
172
|
+
const port = parseInt(process.env.PORT || '3000', 10)
|
|
173
|
+
const { httpServer, rootDomain } = createTunnelServer()
|
|
174
|
+
httpServer.listen(port, () => {
|
|
175
|
+
console.log(`tunnel server listening on :${port} (root: ${rootDomain})`)
|
|
176
|
+
})
|
|
177
|
+
}
|
package/worker/index.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Cloudflare Worker entry for tunnel.symbo.ls.
|
|
2
|
+
// Routes:
|
|
3
|
+
// - `tunnel.symbo.ls/_tunnel?subdomain=X` (WS upgrade) → TunnelRoom DO named X
|
|
4
|
+
// - `<sub>.tunnel.symbo.ls/*` → TunnelRoom DO named <sub>
|
|
5
|
+
// - `tunnel.symbo.ls/health` → JSON ok
|
|
6
|
+
// - `tunnel.symbo.ls/*` → landing
|
|
7
|
+
|
|
8
|
+
import { randomSubdomain } from '../src/protocol.js'
|
|
9
|
+
export { TunnelRoom } from './tunnel-room.js'
|
|
10
|
+
|
|
11
|
+
// Validate a Symbols auth token via the Symbols API's /core/auth/me endpoint.
|
|
12
|
+
// Returns an array of lowercase owner slugs the user may claim, or null if
|
|
13
|
+
// the token is invalid. Results are cached in a Map (per-isolate) for 60s to
|
|
14
|
+
// avoid a round-trip per reconnect.
|
|
15
|
+
const authCache = new Map()
|
|
16
|
+
const AUTH_TTL_MS = 60_000
|
|
17
|
+
|
|
18
|
+
const authenticate = async (token, env) => {
|
|
19
|
+
const cached = authCache.get(token)
|
|
20
|
+
if (cached && cached.expires > Date.now()) return cached.owners
|
|
21
|
+
|
|
22
|
+
const apiBase = (env.SYMBOLS_API_URL || 'https://api.symbols.app').replace(/\/+$/, '')
|
|
23
|
+
const res = await fetch(`${apiBase}/core/auth/me`, {
|
|
24
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
25
|
+
cf: { cacheTtl: 0 }
|
|
26
|
+
}).catch(() => null)
|
|
27
|
+
if (!res || !res.ok) return null
|
|
28
|
+
|
|
29
|
+
const data = await res.json().catch(() => null)
|
|
30
|
+
const user = data?.data?.user || data?.user || data?.data || data
|
|
31
|
+
if (!user) return null
|
|
32
|
+
|
|
33
|
+
const owners = new Set()
|
|
34
|
+
const add = (v) => { if (typeof v === 'string' && v.trim()) owners.add(v.toLowerCase()) }
|
|
35
|
+
add(user.username); add(user.handle); add(user.slug); add(user.ownerSlug); add(user.owner)
|
|
36
|
+
for (const o of user.owners || []) add(typeof o === 'string' ? o : (o?.slug || o?.key || o?.username))
|
|
37
|
+
for (const t of user.teams || []) add(typeof t === 'string' ? t : (t?.slug || t?.key))
|
|
38
|
+
|
|
39
|
+
if (!owners.size) return null
|
|
40
|
+
const list = [...owners]
|
|
41
|
+
authCache.set(token, { owners: list, expires: Date.now() + AUTH_TTL_MS })
|
|
42
|
+
return list
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const extractSubdomain = (host, root) => {
|
|
46
|
+
if (!host) return null
|
|
47
|
+
host = host.split(':')[0].toLowerCase()
|
|
48
|
+
if (host === root) return null
|
|
49
|
+
if (!host.endsWith('.' + root)) return null
|
|
50
|
+
return host.slice(0, -('.' + root).length)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export default {
|
|
54
|
+
async fetch (request, env) {
|
|
55
|
+
const url = new URL(request.url)
|
|
56
|
+
const root = env.TUNNEL_ROOT_DOMAIN || 'tunnel.symbo.ls'
|
|
57
|
+
// In production, CF routes requests by Host header. For local `wrangler dev`
|
|
58
|
+
// Miniflare normalizes Host to `localhost`, so allow an `x-forwarded-host`
|
|
59
|
+
// override to test subdomain routing.
|
|
60
|
+
// In production, CF routes requests by real Host header. `x-forwarded-host`
|
|
61
|
+
// is honored for reverse-proxy / local-dev scenarios (Miniflare rewrites Host).
|
|
62
|
+
const host = (request.headers.get('x-forwarded-host') || request.headers.get('host') || url.hostname || '').toLowerCase()
|
|
63
|
+
|
|
64
|
+
// Client registration WS upgrade — authenticate and enforce owner scoping.
|
|
65
|
+
if (url.pathname === '/_tunnel' && request.headers.get('upgrade') === 'websocket') {
|
|
66
|
+
const token = url.searchParams.get('token') || request.headers.get('x-tunnel-token')
|
|
67
|
+
if (!token) return new Response('authentication required', { status: 401 })
|
|
68
|
+
|
|
69
|
+
const allowedOwners = await authenticate(token, env)
|
|
70
|
+
if (!allowedOwners) return new Response('invalid or expired token', { status: 401 })
|
|
71
|
+
|
|
72
|
+
const requested = (url.searchParams.get('subdomain') || '').toLowerCase()
|
|
73
|
+
const requestedOwner = requested.includes('--') ? requested.split('--', 1)[0] : null
|
|
74
|
+
|
|
75
|
+
let sub
|
|
76
|
+
if (requested) {
|
|
77
|
+
if (!/^[a-z0-9-]{2,63}$/.test(requested)) return new Response('invalid subdomain', { status: 400 })
|
|
78
|
+
if (!requestedOwner || !allowedOwners.includes(requestedOwner)) {
|
|
79
|
+
return new Response(
|
|
80
|
+
`forbidden: subdomain must start with one of your owner scopes (${allowedOwners.join(', ')}) followed by '--'`,
|
|
81
|
+
{ status: 403 }
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
sub = requested
|
|
85
|
+
} else {
|
|
86
|
+
sub = `${allowedOwners[0]}--${randomSubdomain()}`
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const id = env.TUNNEL_ROOM.idFromName(sub)
|
|
90
|
+
const stub = env.TUNNEL_ROOM.get(id)
|
|
91
|
+
const rewritten = new Request(`https://internal/_register?subdomain=${sub}&root=${encodeURIComponent(root)}`, request)
|
|
92
|
+
return stub.fetch(rewritten)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const sub = extractSubdomain(host, root)
|
|
96
|
+
if (!sub) {
|
|
97
|
+
if (url.pathname === '/health') {
|
|
98
|
+
return new Response(JSON.stringify({ ok: true }), { headers: { 'content-type': 'application/json' } })
|
|
99
|
+
}
|
|
100
|
+
return new Response(
|
|
101
|
+
`tunnel.symbo.ls — run \`smbls tunnel <port>\` to expose a local server.\n`,
|
|
102
|
+
{ headers: { 'content-type': 'text/plain' } }
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const id = env.TUNNEL_ROOM.idFromName(sub)
|
|
107
|
+
const stub = env.TUNNEL_ROOM.get(id)
|
|
108
|
+
return stub.fetch(request)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { MSG, randomId } from '../src/protocol.js'
|
|
2
|
+
|
|
3
|
+
// One Durable Object per subdomain. Holds the client WebSocket connection
|
|
4
|
+
// and bridges incoming HTTP requests over JSON frames (see ../src/protocol.js).
|
|
5
|
+
//
|
|
6
|
+
// Uses the Hibernation API (`state.acceptWebSocket`) so the DO can be evicted
|
|
7
|
+
// while the CLI client's WS stays open. `this.pending` lives only while the
|
|
8
|
+
// DO is hot; if it wakes for a request and finds no client (`getWebSockets()`
|
|
9
|
+
// empty), we return 502.
|
|
10
|
+
|
|
11
|
+
export class TunnelRoom {
|
|
12
|
+
constructor (state, env) {
|
|
13
|
+
this.state = state
|
|
14
|
+
this.env = env
|
|
15
|
+
this.pending = new Map() // reqId -> { controller, resolveHead, headSent }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async fetch (request) {
|
|
19
|
+
const url = new URL(request.url)
|
|
20
|
+
if (url.pathname === '/_register') return this.register(request, url)
|
|
21
|
+
|
|
22
|
+
const [ws] = this.state.getWebSockets('client')
|
|
23
|
+
if (!ws) {
|
|
24
|
+
const sub = (await this.state.storage.get('subdomain')) || 'this subdomain'
|
|
25
|
+
return new Response(`No tunnel registered for ${sub}.`, { status: 502 })
|
|
26
|
+
}
|
|
27
|
+
return this.proxyRequest(request, ws)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async register (request, url) {
|
|
31
|
+
if (request.headers.get('upgrade') !== 'websocket') {
|
|
32
|
+
return new Response('expected websocket', { status: 426 })
|
|
33
|
+
}
|
|
34
|
+
const subdomain = url.searchParams.get('subdomain')
|
|
35
|
+
const root = url.searchParams.get('root') || 'tunnel.symbo.ls'
|
|
36
|
+
await this.state.storage.put('subdomain', subdomain)
|
|
37
|
+
await this.state.storage.put('root', root)
|
|
38
|
+
|
|
39
|
+
// Close any previously attached client — a new registration wins.
|
|
40
|
+
for (const existing of this.state.getWebSockets('client')) {
|
|
41
|
+
try { existing.close(1000, 'replaced') } catch {}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const pair = new WebSocketPair()
|
|
45
|
+
const [clientSide, serverSide] = [pair[0], pair[1]]
|
|
46
|
+
this.state.acceptWebSocket(serverSide, ['client'])
|
|
47
|
+
|
|
48
|
+
serverSide.send(JSON.stringify({
|
|
49
|
+
type: MSG.REGISTERED,
|
|
50
|
+
subdomain,
|
|
51
|
+
publicUrl: `https://${subdomain}.${root}`
|
|
52
|
+
}))
|
|
53
|
+
|
|
54
|
+
return new Response(null, { status: 101, webSocket: clientSide })
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Hibernation handlers — called by the runtime even after the DO was evicted.
|
|
58
|
+
async webSocketMessage (ws, data) {
|
|
59
|
+
let msg
|
|
60
|
+
try { msg = JSON.parse(typeof data === 'string' ? data : new TextDecoder().decode(data)) } catch { return }
|
|
61
|
+
if (msg.type === MSG.PING) {
|
|
62
|
+
ws.send(JSON.stringify({ type: MSG.PONG, id: msg.id }))
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
const entry = msg.id && this.pending.get(msg.id)
|
|
66
|
+
if (!entry) return
|
|
67
|
+
if (msg.type === MSG.RESPONSE) {
|
|
68
|
+
const stream = new ReadableStream({ start: (c) => { entry.controller = c } })
|
|
69
|
+
entry.headSent = true
|
|
70
|
+
entry.resolveHead(new Response(stream, {
|
|
71
|
+
status: msg.status || 200,
|
|
72
|
+
headers: sanitizeHeaders(msg.headers || {})
|
|
73
|
+
}))
|
|
74
|
+
} else if (msg.type === MSG.RESPONSE_BODY) {
|
|
75
|
+
if (msg.chunk && entry.controller) entry.controller.enqueue(base64ToBytes(msg.chunk))
|
|
76
|
+
} else if (msg.type === MSG.RESPONSE_END) {
|
|
77
|
+
try { entry.controller && entry.controller.close() } catch {}
|
|
78
|
+
this.pending.delete(msg.id)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async webSocketClose (_ws, _code, _reason, _clean) {
|
|
83
|
+
this.failAllPending('tunnel closed')
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async webSocketError (_ws, _err) {
|
|
87
|
+
this.failAllPending('tunnel error')
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
failAllPending (reason) {
|
|
91
|
+
for (const [, p] of this.pending) {
|
|
92
|
+
if (!p.headSent) p.resolveHead(new Response(reason, { status: 502 }))
|
|
93
|
+
else { try { p.controller.error(new Error(reason)) } catch {} }
|
|
94
|
+
}
|
|
95
|
+
this.pending.clear()
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async proxyRequest (request, ws) {
|
|
99
|
+
const id = randomId()
|
|
100
|
+
const url = new URL(request.url)
|
|
101
|
+
const headers = {}
|
|
102
|
+
for (const [k, v] of request.headers) headers[k] = v
|
|
103
|
+
|
|
104
|
+
const bodyBuf = ['GET', 'HEAD'].includes(request.method) ? null : new Uint8Array(await request.arrayBuffer())
|
|
105
|
+
|
|
106
|
+
const entry = { controller: null, headSent: false }
|
|
107
|
+
const headPromise = new Promise((resolve) => { entry.resolveHead = resolve })
|
|
108
|
+
this.pending.set(id, entry)
|
|
109
|
+
|
|
110
|
+
ws.send(JSON.stringify({
|
|
111
|
+
type: MSG.REQUEST, id,
|
|
112
|
+
method: request.method,
|
|
113
|
+
url: url.pathname + url.search,
|
|
114
|
+
headers
|
|
115
|
+
}))
|
|
116
|
+
if (bodyBuf && bodyBuf.byteLength) {
|
|
117
|
+
ws.send(JSON.stringify({ type: MSG.REQUEST_BODY, id, chunk: bytesToBase64(bodyBuf) }))
|
|
118
|
+
}
|
|
119
|
+
ws.send(JSON.stringify({ type: MSG.REQUEST_END, id }))
|
|
120
|
+
|
|
121
|
+
const timeout = new Promise((resolve) => setTimeout(() => {
|
|
122
|
+
if (this.pending.has(id)) {
|
|
123
|
+
this.pending.delete(id)
|
|
124
|
+
resolve(new Response('Tunnel request timed out', { status: 504 }))
|
|
125
|
+
}
|
|
126
|
+
}, 30000))
|
|
127
|
+
|
|
128
|
+
return Promise.race([headPromise, timeout])
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const HOP_BY_HOP = new Set(['connection', 'keep-alive', 'transfer-encoding', 'upgrade', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'content-length'])
|
|
133
|
+
const sanitizeHeaders = (h) => {
|
|
134
|
+
const out = {}
|
|
135
|
+
for (const [k, v] of Object.entries(h)) {
|
|
136
|
+
if (!HOP_BY_HOP.has(k.toLowerCase())) out[k] = v
|
|
137
|
+
}
|
|
138
|
+
return out
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const bytesToBase64 = (bytes) => {
|
|
142
|
+
let s = ''
|
|
143
|
+
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i])
|
|
144
|
+
return btoa(s)
|
|
145
|
+
}
|
|
146
|
+
const base64ToBytes = (b64) => {
|
|
147
|
+
const s = atob(b64)
|
|
148
|
+
const out = new Uint8Array(s.length)
|
|
149
|
+
for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i)
|
|
150
|
+
return out
|
|
151
|
+
}
|
package/wrangler.toml
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
name = "symbols-tunnel"
|
|
2
|
+
main = "worker/index.js"
|
|
3
|
+
compatibility_date = "2025-01-01"
|
|
4
|
+
account_id = "98091ed46ea209bb648b108950bd78e7"
|
|
5
|
+
|
|
6
|
+
[vars]
|
|
7
|
+
TUNNEL_ROOT_DOMAIN = "tunnel.symbo.ls"
|
|
8
|
+
# Tokens issued by next.api.symbols.app verify here.
|
|
9
|
+
# For a main-channel deployment override this per-env (see [env.main] below).
|
|
10
|
+
SYMBOLS_API_URL = "https://next.api.symbols.app"
|
|
11
|
+
|
|
12
|
+
# Main-channel deployment: wrangler deploy --env main
|
|
13
|
+
[env.main.vars]
|
|
14
|
+
TUNNEL_ROOT_DOMAIN = "tunnel.symbo.ls"
|
|
15
|
+
SYMBOLS_API_URL = "https://api.symbols.app"
|
|
16
|
+
|
|
17
|
+
# Next-channel deployment (default above): wrangler deploy
|
|
18
|
+
[env.next.vars]
|
|
19
|
+
TUNNEL_ROOT_DOMAIN = "tunnel.symbo.ls"
|
|
20
|
+
SYMBOLS_API_URL = "https://next.api.symbols.app"
|
|
21
|
+
|
|
22
|
+
[[durable_objects.bindings]]
|
|
23
|
+
name = "TUNNEL_ROOM"
|
|
24
|
+
class_name = "TunnelRoom"
|
|
25
|
+
|
|
26
|
+
[[migrations]]
|
|
27
|
+
tag = "v1"
|
|
28
|
+
new_sqlite_classes = ["TunnelRoom"]
|
|
29
|
+
|
|
30
|
+
# Attach once the DNS + zone are in place:
|
|
31
|
+
# wrangler deploy
|
|
32
|
+
# Routes require *.tunnel.symbo.ls and tunnel.symbo.ls to be proxied through
|
|
33
|
+
# the Cloudflare zone for symbo.ls.
|
|
34
|
+
[[routes]]
|
|
35
|
+
pattern = "tunnel.symbo.ls"
|
|
36
|
+
custom_domain = true
|
|
37
|
+
|
|
38
|
+
# Explicit apex path route — more specific than `*.symbo.ls/*` (mermaid) so
|
|
39
|
+
# tunnel paths like `/_tunnel`, `/health` are not swallowed by other workers.
|
|
40
|
+
[[routes]]
|
|
41
|
+
pattern = "tunnel.symbo.ls/*"
|
|
42
|
+
zone_name = "symbo.ls"
|
|
43
|
+
|
|
44
|
+
# Wildcard subdomains require a wildcard DNS record (`*.tunnel` → proxied)
|
|
45
|
+
# in the Cloudflare zone. Once present, this route activates for every
|
|
46
|
+
# `<sub>.tunnel.symbo.ls`.
|
|
47
|
+
[[routes]]
|
|
48
|
+
pattern = "*.tunnel.symbo.ls/*"
|
|
49
|
+
zone_name = "symbo.ls"
|