Build customizable SSH portfolio MVP
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
#!/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, wrap } from './terminal.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 render(ctx, body = '') {
|
||||
const p = config.persona || {}; const primary = config.theme?.primary || 'cyan';
|
||||
return `${clear}${color(primary, `● ${p.name || 'Your Name'} — ${p.tagline || ''}`)}\n${color(config.theme?.muted || 'brightBlack', `${p.location || ''} • ${count()} connected`)}\n\n${body}\n\n${color(primary, 'portfolio')} ${isAdmin(ctx) ? color('yellow', '[admin]') : ''}> `;
|
||||
}
|
||||
function page(ctx, name) {
|
||||
const p = config.pages?.[name]; if (!p) return `${color('red', `No page named “${name}”.`)}\nTry: pages`;
|
||||
return `${color(config.theme?.accent || 'yellow', p.title || name)}\n${wrap((p.lines || []).join('\n')).join('\n')}`;
|
||||
}
|
||||
function home(ctx) { const p = config.persona || {}; return `${color(config.theme?.accent || 'yellow', `Welcome to ${p.name || 'my portfolio'}`)}\n\n${wrap(p.bio || 'You are exploring a portfolio over SSH. Type “help” to get started.').join('\n')}\n\nCommands: home · pages · open <page> · chat · contact · clear · quit`; }
|
||||
function help(ctx) { return `help Show this help\nhome Return home\npages List available pages\nopen <page> Open a page\nchat <message> Chat with the owner/visitors\ncontact Show contact details\nclear Clear the screen\nquit Disconnect${isAdmin(ctx) ? '\n\nAdmin: reload · theme <color> · announce <message>' : ''}`; }
|
||||
function execute(ctx, input) {
|
||||
const [command, ...rest] = input.trim().split(/\s+/); const arg = rest.join(' ');
|
||||
if (!command) return home(ctx);
|
||||
if (command === 'help') return help(ctx); if (command === 'home') return home(ctx); if (command === 'clear') return '';
|
||||
if (command === 'pages') return Object.entries(config.pages || {}).map(([k, v]) => `${color(config.theme?.accent || 'yellow', k.padEnd(14))} ${v.title || ''}`).join('\n') || 'No pages configured.';
|
||||
if (command === 'open') return arg ? page(ctx, arg) : 'Usage: open <page>';
|
||||
if (command === 'contact') return `Email: ${config.persona?.email || 'not configured'}`;
|
||||
if (command === 'chat') { if (!arg) return 'Usage: chat <message>'; const line = `${config.persona?.name || 'visitor'}: ${arg}`; for (const s of sessions) s.write(`\r\n${color(config.theme?.accent || 'yellow', line)}\r\n` + render(s.ctx)); sendNotification(line); return 'Message sent.'; }
|
||||
if (command === 'reload' && isAdmin(ctx)) { Object.assign(config, loadConfig()); return 'Configuration reloaded.'; }
|
||||
if (command === 'theme' && isAdmin(ctx) && arg) { config.theme.primary = arg; return `Primary color set to ${arg} (runtime only; edit config to persist).`; }
|
||||
if (command === 'announce' && isAdmin(ctx) && arg) { for (const s of sessions) s.write(`\r\n${color(config.theme?.accent || 'yellow', `OWNER: ${arg}`)}\r\n` + render(s.ctx)); sendNotification(`OWNER: ${arg}`); return 'Announcement sent.'; }
|
||||
if (command === 'quit' || command === 'exit') { ctx.channel.end(); return ''; }
|
||||
return `Unknown command: ${command}. Try “help”.`;
|
||||
}
|
||||
function handleSession(channel, ctx) {
|
||||
let buffer = ''; ctx.channel = channel; sessions.add(ctx); channel.write(render(ctx, home(ctx)));
|
||||
channel.on('data', data => { for (const byte of data.toString()) { if (byte === '\r' || byte === '\n') { channel.write(`\r\n${execute(ctx, buffer)}\n` + render(ctx)); buffer = ''; } else if (byte === '\x7f') { if (buffer) { buffer = buffer.slice(0, -1); channel.write('\b \b'); } } else if (byte >= ' ') { buffer += byte; channel.write(byte); } } });
|
||||
channel.on('close', () => sessions.delete(ctx));
|
||||
}
|
||||
const server = new Server({ hostKeys: [fs.readFileSync(hostKey)], ident: 'ssh-portfolio' }, client => {
|
||||
const ctx = { admin: false, channel: null };
|
||||
client.on('authentication', ctxAuth => {
|
||||
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('shell', (acceptShell) => handleSession(acceptShell(), ctx)); sshSession.on('exec', (acceptExec, rejectExec, info) => { const e = acceptExec(); e.write((execute(ctx, info.command) || '') + '\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');
|
||||
Reference in New Issue
Block a user