bolthole 0.0.0-stage → 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/README.md +142 -2
- package/package.json +35 -4
- package/src/agent.js +161 -0
- package/src/bytes.js +93 -0
- package/src/client.js +167 -0
- package/src/codes.js +63 -0
- package/src/index.js +9 -0
- package/src/kinds.js +193 -0
- package/src/mux.js +394 -0
- package/src/noise.js +223 -0
- package/src/relay.js +287 -0
package/README.md
CHANGED
|
@@ -1,3 +1,143 @@
|
|
|
1
|
-
#
|
|
1
|
+
# bolthole (JavaScript)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Reach any service from anywhere, through a relay that can't read your traffic.**
|
|
4
|
+
|
|
5
|
+
JavaScript SDK for [Bolthole](https://github.com/abcdlsj/bolthole). Runs in
|
|
6
|
+
browsers, Node 22+, Deno, Bun and Cloudflare Workers. Plain ES modules on
|
|
7
|
+
WebCrypto — no build step, no dependencies.
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
your service ── agent ──outbound WSS──▶ relay ◀──HTTPS/WSS── client (phone, browser, server)
|
|
11
|
+
└──────────── end-to-end encrypted (Noise) ───────────┘
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
The agent dials **out** to the relay and keeps the connection open. Clients go
|
|
15
|
+
through the relay to the agent. Both ends derive keys from a 256-bit code that
|
|
16
|
+
travels in the URL fragment, so the relay only ever forwards ciphertext.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install bolthole
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Need a relay? Deploy one to Cloudflare Workers for free, or run the Go binary:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npx wrangler deploy # see github.com/abcdlsj/bolthole
|
|
28
|
+
bolthole serve --addr :8787 # or a relay on your own box
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Client
|
|
32
|
+
|
|
33
|
+
```js
|
|
34
|
+
import { Client } from 'bolthole';
|
|
35
|
+
|
|
36
|
+
const c = new Client('https://relay.example.com/#k=6Xo2…');
|
|
37
|
+
|
|
38
|
+
const res = await c.fetch('/api/status', { method: 'POST', body: 'x' });
|
|
39
|
+
const ws = await c.websocket('/live'); // send() / read() / close()
|
|
40
|
+
const sock = await c.tcp(); // raw byte stream
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`c.fetch` and `c.websocket` take the same arguments as the globals; paths are
|
|
44
|
+
resolved against the agent's address, not the relay's.
|
|
45
|
+
|
|
46
|
+
| Method | |
|
|
47
|
+
| --- | --- |
|
|
48
|
+
| `fetch(input, init)` | HTTP through the agent, response streamed back |
|
|
49
|
+
| `websocket(path, protocols)` | WebSocket over the tunnel: `send()`, `read()`, `close()`, `protocol` |
|
|
50
|
+
| `tcp(target)` | Raw stream to a target the agent allows |
|
|
51
|
+
| `open(kind, meta)` | Any stream kind, including ones you define; `st.acceptMeta` holds the agent's reply |
|
|
52
|
+
| `session()` | The live encrypted session, dialing if needed |
|
|
53
|
+
| `close()` | Drop the connection |
|
|
54
|
+
|
|
55
|
+
Options: `{ mode: 'auto' | 'relay' | 'direct', direct: [url], relayDelayMs,
|
|
56
|
+
directTimeoutMs, WebSocket }`. In the default `auto` mode the client races
|
|
57
|
+
direct addresses against the relay and settles on whichever answers first.
|
|
58
|
+
|
|
59
|
+
## Agent
|
|
60
|
+
|
|
61
|
+
```js
|
|
62
|
+
import { Agent } from 'bolthole';
|
|
63
|
+
|
|
64
|
+
const agent = new Agent({
|
|
65
|
+
relay: 'https://relay.example.com',
|
|
66
|
+
// code: '…', // persistent 43-char code; random if omitted
|
|
67
|
+
fetch: (req) => new Response('hi'), // your service
|
|
68
|
+
});
|
|
69
|
+
agent.run(); // reconnects forever
|
|
70
|
+
console.log(agent.connString); // send this to clients — it is a password
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Options: `{ relay, code, fetch, websocket, kinds, public, direct, auth, name,
|
|
74
|
+
onStatus, onSession, WebSocket }`. `websocket` handles `ws` streams,
|
|
75
|
+
`kinds` maps a custom kind name to `(stream, info) => …`.
|
|
76
|
+
|
|
77
|
+
`fetch` is any fetch-style handler, so an existing `Request → Response`
|
|
78
|
+
function works unchanged. Set `public: true` to also serve plaintext HTTP
|
|
79
|
+
through the relay for things meant to be public (webhooks, a landing page) —
|
|
80
|
+
in that mode the relay can see and alter that traffic. `auth` carries a relay
|
|
81
|
+
registration key or JWT; `onStatus` reports `{connected, error, retryMs}`.
|
|
82
|
+
|
|
83
|
+
Shorthands: `connect(connString, opts)` and `serve(opts)`.
|
|
84
|
+
|
|
85
|
+
## Custom stream kinds
|
|
86
|
+
|
|
87
|
+
HTTP, WebSocket and TCP are just stream kinds. Define your own when you want
|
|
88
|
+
your own protocol inside the encrypted channel:
|
|
89
|
+
|
|
90
|
+
```js
|
|
91
|
+
// Agent side: register a handler per kind.
|
|
92
|
+
new Agent({
|
|
93
|
+
relay: 'https://relay.example.com',
|
|
94
|
+
fetch: handler,
|
|
95
|
+
kinds: {
|
|
96
|
+
'myapp.sync': async (st, info) => {
|
|
97
|
+
await st.accept({ ok: true }); // send meta back to the caller
|
|
98
|
+
for await (const chunk of st.readable()) { /* … */ }
|
|
99
|
+
await st.write(reply); // backpressure-aware
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
}).run();
|
|
103
|
+
|
|
104
|
+
// Client side: open that kind.
|
|
105
|
+
const st = await c.open('myapp.sync', { room: 'dev' });
|
|
106
|
+
console.log(st.acceptMeta); // { ok: true }
|
|
107
|
+
await st.write(bytes);
|
|
108
|
+
await st.closeWrite();
|
|
109
|
+
for await (const chunk of st.readable()) { /* … */ }
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
A stream is a byte pipe with flow control: `write`, `read`, `readable()`
|
|
113
|
+
(a web `ReadableStream`), `pipeFrom`, `closeWrite`, `reset(code)`.
|
|
114
|
+
|
|
115
|
+
Stream kinds are documented in the
|
|
116
|
+
[protocol spec](https://github.com/abcdlsj/bolthole/blob/main/spec/PROTOCOL.md).
|
|
117
|
+
|
|
118
|
+
## Relay
|
|
119
|
+
|
|
120
|
+
`bolthole/relay` is the relay core, so you can host one without the Go binary:
|
|
121
|
+
|
|
122
|
+
```js
|
|
123
|
+
import { RelayHub } from 'bolthole/relay';
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The Workers edition lives in the main repository under `cloudflare/`.
|
|
127
|
+
|
|
128
|
+
## What this package does not hide
|
|
129
|
+
|
|
130
|
+
The relay sees timing, sizes, and which token hash is talked to. Put it behind
|
|
131
|
+
TLS. A page served through the relay could be altered by a malicious relay
|
|
132
|
+
before it runs — for real deployments, host the client page yourself.
|
|
133
|
+
|
|
134
|
+
## Credits
|
|
135
|
+
|
|
136
|
+
The approach came from [alma-relay](https://github.com/yetone/alma-relay):
|
|
137
|
+
an outbound-only agent connection, a relay blind to its traffic, and a Workers
|
|
138
|
+
deployment that costs nothing while idle. Bolthole generalizes it into a
|
|
139
|
+
protocol and SDK for any service.
|
|
140
|
+
|
|
141
|
+
## License
|
|
142
|
+
|
|
143
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bolthole",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Reach any service from anywhere through a relay that can't read your traffic. Browser, Node, Deno, Bun and Workers.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "abcdlsj",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/abcdlsj/bolthole.git",
|
|
11
|
+
"directory": "js"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/abcdlsj/bolthole/tree/main/js#readme",
|
|
14
|
+
"bugs": "https://github.com/abcdlsj/bolthole/issues",
|
|
15
|
+
"keywords": [
|
|
16
|
+
"relay",
|
|
17
|
+
"tunnel",
|
|
18
|
+
"reverse-proxy",
|
|
19
|
+
"end-to-end-encryption",
|
|
20
|
+
"noise-protocol",
|
|
21
|
+
"websocket",
|
|
22
|
+
"cloudflare-workers",
|
|
23
|
+
"nat-traversal"
|
|
24
|
+
],
|
|
25
|
+
"exports": {
|
|
26
|
+
".": "./src/index.js",
|
|
27
|
+
"./relay": "./src/relay.js"
|
|
28
|
+
},
|
|
29
|
+
"files": ["src"],
|
|
30
|
+
"engines": { "node": ">=22" },
|
|
31
|
+
"scripts": {
|
|
32
|
+
"test": "node --test test/*.test.js"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"ws": "^8.18.0"
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/agent.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// Agent: expose a fetch-style handler (and optional WebSocket / custom
|
|
2
|
+
// stream handlers) through a relay, end-to-end encrypted. Runs anywhere
|
|
3
|
+
// with WebSocket + WebCrypto: Node 22+, Deno, Bun.
|
|
4
|
+
import { derive, formatConnString, newCode } from './codes.js';
|
|
5
|
+
import { agentHandshake, PROTO_MUX, MAX_RECORD } from './noise.js';
|
|
6
|
+
import { Session } from './mux.js';
|
|
7
|
+
import { wsConn, serveHttp, WsStream } from './kinds.js';
|
|
8
|
+
import { enc, dec } from './bytes.js';
|
|
9
|
+
|
|
10
|
+
const E2E_PATH = '/.bolthole/e2e';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @typedef {object} AgentOptions
|
|
14
|
+
* @property {string} relay relay base URL
|
|
15
|
+
* @property {string} [code] connect code (generated if omitted)
|
|
16
|
+
* @property {(req: Request, info: {encrypted: boolean, session: Session}) => Response|Promise<Response>} [fetch] handler for `http` streams
|
|
17
|
+
* @property {(ws: WsStream, meta: object) => void} [websocket] handler for `ws` streams
|
|
18
|
+
* @property {Record<string, (stream: import('./mux.js').Stream, info: object) => void>} [kinds] custom stream kinds
|
|
19
|
+
* @property {(session: Session, hello: object) => void} [onSession] each connected client
|
|
20
|
+
* @property {string} [name]
|
|
21
|
+
* @property {string} [auth] registration key or JWT
|
|
22
|
+
* @property {boolean} [public] also serve plaintext streams from the relay
|
|
23
|
+
* @property {string[]} [direct] direct URLs to advertise to clients
|
|
24
|
+
* @property {(s: {connected: boolean, error?: Error, retryMs?: number}) => void} [onStatus]
|
|
25
|
+
* @property {typeof WebSocket} [WebSocket]
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
export class Agent {
|
|
29
|
+
/** @param {AgentOptions} opts */
|
|
30
|
+
constructor(opts) {
|
|
31
|
+
this.opts = opts;
|
|
32
|
+
this.code = opts.code ?? newCode();
|
|
33
|
+
this.WS = opts.WebSocket ?? globalThis.WebSocket;
|
|
34
|
+
this.keys = derive(this.code);
|
|
35
|
+
this.stopped = false;
|
|
36
|
+
this.outer = null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The connection string clients need. */
|
|
40
|
+
get connString() {
|
|
41
|
+
return formatConnString(this.opts.relay, this.code);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Where plaintext traffic lands when `public` is set. */
|
|
45
|
+
async publicURL() {
|
|
46
|
+
return `${this.opts.relay.replace(/\/+$/, '')}/c/${(await this.keys).routingToken}/`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Connects and keeps reconnecting until stop(). Resolves when stopped. */
|
|
50
|
+
async run() {
|
|
51
|
+
let attempt = 0;
|
|
52
|
+
while (!this.stopped) {
|
|
53
|
+
const started = Date.now();
|
|
54
|
+
try {
|
|
55
|
+
const sess = await this._connect();
|
|
56
|
+
attempt = 0;
|
|
57
|
+
this.opts.onStatus?.({ connected: true });
|
|
58
|
+
await Promise.race([
|
|
59
|
+
sess.done,
|
|
60
|
+
new Promise((r) => (sess.onGoAway = r)), // relay draining: redial now
|
|
61
|
+
]);
|
|
62
|
+
if (sess.remoteGoAway && !this.stopped) continue;
|
|
63
|
+
throw sess.err ?? new Error('bolthole: relay connection closed');
|
|
64
|
+
} catch (e) {
|
|
65
|
+
if (this.stopped) break;
|
|
66
|
+
if (e.permanent) {
|
|
67
|
+
this.opts.onStatus?.({ connected: false, error: e });
|
|
68
|
+
throw e;
|
|
69
|
+
}
|
|
70
|
+
if (Date.now() - started > 30000) attempt = 0;
|
|
71
|
+
let delay = Math.random() * Math.min(30000, 500 * 2 ** attempt) + 250;
|
|
72
|
+
if (e.code === 4000) delay = 30000; // replaced by another agent with our code
|
|
73
|
+
attempt++;
|
|
74
|
+
this.opts.onStatus?.({ connected: false, error: e, retryMs: delay });
|
|
75
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
stop() {
|
|
81
|
+
this.stopped = true;
|
|
82
|
+
this.outer?.goAway('going_away', 'agent shutting down');
|
|
83
|
+
this.outer?.close();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async _connect() {
|
|
87
|
+
const { tokenHash } = await this.keys;
|
|
88
|
+
const base = this.opts.relay.replace(/^http/, 'ws').replace(/\/+$/, '');
|
|
89
|
+
const conn = await wsConn(new this.WS(`${base}/v1/agent?th=${tokenHash}`, ['relay.v1']));
|
|
90
|
+
await conn.write(JSON.stringify({ type: 'hello', v: 1, token_hash: tokenHash, name: this.opts.name, auth: this.opts.auth, version: '0.1.0', caps: ['mux/1'] }));
|
|
91
|
+
let w;
|
|
92
|
+
try {
|
|
93
|
+
w = JSON.parse(dec.decode(await conn.read()));
|
|
94
|
+
} catch (e) {
|
|
95
|
+
if (e.code === 4002 || e.code === 4003) e.permanent = true;
|
|
96
|
+
throw e;
|
|
97
|
+
}
|
|
98
|
+
if (w.type === 'error') {
|
|
99
|
+
const err = new Error(`bolthole: relay refused registration: ${w.code}: ${w.message}`);
|
|
100
|
+
err.permanent = w.code === 'unauthorized' || w.code === 'invalid_token_hash';
|
|
101
|
+
conn.close();
|
|
102
|
+
throw err;
|
|
103
|
+
}
|
|
104
|
+
const sess = new Session(conn, { dialer: true, heartbeatMs: w.heartbeat_ms || 20000, onStream: (st) => this._outer(st) });
|
|
105
|
+
this.outer = sess;
|
|
106
|
+
return sess;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
_outer(st) {
|
|
110
|
+
if (st.kind === 'ws' && (st.meta?.path || '').split('?')[0] === E2E_PATH) {
|
|
111
|
+
st.accept({}).then(() => this.serveSecure(new WsStream(st).asConn())).catch(() => st.reset('internal'));
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (!this.opts.public) return st.reset('e2e_required', 'this agent only accepts end-to-end encrypted sessions');
|
|
115
|
+
this._dispatch(st, { encrypted: false, session: this.outer });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Runs the E2E handshake on any transport (relay stream or direct socket). */
|
|
119
|
+
async serveSecure(conn) {
|
|
120
|
+
const { psk } = await this.keys;
|
|
121
|
+
let res;
|
|
122
|
+
try {
|
|
123
|
+
res = await agentHandshake(conn, psk, (h) => {
|
|
124
|
+
if (!(h.protos || []).includes(PROTO_MUX)) throw new Error('no common protocol');
|
|
125
|
+
return { proto: PROTO_MUX, name: this.opts.name, direct: this.opts.direct };
|
|
126
|
+
});
|
|
127
|
+
} catch {
|
|
128
|
+
conn.close();
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const inner = new Session(res.channel, { dialer: false, maxFrame: MAX_RECORD, onStream: (st) => this._dispatch(st, { encrypted: true, session: inner }) });
|
|
132
|
+
this.opts.onSession?.(inner, res.hello);
|
|
133
|
+
await inner.done;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
_dispatch(st, info) {
|
|
137
|
+
const o = this.opts;
|
|
138
|
+
if (st.kind === 'http' && o.fetch) return void serveHttp(st, o.fetch, info).catch(() => st.reset('internal'));
|
|
139
|
+
if (st.kind === 'ws' && o.websocket) {
|
|
140
|
+
return void st
|
|
141
|
+
.accept({})
|
|
142
|
+
.then(() => o.websocket(new WsStream(st), st.meta || {}, info))
|
|
143
|
+
.catch(() => st.reset('internal'));
|
|
144
|
+
}
|
|
145
|
+
const h = o.kinds?.[st.kind];
|
|
146
|
+
if (h) return void Promise.resolve()
|
|
147
|
+
.then(() => h(st, info))
|
|
148
|
+
.catch(() => st.reset('internal'));
|
|
149
|
+
st.reset('unsupported_kind', st.kind);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Shorthand: start an agent and return it. */
|
|
154
|
+
export function serve(opts) {
|
|
155
|
+
const a = new Agent(opts);
|
|
156
|
+
a.running = a.run();
|
|
157
|
+
a.running.catch(() => {});
|
|
158
|
+
return a;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export { enc };
|
package/src/bytes.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Small byte helpers shared by every module. No dependencies.
|
|
2
|
+
|
|
3
|
+
export const enc = new TextEncoder();
|
|
4
|
+
export const dec = new TextDecoder();
|
|
5
|
+
|
|
6
|
+
/** @param {...Uint8Array} parts */
|
|
7
|
+
export function concat(...parts) {
|
|
8
|
+
let n = 0;
|
|
9
|
+
for (const p of parts) n += p.length;
|
|
10
|
+
const out = new Uint8Array(n);
|
|
11
|
+
let o = 0;
|
|
12
|
+
for (const p of parts) {
|
|
13
|
+
out.set(p, o);
|
|
14
|
+
o += p.length;
|
|
15
|
+
}
|
|
16
|
+
return out;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function b64url(bytes) {
|
|
20
|
+
let s = '';
|
|
21
|
+
for (const b of bytes) s += String.fromCharCode(b);
|
|
22
|
+
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function unb64url(s) {
|
|
26
|
+
const t = s.replace(/-/g, '+').replace(/_/g, '/');
|
|
27
|
+
const bin = atob(t + '='.repeat((4 - (t.length % 4)) % 4));
|
|
28
|
+
const out = new Uint8Array(bin.length);
|
|
29
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function hex(bytes) {
|
|
34
|
+
let s = '';
|
|
35
|
+
for (const b of bytes) s += b.toString(16).padStart(2, '0');
|
|
36
|
+
return s;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function u32(n) {
|
|
40
|
+
const b = new Uint8Array(4);
|
|
41
|
+
new DataView(b.buffer).setUint32(0, n >>> 0);
|
|
42
|
+
return b;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function readU32(b, off = 0) {
|
|
46
|
+
return new DataView(b.buffer, b.byteOffset, b.byteLength).getUint32(off);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Normalise ArrayBuffer / views / Node Buffers to a plain Uint8Array. */
|
|
50
|
+
export function toBytes(x) {
|
|
51
|
+
if (x instanceof Uint8Array) return x;
|
|
52
|
+
if (x instanceof ArrayBuffer) return new Uint8Array(x);
|
|
53
|
+
if (ArrayBuffer.isView(x)) return new Uint8Array(x.buffer, x.byteOffset, x.byteLength);
|
|
54
|
+
if (typeof x === 'string') return enc.encode(x);
|
|
55
|
+
throw new TypeError('expected bytes');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A promise you can resolve from outside. */
|
|
59
|
+
export function deferred() {
|
|
60
|
+
let resolve, reject;
|
|
61
|
+
const promise = new Promise((res, rej) => {
|
|
62
|
+
resolve = res;
|
|
63
|
+
reject = rej;
|
|
64
|
+
});
|
|
65
|
+
return { promise, resolve, reject };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** An unbounded async FIFO: push() items, await shift(). */
|
|
69
|
+
export class Queue {
|
|
70
|
+
constructor() {
|
|
71
|
+
this.items = [];
|
|
72
|
+
this.waiters = [];
|
|
73
|
+
this.error = null;
|
|
74
|
+
}
|
|
75
|
+
push(item) {
|
|
76
|
+
if (this.error) return;
|
|
77
|
+
const w = this.waiters.shift();
|
|
78
|
+
if (w) w.resolve(item);
|
|
79
|
+
else this.items.push(item);
|
|
80
|
+
}
|
|
81
|
+
fail(err) {
|
|
82
|
+
if (this.error) return;
|
|
83
|
+
this.error = err;
|
|
84
|
+
for (const w of this.waiters.splice(0)) w.reject(err);
|
|
85
|
+
}
|
|
86
|
+
shift() {
|
|
87
|
+
if (this.items.length) return Promise.resolve(this.items.shift());
|
|
88
|
+
if (this.error) return Promise.reject(this.error);
|
|
89
|
+
const d = deferred();
|
|
90
|
+
this.waiters.push(d);
|
|
91
|
+
return d.promise;
|
|
92
|
+
}
|
|
93
|
+
}
|
package/src/client.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// Client: reach an agent through a relay (or directly) and talk to it with
|
|
2
|
+
// fetch(), WebSockets, raw streams, or your own stream kinds.
|
|
3
|
+
import { parseConnString, derive } from './codes.js';
|
|
4
|
+
import { clientHandshake, PROTO_MUX, MAX_RECORD } from './noise.js';
|
|
5
|
+
import { Session } from './mux.js';
|
|
6
|
+
import { wsConn, fetchOver, WsStream } from './kinds.js';
|
|
7
|
+
|
|
8
|
+
const E2E_PATH = '/.bolthole/e2e';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {object} ConnectOptions
|
|
12
|
+
* @property {string} [name] sent to the agent inside the encrypted hello
|
|
13
|
+
* @property {'auto'|'relay'|'direct'} [mode]
|
|
14
|
+
* @property {string[]} [direct] extra direct candidates (ws://host:port/.bolthole/e2e)
|
|
15
|
+
* @property {number} [relayDelayMs] head start for direct candidates (250)
|
|
16
|
+
* @property {number} [directTimeoutMs] (2000)
|
|
17
|
+
* @property {(path: string) => void} [onPath]
|
|
18
|
+
* @property {(stream: import('./mux.js').Stream) => void} [onStream] agent-opened streams
|
|
19
|
+
* @property {typeof WebSocket} [WebSocket] constructor to use (defaults to the global)
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A resilient connection to one agent. Reconnects on demand and remembers
|
|
24
|
+
* direct candidates the agent advertises.
|
|
25
|
+
*/
|
|
26
|
+
export class Client {
|
|
27
|
+
/** @param {string} connString @param {ConnectOptions} [opts] */
|
|
28
|
+
constructor(connString, opts = {}) {
|
|
29
|
+
this.cs = parseConnString(connString);
|
|
30
|
+
this.opts = { mode: 'auto', relayDelayMs: 250, directTimeoutMs: 2000, ...opts };
|
|
31
|
+
this.WS = opts.WebSocket ?? globalThis.WebSocket;
|
|
32
|
+
if (!this.WS) throw new Error('bolthole: no WebSocket implementation; pass opts.WebSocket');
|
|
33
|
+
this.direct = [...(opts.direct ?? [])];
|
|
34
|
+
this.keys = derive(this.cs.code);
|
|
35
|
+
this.current = null;
|
|
36
|
+
this.dialing = null;
|
|
37
|
+
this.path = '';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Returns a live session, dialing if needed. */
|
|
41
|
+
async session() {
|
|
42
|
+
if (this.current && !this.current.session.isClosed && !this.current.session.remoteGoAway) return this.current.session;
|
|
43
|
+
if (!this.dialing) {
|
|
44
|
+
this.dialing = this._race().finally(() => (this.dialing = null));
|
|
45
|
+
}
|
|
46
|
+
const c = await this.dialing;
|
|
47
|
+
return c.session;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async _race() {
|
|
51
|
+
const { routingToken, psk } = await this.keys;
|
|
52
|
+
const cands = [];
|
|
53
|
+
if (this.opts.mode !== 'relay') for (const url of this.direct) cands.push({ path: 'direct ' + url, url, delay: 0, timeout: this.opts.directTimeoutMs });
|
|
54
|
+
if (this.opts.mode !== 'direct') {
|
|
55
|
+
const base = this.cs.relay.replace(/^http/, 'ws');
|
|
56
|
+
cands.push({ path: 'relay', url: `${base}/c/${routingToken}${E2E_PATH}`, delay: cands.length ? this.opts.relayDelayMs : 0, timeout: 30000 });
|
|
57
|
+
}
|
|
58
|
+
if (!cands.length) throw new Error('bolthole: no path to the agent');
|
|
59
|
+
|
|
60
|
+
const ctrl = { done: false };
|
|
61
|
+
const attempts = cands.map((c) => this._dialOne(c, psk, ctrl));
|
|
62
|
+
let winner;
|
|
63
|
+
try {
|
|
64
|
+
winner = await Promise.any(attempts);
|
|
65
|
+
} catch (e) {
|
|
66
|
+
throw new AggregateError(e.errors, 'bolthole: could not reach the agent');
|
|
67
|
+
}
|
|
68
|
+
ctrl.done = true;
|
|
69
|
+
// Close any loser that also finished.
|
|
70
|
+
for (const a of attempts) a.then((c) => c !== winner && c.session.close(), () => {});
|
|
71
|
+
this._install(winner);
|
|
72
|
+
return winner;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async _dialOne(cand, psk, ctrl) {
|
|
76
|
+
if (cand.delay) await new Promise((r) => setTimeout(r, cand.delay));
|
|
77
|
+
if (ctrl.done) throw new Error('lost the race');
|
|
78
|
+
const ws = new this.WS(cand.url);
|
|
79
|
+
const timer = setTimeout(() => {
|
|
80
|
+
try {
|
|
81
|
+
ws.close();
|
|
82
|
+
} catch {}
|
|
83
|
+
}, cand.timeout);
|
|
84
|
+
try {
|
|
85
|
+
const raw = await wsConn(ws);
|
|
86
|
+
const { channel, hello } = await clientHandshake(raw, psk, { protos: [PROTO_MUX], name: this.opts.name || undefined });
|
|
87
|
+
clearTimeout(timer);
|
|
88
|
+
const session = new Session(channel, { dialer: true, maxFrame: MAX_RECORD, onStream: this.opts.onStream });
|
|
89
|
+
return { session, hello, path: cand.path };
|
|
90
|
+
} catch (e) {
|
|
91
|
+
clearTimeout(timer);
|
|
92
|
+
try {
|
|
93
|
+
ws.close();
|
|
94
|
+
} catch {}
|
|
95
|
+
throw e;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
_install(c) {
|
|
100
|
+
const old = this.current;
|
|
101
|
+
this.current = c;
|
|
102
|
+
this.path = c.path;
|
|
103
|
+
for (const u of c.hello.direct ?? []) if (!this.direct.includes(u)) this.direct.push(u);
|
|
104
|
+
if (old) {
|
|
105
|
+
old.session.goAway('going_away', 'client switched path');
|
|
106
|
+
const drain = () => (old.session.streams.size === 0 ? old.session.close() : setTimeout(drain, 500));
|
|
107
|
+
drain();
|
|
108
|
+
}
|
|
109
|
+
this.opts.onPath?.(c.path);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** fetch() to the agent. Only the URL's path and query are used. */
|
|
113
|
+
async fetch(input, init) {
|
|
114
|
+
return fetchOver(await this.session(), input, init);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Opens a WebSocket to `path` on the agent. */
|
|
118
|
+
async websocket(path, protocols = []) {
|
|
119
|
+
const s = await this.session();
|
|
120
|
+
const st = s.open('ws', { path, protocols });
|
|
121
|
+
const meta = await st.accepted;
|
|
122
|
+
const ws = new WsStream(st);
|
|
123
|
+
ws.protocol = meta.protocol ?? '';
|
|
124
|
+
return ws;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Opens a raw byte stream to a named TCP target on the agent. */
|
|
128
|
+
async tcp(target = '') {
|
|
129
|
+
const s = await this.session();
|
|
130
|
+
const st = s.open('tcp', { target });
|
|
131
|
+
await st.accepted;
|
|
132
|
+
return st;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Opens a stream of your own kind. Resolves after the agent accepts. */
|
|
136
|
+
async open(kind, meta) {
|
|
137
|
+
const s = await this.session();
|
|
138
|
+
const st = s.open(kind, meta);
|
|
139
|
+
st.acceptMeta = await st.accepted;
|
|
140
|
+
return st;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Tries direct candidates now and switches if one answers. */
|
|
144
|
+
async tryUpgrade() {
|
|
145
|
+
if (this.path !== 'relay' || !this.direct.length) return false;
|
|
146
|
+
const { psk } = await this.keys;
|
|
147
|
+
const ctrl = { done: false };
|
|
148
|
+
try {
|
|
149
|
+
const c = await Promise.any(this.direct.map((url) => this._dialOne({ path: 'direct ' + url, url, timeout: this.opts.directTimeoutMs }, psk, ctrl)));
|
|
150
|
+
ctrl.done = true;
|
|
151
|
+
this._install(c);
|
|
152
|
+
return true;
|
|
153
|
+
} catch {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
close() {
|
|
159
|
+
this.current?.session.close();
|
|
160
|
+
this.current = null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Shorthand: new Client(connString, opts). */
|
|
165
|
+
export function connect(connString, opts) {
|
|
166
|
+
return new Client(connString, opts);
|
|
167
|
+
}
|
package/src/codes.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Connect codes, routing tokens and connection strings (spec §1).
|
|
2
|
+
import { b64url, unb64url, hex, enc } from './bytes.js';
|
|
3
|
+
|
|
4
|
+
const SALT = enc.encode('bolthole/v1');
|
|
5
|
+
const subtle = globalThis.crypto.subtle;
|
|
6
|
+
|
|
7
|
+
/** A fresh random 32-byte connect code, base64url encoded. */
|
|
8
|
+
export function newCode() {
|
|
9
|
+
return b64url(crypto.getRandomValues(new Uint8Array(32)));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function parseCode(s) {
|
|
13
|
+
const b = unb64url(String(s).trim());
|
|
14
|
+
if (b.length !== 32) throw new Error('bolthole: a connect code is 43 base64url characters');
|
|
15
|
+
return b;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function hkdf(ikm, info, len) {
|
|
19
|
+
const key = await subtle.importKey('raw', ikm, 'HKDF', false, ['deriveBits']);
|
|
20
|
+
const bits = await subtle.deriveBits({ name: 'HKDF', hash: 'SHA-256', salt: SALT, info: enc.encode(info) }, key, len * 8);
|
|
21
|
+
return new Uint8Array(bits);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** SHA-256 of the routing token, lowercase hex. */
|
|
25
|
+
export async function hashToken(token) {
|
|
26
|
+
return hex(new Uint8Array(await subtle.digest('SHA-256', enc.encode(token))));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Everything derived from a code.
|
|
31
|
+
* @param {Uint8Array|string} code
|
|
32
|
+
* @returns {Promise<{routingToken: string, tokenHash: string, psk: Uint8Array}>}
|
|
33
|
+
*/
|
|
34
|
+
export async function derive(code) {
|
|
35
|
+
const c = typeof code === 'string' ? parseCode(code) : code;
|
|
36
|
+
const routingToken = b64url(await hkdf(c, 'routing-token', 24));
|
|
37
|
+
const psk = await hkdf(c, 'e2e-psk', 32);
|
|
38
|
+
return { routingToken, tokenHash: await hashToken(routingToken), psk };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** "https://relay/#k=<code>" */
|
|
42
|
+
export function formatConnString(relay, code) {
|
|
43
|
+
const u = new URL(relay);
|
|
44
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('bolthole: relay URL must be http(s)');
|
|
45
|
+
u.hash = '';
|
|
46
|
+
if (!u.pathname.endsWith('/')) u.pathname += '/';
|
|
47
|
+
return `${u.toString()}#k=${typeof code === 'string' ? code : b64url(code)}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Parses "https://relay/#k=<code>[&name=…]".
|
|
52
|
+
* @returns {{relay: string, code: Uint8Array, name: string}} relay has no trailing slash.
|
|
53
|
+
*/
|
|
54
|
+
export function parseConnString(s) {
|
|
55
|
+
const u = new URL(String(s).trim());
|
|
56
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('bolthole: connection string must start with http(s)://');
|
|
57
|
+
const frag = new URLSearchParams(u.hash.replace(/^#/, ''));
|
|
58
|
+
const code = parseCode(frag.get('k') || '');
|
|
59
|
+
u.hash = '';
|
|
60
|
+
u.search = '';
|
|
61
|
+
const relay = u.toString().replace(/\/+$/, '');
|
|
62
|
+
return { relay, code, name: frag.get('name') || '' };
|
|
63
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// bolthole: reach any service from anywhere through a relay that can't read
|
|
2
|
+
// your traffic. This entry point works in browsers, Node 22+, Deno, Bun and
|
|
3
|
+
// Cloudflare Workers.
|
|
4
|
+
export { Client, connect } from './client.js';
|
|
5
|
+
export { Agent, serve } from './agent.js';
|
|
6
|
+
export { newCode, parseCode, derive, hashToken, formatConnString, parseConnString } from './codes.js';
|
|
7
|
+
export { Session, Stream, ResetError, encodeFrame, decodeFrame, T as FrameType } from './mux.js';
|
|
8
|
+
export { WsStream, wsConn, fetchOver, serveHttp, encodeHeaders, decodeHeaders } from './kinds.js';
|
|
9
|
+
export { clientHandshake, agentHandshake, Channel, PROTO_MUX } from './noise.js';
|