Files
ssh-portfolio/src/server.js

57 lines
4.9 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import net from 'node:net';
import crypto from 'node:crypto';
import ssh2 from 'ssh2';
import { loadConfig, configPath } from './config.js';
import { color, clear, header, box } from './terminal.js';
import { createCommandRouter } from './commands.js';
import { configureTerminal, createTuiSession } from './tui.js';
const { Server } = ssh2;
const config = loadConfig();
const sessions = new Set();
const notifications = new Set();
const hostKey = path.resolve(config.host.hostKeyPath || 'data/host.key');
if (!fs.existsSync(hostKey)) { fs.mkdirSync(path.dirname(hostKey), { recursive: true }); fs.writeFileSync(hostKey, crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey.export({ type: 'pkcs1', format: 'pem' })); }
function isAdmin(ctx) { return ctx.admin === true; }
function count() { return sessions.size; }
function sendNotification(message) { for (const socket of notifications) socket.write(JSON.stringify({ type: 'notification', message }) + '\n'); }
function prompt(ctx) { return `${color(config.theme?.primary || 'cyan', 'portfolio')} ${isAdmin(ctx) ? color('yellow', '[admin] ') : ''} `; }
function renderHome(ctx) {
const p = config.persona || {}; const width = Math.max(40, ctx.cols || 80); const title = `${p.name || 'Your Name'}${p.tagline || ''}`;
const content = router.home(ctx).split('\n');
return `${clear}${header(title, `${count()} connected`, width, config.theme?.primary, config.theme?.muted)}\r\n${color(config.theme?.muted || 'brightBlack', p.location || '')}\r\n\r\n${box('Portfolio', content, width, config.theme)}\r\n\r\n`;
}
function renderPage(ctx, input, content = '') {
const width = Math.max(40, ctx.cols || 80); const [command, argument] = input.split(/\s+/); const pageName = command === 'open' ? argument : command;
const title = config.pages?.[pageName]?.title || (command ? command[0].toUpperCase() + command.slice(1) : 'Portfolio');
return `${clear}${header(`${config.persona?.name || 'Your Name'}${config.persona?.tagline || ''}`, `${count()} connected`, width, config.theme?.primary, config.theme?.muted)}\r\n${color(config.theme?.muted || 'brightBlack', config.persona?.location || '')}\r\n\r\n${box(title, String(content || '').split(/\r?\n/), width, config.theme)}\r\n\r\n`;
}
function refreshHeaders() { for (const s of sessions) s.tui?.updateHeader(header(`${config.persona?.name || 'Your Name'}${config.persona?.tagline || ''}`, `${count()} connected`, s.ctx.cols, config.theme?.primary, config.theme?.muted)); }
const router = createCommandRouter({ config, isAdmin, ownerOnline: () => [...sessions].some(s => s.ctx.admin), sendNotification, reload: () => Object.assign(config, loadConfig()), broadcast: line => { for (const s of sessions) s.tui?.notify(color(config.theme?.accent || 'yellow', line)); } });
function handleSession(channel, ctx) {
ctx.channel = channel; const active = { channel, ctx, tui: null }; sessions.add(active); refreshHeaders();
configureTerminal(channel, config.persona?.name || 'SSH Portfolio');
active.tui = createTuiSession({ channel, ctx, router, prompt, renderHome, renderPage, onClose: () => { sessions.delete(active); refreshHeaders(); } });
active.tui.showHome();
}
const server = new Server({ hostKeys: [fs.readFileSync(hostKey)], ident: 'ssh-portfolio' }, client => {
const ctx = { admin: false, channel: null, cols: 80, username: 'visitor' };
client.on('authentication', ctxAuth => {
ctx.username = ctxAuth.username || 'visitor';
if (ctxAuth.method === 'none') { ctxAuth.accept(); return; }
if (ctxAuth.method === 'publickey' && ctxAuth.key) {
const key = ctxAuth.key.data.toString('base64');
const allowed = (config.adminPublicKeys || []).some(entry => String(entry).trim().split(/\s+/).includes(key));
if (allowed && (!ctxAuth.signature || ctxAuth.key.verify(ctxAuth.blob, ctxAuth.signature, ctxAuth.hashAlgo))) { ctx.admin = true; ctxAuth.accept(); return; }
}
ctxAuth.reject(['none', 'publickey']);
}).on('ready', () => client.on('session', accept => { const sshSession = accept(); sshSession.on('pty', (acceptPty, rejectPty, info) => { ctx.cols = info.cols || 80; acceptPty(); sshSession.on('window-change', (acceptResize, rejectResize, resize) => { ctx.cols = resize.cols || ctx.cols; refreshHeaders(); }); }); sshSession.on('shell', (acceptShell) => handleSession(acceptShell(), ctx)); sshSession.on('exec', (acceptExec, rejectExec, info) => { const e = acceptExec(); e.write((router.execute(ctx, info.command) || '') + '\r\n'); e.exit(0); e.end(); }); })).on('error', () => {});
});
server.listen(config.host.port, '0.0.0.0', () => console.log(`SSH portfolio listening on port ${config.host.port}; config: ${configPath}`));
net.createServer(socket => { notifications.add(socket); socket.on('data', () => {}); socket.on('close', () => notifications.delete(socket)); }).listen(config.host.notificationPort, '127.0.0.1');