javascript-solid-server 0.0.117 → 0.0.119
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 +5 -1
- package/bin/jss.js +134 -0
- package/docs/configuration.md +1 -0
- package/docs/terminal.md +75 -0
- package/package.json +1 -1
- package/src/config.js +4 -0
- package/src/server.js +12 -0
- package/src/terminal/index.js +128 -0
package/README.md
CHANGED
|
@@ -24,6 +24,8 @@ A minimal, fast, JSON-LD native Solid server.
|
|
|
24
24
|
- **MongoDB Storage** — Optional `/db/` route for JSON-LD at scale
|
|
25
25
|
- **WebRTC Signaling** — Peer-to-peer connections via WebID-authenticated signaling
|
|
26
26
|
- **Tunnel Proxy** — Decentralized ngrok through your pod
|
|
27
|
+
- **Terminal** — WebSocket shell access via `--terminal`
|
|
28
|
+
- **Password CLI** — `jss passwd` for user password management
|
|
27
29
|
- **HTTP 402 Payments** — Monetize endpoints with per-request sat payments
|
|
28
30
|
- **Mashlib / SolidOS UI** — Optional data browser (CDN, local, or ES module)
|
|
29
31
|
- **Storage Quotas** — Per-user limits with CLI management
|
|
@@ -79,9 +81,10 @@ jss start [options] # Start the server
|
|
|
79
81
|
jss init [options] # Initialize configuration
|
|
80
82
|
jss invite <cmd> # Manage invite codes
|
|
81
83
|
jss quota <cmd> # Manage storage quotas
|
|
84
|
+
jss passwd <username> # Manage user passwords
|
|
82
85
|
```
|
|
83
86
|
|
|
84
|
-
Key options: `--port`, `--idp`, `--conneg`, `--mashlib`, `--git`, `--nostr`, `--activitypub`, `--webrtc`, `--tunnel`, `--mongo`, `--pay`, `--public`, `--single-user`
|
|
87
|
+
Key options: `--port`, `--idp`, `--conneg`, `--mashlib`, `--git`, `--nostr`, `--activitypub`, `--webrtc`, `--tunnel`, `--terminal`, `--mongo`, `--pay`, `--public`, `--single-user`
|
|
85
88
|
|
|
86
89
|
Full options: [docs/configuration.md](docs/configuration.md)
|
|
87
90
|
|
|
@@ -98,6 +101,7 @@ Full options: [docs/configuration.md](docs/configuration.md)
|
|
|
98
101
|
| ActivityPub & Mastodon API | [docs/activitypub.md](docs/activitypub.md) |
|
|
99
102
|
| remoteStorage | [docs/remotestorage.md](docs/remotestorage.md) |
|
|
100
103
|
| WebRTC & Tunnel | [docs/webrtc.md](docs/webrtc.md) |
|
|
104
|
+
| Terminal & Password CLI | [docs/terminal.md](docs/terminal.md) |
|
|
101
105
|
| MongoDB `/db/` Route | [docs/mongodb.md](docs/mongodb.md) |
|
|
102
106
|
| HTTP 402 Payments | [docs/payments.md](docs/payments.md) |
|
|
103
107
|
| Storage Quotas | [docs/quotas.md](docs/quotas.md) |
|
package/bin/jss.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* Usage:
|
|
7
7
|
* jss start [options] Start the server
|
|
8
8
|
* jss init Initialize configuration
|
|
9
|
+
* jss passwd <username> Change user password
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
import { Command } from 'commander';
|
|
@@ -14,6 +15,7 @@ import { loadConfig, saveConfig, printConfig, defaults } from '../src/config.js'
|
|
|
14
15
|
import { createInvite, listInvites, revokeInvite } from '../src/idp/invites.js';
|
|
15
16
|
import { setQuotaLimit, getQuotaInfo, reconcileQuota, formatBytes } from '../src/storage/quota.js';
|
|
16
17
|
import { parseSize } from '../src/config.js';
|
|
18
|
+
import crypto from 'crypto';
|
|
17
19
|
import fs from 'fs-extra';
|
|
18
20
|
import path from 'path';
|
|
19
21
|
import { fileURLToPath } from 'url';
|
|
@@ -66,6 +68,8 @@ program
|
|
|
66
68
|
.option('--webrtc', 'Enable WebRTC signaling server')
|
|
67
69
|
.option('--no-webrtc', 'Disable WebRTC signaling server')
|
|
68
70
|
.option('--webrtc-path <path>', 'WebRTC signaling WebSocket path (default: /.webrtc)')
|
|
71
|
+
.option('--terminal', 'Enable WebSocket terminal (shell access)')
|
|
72
|
+
.option('--no-terminal', 'Disable WebSocket terminal')
|
|
69
73
|
.option('--tunnel', 'Enable tunnel proxy (decentralized ngrok)')
|
|
70
74
|
.option('--no-tunnel', 'Disable tunnel proxy')
|
|
71
75
|
.option('--tunnel-path <path>', 'Tunnel WebSocket path (default: /.tunnel)')
|
|
@@ -147,6 +151,7 @@ program
|
|
|
147
151
|
nostrMaxEvents: config.nostrMaxEvents,
|
|
148
152
|
webrtc: config.webrtc,
|
|
149
153
|
webrtcPath: config.webrtcPath,
|
|
154
|
+
terminal: config.terminal,
|
|
150
155
|
tunnel: config.tunnel,
|
|
151
156
|
tunnelPath: config.tunnelPath,
|
|
152
157
|
activitypub: config.activitypub,
|
|
@@ -193,6 +198,7 @@ program
|
|
|
193
198
|
if (config.git) console.log(' Git: enabled (clone/push support)');
|
|
194
199
|
if (config.nostr) console.log(` Nostr: enabled (${config.nostrPath})`);
|
|
195
200
|
if (config.webrtc) console.log(` WebRTC: enabled (${config.webrtcPath || '/.webrtc'})`);
|
|
201
|
+
if (config.terminal) console.log(' Terminal: enabled (/.terminal)');
|
|
196
202
|
if (config.tunnel) console.log(` Tunnel: enabled (${config.tunnelPath || '/.tunnel'})`);
|
|
197
203
|
if (config.activitypub) console.log(` ActivityPub: enabled (@${config.apUsername || 'me'})`);
|
|
198
204
|
if (config.singleUser) console.log(` Single-user: ${config.singleUserName || 'me'} (registration disabled)`);
|
|
@@ -616,6 +622,134 @@ tokenCmd
|
|
|
616
622
|
}
|
|
617
623
|
});
|
|
618
624
|
|
|
625
|
+
/**
|
|
626
|
+
* Passwd command - change a user's password
|
|
627
|
+
*/
|
|
628
|
+
program
|
|
629
|
+
.command('passwd <username>')
|
|
630
|
+
.description('Change password for a user account')
|
|
631
|
+
.option('-p, --password <password>', 'New password (non-interactive)')
|
|
632
|
+
.option('-g, --generate', 'Generate a random password')
|
|
633
|
+
.option('-r, --root <path>', 'Data directory')
|
|
634
|
+
.action(async (username, options) => {
|
|
635
|
+
try {
|
|
636
|
+
if (options.root) {
|
|
637
|
+
process.env.DATA_ROOT = path.resolve(options.root);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// Load account by username
|
|
641
|
+
const dataRoot = process.env.DATA_ROOT || './data';
|
|
642
|
+
const accountsDir = path.join(dataRoot, '.idp', 'accounts');
|
|
643
|
+
const indexPath = path.join(accountsDir, '_username_index.json');
|
|
644
|
+
|
|
645
|
+
let usernameIndex;
|
|
646
|
+
try {
|
|
647
|
+
usernameIndex = await fs.readJson(indexPath);
|
|
648
|
+
} catch (err) {
|
|
649
|
+
if (err.code === 'ENOENT') {
|
|
650
|
+
console.error(`Error: No accounts found (missing ${indexPath})`);
|
|
651
|
+
process.exit(1);
|
|
652
|
+
}
|
|
653
|
+
throw err;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const normalizedUsername = username.toLowerCase().trim();
|
|
657
|
+
const accountId = usernameIndex[normalizedUsername];
|
|
658
|
+
if (!accountId) {
|
|
659
|
+
console.error(`Error: User not found: ${username}`);
|
|
660
|
+
process.exit(1);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const accountPath = path.join(accountsDir, `${accountId}.json`);
|
|
664
|
+
const account = await fs.readJson(accountPath);
|
|
665
|
+
|
|
666
|
+
// Determine new password
|
|
667
|
+
let newPassword;
|
|
668
|
+
|
|
669
|
+
if (options.generate) {
|
|
670
|
+
newPassword = crypto.randomBytes(16).toString('base64url');
|
|
671
|
+
} else if (options.password) {
|
|
672
|
+
newPassword = options.password;
|
|
673
|
+
} else {
|
|
674
|
+
// Interactive prompt
|
|
675
|
+
newPassword = await promptPassword('New password: ');
|
|
676
|
+
const confirm = await promptPassword('Confirm password: ');
|
|
677
|
+
if (newPassword !== confirm) {
|
|
678
|
+
console.error('Error: Passwords do not match');
|
|
679
|
+
process.exit(1);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
if (!newPassword) {
|
|
684
|
+
console.error('Error: Password cannot be empty');
|
|
685
|
+
process.exit(1);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// Hash and save
|
|
689
|
+
const bcrypt = await import('bcryptjs').then(m => m.default);
|
|
690
|
+
account.passwordHash = await bcrypt.hash(newPassword, 10);
|
|
691
|
+
account.passwordChangedAt = new Date().toISOString();
|
|
692
|
+
await fs.writeJson(accountPath, account, { spaces: 2 });
|
|
693
|
+
|
|
694
|
+
if (options.generate) {
|
|
695
|
+
console.log(`\nPassword updated for ${normalizedUsername}`);
|
|
696
|
+
console.log(`Generated password: ${newPassword}\n`);
|
|
697
|
+
} else {
|
|
698
|
+
console.log(`\nPassword updated for ${normalizedUsername}\n`);
|
|
699
|
+
}
|
|
700
|
+
} catch (err) {
|
|
701
|
+
console.error(`Error: ${err.message}`);
|
|
702
|
+
process.exit(1);
|
|
703
|
+
}
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* Helper: Prompt for a password (hidden input)
|
|
708
|
+
*/
|
|
709
|
+
async function promptPassword(question) {
|
|
710
|
+
const rl = readline.createInterface({
|
|
711
|
+
input: process.stdin,
|
|
712
|
+
output: process.stdout
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
return new Promise((resolve) => {
|
|
716
|
+
// Disable echo for password input
|
|
717
|
+
if (process.stdin.isTTY) {
|
|
718
|
+
process.stdout.write(` ${question}`);
|
|
719
|
+
const stdin = process.openStdin();
|
|
720
|
+
process.stdin.setRawMode(true);
|
|
721
|
+
let password = '';
|
|
722
|
+
const onData = (ch) => {
|
|
723
|
+
const c = ch.toString('utf8');
|
|
724
|
+
if (c === '\n' || c === '\r' || c === '\u0004') {
|
|
725
|
+
process.stdin.setRawMode(false);
|
|
726
|
+
process.stdin.removeListener('data', onData);
|
|
727
|
+
process.stdout.write('\n');
|
|
728
|
+
rl.close();
|
|
729
|
+
resolve(password);
|
|
730
|
+
} else if (c === '\u0003') {
|
|
731
|
+
// Ctrl+C
|
|
732
|
+
process.exit(0);
|
|
733
|
+
} else if (c === '\u007f' || c === '\b') {
|
|
734
|
+
// Backspace
|
|
735
|
+
if (password.length > 0) {
|
|
736
|
+
password = password.slice(0, -1);
|
|
737
|
+
}
|
|
738
|
+
} else {
|
|
739
|
+
password += c;
|
|
740
|
+
}
|
|
741
|
+
};
|
|
742
|
+
process.stdin.on('data', onData);
|
|
743
|
+
} else {
|
|
744
|
+
// Non-TTY: read line normally (piped input)
|
|
745
|
+
rl.question(` ${question}`, (answer) => {
|
|
746
|
+
rl.close();
|
|
747
|
+
resolve(answer.trim());
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
|
|
619
753
|
/**
|
|
620
754
|
* Helper: Prompt for input
|
|
621
755
|
*/
|
package/docs/configuration.md
CHANGED
|
@@ -177,6 +177,7 @@ Server: pub http://localhost:3000/alice/public/data.json (on change)
|
|
|
177
177
|
| `--webrtc-path <path>` | WebRTC signaling WebSocket path | /.webrtc |
|
|
178
178
|
| `--tunnel` | Enable tunnel proxy (decentralized ngrok) | false |
|
|
179
179
|
| `--tunnel-path <path>` | Tunnel WebSocket path | /.tunnel |
|
|
180
|
+
| `--terminal` | Enable WebSocket shell at `/.terminal` | false |
|
|
180
181
|
| `-q, --quiet` | Suppress logs | false |
|
|
181
182
|
|
|
182
183
|
### Environment Variables
|
package/docs/terminal.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Terminal & Password CLI
|
|
2
|
+
|
|
3
|
+
## WebSocket Terminal (`--terminal`)
|
|
4
|
+
|
|
5
|
+
Enable a WebSocket shell endpoint at `/.terminal` for browser-based terminal access.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
jss start --terminal
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
### How it works
|
|
12
|
+
|
|
13
|
+
- Spawns `/bin/sh` per authenticated WebSocket connection
|
|
14
|
+
- Pipes stdin/stdout/stderr between WebSocket and shell
|
|
15
|
+
- Requires authentication (only authenticated users can connect)
|
|
16
|
+
- Disabled by default, opt-in via `--terminal`
|
|
17
|
+
|
|
18
|
+
### Environment variable
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
JSS_TERMINAL=true jss start
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### Browser integration
|
|
25
|
+
|
|
26
|
+
Connect with [xterm.js](https://xtermjs.org/) or any WebSocket terminal client:
|
|
27
|
+
|
|
28
|
+
```javascript
|
|
29
|
+
const ws = new WebSocket('wss://your.pod/.terminal')
|
|
30
|
+
ws.onmessage = (e) => terminal.write(e.data)
|
|
31
|
+
terminal.onData((data) => ws.send(data))
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Security
|
|
35
|
+
|
|
36
|
+
- Authentication required — unauthenticated connections are rejected
|
|
37
|
+
- Experimental — not recommended for production multi-user servers without additional access controls
|
|
38
|
+
- Consider using `--single-user` mode for personal pod servers
|
|
39
|
+
|
|
40
|
+
## Password Management (`jss passwd`)
|
|
41
|
+
|
|
42
|
+
Manage IdP user passwords from the command line.
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
# Set password interactively
|
|
46
|
+
jss passwd <username>
|
|
47
|
+
|
|
48
|
+
# Set password directly
|
|
49
|
+
jss passwd <username> --password <newpassword>
|
|
50
|
+
|
|
51
|
+
# Generate and print a random password
|
|
52
|
+
jss passwd <username> --generate
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Options
|
|
56
|
+
|
|
57
|
+
| Flag | Description |
|
|
58
|
+
|------|-------------|
|
|
59
|
+
| `--password <pw>` | Set password non-interactively |
|
|
60
|
+
| `--generate` | Generate random password and print it |
|
|
61
|
+
| `-r, --root <path>` | Data directory (default: `./data`) |
|
|
62
|
+
|
|
63
|
+
### Examples
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
# Reset a user's password
|
|
67
|
+
jss passwd alice --password newsecretpass
|
|
68
|
+
|
|
69
|
+
# Generate a random password for a user
|
|
70
|
+
jss passwd bob --generate
|
|
71
|
+
# Output: New password for bob: a7Bx9kQ2mP...
|
|
72
|
+
|
|
73
|
+
# Interactive (prompts for password)
|
|
74
|
+
jss passwd alice
|
|
75
|
+
```
|
package/package.json
CHANGED
package/src/config.js
CHANGED
|
@@ -55,6 +55,9 @@ export const defaults = {
|
|
|
55
55
|
webrtc: false,
|
|
56
56
|
webrtcPath: '/.webrtc',
|
|
57
57
|
|
|
58
|
+
// Terminal (WebSocket shell access)
|
|
59
|
+
terminal: false,
|
|
60
|
+
|
|
58
61
|
// Tunnel (decentralized ngrok)
|
|
59
62
|
tunnel: false,
|
|
60
63
|
tunnelPath: '/.tunnel',
|
|
@@ -140,6 +143,7 @@ const envMap = {
|
|
|
140
143
|
JSS_NOSTR_MAX_EVENTS: 'nostrMaxEvents',
|
|
141
144
|
JSS_WEBRTC: 'webrtc',
|
|
142
145
|
JSS_WEBRTC_PATH: 'webrtcPath',
|
|
146
|
+
JSS_TERMINAL: 'terminal',
|
|
143
147
|
JSS_TUNNEL: 'tunnel',
|
|
144
148
|
JSS_TUNNEL_PATH: 'tunnelPath',
|
|
145
149
|
JSS_ACTIVITYPUB: 'activitypub',
|
package/src/server.js
CHANGED
|
@@ -20,6 +20,7 @@ import { remoteStoragePlugin } from './remotestorage.js';
|
|
|
20
20
|
import { dbPlugin } from './db/index.js';
|
|
21
21
|
import { webrtcPlugin } from './webrtc/index.js';
|
|
22
22
|
import { tunnelPlugin } from './tunnel/index.js';
|
|
23
|
+
import { terminalPlugin } from './terminal/index.js';
|
|
23
24
|
|
|
24
25
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
25
26
|
|
|
@@ -76,6 +77,8 @@ export function createServer(options = {}) {
|
|
|
76
77
|
// WebRTC signaling is OFF by default
|
|
77
78
|
const webrtcEnabled = options.webrtc ?? false;
|
|
78
79
|
const webrtcPath = options.webrtcPath ?? '/.webrtc';
|
|
80
|
+
// Terminal (WebSocket shell) is OFF by default
|
|
81
|
+
const terminalEnabled = options.terminal ?? false;
|
|
79
82
|
// Tunnel proxy is OFF by default
|
|
80
83
|
const tunnelEnabled = options.tunnel ?? false;
|
|
81
84
|
const tunnelPath = options.tunnelPath ?? '/.tunnel';
|
|
@@ -248,6 +251,11 @@ export function createServer(options = {}) {
|
|
|
248
251
|
fastify.register(webrtcPlugin, { path: webrtcPath });
|
|
249
252
|
}
|
|
250
253
|
|
|
254
|
+
// Register terminal (WebSocket shell) if enabled
|
|
255
|
+
if (terminalEnabled) {
|
|
256
|
+
fastify.register(terminalPlugin, { path: '/.terminal', public: options.public || false });
|
|
257
|
+
}
|
|
258
|
+
|
|
251
259
|
// Register tunnel proxy if enabled
|
|
252
260
|
if (tunnelEnabled) {
|
|
253
261
|
fastify.register(tunnelPlugin, { path: tunnelPath });
|
|
@@ -352,6 +360,9 @@ export function createServer(options = {}) {
|
|
|
352
360
|
if (webrtcEnabled && urlNoQuery === webrtcPath) {
|
|
353
361
|
return;
|
|
354
362
|
}
|
|
363
|
+
if (terminalEnabled && urlNoQuery === '/.terminal') {
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
355
366
|
|
|
356
367
|
const segments = request.url.split('/').map(s => s.split('?')[0]); // Remove query strings
|
|
357
368
|
const hasForbiddenDotfile = segments.some(seg =>
|
|
@@ -427,6 +438,7 @@ export function createServer(options = {}) {
|
|
|
427
438
|
(payEnabled && isPayRequest(request.url)) ||
|
|
428
439
|
(mongoEnabled && (request.url === '/db' || request.url.startsWith('/db/'))) ||
|
|
429
440
|
(webrtcEnabled && (request.url === webrtcPath || request.url.startsWith(webrtcPath + '?'))) ||
|
|
441
|
+
(terminalEnabled && (request.url === '/.terminal' || request.url.startsWith('/.terminal?'))) ||
|
|
430
442
|
(tunnelEnabled && (request.url === tunnelPath || request.url.startsWith(tunnelPath + '?') || request.url.startsWith('/tunnel/'))) ||
|
|
431
443
|
mashlibPaths.some(p => request.url === p || request.url.startsWith(p + '.'))) {
|
|
432
444
|
return;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal Plugin — WebSocket Shell Access
|
|
3
|
+
*
|
|
4
|
+
* Provides a remote shell over WebSocket for authenticated pod owners.
|
|
5
|
+
* Spawns /bin/sh on connection and pipes stdin/stdout/stderr between
|
|
6
|
+
* the WebSocket and the shell process.
|
|
7
|
+
*
|
|
8
|
+
* SECURITY: Requires authentication. The connecting user's webId must
|
|
9
|
+
* be present (verified via token). This is a privileged endpoint.
|
|
10
|
+
*
|
|
11
|
+
* Usage: jss start --terminal
|
|
12
|
+
* Endpoint: wss://your.pod/.terminal
|
|
13
|
+
*
|
|
14
|
+
* Protocol (binary/text over WebSocket):
|
|
15
|
+
* -> (text/binary) stdin data sent to shell
|
|
16
|
+
* <- (text/binary) stdout/stderr data from shell
|
|
17
|
+
* <- JSON { type: "exit", code: <n> } shell exited
|
|
18
|
+
* <- JSON { type: "error", message: "..." }
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import websocket from '@fastify/websocket';
|
|
22
|
+
import { getWebIdFromRequestAsync } from '../auth/token.js';
|
|
23
|
+
import { spawn } from 'child_process';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Register terminal WebSocket route on Fastify instance
|
|
27
|
+
*
|
|
28
|
+
* @param {object} fastify - Fastify instance
|
|
29
|
+
* @param {object} options - Options
|
|
30
|
+
* @param {string} options.path - WebSocket path (default: '/.terminal')
|
|
31
|
+
*/
|
|
32
|
+
export async function terminalPlugin(fastify, options = {}) {
|
|
33
|
+
const wsPath = options.path || '/.terminal';
|
|
34
|
+
|
|
35
|
+
// Track active shell processes for cleanup
|
|
36
|
+
const shells = new Set();
|
|
37
|
+
|
|
38
|
+
if (!fastify.websocketServer) {
|
|
39
|
+
await fastify.register(websocket);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Clean up all shells on server close
|
|
43
|
+
fastify.addHook('onClose', async () => {
|
|
44
|
+
for (const proc of shells) {
|
|
45
|
+
try { proc.kill(); } catch { /* already dead */ }
|
|
46
|
+
}
|
|
47
|
+
shells.clear();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
fastify.get(wsPath, { websocket: true }, async (connection, request) => {
|
|
51
|
+
const socket = connection.socket || connection;
|
|
52
|
+
|
|
53
|
+
// Authenticate — query param token support for browser WebSocket
|
|
54
|
+
const queryToken = request.query?.token;
|
|
55
|
+
if (queryToken && !request.headers.authorization) {
|
|
56
|
+
request.headers.authorization = `Bearer ${queryToken}`;
|
|
57
|
+
}
|
|
58
|
+
const { webId } = await getWebIdFromRequestAsync(request);
|
|
59
|
+
|
|
60
|
+
if (!webId && !options.public) {
|
|
61
|
+
socket.send(JSON.stringify({ type: 'error', message: 'Authentication required' }));
|
|
62
|
+
socket.close();
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Spawn shell
|
|
67
|
+
const shellCommand = process.env.SHELL || 'bash';
|
|
68
|
+
const shell = spawn(shellCommand, ['-i'], {
|
|
69
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
70
|
+
env: { ...process.env, TERM: 'xterm-256color' },
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
shells.add(shell);
|
|
74
|
+
|
|
75
|
+
// Pipe shell stdout to WebSocket
|
|
76
|
+
shell.stdout.on('data', (data) => {
|
|
77
|
+
if (socket.readyState === 1) {
|
|
78
|
+
try { socket.send(data.toString().replace(/\r?\n/g, '\r\n')); } catch { /* socket closed */ }
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Pipe shell stderr to WebSocket
|
|
83
|
+
shell.stderr.on('data', (data) => {
|
|
84
|
+
if (socket.readyState === 1) {
|
|
85
|
+
try { socket.send(data.toString().replace(/\r?\n/g, '\r\n')); } catch { /* socket closed */ }
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Shell exited
|
|
90
|
+
shell.on('close', (code) => {
|
|
91
|
+
shells.delete(shell);
|
|
92
|
+
if (socket.readyState === 1) {
|
|
93
|
+
try {
|
|
94
|
+
socket.send(JSON.stringify({ type: 'exit', code: code ?? 1 }));
|
|
95
|
+
socket.close();
|
|
96
|
+
} catch { /* socket already closed */ }
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
shell.on('error', (err) => {
|
|
101
|
+
shells.delete(shell);
|
|
102
|
+
if (socket.readyState === 1) {
|
|
103
|
+
try {
|
|
104
|
+
socket.send(JSON.stringify({ type: 'error', message: err.message }));
|
|
105
|
+
socket.close();
|
|
106
|
+
} catch { /* socket already closed */ }
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// Pipe WebSocket messages to shell stdin
|
|
111
|
+
socket.on('message', (data) => {
|
|
112
|
+
if (shell.stdin.writable) {
|
|
113
|
+
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
114
|
+
try { shell.stdin.write(buf); } catch { /* stdin closed */ }
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// WebSocket closed — kill the shell
|
|
119
|
+
socket.on('close', () => {
|
|
120
|
+
shells.delete(shell);
|
|
121
|
+
try { shell.kill(); } catch { /* already dead */ }
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
socket.on('error', () => {});
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export default terminalPlugin;
|