javascript-solid-server 0.0.107 → 0.0.109
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 +71 -0
- package/bin/jss.js +12 -0
- package/package.json +1 -1
- package/src/config.js +12 -0
- package/src/handlers/pay.js +37 -13
- package/src/server.js +29 -0
- package/src/tunnel/index.js +226 -0
- package/src/webrtc/index.js +159 -0
- package/test/tunnel.test.js +202 -0
- package/test/webrtc.test.js +212 -0
package/README.md
CHANGED
|
@@ -162,6 +162,10 @@ jss --help # Show help
|
|
|
162
162
|
| `--mongo` | Enable MongoDB-backed /db/ route | false |
|
|
163
163
|
| `--mongo-url <url>` | MongoDB connection URL | mongodb://localhost:27017 |
|
|
164
164
|
| `--mongo-database <name>` | MongoDB database name | solid |
|
|
165
|
+
| `--webrtc` | Enable WebRTC signaling server | false |
|
|
166
|
+
| `--webrtc-path <path>` | WebRTC signaling WebSocket path | /.webrtc |
|
|
167
|
+
| `--tunnel` | Enable tunnel proxy (decentralized ngrok) | false |
|
|
168
|
+
| `--tunnel-path <path>` | Tunnel WebSocket path | /.tunnel |
|
|
165
169
|
| `-q, --quiet` | Suppress logs | false |
|
|
166
170
|
|
|
167
171
|
### Environment Variables
|
|
@@ -195,6 +199,7 @@ export JSS_PAY_RATE=10
|
|
|
195
199
|
export JSS_MONGO=true
|
|
196
200
|
export JSS_MONGO_URL=mongodb://localhost:27017
|
|
197
201
|
export JSS_MONGO_DATABASE=solid
|
|
202
|
+
export JSS_WEBRTC=true
|
|
198
203
|
jss start
|
|
199
204
|
```
|
|
200
205
|
|
|
@@ -823,6 +828,72 @@ curl -X DELETE http://localhost:3000/db/alice/notes/1 \
|
|
|
823
828
|
|
|
824
829
|
Supported formats: `50MB`, `1GB`, `500KB`, `1TB`
|
|
825
830
|
|
|
831
|
+
## WebRTC Signaling
|
|
832
|
+
|
|
833
|
+
Peer-to-peer communication via WebRTC, using JSS as the signaling server. Once peers are connected, all media and data flows directly between them.
|
|
834
|
+
|
|
835
|
+
```bash
|
|
836
|
+
jss start --webrtc
|
|
837
|
+
```
|
|
838
|
+
|
|
839
|
+
### How It Works
|
|
840
|
+
|
|
841
|
+
1. Both peers connect to `wss://your.pod/.webrtc` (WebID auth required)
|
|
842
|
+
2. Caller sends an SDP offer targeting the callee's WebID
|
|
843
|
+
3. JSS relays the offer/answer and ICE candidates between peers
|
|
844
|
+
4. Once a direct path is found, the peer-to-peer connection is established
|
|
845
|
+
5. JSS steps out — video, audio, files, and data flow directly between peers
|
|
846
|
+
|
|
847
|
+
### Protocol
|
|
848
|
+
|
|
849
|
+
Messages are JSON over WebSocket:
|
|
850
|
+
|
|
851
|
+
```js
|
|
852
|
+
// Send an offer to another user
|
|
853
|
+
{ "type": "offer", "to": "https://bob.example/profile/card#me", "sdp": "..." }
|
|
854
|
+
|
|
855
|
+
// Receive an offer from another user
|
|
856
|
+
{ "type": "offer", "from": "https://alice.example/profile/card#me", "sdp": "..." }
|
|
857
|
+
|
|
858
|
+
// ICE candidate exchange
|
|
859
|
+
{ "type": "candidate", "to": "https://bob.example/profile/card#me", "candidate": {...} }
|
|
860
|
+
|
|
861
|
+
// Hang up
|
|
862
|
+
{ "type": "hangup", "to": "https://bob.example/profile/card#me" }
|
|
863
|
+
```
|
|
864
|
+
|
|
865
|
+
On connect, peers receive a list of online users and get notified when others join or leave.
|
|
866
|
+
|
|
867
|
+
## Tunnel Proxy (Decentralized ngrok)
|
|
868
|
+
|
|
869
|
+
Expose a local dev server to the internet through your JSS pod. A tunnel client connects via WebSocket, registers a name, and receives proxied HTTP requests.
|
|
870
|
+
|
|
871
|
+
```bash
|
|
872
|
+
jss start --tunnel
|
|
873
|
+
```
|
|
874
|
+
|
|
875
|
+
### How It Works
|
|
876
|
+
|
|
877
|
+
1. Tunnel client connects to `wss://your.pod/.tunnel` (WebID auth required)
|
|
878
|
+
2. Client registers a name: `{ "type": "register", "name": "myapp" }`
|
|
879
|
+
3. Public URL becomes available at `https://your.pod/tunnel/myapp/`
|
|
880
|
+
4. HTTP requests to that URL are serialized and sent to the tunnel client over WebSocket
|
|
881
|
+
5. Tunnel client forwards to localhost, returns the response
|
|
882
|
+
|
|
883
|
+
### Tunnel Client Protocol
|
|
884
|
+
|
|
885
|
+
```js
|
|
886
|
+
// 1. Register a tunnel
|
|
887
|
+
→ { "type": "register", "name": "myapp" }
|
|
888
|
+
← { "type": "registered", "name": "myapp", "url": "/tunnel/myapp/" }
|
|
889
|
+
|
|
890
|
+
// 2. Receive proxied HTTP requests
|
|
891
|
+
← { "type": "request", "id": "uuid", "method": "GET", "path": "/api/hello", "headers": {...} }
|
|
892
|
+
|
|
893
|
+
// 3. Return the response
|
|
894
|
+
→ { "type": "response", "id": "uuid", "status": 200, "headers": {...}, "body": "..." }
|
|
895
|
+
```
|
|
896
|
+
|
|
826
897
|
## HTTP 402 Paid Access
|
|
827
898
|
|
|
828
899
|
Monetize API endpoints with per-request satoshi payments. Resources under `/pay/*` require NIP-98 authentication and a positive balance.
|
package/bin/jss.js
CHANGED
|
@@ -65,6 +65,12 @@ program
|
|
|
65
65
|
.option('--no-nostr', 'Disable Nostr relay')
|
|
66
66
|
.option('--nostr-path <path>', 'Nostr relay WebSocket path (default: /relay)')
|
|
67
67
|
.option('--nostr-max-events <n>', 'Max events in relay memory (default: 1000)', parseInt)
|
|
68
|
+
.option('--webrtc', 'Enable WebRTC signaling server')
|
|
69
|
+
.option('--no-webrtc', 'Disable WebRTC signaling server')
|
|
70
|
+
.option('--webrtc-path <path>', 'WebRTC signaling WebSocket path (default: /.webrtc)')
|
|
71
|
+
.option('--tunnel', 'Enable tunnel proxy (decentralized ngrok)')
|
|
72
|
+
.option('--no-tunnel', 'Disable tunnel proxy')
|
|
73
|
+
.option('--tunnel-path <path>', 'Tunnel WebSocket path (default: /.tunnel)')
|
|
68
74
|
.option('--activitypub', 'Enable ActivityPub federation')
|
|
69
75
|
.option('--no-activitypub', 'Disable ActivityPub federation')
|
|
70
76
|
.option('--ap-username <name>', 'ActivityPub username (default: me)')
|
|
@@ -142,6 +148,10 @@ program
|
|
|
142
148
|
nostr: config.nostr,
|
|
143
149
|
nostrPath: config.nostrPath,
|
|
144
150
|
nostrMaxEvents: config.nostrMaxEvents,
|
|
151
|
+
webrtc: config.webrtc,
|
|
152
|
+
webrtcPath: config.webrtcPath,
|
|
153
|
+
tunnel: config.tunnel,
|
|
154
|
+
tunnelPath: config.tunnelPath,
|
|
145
155
|
activitypub: config.activitypub,
|
|
146
156
|
apUsername: config.apUsername,
|
|
147
157
|
apDisplayName: config.apDisplayName,
|
|
@@ -186,6 +196,8 @@ program
|
|
|
186
196
|
if (config.solidosUi) console.log(' SolidOS UI: enabled (modern interface)');
|
|
187
197
|
if (config.git) console.log(' Git: enabled (clone/push support)');
|
|
188
198
|
if (config.nostr) console.log(` Nostr: enabled (${config.nostrPath})`);
|
|
199
|
+
if (config.webrtc) console.log(` WebRTC: enabled (${config.webrtcPath || '/.webrtc'})`);
|
|
200
|
+
if (config.tunnel) console.log(` Tunnel: enabled (${config.tunnelPath || '/.tunnel'})`);
|
|
189
201
|
if (config.activitypub) console.log(` ActivityPub: enabled (@${config.apUsername || 'me'})`);
|
|
190
202
|
if (config.singleUser) console.log(` Single-user: ${config.singleUserName || 'me'} (registration disabled)`);
|
|
191
203
|
else if (config.inviteOnly) console.log(' Registration: invite-only');
|
package/package.json
CHANGED
package/src/config.js
CHANGED
|
@@ -54,6 +54,14 @@ export const defaults = {
|
|
|
54
54
|
nostrPath: '/relay',
|
|
55
55
|
nostrMaxEvents: 1000,
|
|
56
56
|
|
|
57
|
+
// WebRTC signaling
|
|
58
|
+
webrtc: false,
|
|
59
|
+
webrtcPath: '/.webrtc',
|
|
60
|
+
|
|
61
|
+
// Tunnel (decentralized ngrok)
|
|
62
|
+
tunnel: false,
|
|
63
|
+
tunnelPath: '/.tunnel',
|
|
64
|
+
|
|
57
65
|
// ActivityPub federation
|
|
58
66
|
activitypub: false,
|
|
59
67
|
apUsername: 'me',
|
|
@@ -134,6 +142,10 @@ const envMap = {
|
|
|
134
142
|
JSS_NOSTR: 'nostr',
|
|
135
143
|
JSS_NOSTR_PATH: 'nostrPath',
|
|
136
144
|
JSS_NOSTR_MAX_EVENTS: 'nostrMaxEvents',
|
|
145
|
+
JSS_WEBRTC: 'webrtc',
|
|
146
|
+
JSS_WEBRTC_PATH: 'webrtcPath',
|
|
147
|
+
JSS_TUNNEL: 'tunnel',
|
|
148
|
+
JSS_TUNNEL_PATH: 'tunnelPath',
|
|
137
149
|
JSS_ACTIVITYPUB: 'activitypub',
|
|
138
150
|
JSS_AP_USERNAME: 'apUsername',
|
|
139
151
|
JSS_AP_DISPLAY_NAME: 'apDisplayName',
|
package/src/handlers/pay.js
CHANGED
|
@@ -424,13 +424,17 @@ export function createPayHandler(options = {}) {
|
|
|
424
424
|
return reply.code(400).send({ error: 'Amount must be positive' });
|
|
425
425
|
}
|
|
426
426
|
|
|
427
|
-
//
|
|
427
|
+
// Determine payment currency — chain-specific (e.g. "tbtc4") or generic "sat"
|
|
428
|
+
const currency = (body?.currency && payChains && payChains.includes(body.currency))
|
|
429
|
+
? body.currency : null;
|
|
430
|
+
|
|
431
|
+
// Check balance
|
|
428
432
|
const didUri = pubkeyToDidNostr(pubkey);
|
|
429
433
|
const ledger = await readLedger();
|
|
430
|
-
const balance = getBalance(ledger, didUri);
|
|
434
|
+
const balance = getBalance(ledger, didUri, currency);
|
|
431
435
|
if (balance < satCost) {
|
|
432
436
|
return reply.code(402).send({
|
|
433
|
-
error:
|
|
437
|
+
error: `Insufficient ${currency || 'sat'} balance`,
|
|
434
438
|
balance,
|
|
435
439
|
cost: satCost,
|
|
436
440
|
rate: payRate,
|
|
@@ -457,8 +461,8 @@ export function createPayHandler(options = {}) {
|
|
|
457
461
|
return reply.code(500).send({ error: `Transfer failed: ${err.message}` });
|
|
458
462
|
}
|
|
459
463
|
|
|
460
|
-
// Debit
|
|
461
|
-
debit(ledger, didUri, satCost);
|
|
464
|
+
// Debit from buyer
|
|
465
|
+
debit(ledger, didUri, satCost, currency);
|
|
462
466
|
await writeLedger(ledger);
|
|
463
467
|
|
|
464
468
|
return reply.send({
|
|
@@ -466,8 +470,8 @@ export function createPayHandler(options = {}) {
|
|
|
466
470
|
ticker,
|
|
467
471
|
cost: satCost,
|
|
468
472
|
rate: payRate,
|
|
469
|
-
balance: getBalance(ledger, didUri),
|
|
470
|
-
unit: 'sat',
|
|
473
|
+
balance: getBalance(ledger, didUri, currency),
|
|
474
|
+
unit: currency || 'sat',
|
|
471
475
|
txid: result.txid,
|
|
472
476
|
proof: {
|
|
473
477
|
state: result.state,
|
|
@@ -984,21 +988,41 @@ export function createPayHandler(options = {}) {
|
|
|
984
988
|
|
|
985
989
|
const didUri = pubkeyToDidNostr(pubkey);
|
|
986
990
|
const ledger = await readLedger();
|
|
987
|
-
const { success, balance } = debit(ledger, didUri, cost);
|
|
988
991
|
|
|
989
|
-
|
|
990
|
-
|
|
992
|
+
// Try generic sat balance first, then fall back to chain balances
|
|
993
|
+
const currency = request.headers['x-pay-currency'] || null;
|
|
994
|
+
let payUnit = currency && payChains && payChains.includes(currency) ? currency : null;
|
|
995
|
+
let result = debit(ledger, didUri, cost, payUnit);
|
|
996
|
+
|
|
997
|
+
// If generic sat failed and no explicit currency, try each chain balance
|
|
998
|
+
if (!result.success && !payUnit && payChains) {
|
|
999
|
+
for (const chainId of payChains) {
|
|
1000
|
+
result = debit(ledger, didUri, cost, chainId);
|
|
1001
|
+
if (result.success) { payUnit = chainId; break; }
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
if (!result.success) {
|
|
1006
|
+
const response = {
|
|
991
1007
|
error: 'Payment Required',
|
|
992
|
-
balance,
|
|
1008
|
+
balance: result.balance,
|
|
993
1009
|
cost,
|
|
994
1010
|
unit: 'sat',
|
|
995
1011
|
deposit: '/pay/.deposit'
|
|
996
|
-
}
|
|
1012
|
+
};
|
|
1013
|
+
if (payChains) {
|
|
1014
|
+
response.balances = {};
|
|
1015
|
+
for (const chainId of payChains) {
|
|
1016
|
+
response.balances[chainId] = getBalance(ledger, didUri, chainId);
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
return reply.code(402).send(response);
|
|
997
1020
|
}
|
|
998
1021
|
|
|
999
1022
|
await writeLedger(ledger);
|
|
1000
|
-
reply.header('X-Balance', String(balance));
|
|
1023
|
+
reply.header('X-Balance', String(result.balance));
|
|
1001
1024
|
reply.header('X-Cost', String(cost));
|
|
1025
|
+
if (payUnit) reply.header('X-Pay-Currency', payUnit);
|
|
1002
1026
|
return; // continue to normal resource handler
|
|
1003
1027
|
}
|
|
1004
1028
|
|
package/src/server.js
CHANGED
|
@@ -18,6 +18,8 @@ import { createPayHandler, isPayRequest } from './handlers/pay.js';
|
|
|
18
18
|
import { activityPubPlugin, getActorHandler } from './ap/index.js';
|
|
19
19
|
import { remoteStoragePlugin } from './remotestorage.js';
|
|
20
20
|
import { dbPlugin } from './db/index.js';
|
|
21
|
+
import { webrtcPlugin } from './webrtc/index.js';
|
|
22
|
+
import { tunnelPlugin } from './tunnel/index.js';
|
|
21
23
|
|
|
22
24
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
23
25
|
|
|
@@ -74,6 +76,12 @@ export function createServer(options = {}) {
|
|
|
74
76
|
const nostrEnabled = options.nostr ?? false;
|
|
75
77
|
const nostrPath = options.nostrPath ?? '/relay';
|
|
76
78
|
const nostrMaxEvents = options.nostrMaxEvents ?? 1000;
|
|
79
|
+
// WebRTC signaling is OFF by default
|
|
80
|
+
const webrtcEnabled = options.webrtc ?? false;
|
|
81
|
+
const webrtcPath = options.webrtcPath ?? '/.webrtc';
|
|
82
|
+
// Tunnel proxy is OFF by default
|
|
83
|
+
const tunnelEnabled = options.tunnel ?? false;
|
|
84
|
+
const tunnelPath = options.tunnelPath ?? '/.tunnel';
|
|
77
85
|
// ActivityPub federation is OFF by default
|
|
78
86
|
const activitypubEnabled = options.activitypub ?? false;
|
|
79
87
|
const apUsername = options.apUsername ?? 'me';
|
|
@@ -240,6 +248,16 @@ export function createServer(options = {}) {
|
|
|
240
248
|
});
|
|
241
249
|
}
|
|
242
250
|
|
|
251
|
+
// Register WebRTC signaling if enabled
|
|
252
|
+
if (webrtcEnabled) {
|
|
253
|
+
fastify.register(webrtcPlugin, { path: webrtcPath });
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Register tunnel proxy if enabled
|
|
257
|
+
if (tunnelEnabled) {
|
|
258
|
+
fastify.register(tunnelPlugin, { path: tunnelPath });
|
|
259
|
+
}
|
|
260
|
+
|
|
243
261
|
// Register ActivityPub plugin if enabled
|
|
244
262
|
if (activitypubEnabled) {
|
|
245
263
|
fastify.register(activityPubPlugin, {
|
|
@@ -331,6 +349,15 @@ export function createServer(options = {}) {
|
|
|
331
349
|
return;
|
|
332
350
|
}
|
|
333
351
|
|
|
352
|
+
// Allow WebRTC and tunnel endpoints through when enabled
|
|
353
|
+
const urlNoQuery = request.url.split('?')[0];
|
|
354
|
+
if (tunnelEnabled && (urlNoQuery === tunnelPath || urlNoQuery.startsWith('/tunnel/'))) {
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
if (webrtcEnabled && urlNoQuery === webrtcPath) {
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
|
|
334
361
|
const segments = request.url.split('/').map(s => s.split('?')[0]); // Remove query strings
|
|
335
362
|
const hasForbiddenDotfile = segments.some(seg =>
|
|
336
363
|
seg.startsWith('.') &&
|
|
@@ -405,6 +432,8 @@ export function createServer(options = {}) {
|
|
|
405
432
|
request.url.startsWith('/storage/') ||
|
|
406
433
|
(payEnabled && isPayRequest(request.url)) ||
|
|
407
434
|
(mongoEnabled && (request.url === '/db' || request.url.startsWith('/db/'))) ||
|
|
435
|
+
(webrtcEnabled && (request.url === webrtcPath || request.url.startsWith(webrtcPath + '?'))) ||
|
|
436
|
+
(tunnelEnabled && (request.url === tunnelPath || request.url.startsWith(tunnelPath + '?') || request.url.startsWith('/tunnel/'))) ||
|
|
408
437
|
mashlibPaths.some(p => request.url === p || request.url.startsWith(p + '.'))) {
|
|
409
438
|
return;
|
|
410
439
|
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tunnel Plugin — Decentralized ngrok
|
|
3
|
+
*
|
|
4
|
+
* Tunnels HTTP traffic to a local dev server through JSS via WebSocket.
|
|
5
|
+
* A tunnel client connects over WebSocket, registers a name, and receives
|
|
6
|
+
* proxied HTTP requests which it forwards to localhost.
|
|
7
|
+
*
|
|
8
|
+
* Usage: jss start --tunnel
|
|
9
|
+
* Tunnel client connects to: wss://your.pod/.tunnel
|
|
10
|
+
* Public URL: https://your.pod/tunnel/{name}/path
|
|
11
|
+
*
|
|
12
|
+
* Tunnel client protocol (JSON over WebSocket):
|
|
13
|
+
* → { type: "register", name: "myapp" }
|
|
14
|
+
* ← { type: "registered", name: "myapp", url: "/tunnel/myapp/" }
|
|
15
|
+
* ← { type: "request", id: "<uuid>", method: "GET", path: "/api/hello", headers: {...}, body: "..." }
|
|
16
|
+
* → { type: "response", id: "<uuid>", status: 200, headers: {...}, body: "..." }
|
|
17
|
+
* ← { type: "error", message: "..." }
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import websocket from '@fastify/websocket';
|
|
21
|
+
import { getWebIdFromRequestAsync } from '../auth/token.js';
|
|
22
|
+
import { randomUUID } from 'crypto';
|
|
23
|
+
|
|
24
|
+
const REQUEST_TIMEOUT = 30000; // 30s timeout for tunnel responses
|
|
25
|
+
const MAX_MESSAGE_SIZE = 10 * 1024 * 1024; // 10MB
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {object} fastify - Fastify instance
|
|
29
|
+
* @param {object} options - Options
|
|
30
|
+
* @param {string} options.path - WebSocket path for tunnel clients (default: '/.tunnel')
|
|
31
|
+
*/
|
|
32
|
+
export async function tunnelPlugin(fastify, options = {}) {
|
|
33
|
+
const wsPath = options.path || '/.tunnel';
|
|
34
|
+
|
|
35
|
+
// Instance-scoped: tunnel name → { socket, webId }
|
|
36
|
+
const tunnels = new Map();
|
|
37
|
+
// Pending HTTP requests waiting for tunnel response: id → { resolve, timer }
|
|
38
|
+
const pending = new Map();
|
|
39
|
+
|
|
40
|
+
if (!fastify.websocketServer) {
|
|
41
|
+
await fastify.register(websocket);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
fastify.addHook('onClose', async () => {
|
|
45
|
+
for (const [, tunnel] of tunnels) {
|
|
46
|
+
tunnel.socket.close();
|
|
47
|
+
}
|
|
48
|
+
tunnels.clear();
|
|
49
|
+
for (const [, p] of pending) {
|
|
50
|
+
clearTimeout(p.timer);
|
|
51
|
+
p.resolve({ status: 502, headers: {}, body: 'Tunnel shutting down' });
|
|
52
|
+
}
|
|
53
|
+
pending.clear();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// WebSocket endpoint for tunnel clients
|
|
57
|
+
fastify.get(wsPath, { websocket: true }, async (connection, request) => {
|
|
58
|
+
const socket = connection.socket;
|
|
59
|
+
|
|
60
|
+
// Authenticate
|
|
61
|
+
const { webId } = await getWebIdFromRequestAsync(request);
|
|
62
|
+
if (!webId) {
|
|
63
|
+
socket.send(JSON.stringify({ type: 'error', message: 'Authentication required' }));
|
|
64
|
+
socket.close();
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let tunnelName = null;
|
|
69
|
+
|
|
70
|
+
socket.on('message', (data) => {
|
|
71
|
+
const raw = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
72
|
+
if (raw.byteLength > MAX_MESSAGE_SIZE) {
|
|
73
|
+
socket.send(JSON.stringify({ type: 'error', message: 'Message too large' }));
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let msg;
|
|
78
|
+
try {
|
|
79
|
+
msg = JSON.parse(raw.toString());
|
|
80
|
+
} catch {
|
|
81
|
+
socket.send(JSON.stringify({ type: 'error', message: 'Invalid JSON' }));
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (msg.type === 'register') {
|
|
86
|
+
// Register a tunnel name
|
|
87
|
+
const name = (msg.name || '').replace(/[^a-zA-Z0-9_-]/g, '');
|
|
88
|
+
if (!name) {
|
|
89
|
+
socket.send(JSON.stringify({ type: 'error', message: 'Invalid tunnel name' }));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const existing = tunnels.get(name);
|
|
94
|
+
if (existing && existing.webId !== webId) {
|
|
95
|
+
socket.send(JSON.stringify({ type: 'error', message: 'Tunnel name taken by another user' }));
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Close old tunnel with same name from same user
|
|
100
|
+
if (existing) {
|
|
101
|
+
existing.socket.close();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
tunnelName = name;
|
|
105
|
+
tunnels.set(name, { socket, webId });
|
|
106
|
+
socket.send(JSON.stringify({ type: 'registered', name, url: `/tunnel/${name}/` }));
|
|
107
|
+
|
|
108
|
+
} else if (msg.type === 'response') {
|
|
109
|
+
// Tunnel client returning an HTTP response
|
|
110
|
+
if (!msg.id) return;
|
|
111
|
+
const p = pending.get(msg.id);
|
|
112
|
+
if (p) {
|
|
113
|
+
clearTimeout(p.timer);
|
|
114
|
+
pending.delete(msg.id);
|
|
115
|
+
p.resolve({
|
|
116
|
+
status: msg.status || 502,
|
|
117
|
+
headers: msg.headers || {},
|
|
118
|
+
body: msg.body || '',
|
|
119
|
+
bodyEncoding: msg.bodyEncoding
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
socket.on('close', () => {
|
|
126
|
+
if (tunnelName && tunnels.get(tunnelName)?.socket === socket) {
|
|
127
|
+
tunnels.delete(tunnelName);
|
|
128
|
+
// Resolve pending requests for this tunnel only with 502
|
|
129
|
+
for (const [id, p] of pending) {
|
|
130
|
+
if (p.tunnelName === tunnelName) {
|
|
131
|
+
clearTimeout(p.timer);
|
|
132
|
+
pending.delete(id);
|
|
133
|
+
p.resolve({ status: 502, headers: {}, body: 'Tunnel disconnected' });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
socket.on('error', () => {});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// HTTP proxy: /tunnel/{name}/*
|
|
143
|
+
fastify.all('/tunnel/:name/*', async (request, reply) => {
|
|
144
|
+
const { name } = request.params;
|
|
145
|
+
const tunnel = tunnels.get(name);
|
|
146
|
+
|
|
147
|
+
if (!tunnel || tunnel.socket.readyState !== 1) {
|
|
148
|
+
return reply.code(502).send({ error: 'Bad Gateway', message: 'Tunnel not connected' });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Build the downstream path (strip /tunnel/{name} prefix)
|
|
152
|
+
const fullPath = request.url.replace(`/tunnel/${name}`, '') || '/';
|
|
153
|
+
const id = randomUUID();
|
|
154
|
+
|
|
155
|
+
// Serialize the HTTP request
|
|
156
|
+
const tunnelReq = Object.create(null);
|
|
157
|
+
tunnelReq.type = 'request';
|
|
158
|
+
tunnelReq.id = id;
|
|
159
|
+
tunnelReq.method = request.method;
|
|
160
|
+
tunnelReq.path = fullPath;
|
|
161
|
+
tunnelReq.headers = Object.create(null);
|
|
162
|
+
// Forward relevant headers (skip hop-by-hop)
|
|
163
|
+
const skipHeaders = new Set(['host', 'connection', 'upgrade', 'transfer-encoding', 'cookie', 'authorization', 'proxy-authorization']);
|
|
164
|
+
for (const [k, v] of Object.entries(request.headers)) {
|
|
165
|
+
if (!skipHeaders.has(k.toLowerCase())) {
|
|
166
|
+
tunnelReq.headers[k] = v;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// Forward body if present
|
|
170
|
+
if (request.body) {
|
|
171
|
+
tunnelReq.body = Buffer.isBuffer(request.body)
|
|
172
|
+
? request.body.toString('base64')
|
|
173
|
+
: typeof request.body === 'string' ? request.body : JSON.stringify(request.body);
|
|
174
|
+
tunnelReq.bodyEncoding = Buffer.isBuffer(request.body) ? 'base64' : 'utf8';
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Send to tunnel client and wait for response
|
|
178
|
+
const responsePromise = new Promise((resolve) => {
|
|
179
|
+
const timer = setTimeout(() => {
|
|
180
|
+
pending.delete(id);
|
|
181
|
+
resolve({ status: 504, headers: {}, body: 'Gateway Timeout' });
|
|
182
|
+
}, REQUEST_TIMEOUT);
|
|
183
|
+
pending.set(id, { resolve, timer, tunnelName: name });
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
tunnel.socket.send(JSON.stringify(tunnelReq));
|
|
188
|
+
} catch {
|
|
189
|
+
const p = pending.get(id);
|
|
190
|
+
if (p) { clearTimeout(p.timer); pending.delete(id); }
|
|
191
|
+
return reply.code(502).send({ error: 'Bad Gateway', message: 'Failed to reach tunnel client' });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const res = await responsePromise;
|
|
195
|
+
|
|
196
|
+
// Set response headers
|
|
197
|
+
const hopHeaders = new Set(['connection', 'transfer-encoding', 'keep-alive', 'set-cookie']);
|
|
198
|
+
for (const [k, v] of Object.entries(res.headers)) {
|
|
199
|
+
if (!hopHeaders.has(k.toLowerCase())) {
|
|
200
|
+
reply.header(k, v);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Decode body if base64
|
|
205
|
+
const body = res.bodyEncoding === 'base64' && res.body
|
|
206
|
+
? Buffer.from(res.body, 'base64')
|
|
207
|
+
: res.body || '';
|
|
208
|
+
|
|
209
|
+
return reply.code(res.status).send(body);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// Also handle /tunnel/{name} without trailing path
|
|
213
|
+
fastify.all('/tunnel/:name', async (request, reply) => {
|
|
214
|
+
// Redirect to add trailing slash, or proxy as root
|
|
215
|
+
const { name } = request.params;
|
|
216
|
+
const tunnel = tunnels.get(name);
|
|
217
|
+
|
|
218
|
+
if (!tunnel || tunnel.socket.readyState !== 1) {
|
|
219
|
+
return reply.code(502).send({ error: 'Bad Gateway', message: 'Tunnel not connected' });
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return reply.redirect(308, `/tunnel/${name}/`);
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export default tunnelPlugin;
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebRTC Signaling Server Plugin
|
|
3
|
+
*
|
|
4
|
+
* Lightweight signaling server for WebRTC peer-to-peer connections.
|
|
5
|
+
* Relays SDP offers/answers and ICE candidates between authenticated users.
|
|
6
|
+
* The actual media/data flow directly between peers — JSS just introduces them.
|
|
7
|
+
*
|
|
8
|
+
* Usage: jss start --webrtc
|
|
9
|
+
* Endpoint: wss://your.pod/.webrtc
|
|
10
|
+
*
|
|
11
|
+
* Protocol (JSON over WebSocket):
|
|
12
|
+
* → { type: "offer", to: "<webid>", sdp: "..." }
|
|
13
|
+
* → { type: "answer", to: "<webid>", sdp: "..." }
|
|
14
|
+
* → { type: "candidate", to: "<webid>", candidate: {...} }
|
|
15
|
+
* → { type: "hangup", to: "<webid>" }
|
|
16
|
+
* ← { type: "offer", from: "<webid>", sdp: "..." }
|
|
17
|
+
* ← { type: "answer", from: "<webid>", sdp: "..." }
|
|
18
|
+
* ← { type: "candidate", from: "<webid>", candidate: {...} }
|
|
19
|
+
* ← { type: "hangup", from: "<webid>" }
|
|
20
|
+
* ← { type: "error", message: "..." }
|
|
21
|
+
* ← { type: "peers", you: "<webid>", peers: ["<webid>", ...] }
|
|
22
|
+
* ← { type: "peer-joined", webId: "<webid>" }
|
|
23
|
+
* ← { type: "peer-left", webId: "<webid>" }
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import websocket from '@fastify/websocket';
|
|
27
|
+
import { getWebIdFromRequestAsync } from '../auth/token.js';
|
|
28
|
+
|
|
29
|
+
const ALLOWED_TYPES = new Set(['offer', 'answer', 'candidate', 'hangup']);
|
|
30
|
+
const MAX_MESSAGE_SIZE = 64 * 1024; // 64KB
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Register WebRTC signaling routes on Fastify instance
|
|
34
|
+
*
|
|
35
|
+
* @param {object} fastify - Fastify instance
|
|
36
|
+
* @param {object} options - Options
|
|
37
|
+
* @param {string} options.path - WebSocket path (default: '/.webrtc')
|
|
38
|
+
*/
|
|
39
|
+
export async function webrtcPlugin(fastify, options = {}) {
|
|
40
|
+
const path = options.path || '/.webrtc';
|
|
41
|
+
|
|
42
|
+
// Instance-scoped peer state
|
|
43
|
+
const peers = new Map();
|
|
44
|
+
|
|
45
|
+
// Only register @fastify/websocket if not already registered
|
|
46
|
+
if (!fastify.websocketServer) {
|
|
47
|
+
await fastify.register(websocket);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Clean up all connections on server close
|
|
51
|
+
fastify.addHook('onClose', async () => {
|
|
52
|
+
for (const [, socket] of peers) {
|
|
53
|
+
socket.close();
|
|
54
|
+
}
|
|
55
|
+
peers.clear();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
function broadcast(senderWebId, msg) {
|
|
59
|
+
const data = JSON.stringify(msg);
|
|
60
|
+
for (const [id, socket] of peers) {
|
|
61
|
+
if (id !== senderWebId && socket.readyState === 1) {
|
|
62
|
+
try { socket.send(data); } catch { /* socket closed between check and send */ }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
fastify.get(path, { websocket: true }, async (connection, request) => {
|
|
68
|
+
const socket = connection.socket;
|
|
69
|
+
|
|
70
|
+
// Authenticate the connection
|
|
71
|
+
const { webId } = await getWebIdFromRequestAsync(request);
|
|
72
|
+
if (!webId) {
|
|
73
|
+
socket.send(JSON.stringify({ type: 'error', message: 'Authentication required' }));
|
|
74
|
+
socket.close();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Register this peer (close old connection if reconnecting)
|
|
79
|
+
const existing = peers.get(webId);
|
|
80
|
+
const isReconnect = !!existing;
|
|
81
|
+
if (existing) {
|
|
82
|
+
peers.delete(webId);
|
|
83
|
+
existing.close();
|
|
84
|
+
}
|
|
85
|
+
peers.set(webId, socket);
|
|
86
|
+
socket.webId = webId;
|
|
87
|
+
|
|
88
|
+
// Notify the peer of their identity and online peers
|
|
89
|
+
socket.send(JSON.stringify({
|
|
90
|
+
type: 'peers',
|
|
91
|
+
you: webId,
|
|
92
|
+
peers: [...peers.keys()].filter(id => id !== webId)
|
|
93
|
+
}));
|
|
94
|
+
|
|
95
|
+
// Only broadcast peer-joined for new connections, not reconnects
|
|
96
|
+
if (!isReconnect) {
|
|
97
|
+
broadcast(webId, { type: 'peer-joined', webId });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
socket.on('message', (data) => {
|
|
101
|
+
// Enforce max message size (Buffer.byteLength for reliable byte count)
|
|
102
|
+
const raw = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
103
|
+
if (raw.byteLength > MAX_MESSAGE_SIZE) {
|
|
104
|
+
socket.send(JSON.stringify({ type: 'error', message: 'Message too large' }));
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let msg;
|
|
109
|
+
try {
|
|
110
|
+
msg = JSON.parse(raw.toString());
|
|
111
|
+
} catch {
|
|
112
|
+
socket.send(JSON.stringify({ type: 'error', message: 'Invalid JSON' }));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (!msg.to || !msg.type) {
|
|
117
|
+
socket.send(JSON.stringify({ type: 'error', message: 'Missing "to" or "type" field' }));
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Only relay known signaling types
|
|
122
|
+
if (!ALLOWED_TYPES.has(msg.type)) {
|
|
123
|
+
socket.send(JSON.stringify({ type: 'error', message: `Unknown type "${msg.type}"` }));
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const target = peers.get(msg.to);
|
|
128
|
+
if (!target || target.readyState !== 1) {
|
|
129
|
+
socket.send(JSON.stringify({ type: 'error', message: 'Peer not online', peer: msg.to }));
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Build relay payload with whitelisted fields only (prevent prototype pollution)
|
|
134
|
+
const relay = Object.create(null);
|
|
135
|
+
relay.type = msg.type;
|
|
136
|
+
relay.from = webId;
|
|
137
|
+
if (typeof msg.sdp === 'string') relay.sdp = msg.sdp;
|
|
138
|
+
if (msg.candidate != null && typeof msg.candidate === 'object' && !Array.isArray(msg.candidate)) {
|
|
139
|
+
relay.candidate = msg.candidate;
|
|
140
|
+
}
|
|
141
|
+
try { target.send(JSON.stringify(relay)); } catch {
|
|
142
|
+
socket.send(JSON.stringify({ type: 'error', message: 'Peer not online', peer: msg.to }));
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
socket.on('close', () => {
|
|
147
|
+
// Only remove if this socket is still the registered one (not replaced by reconnect)
|
|
148
|
+
if (peers.get(webId) === socket) {
|
|
149
|
+
peers.delete(webId);
|
|
150
|
+
broadcast(webId, { type: 'peer-left', webId });
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// Error handler: close event will follow and handle cleanup
|
|
155
|
+
socket.on('error', () => {});
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export default webrtcPlugin;
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tunnel Proxy Tests
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, before, after } from 'node:test';
|
|
6
|
+
import assert from 'node:assert';
|
|
7
|
+
import { WebSocket } from 'ws';
|
|
8
|
+
import {
|
|
9
|
+
startTestServer,
|
|
10
|
+
stopTestServer,
|
|
11
|
+
createTestPod,
|
|
12
|
+
getBaseUrl,
|
|
13
|
+
getPodToken
|
|
14
|
+
} from './helpers.js';
|
|
15
|
+
|
|
16
|
+
describe('Tunnel Proxy', () => {
|
|
17
|
+
let wsUrl, baseUrl;
|
|
18
|
+
|
|
19
|
+
before(async () => {
|
|
20
|
+
await startTestServer({ tunnel: true });
|
|
21
|
+
await createTestPod('tunneler');
|
|
22
|
+
baseUrl = getBaseUrl();
|
|
23
|
+
wsUrl = baseUrl.replace('http', 'ws') + '/.tunnel';
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
after(async () => {
|
|
27
|
+
await stopTestServer();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
function connectTunnel() {
|
|
31
|
+
const token = getPodToken('tunneler');
|
|
32
|
+
return new WebSocket(wsUrl, {
|
|
33
|
+
headers: { 'Authorization': `Bearer ${token}` }
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function waitMsg(ws, type, timeout = 5000) {
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
function handler(data) {
|
|
40
|
+
const msg = JSON.parse(data.toString());
|
|
41
|
+
if (msg.type === type) {
|
|
42
|
+
clearTimeout(timer);
|
|
43
|
+
ws.removeListener('message', handler);
|
|
44
|
+
ws.removeListener('close', onClose);
|
|
45
|
+
resolve(msg);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function onClose() {
|
|
49
|
+
clearTimeout(timer);
|
|
50
|
+
ws.removeListener('message', handler);
|
|
51
|
+
reject(new Error(`WebSocket closed while waiting for "${type}"`));
|
|
52
|
+
}
|
|
53
|
+
const timer = setTimeout(() => {
|
|
54
|
+
ws.removeListener('message', handler);
|
|
55
|
+
ws.removeListener('close', onClose);
|
|
56
|
+
reject(new Error(`Timeout waiting for "${type}"`));
|
|
57
|
+
}, timeout);
|
|
58
|
+
ws.on('message', handler);
|
|
59
|
+
ws.on('close', onClose);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe('Registration', () => {
|
|
64
|
+
it('should reject unauthenticated connections', async () => {
|
|
65
|
+
const ws = new WebSocket(wsUrl);
|
|
66
|
+
const msg = await waitMsg(ws, 'error');
|
|
67
|
+
assert.ok(msg.message.includes('Authentication'));
|
|
68
|
+
ws.close();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('should register a tunnel name', async () => {
|
|
72
|
+
const ws = connectTunnel();
|
|
73
|
+
await new Promise(r => ws.on('open', r));
|
|
74
|
+
|
|
75
|
+
ws.send(JSON.stringify({ type: 'register', name: 'myapp' }));
|
|
76
|
+
const msg = await waitMsg(ws, 'registered');
|
|
77
|
+
assert.strictEqual(msg.name, 'myapp');
|
|
78
|
+
assert.strictEqual(msg.url, '/tunnel/myapp/');
|
|
79
|
+
|
|
80
|
+
ws.close();
|
|
81
|
+
await new Promise(r => setTimeout(r, 50));
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should reject invalid tunnel names', async () => {
|
|
85
|
+
const ws = connectTunnel();
|
|
86
|
+
await new Promise(r => ws.on('open', r));
|
|
87
|
+
|
|
88
|
+
ws.send(JSON.stringify({ type: 'register', name: '...' }));
|
|
89
|
+
const msg = await waitMsg(ws, 'error');
|
|
90
|
+
assert.ok(msg.message.includes('Invalid'));
|
|
91
|
+
|
|
92
|
+
ws.close();
|
|
93
|
+
await new Promise(r => setTimeout(r, 50));
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('HTTP Proxying', () => {
|
|
98
|
+
it('should proxy GET requests through the tunnel', async () => {
|
|
99
|
+
const ws = connectTunnel();
|
|
100
|
+
await new Promise(r => ws.on('open', r));
|
|
101
|
+
|
|
102
|
+
// Register tunnel
|
|
103
|
+
ws.send(JSON.stringify({ type: 'register', name: 'testapp' }));
|
|
104
|
+
await waitMsg(ws, 'registered');
|
|
105
|
+
|
|
106
|
+
// Listen for tunnel requests and respond
|
|
107
|
+
ws.on('message', (data) => {
|
|
108
|
+
const msg = JSON.parse(data.toString());
|
|
109
|
+
if (msg.type === 'request') {
|
|
110
|
+
ws.send(JSON.stringify({
|
|
111
|
+
type: 'response',
|
|
112
|
+
id: msg.id,
|
|
113
|
+
status: 200,
|
|
114
|
+
headers: { 'content-type': 'application/json' },
|
|
115
|
+
body: JSON.stringify({ hello: 'world', path: msg.path })
|
|
116
|
+
}));
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// Make HTTP request through the tunnel
|
|
121
|
+
const res = await fetch(`${baseUrl}/tunnel/testapp/api/hello`);
|
|
122
|
+
assert.strictEqual(res.status, 200);
|
|
123
|
+
const body = await res.json();
|
|
124
|
+
assert.strictEqual(body.hello, 'world');
|
|
125
|
+
assert.strictEqual(body.path, '/api/hello');
|
|
126
|
+
|
|
127
|
+
ws.close();
|
|
128
|
+
await new Promise(r => setTimeout(r, 50));
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('should proxy POST requests with body', async () => {
|
|
132
|
+
const ws = connectTunnel();
|
|
133
|
+
await new Promise(r => ws.on('open', r));
|
|
134
|
+
|
|
135
|
+
ws.send(JSON.stringify({ type: 'register', name: 'postapp' }));
|
|
136
|
+
await waitMsg(ws, 'registered');
|
|
137
|
+
|
|
138
|
+
ws.on('message', (data) => {
|
|
139
|
+
const msg = JSON.parse(data.toString());
|
|
140
|
+
if (msg.type === 'request') {
|
|
141
|
+
assert.strictEqual(msg.method, 'POST');
|
|
142
|
+
ws.send(JSON.stringify({
|
|
143
|
+
type: 'response',
|
|
144
|
+
id: msg.id,
|
|
145
|
+
status: 201,
|
|
146
|
+
headers: { 'content-type': 'text/plain' },
|
|
147
|
+
body: 'Created'
|
|
148
|
+
}));
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const res = await fetch(`${baseUrl}/tunnel/postapp/items`, {
|
|
153
|
+
method: 'POST',
|
|
154
|
+
headers: { 'Content-Type': 'application/json' },
|
|
155
|
+
body: JSON.stringify({ name: 'test' })
|
|
156
|
+
});
|
|
157
|
+
assert.strictEqual(res.status, 201);
|
|
158
|
+
assert.strictEqual(await res.text(), 'Created');
|
|
159
|
+
|
|
160
|
+
ws.close();
|
|
161
|
+
await new Promise(r => setTimeout(r, 50));
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('should return 502 for unregistered tunnel', async () => {
|
|
165
|
+
const res = await fetch(`${baseUrl}/tunnel/nonexistent/path`);
|
|
166
|
+
assert.strictEqual(res.status, 502);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('should forward custom headers', async () => {
|
|
170
|
+
const ws = connectTunnel();
|
|
171
|
+
await new Promise(r => ws.on('open', r));
|
|
172
|
+
|
|
173
|
+
ws.send(JSON.stringify({ type: 'register', name: 'headerapp' }));
|
|
174
|
+
await waitMsg(ws, 'registered');
|
|
175
|
+
|
|
176
|
+
let receivedHeaders;
|
|
177
|
+
ws.on('message', (data) => {
|
|
178
|
+
const msg = JSON.parse(data.toString());
|
|
179
|
+
if (msg.type === 'request') {
|
|
180
|
+
receivedHeaders = msg.headers;
|
|
181
|
+
ws.send(JSON.stringify({
|
|
182
|
+
type: 'response',
|
|
183
|
+
id: msg.id,
|
|
184
|
+
status: 200,
|
|
185
|
+
headers: { 'x-custom-response': 'from-tunnel' },
|
|
186
|
+
body: 'ok'
|
|
187
|
+
}));
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const res = await fetch(`${baseUrl}/tunnel/headerapp/`, {
|
|
192
|
+
headers: { 'X-Custom-Request': 'to-tunnel' }
|
|
193
|
+
});
|
|
194
|
+
assert.strictEqual(res.status, 200);
|
|
195
|
+
assert.strictEqual(res.headers.get('x-custom-response'), 'from-tunnel');
|
|
196
|
+
assert.strictEqual(receivedHeaders['x-custom-request'], 'to-tunnel');
|
|
197
|
+
|
|
198
|
+
ws.close();
|
|
199
|
+
await new Promise(r => setTimeout(r, 50));
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
});
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebRTC Signaling Server Tests
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, before, after } from 'node:test';
|
|
6
|
+
import assert from 'node:assert';
|
|
7
|
+
import { WebSocket } from 'ws';
|
|
8
|
+
import {
|
|
9
|
+
startTestServer,
|
|
10
|
+
stopTestServer,
|
|
11
|
+
createTestPod,
|
|
12
|
+
getBaseUrl,
|
|
13
|
+
getPodToken
|
|
14
|
+
} from './helpers.js';
|
|
15
|
+
|
|
16
|
+
describe('WebRTC Signaling', () => {
|
|
17
|
+
let wsUrl;
|
|
18
|
+
|
|
19
|
+
before(async () => {
|
|
20
|
+
await startTestServer({ webrtc: true });
|
|
21
|
+
await createTestPod('alice');
|
|
22
|
+
await createTestPod('bob');
|
|
23
|
+
const base = getBaseUrl();
|
|
24
|
+
wsUrl = base.replace('http', 'ws') + '/.webrtc';
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
after(async () => {
|
|
28
|
+
await stopTestServer();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
/** Create an authenticated WebSocket for a pod user */
|
|
32
|
+
function connectPeer(podName) {
|
|
33
|
+
const token = getPodToken(podName);
|
|
34
|
+
const ws = new WebSocket(wsUrl, {
|
|
35
|
+
headers: { 'Authorization': `Bearer ${token}` }
|
|
36
|
+
});
|
|
37
|
+
return ws;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Connect a peer and wait for the 'peers' welcome message */
|
|
41
|
+
async function connectAndWait(podName) {
|
|
42
|
+
const ws = connectPeer(podName);
|
|
43
|
+
const msg = await waitForMessage(ws, 'peers');
|
|
44
|
+
return { ws, ...msg };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Wait for a specific message type from a WebSocket */
|
|
48
|
+
function waitForMessage(ws, type, timeout = 3000) {
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
function handler(data) {
|
|
51
|
+
const msg = JSON.parse(data.toString());
|
|
52
|
+
if (msg.type === type) {
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
ws.removeListener('message', handler);
|
|
55
|
+
ws.removeListener('close', onClose);
|
|
56
|
+
resolve(msg);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function onClose() {
|
|
60
|
+
clearTimeout(timer);
|
|
61
|
+
ws.removeListener('message', handler);
|
|
62
|
+
reject(new Error(`WebSocket closed while waiting for "${type}"`));
|
|
63
|
+
}
|
|
64
|
+
const timer = setTimeout(() => {
|
|
65
|
+
ws.removeListener('message', handler);
|
|
66
|
+
ws.removeListener('close', onClose);
|
|
67
|
+
reject(new Error(`Timeout waiting for "${type}"`));
|
|
68
|
+
}, timeout);
|
|
69
|
+
ws.on('message', handler);
|
|
70
|
+
ws.on('close', onClose);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Collect messages from a WebSocket for a duration */
|
|
75
|
+
function collectMessages(ws, duration = 500) {
|
|
76
|
+
return new Promise((resolve) => {
|
|
77
|
+
const msgs = [];
|
|
78
|
+
const handler = (data) => msgs.push(JSON.parse(data.toString()));
|
|
79
|
+
ws.on('message', handler);
|
|
80
|
+
setTimeout(() => {
|
|
81
|
+
ws.removeListener('message', handler);
|
|
82
|
+
resolve(msgs);
|
|
83
|
+
}, duration);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
describe('Authentication', () => {
|
|
88
|
+
it('should reject unauthenticated connections', async () => {
|
|
89
|
+
const ws = new WebSocket(wsUrl);
|
|
90
|
+
|
|
91
|
+
const msg = await waitForMessage(ws, 'error');
|
|
92
|
+
assert.strictEqual(msg.type, 'error');
|
|
93
|
+
assert.ok(msg.message.includes('Authentication'));
|
|
94
|
+
ws.close();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should accept authenticated connections', async () => {
|
|
98
|
+
const ws = connectPeer('alice');
|
|
99
|
+
|
|
100
|
+
const msg = await waitForMessage(ws, 'peers');
|
|
101
|
+
assert.strictEqual(msg.type, 'peers');
|
|
102
|
+
assert.ok(msg.you, 'Should include own WebID');
|
|
103
|
+
assert.ok(Array.isArray(msg.peers), 'Should include peers list');
|
|
104
|
+
ws.close();
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('Peer Presence and Signaling Relay', () => {
|
|
109
|
+
it('should handle full signaling lifecycle', async () => {
|
|
110
|
+
// Alice connects first — should see no peers
|
|
111
|
+
const { ws: alice, you: aliceId } = await connectAndWait('alice');
|
|
112
|
+
|
|
113
|
+
// Bob joins — set up listener for peer-joined before bob connects
|
|
114
|
+
const joinPromise = waitForMessage(alice, 'peer-joined');
|
|
115
|
+
const { ws: bob, you: bobId, peers: bobPeerList } = await connectAndWait('bob');
|
|
116
|
+
|
|
117
|
+
// Bob should see alice in the peer list
|
|
118
|
+
assert.strictEqual(bobPeerList.length, 1, 'Bob should see Alice');
|
|
119
|
+
|
|
120
|
+
// Alice should get peer-joined notification
|
|
121
|
+
const joinMsg = await joinPromise;
|
|
122
|
+
assert.strictEqual(joinMsg.type, 'peer-joined');
|
|
123
|
+
|
|
124
|
+
// 1. Alice sends offer to Bob
|
|
125
|
+
const offerPromise = waitForMessage(bob, 'offer');
|
|
126
|
+
alice.send(JSON.stringify({ type: 'offer', to: bobId, sdp: 'v=0\r\n' }));
|
|
127
|
+
|
|
128
|
+
const offer = await offerPromise;
|
|
129
|
+
assert.strictEqual(offer.type, 'offer');
|
|
130
|
+
assert.strictEqual(offer.from, aliceId);
|
|
131
|
+
assert.ok(offer.sdp, 'Should include SDP');
|
|
132
|
+
assert.strictEqual(offer.to, undefined, 'Should strip "to" field');
|
|
133
|
+
|
|
134
|
+
// 2. Bob sends answer to Alice
|
|
135
|
+
const answerPromise = waitForMessage(alice, 'answer');
|
|
136
|
+
bob.send(JSON.stringify({ type: 'answer', to: aliceId, sdp: 'v=0\r\n' }));
|
|
137
|
+
|
|
138
|
+
const answer = await answerPromise;
|
|
139
|
+
assert.strictEqual(answer.type, 'answer');
|
|
140
|
+
assert.strictEqual(answer.from, bobId);
|
|
141
|
+
|
|
142
|
+
// 3. Alice sends ICE candidate to Bob
|
|
143
|
+
const candidatePromise = waitForMessage(bob, 'candidate');
|
|
144
|
+
alice.send(JSON.stringify({
|
|
145
|
+
type: 'candidate', to: bobId,
|
|
146
|
+
candidate: { candidate: 'candidate:1 1 UDP 2122252543 192.168.1.1 12345 typ host', sdpMid: '0' }
|
|
147
|
+
}));
|
|
148
|
+
|
|
149
|
+
const candidate = await candidatePromise;
|
|
150
|
+
assert.strictEqual(candidate.type, 'candidate');
|
|
151
|
+
assert.ok(candidate.candidate.candidate);
|
|
152
|
+
|
|
153
|
+
// 4. Alice sends hangup to Bob
|
|
154
|
+
const hangupPromise = waitForMessage(bob, 'hangup');
|
|
155
|
+
alice.send(JSON.stringify({ type: 'hangup', to: bobId }));
|
|
156
|
+
|
|
157
|
+
const hangup = await hangupPromise;
|
|
158
|
+
assert.strictEqual(hangup.type, 'hangup');
|
|
159
|
+
assert.strictEqual(hangup.from, aliceId);
|
|
160
|
+
|
|
161
|
+
// 5. Bob leaves — alice should get notified
|
|
162
|
+
const leavePromise = waitForMessage(alice, 'peer-left');
|
|
163
|
+
bob.close();
|
|
164
|
+
|
|
165
|
+
const leaveMsg = await leavePromise;
|
|
166
|
+
assert.strictEqual(leaveMsg.type, 'peer-left');
|
|
167
|
+
|
|
168
|
+
alice.close();
|
|
169
|
+
await new Promise(r => setTimeout(r, 100));
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe('Error Handling', () => {
|
|
174
|
+
it('should reject invalid JSON', async () => {
|
|
175
|
+
const alice = connectPeer('alice');
|
|
176
|
+
await waitForMessage(alice, 'peers');
|
|
177
|
+
|
|
178
|
+
alice.send('not json');
|
|
179
|
+
const err = await waitForMessage(alice, 'error');
|
|
180
|
+
assert.strictEqual(err.message, 'Invalid JSON');
|
|
181
|
+
|
|
182
|
+
alice.close();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('should reject messages without "to" field', async () => {
|
|
186
|
+
const alice = connectPeer('alice');
|
|
187
|
+
await waitForMessage(alice, 'peers');
|
|
188
|
+
|
|
189
|
+
alice.send(JSON.stringify({ type: 'offer', sdp: '...' }));
|
|
190
|
+
const err = await waitForMessage(alice, 'error');
|
|
191
|
+
assert.ok(err.message.includes('Missing'));
|
|
192
|
+
|
|
193
|
+
alice.close();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('should error when target peer is not online', async () => {
|
|
197
|
+
const alice = connectPeer('alice');
|
|
198
|
+
await waitForMessage(alice, 'peers');
|
|
199
|
+
|
|
200
|
+
alice.send(JSON.stringify({
|
|
201
|
+
type: 'offer',
|
|
202
|
+
to: 'https://nobody.example/profile/card#me',
|
|
203
|
+
sdp: '...'
|
|
204
|
+
}));
|
|
205
|
+
const err = await waitForMessage(alice, 'error');
|
|
206
|
+
assert.ok(err.message.includes('not online'));
|
|
207
|
+
|
|
208
|
+
alice.close();
|
|
209
|
+
await new Promise(r => setTimeout(r, 50));
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
});
|