FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

Add --terminal flag for WebSocket shell access by melvincarvalho · Pull Request #235 · JavaScriptSolidServer/JavaScriptSolidServer · GitHub

Repository navigation

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension .js  (4) All 1 file type selected
Viewed files
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Unified
Split
Hide whitespace
Diff view
Unified
Split
Hide whitespace
134 changes: 134 additions & 0 deletions bin/jss.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* Usage:
* jss start [options] Start the server
* jss init Initialize configuration
* jss passwd <username> Change user password
*/

import { Command } from 'commander';
Expand All @@ -14,6 +15,7 @@ import { loadConfig, saveConfig, printConfig, defaults } from '../src/config.js'
import { createInvite, listInvites, revokeInvite } from '../src/idp/invites.js';
import { setQuotaLimit, getQuotaInfo, reconcileQuota, formatBytes } from '../src/storage/quota.js';
import { parseSize } from '../src/config.js';
import crypto from 'crypto';
import fs from 'fs-extra';
import path from 'path';
import { fileURLToPath } from 'url';
Expand Down Expand Up @@ -66,6 +68,8 @@ program
.option('--webrtc', 'Enable WebRTC signaling server')
.option('--no-webrtc', 'Disable WebRTC signaling server')
.option('--webrtc-path <path>', 'WebRTC signaling WebSocket path (default: /.webrtc)')
.option('--terminal', 'Enable WebSocket terminal (shell access)')
.option('--no-terminal', 'Disable WebSocket terminal')
.option('--tunnel', 'Enable tunnel proxy (decentralized ngrok)')
.option('--no-tunnel', 'Disable tunnel proxy')
.option('--tunnel-path <path>', 'Tunnel WebSocket path (default: /.tunnel)')
Expand Down Expand Up @@ -147,6 +151,7 @@ program
nostrMaxEvents: config.nostrMaxEvents,
webrtc: config.webrtc,
webrtcPath: config.webrtcPath,
terminal: config.terminal,
tunnel: config.tunnel,
tunnelPath: config.tunnelPath,
activitypub: config.activitypub,
Expand Down Expand Up @@ -193,6 +198,7 @@ program
if (config.git) console.log(' Git: enabled (clone/push support)');
if (config.nostr) console.log(` Nostr: enabled (${config.nostrPath})`);
if (config.webrtc) console.log(` WebRTC: enabled (${config.webrtcPath || '/.webrtc'})`);
if (config.terminal) console.log(' Terminal: enabled (/.terminal)');
if (config.tunnel) console.log(` Tunnel: enabled (${config.tunnelPath || '/.tunnel'})`);
if (config.activitypub) console.log(` ActivityPub: enabled (@${config.apUsername || 'me'})`);
if (config.singleUser) console.log(` Single-user: ${config.singleUserName || 'me'} (registration disabled)`);
Expand Down Expand Up @@ -616,6 +622,134 @@ tokenCmd
}
});

/**
* Passwd command - change a user's password
*/
program
.command('passwd <username>')
.description('Change password for a user account')
.option('-p, --password <password>', 'New password (non-interactive)')
.option('-g, --generate', 'Generate a random password')
.option('-r, --root <path>', 'Data directory')
.action(async (username, options) => {
try {
if (options.root) {
process.env.DATA_ROOT = path.resolve(options.root);
}

// Load account by username
const dataRoot = process.env.DATA_ROOT || './data';
const accountsDir = path.join(dataRoot, '.idp', 'accounts');
const indexPath = path.join(accountsDir, '_username_index.json');

let usernameIndex;
try {
usernameIndex = await fs.readJson(indexPath);
} catch (err) {
if (err.code === 'ENOENT') {
console.error(`Error: No accounts found (missing ${indexPath})`);
process.exit(1);
}
throw err;
}

const normalizedUsername = username.toLowerCase().trim();
const accountId = usernameIndex[normalizedUsername];
if (!accountId) {
console.error(`Error: User not found: ${username}`);
process.exit(1);
}

const accountPath = path.join(accountsDir, `${accountId}.json`);
const account = await fs.readJson(accountPath);

// Determine new password
let newPassword;

if (options.generate) {
newPassword = crypto.randomBytes(16).toString('base64url');
} else if (options.password) {
newPassword = options.password;
} else {
// Interactive prompt
newPassword = await promptPassword('New password: ');
const confirm = await promptPassword('Confirm password: ');
if (newPassword !== confirm) {
console.error('Error: Passwords do not match');
process.exit(1);
}
}

if (!newPassword) {
console.error('Error: Password cannot be empty');
process.exit(1);
}

// Hash and save
const bcrypt = await import('bcryptjs').then(m => m.default);
account.passwordHash = await bcrypt.hash(newPassword, 10);
account.passwordChangedAt = new Date().toISOString();
await fs.writeJson(accountPath, account, { spaces: 2 });

if (options.generate) {
console.log(`\nPassword updated for ${normalizedUsername}`);
console.log(`Generated password: ${newPassword}\n`);
} else {
console.log(`\nPassword updated for ${normalizedUsername}\n`);
}
} catch (err) {
console.error(`Error: ${err.message}`);
process.exit(1);
}
});

/**
* Helper: Prompt for a password (hidden input)
*/
async function promptPassword(question) {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});

return new Promise((resolve) => {
// Disable echo for password input
if (process.stdin.isTTY) {
process.stdout.write(` ${question}`);
const stdin = process.openStdin();
process.stdin.setRawMode(true);
let password = '';
const onData = (ch) => {
const c = ch.toString('utf8');
if (c === '\n' || c === '\r' || c === '\u0004') {
process.stdin.setRawMode(false);
process.stdin.removeListener('data', onData);
process.stdout.write('\n');
rl.close();
resolve(password);
} else if (c === '\u0003') {
// Ctrl+C
process.exit(0);
} else if (c === '\u007f' || c === '\b') {
// Backspace
if (password.length > 0) {
password = password.slice(0, -1);
}
} else {
password += c;
}
};
process.stdin.on('data', onData);
} else {
// Non-TTY: read line normally (piped input)
rl.question(` ${question}`, (answer) => {
rl.close();
resolve(answer.trim());
});
}
});
}

/**
* Helper: Prompt for input
*/
Expand Down
4 changes: 4 additions & 0 deletions src/config.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ export const defaults = {
webrtc: false,
webrtcPath: '/.webrtc',

// Terminal (WebSocket shell access)
terminal: false,

// Tunnel (decentralized ngrok)
tunnel: false,
tunnelPath: '/.tunnel',
Expand Down Expand Up @@ -140,6 +143,7 @@ const envMap = {
JSS_NOSTR_MAX_EVENTS: 'nostrMaxEvents',
JSS_WEBRTC: 'webrtc',
JSS_WEBRTC_PATH: 'webrtcPath',
JSS_TERMINAL: 'terminal',
JSS_TUNNEL: 'tunnel',
JSS_TUNNEL_PATH: 'tunnelPath',
JSS_ACTIVITYPUB: 'activitypub',
Expand Down
12 changes: 12 additions & 0 deletions src/server.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { remoteStoragePlugin } from './remotestorage.js';
import { dbPlugin } from './db/index.js';
import { webrtcPlugin } from './webrtc/index.js';
import { tunnelPlugin } from './tunnel/index.js';
import { terminalPlugin } from './terminal/index.js';

const __dirname = dirname(fileURLToPath(import.meta.url));

Expand Down Expand Up @@ -76,6 +77,8 @@ export function createServer(options = {}) {
// WebRTC signaling is OFF by default
const webrtcEnabled = options.webrtc ?? false;
const webrtcPath = options.webrtcPath ?? '/.webrtc';
// Terminal (WebSocket shell) is OFF by default
const terminalEnabled = options.terminal ?? false;
// Tunnel proxy is OFF by default
const tunnelEnabled = options.tunnel ?? false;
const tunnelPath = options.tunnelPath ?? '/.tunnel';
Expand Down Expand Up @@ -248,6 +251,11 @@ export function createServer(options = {}) {
fastify.register(webrtcPlugin, { path: webrtcPath });
}

// Register terminal (WebSocket shell) if enabled
if (terminalEnabled) {
fastify.register(terminalPlugin, { path: '/.terminal' });
Comment on lines +255 to +256

Copilot AI Mar 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

/.terminal is hard-coded in multiple places (plugin registration, dotfile bypass, and WAC bypass). This increases the chance of drift if the path ever changes. Consider defining a single terminalPath constant (like webrtcPath/tunnelPath) and reusing it for registration and bypass checks.

Suggested change
if (terminalEnabled) {
fastify.register(terminalPlugin, { path: '/.terminal' });
const terminalPath = '/.terminal';
if (terminalEnabled) {
fastify.register(terminalPlugin, { path: terminalPath });

Copilot uses AI. Check for mistakes.
}

// Register tunnel proxy if enabled
if (tunnelEnabled) {
fastify.register(tunnelPlugin, { path: tunnelPath });
Expand Down Expand Up @@ -352,6 +360,9 @@ export function createServer(options = {}) {
if (webrtcEnabled && urlNoQuery === webrtcPath) {
return;
}
if (terminalEnabled && urlNoQuery === '/.terminal') {
return;
}

const segments = request.url.split('/').map(s => s.split('?')[0]); // Remove query strings
const hasForbiddenDotfile = segments.some(seg =>
Expand Down Expand Up @@ -427,6 +438,7 @@ export function createServer(options = {}) {
(payEnabled && isPayRequest(request.url)) ||
(mongoEnabled && (request.url === '/db' || request.url.startsWith('/db/'))) ||
(webrtcEnabled && (request.url === webrtcPath || request.url.startsWith(webrtcPath + '?'))) ||
(terminalEnabled && (request.url === '/.terminal' || request.url.startsWith('/.terminal?'))) ||
(tunnelEnabled && (request.url === tunnelPath || request.url.startsWith(tunnelPath + '?') || request.url.startsWith('/tunnel/'))) ||
mashlibPaths.some(p => request.url === p || request.url.startsWith(p + '.'))) {
return;
Expand Down
127 changes: 127 additions & 0 deletions src/terminal/index.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/**
* Terminal Plugin — WebSocket Shell Access
*
* Provides a remote shell over WebSocket for authenticated pod owners.
* Spawns /bin/sh on connection and pipes stdin/stdout/stderr between
* the WebSocket and the shell process.
*
* SECURITY: Requires authentication. The connecting user's webId must
* be present (verified via token). This is a privileged endpoint.
*
* Usage: jss start --terminal
* Endpoint: wss://your.pod/.terminal
*
* Protocol (binary/text over WebSocket):
* -> (text/binary) stdin data sent to shell
* <- (text/binary) stdout/stderr data from shell
* <- JSON { type: "exit", code: <n> } shell exited
* <- JSON { type: "error", message: "..." }
*/

import websocket from '@fastify/websocket';
import { getWebIdFromRequestAsync } from '../auth/token.js';
import { spawn } from 'child_process';

/**
* Register terminal WebSocket route on Fastify instance
*
* @param {object} fastify - Fastify instance
* @param {object} options - Options
* @param {string} options.path - WebSocket path (default: '/.terminal')
*/
export async function terminalPlugin(fastify, options = {}) {
const wsPath = options.path || '/.terminal';

// Track active shell processes for cleanup
const shells = new Set();
Comment on lines +32 to +36

Copilot AI Mar 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

New Terminal WebSocket plugin introduces significant new behavior (auth + process spawning + lifecycle cleanup) but there are no automated tests added. The repo already has WebSocket plugin tests (e.g., test/webrtc.test.js, test/tunnel.test.js); please add a test/terminal.test.js covering at least: unauthenticated rejection, owner-only enforcement, basic command execution, and shell termination on socket close/server shutdown.

Copilot uses AI. Check for mistakes.

if (!fastify.websocketServer) {
await fastify.register(websocket);
}

// Clean up all shells on server close
fastify.addHook('onClose', async () => {
for (const proc of shells) {
try { proc.kill(); } catch { /* already dead */ }
}
shells.clear();
});

fastify.get(wsPath, { websocket: true }, async (connection, request) => {
const socket = connection.socket;

// Authenticate — query param token support for browser WebSocket
const queryToken = request.query?.token;
if (queryToken && !request.headers.authorization) {
request.headers.authorization = `Bearer ${queryToken}`;
Comment on lines +53 to +56

Copilot AI Mar 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

This endpoint allows passing bearer tokens via ?token= and then copies them into the Authorization header. For a privileged shell endpoint, putting credentials in the URL is high-risk (tokens can leak via logs, proxies, referrers, browser history). Consider requiring the Authorization header (or using a safer mechanism like Sec-WebSocket-Protocol / first message auth) and documenting that query-string auth is not supported for /.terminal.

Suggested change
// Authenticate — query param token support for browser WebSocket
const queryToken = request.query?.token;
if (queryToken && !request.headers.authorization) {
request.headers.authorization = `Bearer ${queryToken}`;
// Authenticate — require Authorization header; query-string tokens are not supported
if (request.query?.token) {
socket.send(JSON.stringify({
type: 'error',
message: 'Query-string token authentication is not supported; use the Authorization header.',
}));
socket.close();
return;

Copilot uses AI. Check for mistakes.
}
const { webId } = await getWebIdFromRequestAsync(request);

if (!webId) {
socket.send(JSON.stringify({ type: 'error', message: 'Authentication required' }));
socket.close();
return;
}

Copilot AI Mar 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

Terminal WebSocket currently grants a /bin/sh session to any authenticated user (it only checks that webId is present). For multi-user servers this is a privilege escalation (any user can execute arbitrary commands as the server OS user). Please enforce an ownership check (similar to the /db/* plugin) so only the pod owner (or single-user mode) can open a terminal session, and return 403 otherwise.

Suggested change
// Authorization — only pod owner (or single-user mode) may open a shell
let isOwner = false;
// Explicit single-user mode: any authenticated user is treated as the owner
if (options.singleUser === true) {
isOwner = true;
} else if (typeof options.isOwner === 'function') {
try {
// Allow async/sync ownership check similar to other plugins (e.g. /db/*)
const result = options.isOwner(request, webId);
isOwner = result instanceof Promise ? await result : result;
} catch {
isOwner = false;
}
} else if (typeof options.ownerWebId === 'string') {
isOwner = webId === options.ownerWebId;
}
if (!isOwner) {
// Forbidden: authenticated but not the pod owner
socket.send(JSON.stringify({ type: 'error', code: 403, message: 'Forbidden' }));
try { socket.close(); } catch { /* already closed */ }
return;
}

Copilot uses AI. Check for mistakes.
// Spawn shell
const shell = spawn('/bin/sh', [], {
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env, TERM: 'xterm-256color' },
});

shells.add(shell);

// Pipe shell stdout to WebSocket
shell.stdout.on('data', (data) => {
if (socket.readyState === 1) {
try { socket.send(data); } catch { /* socket closed */ }
}
});

// Pipe shell stderr to WebSocket
shell.stderr.on('data', (data) => {
if (socket.readyState === 1) {
try { socket.send(data); } catch { /* socket closed */ }
}
});

// Shell exited
shell.on('close', (code) => {
shells.delete(shell);
if (socket.readyState === 1) {
try {
socket.send(JSON.stringify({ type: 'exit', code: code ?? 1 }));
socket.close();
} catch { /* socket already closed */ }
}
});

shell.on('error', (err) => {
shells.delete(shell);
if (socket.readyState === 1) {
try {
socket.send(JSON.stringify({ type: 'error', message: err.message }));
socket.close();
} catch { /* socket already closed */ }
}
});

// Pipe WebSocket messages to shell stdin
socket.on('message', (data) => {
if (shell.stdin.writable) {
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
try { shell.stdin.write(buf); } catch { /* stdin closed */ }
}
});
Comment on lines +109 to +115

Copilot AI Mar 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

Incoming WebSocket messages are forwarded to the shell without any size limit. A client can send very large frames and cause high memory/CPU usage or overwhelm the shell. Add a max message size check (like the WebRTC/tunnel plugins) and close the connection on abuse.

Copilot uses AI. Check for mistakes.

// WebSocket closed — kill the shell
socket.on('close', () => {
shells.delete(shell);
try { shell.kill(); } catch { /* already dead */ }
});

socket.on('error', () => {});
});
}

export default terminalPlugin;
Loading

Back | FazBrowse Home | New Git URL