Improve SSH terminal UI and input handling

This commit is contained in:
2026-07-27 09:33:12 +09:00
parent e499ae5adc
commit ec3f569edc
3 changed files with 90 additions and 13 deletions
+14 -10
View File
@@ -5,8 +5,9 @@ import net from 'node:net';
import crypto from 'node:crypto';
import ssh2 from 'ssh2';
import { loadConfig, configPath } from './config.js';
import { color, clear } from './terminal.js';
import { color, clear, header, box } from './terminal.js';
import { createCommandRouter } from './commands.js';
import { configureTerminal, createTuiSession } from './tui.js';
const { Server } = ssh2;
@@ -19,16 +20,19 @@ if (!fs.existsSync(hostKey)) { fs.mkdirSync(path.dirname(hostKey), { recursive:
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';
const title = `${p.name || 'Your Name'}${p.tagline || ''}`; const users = `${count()} connected`; const gap = ' '.repeat(Math.max(2, (ctx.cols || 80) - title.length - users.length));
return `${clear}${color(primary, title)}${gap}${color(config.theme?.muted || 'brightBlack', users)}\n${color(config.theme?.muted || 'brightBlack', p.location || '')}\n\n${body}\n\n${color(primary, 'portfolio')} ${isAdmin(ctx) ? color('yellow', '[admin]') : ''}> `;
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`;
}
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.channel.write(`\r\n${color(config.theme?.accent || 'yellow', line)}\r\n` + render(s.ctx)); } });
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) {
let buffer = ''; ctx.channel = channel; const active = { channel, ctx }; sessions.add(active); channel.write(render(ctx, router.home(ctx)));
channel.on('data', data => { for (const byte of data.toString()) { if (byte === '\r' || byte === '\n') { channel.write(`\r\n${router.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(active));
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, 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' };
@@ -41,7 +45,7 @@ const server = new Server({ hostKeys: [fs.readFileSync(hostKey)], ident: 'ssh-po
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('shell', (acceptShell) => handleSession(acceptShell(), ctx)); sshSession.on('exec', (acceptExec, rejectExec, info) => { const e = acceptExec(); e.write((router.execute(ctx, info.command) || '') + '\n'); e.exit(0); e.end(); }); })).on('error', () => {});
}).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');