pal-explorer-cli 0.4.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.
Files changed (156) hide show
  1. package/LICENSE.md +18 -0
  2. package/README.md +314 -0
  3. package/bin/pal.js +230 -0
  4. package/extensions/@palexplorer/analytics/README.md +45 -0
  5. package/extensions/@palexplorer/analytics/docs/MONETIZATION.md +14 -0
  6. package/extensions/@palexplorer/analytics/docs/PLAN.md +23 -0
  7. package/extensions/@palexplorer/analytics/docs/PRIVACY.md +38 -0
  8. package/extensions/@palexplorer/analytics/extension.json +27 -0
  9. package/extensions/@palexplorer/analytics/index.js +186 -0
  10. package/extensions/@palexplorer/analytics/test/analytics.test.js +82 -0
  11. package/extensions/@palexplorer/audit/extension.json +17 -0
  12. package/extensions/@palexplorer/audit/index.js +2 -0
  13. package/extensions/@palexplorer/auth-email/extension.json +17 -0
  14. package/extensions/@palexplorer/auth-email/index.js +102 -0
  15. package/extensions/@palexplorer/auth-oauth/extension.json +16 -0
  16. package/extensions/@palexplorer/auth-oauth/index.js +199 -0
  17. package/extensions/@palexplorer/chat/extension.json +17 -0
  18. package/extensions/@palexplorer/chat/index.js +2 -0
  19. package/extensions/@palexplorer/discovery/extension.json +16 -0
  20. package/extensions/@palexplorer/discovery/index.js +111 -0
  21. package/extensions/@palexplorer/email-notifications/extension.json +23 -0
  22. package/extensions/@palexplorer/email-notifications/index.js +242 -0
  23. package/extensions/@palexplorer/explorer-integration/extension.json +13 -0
  24. package/extensions/@palexplorer/explorer-integration/index.js +122 -0
  25. package/extensions/@palexplorer/groups/extension.json +17 -0
  26. package/extensions/@palexplorer/groups/index.js +2 -0
  27. package/extensions/@palexplorer/networks/extension.json +17 -0
  28. package/extensions/@palexplorer/networks/index.js +2 -0
  29. package/extensions/@palexplorer/share-links/extension.json +17 -0
  30. package/extensions/@palexplorer/share-links/index.js +2 -0
  31. package/extensions/@palexplorer/sync/extension.json +17 -0
  32. package/extensions/@palexplorer/sync/index.js +2 -0
  33. package/extensions/@palexplorer/user-mgmt/extension.json +17 -0
  34. package/extensions/@palexplorer/user-mgmt/index.js +2 -0
  35. package/extensions/@palexplorer/vfs/extension.json +17 -0
  36. package/extensions/@palexplorer/vfs/index.js +167 -0
  37. package/lib/capabilities.js +263 -0
  38. package/lib/commands/analytics.js +175 -0
  39. package/lib/commands/api-keys.js +131 -0
  40. package/lib/commands/audit.js +235 -0
  41. package/lib/commands/auth.js +137 -0
  42. package/lib/commands/backup.js +76 -0
  43. package/lib/commands/billing.js +148 -0
  44. package/lib/commands/chat.js +217 -0
  45. package/lib/commands/cloud-backup.js +231 -0
  46. package/lib/commands/comment.js +99 -0
  47. package/lib/commands/completion.js +203 -0
  48. package/lib/commands/compliance.js +218 -0
  49. package/lib/commands/config.js +136 -0
  50. package/lib/commands/connect.js +44 -0
  51. package/lib/commands/dept.js +294 -0
  52. package/lib/commands/device.js +146 -0
  53. package/lib/commands/download.js +226 -0
  54. package/lib/commands/explorer.js +178 -0
  55. package/lib/commands/extension.js +970 -0
  56. package/lib/commands/favorite.js +90 -0
  57. package/lib/commands/federation.js +270 -0
  58. package/lib/commands/file.js +533 -0
  59. package/lib/commands/group.js +271 -0
  60. package/lib/commands/gui-share.js +29 -0
  61. package/lib/commands/init.js +61 -0
  62. package/lib/commands/invite.js +59 -0
  63. package/lib/commands/list.js +59 -0
  64. package/lib/commands/log.js +116 -0
  65. package/lib/commands/nearby.js +108 -0
  66. package/lib/commands/network.js +251 -0
  67. package/lib/commands/notify.js +198 -0
  68. package/lib/commands/org.js +273 -0
  69. package/lib/commands/pal.js +180 -0
  70. package/lib/commands/permissions.js +216 -0
  71. package/lib/commands/pin.js +97 -0
  72. package/lib/commands/protocol.js +357 -0
  73. package/lib/commands/rbac.js +147 -0
  74. package/lib/commands/recover.js +36 -0
  75. package/lib/commands/register.js +171 -0
  76. package/lib/commands/relay.js +131 -0
  77. package/lib/commands/remote.js +368 -0
  78. package/lib/commands/revoke.js +50 -0
  79. package/lib/commands/scanner.js +280 -0
  80. package/lib/commands/schedule.js +344 -0
  81. package/lib/commands/scim.js +203 -0
  82. package/lib/commands/search.js +181 -0
  83. package/lib/commands/serve.js +438 -0
  84. package/lib/commands/server.js +350 -0
  85. package/lib/commands/share-link.js +199 -0
  86. package/lib/commands/share.js +323 -0
  87. package/lib/commands/sso.js +200 -0
  88. package/lib/commands/status.js +136 -0
  89. package/lib/commands/stream.js +562 -0
  90. package/lib/commands/su.js +187 -0
  91. package/lib/commands/sync.js +827 -0
  92. package/lib/commands/transfers.js +152 -0
  93. package/lib/commands/uninstall.js +188 -0
  94. package/lib/commands/update.js +204 -0
  95. package/lib/commands/user.js +276 -0
  96. package/lib/commands/vfs.js +84 -0
  97. package/lib/commands/web.js +52 -0
  98. package/lib/commands/webhook.js +180 -0
  99. package/lib/commands/whoami.js +59 -0
  100. package/lib/commands/workspace.js +121 -0
  101. package/lib/core/accessLog.js +54 -0
  102. package/lib/core/analytics.js +99 -0
  103. package/lib/core/backup.js +84 -0
  104. package/lib/core/billing.js +336 -0
  105. package/lib/core/bitfieldStore.js +53 -0
  106. package/lib/core/connectionManager.js +182 -0
  107. package/lib/core/dhtDiscovery.js +148 -0
  108. package/lib/core/discoveryClient.js +408 -0
  109. package/lib/core/extensionAnalyzer.js +357 -0
  110. package/lib/core/extensionSandbox.js +250 -0
  111. package/lib/core/extensionWorkerHost.js +166 -0
  112. package/lib/core/extensions.js +1082 -0
  113. package/lib/core/fileDiff.js +69 -0
  114. package/lib/core/groups.js +119 -0
  115. package/lib/core/identity.js +340 -0
  116. package/lib/core/mdnsService.js +126 -0
  117. package/lib/core/networks.js +81 -0
  118. package/lib/core/permissions.js +109 -0
  119. package/lib/core/pro.js +27 -0
  120. package/lib/core/resolver.js +74 -0
  121. package/lib/core/serverList.js +224 -0
  122. package/lib/core/sharePolicy.js +69 -0
  123. package/lib/core/shares.js +325 -0
  124. package/lib/core/signalingServer.js +441 -0
  125. package/lib/core/streamTransport.js +106 -0
  126. package/lib/core/su.js +55 -0
  127. package/lib/core/syncEngine.js +264 -0
  128. package/lib/core/syncState.js +159 -0
  129. package/lib/core/transfers.js +259 -0
  130. package/lib/core/users.js +225 -0
  131. package/lib/core/vfs.js +216 -0
  132. package/lib/core/webServer.js +702 -0
  133. package/lib/core/webrtcStream.js +396 -0
  134. package/lib/crypto/chatEncryption.js +57 -0
  135. package/lib/crypto/shareEncryption.js +195 -0
  136. package/lib/crypto/sharePassword.js +35 -0
  137. package/lib/crypto/streamEncryption.js +189 -0
  138. package/lib/package.json +1 -0
  139. package/lib/protocol/envelope.js +271 -0
  140. package/lib/protocol/handler.js +191 -0
  141. package/lib/protocol/index.js +27 -0
  142. package/lib/protocol/messages.js +247 -0
  143. package/lib/protocol/negotiation.js +127 -0
  144. package/lib/protocol/policy.js +142 -0
  145. package/lib/protocol/router.js +86 -0
  146. package/lib/protocol/sync.js +122 -0
  147. package/lib/utils/cli.js +15 -0
  148. package/lib/utils/config.js +123 -0
  149. package/lib/utils/configIntegrity.js +87 -0
  150. package/lib/utils/downloadDir.js +9 -0
  151. package/lib/utils/explorer.js +83 -0
  152. package/lib/utils/format.js +12 -0
  153. package/lib/utils/help.js +357 -0
  154. package/lib/utils/logger.js +103 -0
  155. package/lib/utils/torrent.js +203 -0
  156. package/package.json +71 -0
@@ -0,0 +1,441 @@
1
+ import net from 'net';
2
+ import crypto from 'crypto';
3
+ import config from '../utils/config.js';
4
+
5
+ const SIGNALING_PORT = 7474;
6
+ let server = null;
7
+ let messageHandler = null;
8
+
9
+ // Rate limiting: per-IP connection and token bucket tracking
10
+ const MAX_CONNECTIONS_PER_IP = 3;
11
+ const TOKENS_PER_SECOND = 10;
12
+ const MAX_TOKENS = 15;
13
+ const connectionsByIp = new Map();
14
+ const tokenBuckets = new Map();
15
+ const rateLimitOffenders = new Map();
16
+
17
+ // Pending auth challenges (nonce → { publicKey, expiresAt })
18
+ const pendingChallenges = new Map();
19
+ const CHALLENGE_TTL_MS = 30000;
20
+
21
+ // Wire format: 4-byte big-endian length prefix + JSON payload
22
+ // Request: { type: "status" } or { type: "message", envelope: {...} }
23
+ // Response: JSON object (same framing)
24
+
25
+ function frameMessage(obj) {
26
+ const json = JSON.stringify(obj);
27
+ const buf = Buffer.alloc(4 + Buffer.byteLength(json));
28
+ buf.writeUInt32BE(Buffer.byteLength(json), 0);
29
+ buf.write(json, 4);
30
+ return buf;
31
+ }
32
+
33
+ function getSocketIp(socket) {
34
+ return socket.remoteAddress || 'unknown';
35
+ }
36
+
37
+ // Token bucket rate limiter — refills TOKENS_PER_SECOND continuously
38
+ function checkRateLimit(ip) {
39
+ const now = Date.now();
40
+
41
+ // Check if IP is in penalty box (repeat offenders get exponential backoff)
42
+ const offender = rateLimitOffenders.get(ip);
43
+ if (offender && now < offender.blockedUntil) return false;
44
+
45
+ let bucket = tokenBuckets.get(ip);
46
+ if (!bucket) {
47
+ bucket = { tokens: MAX_TOKENS, lastRefill: now };
48
+ tokenBuckets.set(ip, bucket);
49
+ }
50
+
51
+ // Refill tokens based on elapsed time
52
+ const elapsed = (now - bucket.lastRefill) / 1000;
53
+ bucket.tokens = Math.min(MAX_TOKENS, bucket.tokens + elapsed * TOKENS_PER_SECOND);
54
+ bucket.lastRefill = now;
55
+
56
+ if (bucket.tokens < 1) {
57
+ // Track repeat offenders with exponential backoff
58
+ const prev = rateLimitOffenders.get(ip);
59
+ const strikes = prev ? prev.strikes + 1 : 1;
60
+ const blockMs = Math.min(60000, 1000 * Math.pow(2, strikes - 1));
61
+ rateLimitOffenders.set(ip, { strikes, blockedUntil: now + blockMs });
62
+ return false;
63
+ }
64
+
65
+ bucket.tokens -= 1;
66
+ return true;
67
+ }
68
+
69
+ // Periodic cleanup of stale buckets and offender entries (every 60s)
70
+ setInterval(() => {
71
+ const now = Date.now();
72
+ for (const [ip, bucket] of tokenBuckets) {
73
+ if (now - bucket.lastRefill > 60000) tokenBuckets.delete(ip);
74
+ }
75
+ for (const [ip, entry] of rateLimitOffenders) {
76
+ if (now > entry.blockedUntil + 300000) rateLimitOffenders.delete(ip);
77
+ }
78
+ }, 60000).unref();
79
+
80
+ function trackConnection(ip) {
81
+ const count = connectionsByIp.get(ip) || 0;
82
+ if (count >= MAX_CONNECTIONS_PER_IP) return false;
83
+ connectionsByIp.set(ip, count + 1);
84
+ return true;
85
+ }
86
+
87
+ function untrackConnection(ip) {
88
+ const count = connectionsByIp.get(ip) || 0;
89
+ if (count <= 1) connectionsByIp.delete(ip);
90
+ else connectionsByIp.set(ip, count - 1);
91
+ }
92
+
93
+
94
+ // Lazy-loaded crypto module cache
95
+ const await_import_cache = {};
96
+ async function loadCryptoModule() {
97
+ if (!await_import_cache.chatEncryption) {
98
+ try {
99
+ const mod = await import('../crypto/chatEncryption.js');
100
+ await_import_cache.chatEncryption = mod;
101
+ } catch {
102
+ await_import_cache.chatEncryption = {};
103
+ }
104
+ }
105
+ }
106
+
107
+ async function verifyAuth(request) {
108
+ if (!request._auth) return false;
109
+ const { nonce, signature, publicKey } = request._auth;
110
+ if (!nonce || !signature || !publicKey) return false;
111
+
112
+ const challenge = pendingChallenges.get(nonce);
113
+ if (!challenge || Date.now() > challenge.expiresAt) {
114
+ pendingChallenges.delete(nonce);
115
+ return false;
116
+ }
117
+
118
+ try {
119
+ await loadCryptoModule();
120
+ const { verifySignature } = await_import_cache.chatEncryption;
121
+ if (!verifySignature) return false;
122
+ const valid = verifySignature(nonce, signature, publicKey);
123
+ if (valid) pendingChallenges.delete(nonce);
124
+ return valid;
125
+ } catch {
126
+ return false;
127
+ }
128
+ }
129
+
130
+ function isFriend(publicKey) {
131
+ const friends = config.get('friends') || [];
132
+ return friends.some(f => f.id === publicKey || f.publicKey === publicKey);
133
+ }
134
+
135
+ function handleConnection(socket) {
136
+ const ip = getSocketIp(socket);
137
+
138
+ if (!trackConnection(ip)) {
139
+ socket.destroy();
140
+ return;
141
+ }
142
+
143
+ // Token bucket rate limit at connection acceptance
144
+ if (!checkRateLimit(ip)) {
145
+ untrackConnection(ip);
146
+ socket.destroy();
147
+ return;
148
+ }
149
+
150
+ let buffer = Buffer.alloc(0);
151
+
152
+ socket.on('data', (chunk) => {
153
+ buffer = Buffer.concat([buffer, chunk]);
154
+ processBuffer();
155
+ });
156
+
157
+ function processBuffer() {
158
+ if (buffer.length < 4) return;
159
+ const msgLen = buffer.readUInt32BE(0);
160
+
161
+ // Guard against oversized messages (1MB max)
162
+ if (msgLen > 1024 * 1024) {
163
+ socket.destroy();
164
+ return;
165
+ }
166
+
167
+ if (buffer.length < 4 + msgLen) return;
168
+
169
+ if (!checkRateLimit(ip)) {
170
+ socket.destroy();
171
+ return;
172
+ }
173
+
174
+ const jsonBuf = buffer.subarray(4, 4 + msgLen);
175
+ buffer = buffer.subarray(4 + msgLen);
176
+
177
+ let request;
178
+ try {
179
+ request = JSON.parse(jsonBuf.toString());
180
+ } catch {
181
+ const errResp = frameMessage({ error: 'Invalid JSON' });
182
+ socket.write(errResp, () => socket.end());
183
+ return;
184
+ }
185
+
186
+ handleRequest(request, socket);
187
+
188
+ // Process remaining messages in buffer
189
+ if (buffer.length >= 4) processBuffer();
190
+ }
191
+
192
+ socket.on('error', () => {});
193
+ socket.on('close', () => untrackConnection(ip));
194
+ }
195
+
196
+ async function handleRequest(request, socket) {
197
+ try {
198
+ // Phase 1: auth_challenge — issue a nonce for Ed25519 challenge-response
199
+ if (request.type === 'auth_challenge') {
200
+ if (pendingChallenges.size >= 500) {
201
+ socket.write(frameMessage({ error: 'Service busy' }), () => socket.end());
202
+ return;
203
+ }
204
+ const nonce = crypto.randomBytes(32).toString('hex');
205
+ pendingChallenges.set(nonce, { expiresAt: Date.now() + CHALLENGE_TTL_MS });
206
+ // Clean expired challenges periodically
207
+ if (pendingChallenges.size > 100) {
208
+ const now = Date.now();
209
+ for (const [k, v] of pendingChallenges) {
210
+ if (now > v.expiresAt) pendingChallenges.delete(k);
211
+ }
212
+ }
213
+ socket.write(frameMessage({ ok: true, nonce }), () => socket.end());
214
+ return;
215
+ }
216
+
217
+ if (request.type === 'status') {
218
+ // MEDIUM-4 fix: only return protocol info for unauthenticated callers
219
+ const resp = { ok: true, protocol: 'PAL/1.0', version: 1 };
220
+ const authenticated = await verifyAuth(request);
221
+ if (authenticated) {
222
+ const identity = config.get('identity');
223
+ resp.handle = identity?.handle || null;
224
+ resp.publicKey = identity?.publicKey || null;
225
+ }
226
+ socket.write(frameMessage(resp), () => socket.end());
227
+ return;
228
+ }
229
+
230
+ if (request.type === 'share_list') {
231
+ const { listShares } = await import('../core/shares.js');
232
+ const shares = listShares({ allUsers: true });
233
+ const authenticated = await verifyAuth(request);
234
+ const callerPk = authenticated ? request._auth.publicKey : null;
235
+ const callerIsFriend = callerPk ? isFriend(callerPk) : false;
236
+
237
+ const filtered = shares
238
+ .filter(s => {
239
+ if (s.status !== 'active' || s.expired) return false;
240
+ // CRITICAL-1 fix: unauthenticated callers only see public shares
241
+ if (!authenticated) return s.visibility !== 'private';
242
+ // Authenticated non-friends see only public shares
243
+ if (!callerIsFriend) return s.visibility !== 'private';
244
+ return true;
245
+ })
246
+ .map(s => {
247
+ const entry = {
248
+ id: s.id,
249
+ name: s.name,
250
+ type: s.type,
251
+ visibility: s.visibility || 'global',
252
+ streamable: s.streamable || false,
253
+ recipientCount: (s.recipients || []).length,
254
+ createdAt: s.createdAt,
255
+ };
256
+ // Only include magnet for authenticated friends
257
+ if (callerIsFriend) {
258
+ entry.magnet = s.magnet || null;
259
+ }
260
+ return entry;
261
+ });
262
+
263
+ const resp = { ok: true, shares: filtered };
264
+ if (callerIsFriend) {
265
+ const identity = config.get('identity');
266
+ const device = config.get('device');
267
+ resp.handle = identity?.handle || null;
268
+ resp.publicKey = identity?.publicKey || null;
269
+ resp.deviceName = device?.name || null;
270
+ resp.deviceId = device?.id || null;
271
+ resp.torrentPort = config.get('torrentPort') || null;
272
+ }
273
+ socket.write(frameMessage(resp), () => socket.end());
274
+ return;
275
+ }
276
+
277
+ if (request.type === 'share_files' && request.shareId) {
278
+ const { listShares } = await import('../core/shares.js');
279
+ const shares = listShares({ allUsers: true });
280
+ const share = shares.find(s => s.id === request.shareId || s.name === request.shareId);
281
+ if (!share) {
282
+ socket.write(frameMessage({ ok: false, error: 'Share not found' }), () => socket.end());
283
+ return;
284
+ }
285
+
286
+ // CRITICAL-2 fix: private shares require authentication + friendship or recipient check
287
+ if (share.visibility === 'private') {
288
+ const authenticated = await verifyAuth(request);
289
+ const callerPk = authenticated ? request._auth.publicKey : null;
290
+ if (!callerPk || (!isFriend(callerPk) && !(share.recipients || []).some(r => r.publicKey === callerPk || r.id === callerPk))) {
291
+ socket.write(frameMessage({ ok: false, error: 'Share not found' }), () => socket.end());
292
+ return;
293
+ }
294
+ }
295
+
296
+ socket.write(frameMessage({
297
+ ok: true,
298
+ shareId: share.id,
299
+ shareName: share.name,
300
+ files: share.files || [],
301
+ magnet: share.magnet || null,
302
+ }), () => socket.end());
303
+ return;
304
+ }
305
+
306
+ if (request.type === 'message' && request.envelope) {
307
+ const authenticated = await verifyAuth(request);
308
+ if (!authenticated) {
309
+ socket.write(frameMessage({ error: 'Unauthorized' }), () => socket.end());
310
+ return;
311
+ }
312
+ if (messageHandler) {
313
+ const result = await messageHandler(request.envelope);
314
+ socket.write(frameMessage(result), () => socket.end());
315
+ } else {
316
+ socket.write(frameMessage({ received: true }), () => socket.end());
317
+ }
318
+ return;
319
+ }
320
+
321
+ socket.write(frameMessage({ error: 'Unknown request type' }), () => socket.end());
322
+ } catch (e) {
323
+ // LOW-2 fix: never leak internal error details to callers
324
+ try {
325
+ socket.write(frameMessage({ error: 'Internal error' }), () => socket.end());
326
+ } catch {}
327
+ }
328
+ }
329
+
330
+ export function setMessageHandler(handler) {
331
+ messageHandler = handler;
332
+ }
333
+
334
+ export function start(callback) {
335
+ if (server) {
336
+ const result = { port: SIGNALING_PORT, bound: true };
337
+ if (callback) callback(result);
338
+ return result;
339
+ }
340
+
341
+ const srv = net.createServer(handleConnection);
342
+ server = srv; // optimistically set so isRunning() returns true immediately
343
+
344
+ srv.on('error', (err) => {
345
+ if (err.code === 'EADDRINUSE') {
346
+ console.error(`[signaling] Port ${SIGNALING_PORT} in use, skipping`);
347
+ }
348
+ server = null;
349
+ if (callback) callback({ port: SIGNALING_PORT, bound: false, error: err.code });
350
+ });
351
+
352
+ srv.listen(SIGNALING_PORT, '0.0.0.0', () => {
353
+ if (callback) callback({ port: SIGNALING_PORT, bound: true });
354
+ });
355
+
356
+ return { port: SIGNALING_PORT };
357
+ }
358
+
359
+ // Promise-based start for async callers
360
+ export function startAsync() {
361
+ return new Promise((resolve) => start(resolve));
362
+ }
363
+
364
+ export function stop() {
365
+ if (server) {
366
+ server.close();
367
+ server = null;
368
+ }
369
+ connectionsByIp.clear();
370
+ tokenBuckets.clear();
371
+ rateLimitOffenders.clear();
372
+ pendingChallenges.clear();
373
+ }
374
+
375
+ export function isRunning() {
376
+ return server !== null;
377
+ }
378
+
379
+ export function getPort() {
380
+ return SIGNALING_PORT;
381
+ }
382
+
383
+ // Client helper — send a request to a peer's signaling port and get a response
384
+ export function sendRequest(host, port, request, timeout = 3000) {
385
+ return new Promise((resolve, reject) => {
386
+ const socket = net.createConnection({ host, port }, () => {
387
+ socket.write(frameMessage(request));
388
+ });
389
+
390
+ let buffer = Buffer.alloc(0);
391
+ const timer = setTimeout(() => {
392
+ socket.destroy();
393
+ reject(new Error('Timeout'));
394
+ }, timeout);
395
+
396
+ socket.on('data', (chunk) => {
397
+ buffer = Buffer.concat([buffer, chunk]);
398
+ if (buffer.length >= 4) {
399
+ const msgLen = buffer.readUInt32BE(0);
400
+ if (buffer.length >= 4 + msgLen) {
401
+ clearTimeout(timer);
402
+ try {
403
+ const resp = JSON.parse(buffer.subarray(4, 4 + msgLen).toString());
404
+ resolve(resp);
405
+ } catch (e) {
406
+ reject(e);
407
+ }
408
+ socket.end();
409
+ }
410
+ }
411
+ });
412
+
413
+ socket.on('error', (err) => {
414
+ clearTimeout(timer);
415
+ reject(err);
416
+ });
417
+
418
+ socket.on('close', () => {
419
+ clearTimeout(timer);
420
+ });
421
+ });
422
+ }
423
+
424
+ // Client helper — perform authenticated request (challenge-response handshake)
425
+ export async function sendAuthenticatedRequest(host, port, request, privateKey, publicKey, timeout = 5000) {
426
+ // Step 1: get challenge nonce
427
+ const challengeResp = await sendRequest(host, port, { type: 'auth_challenge' }, timeout);
428
+ if (!challengeResp.ok || !challengeResp.nonce) {
429
+ throw new Error('Failed to get auth challenge');
430
+ }
431
+
432
+ // Step 2: sign the nonce
433
+ const { signMessage } = await import('../crypto/chatEncryption.js');
434
+ const signature = signMessage(challengeResp.nonce, privateKey);
435
+
436
+ // Step 3: send authenticated request
437
+ return sendRequest(host, port, {
438
+ ...request,
439
+ _auth: { nonce: challengeResp.nonce, signature, publicKey },
440
+ }, timeout);
441
+ }
@@ -0,0 +1,106 @@
1
+ import config from '../utils/config.js';
2
+ import { fetchTurnCredentials } from './discoveryClient.js';
3
+ import { isPro } from './pro.js';
4
+ import { getNearbyPeers } from './mdnsService.js';
5
+ import { sendRequest } from './signalingServer.js';
6
+ import crypto from 'crypto';
7
+
8
+ // Transport types
9
+ export const TRANSPORT = { LAN: 'lan', P2P: 'p2p', RELAY: 'relay', TUNNEL: 'tunnel' };
10
+
11
+ export function getTransportConfig() {
12
+ const settings = config.get('settings') || {};
13
+ return {
14
+ preferred: settings.streamTransport || 'p2p',
15
+ stunServers: settings.stunServers || ['stun:stun.l.google.com:19302'],
16
+ turnServer: settings.turn_server || 'turn.palexplorer.com',
17
+ customStunServers: settings.customStunServers || [],
18
+ customTurnServers: settings.customTurnServers || [],
19
+ tunnelUrl: settings.tunnelUrl || '',
20
+ };
21
+ }
22
+
23
+ export function setTransportConfig(updates) {
24
+ const settings = config.get('settings') || {};
25
+ if (updates.preferred !== undefined) settings.streamTransport = updates.preferred;
26
+ if (updates.stunServers !== undefined) settings.stunServers = updates.stunServers;
27
+ if (updates.customStunServers !== undefined) settings.customStunServers = updates.customStunServers;
28
+ if (updates.customTurnServers !== undefined) settings.customTurnServers = updates.customTurnServers;
29
+ if (updates.tunnelUrl !== undefined) settings.tunnelUrl = updates.tunnelUrl;
30
+ config.set('settings', settings);
31
+ }
32
+
33
+ export function getIceServers() {
34
+ const tc = getTransportConfig();
35
+ const servers = [];
36
+
37
+ // STUN servers (built-in + custom)
38
+ const stunUrls = [...tc.stunServers, ...tc.customStunServers].filter(Boolean);
39
+ if (stunUrls.length) servers.push({ urls: stunUrls });
40
+
41
+ // Custom TURN servers (user-provided, always included if present)
42
+ for (const turn of tc.customTurnServers) {
43
+ if (turn.urls) servers.push(turn); // { urls, username, credential }
44
+ }
45
+
46
+ return servers;
47
+ }
48
+
49
+ export async function getIceServersWithRelay() {
50
+ const servers = getIceServers();
51
+
52
+ // Add palexplorer-server TURN if available (Pro only)
53
+ try {
54
+ const creds = await fetchTurnCredentials();
55
+ if (creds?.urls) {
56
+ servers.push({
57
+ urls: Array.isArray(creds.urls) ? creds.urls : [creds.urls],
58
+ username: creds.username,
59
+ credential: creds.credential,
60
+ });
61
+ }
62
+ } catch {}
63
+
64
+ return servers;
65
+ }
66
+
67
+ export function generateRoomId(localPK, remotePK) {
68
+ const sorted = [localPK, remotePK].sort();
69
+ return crypto.createHash('sha256').update(sorted.join(':')).digest('hex').slice(0, 32);
70
+ }
71
+
72
+ export function requiresPro(transport) {
73
+ // LAN is free, all internet transports require Pro
74
+ return transport !== TRANSPORT.LAN;
75
+ }
76
+
77
+ export function canUseTransport(transport) {
78
+ if (transport === TRANSPORT.LAN) return true;
79
+ return isPro();
80
+ }
81
+
82
+ export async function selectBestTransport(targetPK) {
83
+ const tc = getTransportConfig();
84
+ const pro = isPro();
85
+
86
+ // LAN check — always try first (pure TCP)
87
+ const nearby = getNearbyPeers();
88
+ const lanPeer = nearby.find(p => p.publicKey === targetPK);
89
+ if (lanPeer) {
90
+ try {
91
+ const resp = await sendRequest(lanPeer.ip, 7474, { type: 'status' }, 2000);
92
+ if (resp.ok) return { transport: TRANSPORT.LAN, address: `${lanPeer.ip}:7474` };
93
+ } catch {}
94
+ }
95
+
96
+ // Internet transports require Pro
97
+ if (!pro) return null;
98
+
99
+ // Check user preference for internet transport
100
+ if (tc.preferred === 'tunnel' && tc.tunnelUrl) {
101
+ return { transport: TRANSPORT.TUNNEL, url: tc.tunnelUrl };
102
+ }
103
+
104
+ // P2P (default for internet)
105
+ return { transport: TRANSPORT.P2P, iceServers: await getIceServersWithRelay() };
106
+ }
package/lib/core/su.js ADDED
@@ -0,0 +1,55 @@
1
+ import crypto from 'crypto';
2
+ import config from '../utils/config.js';
3
+
4
+ let sessionToken = null;
5
+ let sessionExpiresAt = null;
6
+ const SESSION_TTL_MS = 30 * 60 * 1000; // 30 minutes
7
+
8
+ function hashToken(token) {
9
+ return crypto.createHash('sha256').update(token).digest('hex');
10
+ }
11
+
12
+ export function setSuToken(token) {
13
+ config.set('suTokenHash', hashToken(token));
14
+ }
15
+
16
+ export function hasSuToken() {
17
+ return !!config.get('suTokenHash');
18
+ }
19
+
20
+ export function verifySuToken(token) {
21
+ const stored = config.get('suTokenHash');
22
+ if (!stored) return false;
23
+ const computed = hashToken(token);
24
+ if (computed.length !== stored.length) return false;
25
+ return crypto.timingSafeEqual(Buffer.from(computed), Buffer.from(stored));
26
+ }
27
+
28
+ export function authenticate(token) {
29
+ if (!verifySuToken(token)) return false;
30
+ sessionToken = token;
31
+ sessionExpiresAt = Date.now() + SESSION_TTL_MS;
32
+ return true;
33
+ }
34
+
35
+ export function isAuthenticated() {
36
+ if (sessionToken && sessionExpiresAt && Date.now() < sessionExpiresAt && verifySuToken(sessionToken)) return true;
37
+ if (sessionToken && sessionExpiresAt && Date.now() >= sessionExpiresAt) {
38
+ sessionToken = null;
39
+ sessionExpiresAt = null;
40
+ }
41
+ const envToken = process.env.PAL_SU_TOKEN;
42
+ if (envToken && verifySuToken(envToken)) return true;
43
+ return false;
44
+ }
45
+
46
+ export function requireSu() {
47
+ if (isAuthenticated()) return true;
48
+ throw new Error('This command requires super user access. Run "pe su auth" first or set PAL_SU_TOKEN.');
49
+ }
50
+
51
+ export function removeSuToken() {
52
+ config.delete('suTokenHash');
53
+ sessionToken = null;
54
+ sessionExpiresAt = null;
55
+ }