Build customizable SSH portfolio MVP
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
.env
|
||||
data/config.json
|
||||
*.log
|
||||
@@ -0,0 +1,30 @@
|
||||
# SSH Portfolio
|
||||
|
||||
A public, anonymous Node.js SSH server that turns a terminal into a customizable portfolio. Visitors connect with:
|
||||
|
||||
```sh
|
||||
ssh -p 2222 localhost
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```sh
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
|
||||
The first run creates `data/config.json` from [`config.example.json`](config.example.json). Edit persona, pages, colors, ports, and admin keys, then use `reload` as an admin or restart the server.
|
||||
|
||||
## Authentication model
|
||||
|
||||
Anonymous `none` authentication is intentionally accepted for visitors. Add one or more base64 public-key blobs to `adminPublicKeys` to enable administrator mode; key authentication is optional and never required. Admins get `reload`, `theme`, and `announce` commands.
|
||||
|
||||
Because this is an intentionally open service, run it in a container or restricted account, use a dedicated port, and do not expose private data through configured pages.
|
||||
|
||||
## Owner notifications
|
||||
|
||||
Run `npm run notify` on the owner machine (or use `NOTIFICATION_PORT`) to receive chat and announcement notifications over a localhost-only socket. The protocol is newline-delimited JSON, so it is straightforward to replace with a desktop/mobile notification client.
|
||||
|
||||
## Extending
|
||||
|
||||
Pages are data-driven in `config.json`; commands live in `src/server.js` and can be split into modules as the portfolio grows. Pong is intentionally left as the next module: it can use the same session registry and owner-online check.
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"persona": { "name": "Your Name", "tagline": "A short line about you", "location": "Earth", "email": "you@example.com" },
|
||||
"theme": { "primary": "cyan", "accent": "yellow", "muted": "brightBlack" },
|
||||
"pages": {
|
||||
"about": { "title": "About", "lines": ["Tell visitors who you are.", "This file is deliberately easy to edit."] },
|
||||
"projects": { "title": "Projects", "lines": ["Project One — what it does", "Project Two — what you learned"] },
|
||||
"links": { "title": "Links", "lines": ["GitHub: https://github.com/yourname", "Website: https://example.com"] }
|
||||
},
|
||||
"adminPublicKeys": [],
|
||||
"host": { "port": 2222, "hostKeyPath": "data/host.key", "notificationPort": 7777 }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
-----BEGIN RSA PRIVATE KEY-----
|
||||
MIIEowIBAAKCAQEAsFtKu2Sw4Y20f2vBfkFvKjO+p02pfFHD+IeSl6z9+8TREKJt
|
||||
Uu0QbAr+h0LK2Y7Bk/OJHVjk0B/Bg5MPw9VO8hrMUurj0ftpGmhNHSuRVS/X4AMy
|
||||
R81YNNuCK3dbm0pFf/e7SW0ur+pIBkqLnJvQgDjPJh1kf+oqOIhfiIRMfanlpLy6
|
||||
prk/3yVKHSe6zsM+fWS4fx1oKo22rVSqol/35V8HW1KxE4CwVPfC4nZEamz4KiH2
|
||||
wLVgv0Z+J7IBwhvL6jOMwlZGaNTnoNts5JN2lFHzMuYfSBMi0+3cfQ8Oc2OpHQBe
|
||||
8btdEflFY8gD+TyJgdXlvi6n5XvvX/Ljm9FD9wIDAQABAoIBAA6aOIlksHq0o43r
|
||||
a7WbEGvTpgUvWonHMW1aOGqjsdIPfWodsOStaW8wx9uGTcqO1ft8rk1GR4fuITVW
|
||||
T3ddKbDFREbXWS7RXOYJXd089scxc3QCf77PQ275AAG+W9DzaObMKe9OAZAiW31d
|
||||
3cqFYlHDom9a/q/SwVU2AYcJwVWkQQD9Y4aMN2XD1bkzEmlJIxi4cOT/APkZhlJz
|
||||
GUb+wT5qeERbwYsVbXDu46ABlnIj6+THtUjvCIrM2IdKSniquwDBqFv1NCpOuWDy
|
||||
dNu3Q4lQIUoixRUOLkmHwIuVvipTFjS3I6hUuLVmFDBqRpIgvyri9285sgs2BqHR
|
||||
g5yV2j0CgYEA28Tv7+EvS/aUuC1rM1+7n4mSwFwC5lO8AVepsEULet9KJxFdyk/U
|
||||
UAK6XeUjHhzDgP9N9rogiEqROUAyjBeFcW1xygdhq+1RcpccO98mxxnPMlfo4RXe
|
||||
Rc6xmxf0/I+eLDMtF2ii3qFlc2LLXDytkseEU0jcJ8U9d825O8Tbau0CgYEAzW4u
|
||||
kDq2XxfM6JjTEup8n2hFqam5ieCz347raNiZYKOvptArxYlXe2gjL388uEE8xUf0
|
||||
VgtUyCQQ8KRH38RV+VRFutKojNMTwmwLrDyLOE8BuPDgREQ3HJR7rgx35BQpec8h
|
||||
jNymX6DTJlrJtLbn7G/qekWG5nRzH24tChQsOfMCgYAchhEGN1nmKBjhVrPFNTWq
|
||||
3TY41ThjIJcULYaOpEd7KSP8shpUOcw6nkAGE8ldDSqqwXgi/8FdLyBuJ9Dnsd3V
|
||||
/Ph52j9pCyI5FzfI5Yuo58amqBZHsIjq0ormsFNRvGoxHmtJ0IDjGFuMgFQYop98
|
||||
n3yjOp+oYcc6l27lPQWleQKBgBcZQYEUKPduTKsGrA0fcp3V8qdOHz+ilXcYsd2b
|
||||
JTvZi+Gc3J8cf0qv/ezUSLaLF1MzcQTPx1utun3kavR9NhGP6TtFfpA/F6Sv02wt
|
||||
bpYJRC9QXlcuuGetRm7elgh+P9+ja6lUEh0Ej1lrsexw+Ij8WIghEzcMdvlcwRG2
|
||||
DPcDAoGBAM74utJYm2bEiQGmtC16B7c7wdevA6yIsERcgUEdjb8GH3dMed1bcZJ1
|
||||
fFriktFd9iCgxEv/p/jhdMMqY/Ahi/izN3658+mO4NhhwNh0NRCNToVVz1kc9qpd
|
||||
DgZlyYuqEkYRlWFMc3lAKUTEhh4gS41mGHTIN51WlY91jw7ycAEd
|
||||
-----END RSA PRIVATE KEY-----
|
||||
Generated
+99
@@ -0,0 +1,99 @@
|
||||
{
|
||||
"name": "ssh-portfolio",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ssh-portfolio",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"ssh2": "^1.16.0"
|
||||
},
|
||||
"bin": {
|
||||
"ssh-portfolio": "src/server.js",
|
||||
"ssh-portfolio-notify": "src/notify-client.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/asn1": {
|
||||
"version": "0.2.6",
|
||||
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
|
||||
"integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": "~2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bcrypt-pbkdf": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
|
||||
"integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tweetnacl": "^0.14.3"
|
||||
}
|
||||
},
|
||||
"node_modules/buildcheck": {
|
||||
"version": "0.0.7",
|
||||
"resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz",
|
||||
"integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cpu-features": {
|
||||
"version": "0.0.10",
|
||||
"resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz",
|
||||
"integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==",
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"buildcheck": "~0.0.6",
|
||||
"nan": "^2.19.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nan": {
|
||||
"version": "2.28.0",
|
||||
"resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz",
|
||||
"integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ssh2": {
|
||||
"version": "1.17.0",
|
||||
"resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz",
|
||||
"integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"asn1": "^0.2.6",
|
||||
"bcrypt-pbkdf": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.16.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"cpu-features": "~0.0.10",
|
||||
"nan": "^2.23.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tweetnacl": {
|
||||
"version": "0.14.5",
|
||||
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
|
||||
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==",
|
||||
"license": "Unlicense"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "ssh-portfolio",
|
||||
"version": "0.1.0",
|
||||
"description": "A customizable, public SSH portfolio for your online persona",
|
||||
"type": "module",
|
||||
"main": "src/server.js",
|
||||
"bin": { "ssh-portfolio": "src/server.js", "ssh-portfolio-notify": "src/notify-client.js" },
|
||||
"scripts": { "start": "node src/server.js", "notify": "node src/notify-client.js", "check": "node --check src/server.js && node --check src/notify-client.js" },
|
||||
"engines": { "node": ">=20" },
|
||||
"dependencies": { "ssh2": "^1.16.0" }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const root = process.cwd();
|
||||
const file = process.env.PORTFOLIO_CONFIG || path.join(root, 'data', 'config.json');
|
||||
const example = path.join(root, 'config.example.json');
|
||||
export function loadConfig() {
|
||||
if (!fs.existsSync(file)) {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.copyFileSync(example, file);
|
||||
console.log(`Created ${file}; edit it to personalize your portfolio.`);
|
||||
}
|
||||
const config = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
config.host ??= {};
|
||||
config.host.port = Number(process.env.PORT || config.host.port || 2222);
|
||||
config.host.notificationPort = Number(process.env.NOTIFICATION_PORT || config.host.notificationPort || 7777);
|
||||
return config;
|
||||
}
|
||||
export const configPath = file;
|
||||
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
import net from 'node:net';
|
||||
const port = Number(process.env.NOTIFICATION_PORT || 7777);
|
||||
const socket = net.createConnection({ host: '127.0.0.1', port }, () => console.log(`Listening for SSH portfolio notifications on localhost:${port}`));
|
||||
socket.on('data', data => data.toString().split('\n').filter(Boolean).forEach(line => { try { const item = JSON.parse(line); if (item.message) console.log(`\n[ssh-portfolio] ${item.message}`); } catch {} }));
|
||||
socket.on('error', err => { console.error(`Unable to connect to notification server: ${err.message}`); process.exitCode = 1; });
|
||||
@@ -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');
|
||||
@@ -0,0 +1,7 @@
|
||||
export const ESC = '\x1b[';
|
||||
export const colors = { cyan: 36, yellow: 33, green: 32, magenta: 35, blue: 34, white: 37, red: 31, brightBlack: 90 };
|
||||
export const color = (name, text) => `\x1b[${colors[name] || colors.cyan}m${text}\x1b[0m`;
|
||||
export const clear = '\x1b[2J\x1b[H';
|
||||
export function wrap(text, width = 78) {
|
||||
const out = []; for (const paragraph of String(text).split('\n')) { let line = ''; for (const word of paragraph.split(/\s+/)) { if (line && line.length + word.length + 1 > width) { out.push(line); line = word; } else line += (line ? ' ' : '') + word; } out.push(line); } return out;
|
||||
}
|
||||
Reference in New Issue
Block a user