Build a Persistent SSH Client Clone with Node.js

A minimal Termphin‑style SSH client that keeps sessions alive

Welcome to another official UVF IT guide. In this quick‑fire tutorial you’ll spin up a lightweight Node.js app that mimics Termphin’s core promise: persistent SSH sessions that survive network hiccups.

We’ll use only free‑tier tools – Node.js, the open‑source ssh2 library, and SQLite for session logging – so you can have a working prototype in about 45 minutes.

Before you start…

Architecture Overview
LOCAL STACK WebSocket SQL queries Node.js SSH Proxy Express + ssh2 SQLite DB Session logs Browser UI HTML + JS (WebSocket)
1

Phase 1: Scaffold the project

Create a fresh npm project and install the minimal dependencies needed for an Express server, WebSocket bridge, SSH handling, and SQLite storage. This gives us a clean foundation to add the SSH proxy logic later.

All commands are run from the terminal in the folder where you want the project to live. After this step you’ll have a package.json and a node_modules directory ready for coding.

mkdir termphin-clone && cd termphin-clone
npm init -y
npm install express ws ssh2 sqlite3
2

Phase 2: Define the SQLite model

A tiny table records each session’s start time, end time, and remote host – enough to prove persistence across restarts. The db.js module will expose a simple logSession function that other parts of the app can call.

We open (or create) a SQLite file called sessions.db in the project root and ensure the sessions table exists on first run.

db.js

const sqlite3 = require('sqlite3').verbose();
const path = require('path');

// Open (or create) the database file in the project root
const dbPath = path.resolve(__dirname, 'sessions.db');
const db = new sqlite3.Database(dbPath, err => {
  if (err) throw err;
  console.log('✅ SQLite DB opened at', dbPath);
});

// Ensure the sessions table exists
const initSql = `
CREATE TABLE IF NOT EXISTS sessions (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  host TEXT NOT NULL,
  start_time TEXT NOT NULL,
  end_time TEXT
);
`;

db.exec(initSql, err => {
  if (err) throw err;
  console.log('✅ sessions table ready');
});

/**
 * Log a new SSH session start.
 * @param {string} host - Remote host address.
 * @returns {Promise<number>} Resolves with the inserted row id.
 */
function logSessionStart(host) {
  return new Promise((resolve, reject) => {
    const now = new Date().toISOString();
    const stmt = db.prepare('INSERT INTO sessions (host, start_time) VALUES (?, ?)');
    stmt.run(host, now, function(err) {
      if (err) return reject(err);
      resolve(this.lastID);
    });
    stmt.finalize();
  });
}

/**
 * Update the end_time for a session when it closes.
 * @param {number} id - Session row id.
 */
function logSessionEnd(id) {
  return new Promise((resolve, reject) => {
    const now = new Date().toISOString();
    db.run('UPDATE sessions SET end_time = ? WHERE id = ?', now, id, err => {
      if (err) return reject(err);
      resolve();
    });
  });
}

module.exports = {logSessionStart, logSessionEnd, db};
3

Phase 3: Build the SSH‑WebSocket bridge

In this step we turn our Express server into a bridge between a WebSocket client in the browser and an SSH session on the remote host. The server will keep a single SSH connection alive, store its output in SQLite, and forward any keystrokes it receives over the socket. By persisting the SSH client in memory, the browser can be refreshed without losing the remote session – the server continues to stream data when the client reconnects.

server.js

const express = require('express');
const http = require('http');
const { Server } = require('ws');
const { Client } = require('ssh2');
const sqlite3 = require('sqlite3').verbose();

const app = express();
app.use(express.static('public'));
const server = http.createServer(app);
const wss = new Server({ server });

// SQLite DB for session logs
const db = new sqlite3.Database('sessions.db');
db.run('CREATE TABLE IF NOT EXISTS sessions(id INTEGER PRIMARY KEY, host TEXT, start_ts TEXT, log TEXT)');

let ssh = new Client();
let sshStream = null;
let sessionId = null;
let logBuffer = '';

ssh.on('ready',()=>{
  ssh.shell((err, stream)=>{
    if(err) return console.error('Shell error',err);
    sshStream = stream;
    stream.on('data',data=>{
      logBuffer += data.toString();
      wss.clients.forEach(ws=>{ if(ws.readyState===ws.OPEN) ws.send(data.toString()); });
    });
  });
});
ssh.connect({host:'YOUR_HOST',port:22,username:'YOUR_USER',password:'YOUR_PASS'});

wss.on('connection',ws=>{
  // send any buffered output to newly connected client
  if(logBuffer) ws.send(logBuffer);
  ws.on('message',msg=>{ if(sshStream) sshStream.write(msg); });
  ws.on('close',()=>{ /* nothing – keep SSH alive */ });
});

// Graceful shutdown – store session log
function shutdown(){
  if(sessionId===null){
    db.run('INSERT INTO sessions(host,start_ts,log) VALUES(?,?,?)',["YOUR_HOST",new Date().toISOString(),logBuffer]);
  }
  process.exit();
}
process.on('SIGINT',shutdown);
process.on('SIGTERM',shutdown);

server.listen(3000,()=>console.log('App listening on http://localhost:3000'));
4

Phase 4: Minimal UI to interact

The client side is a single HTML page that opens a WebSocket to the server, shows everything the server sends inside a <pre> element, and forwards any keystrokes typed into a text box. We keep it deliberately simple – no xterm.js, just raw text – so you can see the persistence mechanism without extra dependencies. When the page reloads, the WebSocket reconnects and the server pushes the buffered log so the terminal appears exactly where you left it.

public/index.html

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Persistent SSH Demo</title>
<style>body{font-family:monospace;background:#111;color:#0f0;}#term{white-space:pre-wrap;overflow:auto;height:80vh;}</style>
</head>
<body>
<h2>SSH Bridge</h2>
<div id="term"></div>
<input id="input" type="text" placeholder="type command" autocomplete="off" style="width:100%;"/>
<script>
  const ws = new WebSocket(`ws://${location.host}`);
  const term = document.getElementById('term');
  const input = document.getElementById('input');
  ws.onmessage = e=>{ term.textContent += e.data; term.scrollTop = term.scrollHeight; };
  ws.onopen = ()=>{ term.textContent += "\n[connected]\n"; };
  input.addEventListener('keydown',e=>{ if(e.key==='Enter'){ ws.send(input.value+'\n'); input.value=''; } });
</script>
</body>
</html>
5

Phase 5: Run, verify, and extend

Now start the Node server, open the UI in a browser, and try a simple command like ls. After you stop the server (Ctrl‑C) and start it again, reload the page – the terminal will still show the previous output because the server stored the session log in SQLite before exiting. This proves the bridge kept the SSH session alive and persisted its output across restarts. From here you can add reconnection logic, multiple concurrent sessions, or swap SQLite for a more robust database.

# Install dependencies (run once)
npm init -y
npm install express ws ssh2 sqlite3
# Start the server
node server.js
# In another terminal, open the UI
#   open http://localhost:3000  (or use your browser)

Verifying the Stack

CheckCommand / Why it matters
Server starts without errorsnode server.js → console shows ‘App listening on http://localhost:3000’
Web UI loads and shows a blank terminalOpen http://localhost:3000 in a browser – the <pre> area appears
Command echo appears after typing ‘ls’Type ‘ls’ in the input box, press Enter – output streams back
Session row appears in SQLitesqlite3 sessions.db "SELECT * FROM sessions;" – shows host, start_ts

You did it!

You now have a functional, minimal persistent‑SSH prototype. From here you can add reconnection logic, multiple tabs, or swap SQLite for Postgres to scale. Happy hacking!