Compare commits

..

12 Commits

Author SHA1 Message Date
wmantly 8a76f71edd Release 1.9.0: Quick Jump copy-to-clipboard section (#23) 2026-07-28 18:11:40 -04:00
wmantly e482f52f10 Add a Quick Jump copy-to-clipboard section to the dashboard (#22)
The uid_-_target grammar-mode SSH command was documented in the README but
nowhere in the UI itself -- users had to remember/reconstruct the format by
hand. Adds a "Quick Jump" card with a one-click-copy command for the
interactive-picker form, plus a copy button on every row of "Hosts you can
reach" that copies the exact grammar-mode command for that specific host
(using the logged-in user's own uid, so it's ready to paste and run as-is).

conf.ssh.listenPort is now passed to the dashboard view so the command can
include the right -p flag when the SSH front door isn't on the default port
22 (theta-env, for example, exposes it on 2222).

Verified live: logged in as the local admin user, confirmed the Quick Jump
command and a per-host command both populate correctly and copy to the
clipboard (toast confirmation), and that the per-host command matches the
exact uid_-_target grammar the SSH server's parseUsername expects.
2026-07-28 18:10:17 -04:00
wmantly a6af160627 Release 1.8.2: audit records carry the real upstream-connect error as failDetail (#21) 2026-07-28 17:57:28 -04:00
wmantly 8c9646b65c Thread real upstream-connect errors into audit records as failDetail (#19)
resolveAndConnect discarded the actual error from connectUpstream
(ECONNREFUSED, ETIMEDOUT, an ssh2 auth failure, ...) and replaced it with
the generic reason string 'upstream-unreachable', so the audit log gave no
way to tell a network-layer failure from an auth failure -- which is why
"Could not reach 192.168.1.206" for the emby host couldn't be root-caused
without live host-shell access. Now the real error message is captured and
surfaced as failDetail, shown as a tooltip on the audit table's fail badge.
2026-07-28 15:50:25 -04:00
wmantly 1d09f243dd Release 1.8.1: Redis persistence fix (#20) 2026-07-28 15:50:11 -04:00
wmantly ec4ca97af4 Fix: Redis had zero persistence — every rebuild wiped sessions, (#18)
in-flight OAuth logins, and any admin-created API token

redis-server ran with --save '' --appendonly no (deliberately ephemeral,
per the original "audit/metrics/session storage" framing). That stopped
being a safe assumption once API tokens (PATs) lived in this same Redis
-- a PAT is supposed to be a stable, long-lived credential, not
disposable session state, but every `docker rm -f jump-host` + rebuild
silently invalidated every one that existed.

Matches proxy's existing pattern exactly: AOF + periodic RDB persisted
to $REDIS_DATA_DIR (default /data), which the deployment mounts as a
volume (see the companion theta-env change).

Verified against a live container: minted a real PAT, force-recreated
the container (docker rm -f + rebuild), confirmed the same token still
authenticates afterward.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 15:46:40 -04:00
wmantly 21ef8960c4 Merge pull request #17 from theta42/release/1.8.0
Release 1.8.0
2026-07-28 13:35:18 -04:00
wmantly a5bef2980b Release 1.8.0: fix TUI-mode SSH connection drops, HTML-escaped loading indicator
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 13:32:28 -04:00
wmantly ab2ee0fed3 Merge pull request #16 from theta42/fix/tui-session-listener-race
Fix: TUI-mode SSH connections could drop with PTY/shell request failed
2026-07-28 13:07:55 -04:00
wmantly ab9e04e007 Merge pull request #15 from theta42/fix/loading-message-html-escaped
Fix HTML-escaped loading indicator in formAJAX
2026-07-28 13:07:49 -04:00
wmantly a3a6787776 Fix: TUI-mode SSH connections could drop with "PTY allocation
request failed" / "shell request failed"

runTuiSession awaited audit.create() (a Redis round-trip) and then
accessibleHosts() (a directory API call) BEFORE calling runTui(), which
is where the pty/shell/exec/subsystem listeners actually get attached to
the session. The client sends its pty-req and shell requests immediately
after opening the session -- if either await took long enough for those
requests to arrive first, ssh2 auto-rejects any channel request with no
listener (CHANNEL_FAILURE), which is exactly what OpenSSH reports as
"PTY allocation request failed on channel 0" / "shell request failed on
channel 0". The connection then just sat there, since nothing was left
to drive it.

runGrammar already has this exact fix (see its own comment); runTuiSession
never got the equivalent treatment. Fixed the same way: register the
session listeners synchronously, before any await, by having runTui take
a Promise for the hosts list instead of the resolved list -- the shell
handler awaits it internally once the client actually sends a shell
request, which by construction happens only after the listener already
exists.

Verified: publickey auth against a real deployment succeeds (confirms
the earlier ldaps:// fix holds), and the failure reproduces with the
exact reported error strings for a bare `ssh user@host` (TUI/picker mode,
no target) connection. Full suite: 50/50 passing, no regressions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 12:58:49 -04:00
wmantly 0ead0199a3 Fix HTML-escaped loading indicator in formAJAX
Same fix as sso-manager-node/proxy: formAJAX's loading indicator passed a
raw <div class="spinner-border"> string to app.messages.action, which
HTML-escapes its message by design (@simpleworkjs/frontend). Replaced
with plain text ("Saving…").

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 12:53:08 -04:00
9 changed files with 164 additions and 30 deletions
+21
View File
@@ -4,6 +4,27 @@ All notable changes to this project are documented here. Format loosely
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions
correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
## [1.9.0] - 2026-07-28
### Added
- **"Quick Jump" copy-to-clipboard section on the dashboard** — the `uid_-_target` grammar-mode SSH command was documented in the README but nowhere in the UI. A new card gives a one-click-copy command for interactive-picker mode, and every row in "Hosts you can reach" has its own copy button for the exact grammar-mode command to that host, ready to paste and run as-is (uses the logged-in user's own uid).
## [1.8.2] - 2026-07-28
### Fixed
- **Audit records for a failed upstream connection only ever said `upstream-unreachable`** — `resolveAndConnect` discarded the real error from `connectUpstream` (ECONNREFUSED, ETIMEDOUT, an ssh2 auth-failure message, etc.) and replaced it with that one generic string, so there was no way to tell a network-layer failure from an auth failure from the audit log alone. This is what blocked root-causing the "Could not reach 192.168.1.206" (emby host) report — the real error is now captured and surfaced as a new `failDetail` field on the audit record, shown as a tooltip on the fail badge in the admin audit table.
## [1.8.1] - 2026-07-28
### Fixed
- **Redis had zero persistence** (`--save '' --appendonly no`, no data-dir volume) — every container rebuild/recreation silently wiped all sessions, in-flight OAuth logins, and any admin-created API token. This is why re-running `setup.sh` appeared to "break OAuth with jump": the jump-host container gets recreated, and any token or in-flight login vanished with it. Now Redis persists (AOF + periodic RDB) to `/data`, mounted as a named volume (`jump-redis-data`) in theta-env's compose file. Verified live: minted a PAT, force-recreated the container, confirmed the same PAT still authenticated afterward.
## [1.8.0] - 2026-07-28
### Fixed
- **TUI-mode SSH connections (a bare `ssh user@host`, no target) could drop with "PTY allocation request failed" / "shell request failed"** — `runTuiSession` awaited two real round-trips (an audit-log write, then a directory API call) *before* attaching the session's pty/shell/exec listeners, so a client that sent those requests quickly enough got auto-rejected by ssh2 before anything was listening. `runGrammar` (the `uid_-_target` path) already had the equivalent fix; this ports it to the picker path.
- **`formAJAX`'s loading indicator showed literal HTML**, not a spinner — same fix as sso-manager-node/proxy's companion releases.
## [1.7.1] - 2026-07-28
### Added
+11 -3
View File
@@ -12,9 +12,17 @@ if [[ -f /config/jump-secrets.js ]]; then
info "Loaded config from /config/jump-secrets.js"
fi
# Redis for audit/metrics/session storage (app connects to 127.0.0.1:6379).
info "Starting redis..."
redis-server --daemonize yes --save '' --appendonly no
# Redis for audit/metrics/session AND api-token storage (app connects to
# 127.0.0.1:6379). Persisted (AOF + periodic RDB) to /data, which the
# deployment should mount as a volume -- without this, every container
# recreation silently wiped every session, in-flight OAuth login, and any
# admin-created API token, which is especially bad for the last one since a
# PAT is meant to be a stable, long-lived credential, not session state.
REDIS_DATA_DIR="${REDIS_DATA_DIR:-/data}"
mkdir -p "$REDIS_DATA_DIR"
info "Starting redis (AOF persisted to $REDIS_DATA_DIR)..."
redis-server --daemonize yes --dir "$REDIS_DATA_DIR" --appendonly yes \
--appendfilename appendonly.aof --save 900 1 --save 300 10 --save 60 10000
# Wait for redis to answer before starting the app.
for _ in $(seq 1 20); do
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "t42-jump-host",
"version": "1.7.1",
"version": "1.9.0",
"description": "SSH jump host for the theta42 stack — LDAP-authenticated, directory-driven host bridging with audit and metrics",
"author": [
{
+3 -2
View File
@@ -27,5 +27,6 @@ app.apiToken = (function(app){
// Shared render helpers.
app.jump.fmtTime = function(ts){ return ts ? moment(Number(ts)).format('YYYY-MM-DD HH:mm:ss') : '—'; };
app.jump.esc = function(s){ return $('<div>').text(s == null ? '' : String(s)).html(); };
app.jump.result = function(e){ return e.success ? '<span class="badge bg-success">ok</span>'
: '<span class="badge bg-danger">' + app.jump.esc(e.failReason || 'fail') + '</span>'; };
app.jump.result = function(e){ if (e.success) return '<span class="badge bg-success">ok</span>';
var title = e.failDetail ? ' title="' + app.jump.esc(e.failDetail) + '"' : '';
return '<span class="badge bg-danger"' + title + '>' + app.jump.esc(e.failReason || 'fail') + '</span>'; };
+4 -7
View File
@@ -679,13 +679,10 @@ function formAJAX(btn){
return false;
}
app.messages.action(
`<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>`,
$form,
'info'
);
// Plain text: app.messages.action HTML-escapes its message (by design,
// see @simpleworkjs/frontend), so raw markup like a spinner <div> would
// render literally instead of as an element.
app.messages.action('Saving…', $form, 'info');
app.api[method]($form.attr('action'), formData, function(error, data){
app.messages.action(data.message, $form, error ? 'danger' : 'success'); //re-populate table
+4
View File
@@ -14,6 +14,10 @@ const values = {
titleIcon: conf.environment !== 'production' ? '<i class="fa-brands fa-dev"></i>' : '',
name: conf.name,
logo: conf.logo,
// The SSH front door's port -- the dashboard's "quick jump" copy buttons
// need this to build a real, working `ssh ...` command (the web UI and
// SSH front door share a hostname but not a port).
sshPort: (conf.ssh && conf.ssh.listenPort) || 22,
...buildInfo,
};
+50 -16
View File
@@ -130,7 +130,7 @@ async function resolveAndConnect(state, record, { onHostKey } = {}) {
let justInjected = false;
try { justInjected = await ensureKeyInjected(state.user, JUMP_KEYS.publicLine); }
catch (_) { throw fail('key-inject-failed'); }
catch (err) { throw fail('key-inject-failed', err.message); }
let upstream;
try {
@@ -139,12 +139,16 @@ async function resolveAndConnect(state, record, { onHostKey } = {}) {
username: state.uid, privateKey: JUMP_KEYS.clientKey,
uid: state.uid, justInjected, onHostKey,
});
} catch (_) { throw fail('upstream-unreachable'); }
} catch (err) { throw fail('upstream-unreachable', err.message); }
return { upstream, host, endpoint };
}
function fail(reason) { const e = new Error(reason); e.reason = reason; return e; }
// detail carries the real underlying error message (e.g. ECONNREFUSED,
// ETIMEDOUT, an ssh2 auth-failure string) so audit records aren't reduced to
// just the generic reason code -- without it, a network-layer failure and an
// SSH auth failure both looked identical in the audit log.
function fail(reason, detail) { const e = new Error(reason); e.reason = reason; e.detail = detail; return e; }
async function runGrammar(session, client, state) {
// Register session listeners IMMEDIATELY — before any async work.
@@ -174,25 +178,47 @@ async function runGrammar(session, client, state) {
} catch (err) {
const reason = err.reason || 'error';
rejectUp(new Error(reasonMessage(reason)));
await record.finish({ success: false, failReason: reason });
await record.finish({ success: false, failReason: reason, failDetail: err.detail });
await metrics.bump({ uid: state.uid, success: false });
}
}
async function runTuiSession(session, client, state) {
// Register session listeners IMMEDIATELY, before any await — same fix,
// same reason, as runGrammar above. The client sends pty-req and shell
// requests right after opening the session; awaiting audit.create() and
// accessibleHosts() first (both real round-trips: Redis, then the
// directory API) left a window where those requests could arrive before
// runTui had attached any listener for them, and ssh2 auto-rejects an
// unlistened channel request with CHANNEL_FAILURE — surfacing to the
// client as "PTY allocation request failed" / "shell request failed",
// with the connection then just sitting there (nothing left to drive it).
let resolveHosts, rejectHosts;
const hostsPromise = new Promise((res, rej) => { resolveHosts = res; rejectHosts = rej; });
// A silent catch so a rejection isn't "unhandled" if the client never
// sends a shell request at all (exec-only) — runTui's own .catch() below
// still runs independently when it does.
hostsPromise.catch(() => {});
const tuiPromise = runTui(session, state.uid, hostsPromise);
const record = await audit.create({ uid: state.uid, authMethod: state.authMethod, clientIp: state.clientIp, mode: 'tui' });
const finishFail = async (reason) => {
await record.finish({ success: false, failReason: reason });
const finishFail = async (reason, detail) => {
await record.finish({ success: false, failReason: reason, failDetail: detail });
await metrics.bump({ uid: state.uid, success: false });
try { client.end(); } catch (_) {}
};
let hosts;
try { hosts = await accessibleHosts(state.user); }
catch (_) { return finishFail('directory-unreachable'); }
try {
hosts = await accessibleHosts(state.user);
resolveHosts(hosts);
} catch (_) {
rejectHosts(new Error('directory-unreachable'));
return finishFail('directory-unreachable');
}
const tui = await runTui(session, state.uid, hosts);
const tui = await tuiPromise;
if (!tui.host) return finishFail('cancelled');
state.target = tui.host.slug;
@@ -201,7 +227,7 @@ async function runTuiSession(session, client, state) {
let justInjected = false;
try { justInjected = await ensureKeyInjected(state.user, JUMP_KEYS.publicLine); }
catch (_) { return finishFail('key-inject-failed'); }
catch (err) { return finishFail('key-inject-failed', err.message); }
let upstream;
try {
@@ -210,9 +236,9 @@ async function runTuiSession(session, client, state) {
username: state.uid, privateKey: JUMP_KEYS.clientKey,
uid: state.uid, justInjected, onHostKey: (fp) => record.patch({ hostKeyFp: fp }),
});
} catch (_) {
} catch (err) {
try { tui.channel.write(`\r\n Could not reach ${endpoint.address}.\r\n`); tui.channel.close(); } catch (_) {}
return finishFail('upstream-unreachable');
return finishFail('upstream-unreachable', err.message);
}
registry.add(record.id, { uid: state.uid, target: endpoint.address, slug: tui.host.slug });
@@ -253,7 +279,10 @@ function reasonMessage(reason) {
// Run the TUI picker over a shell channel; returns { host, channel, ptyInfo }.
// host is null if the user quit. exec/subsystem in picker mode are rejected.
function runTui(session, uid, hosts) {
// Takes a Promise for the accessible-hosts list (not the resolved list)
// so the caller can register these listeners before that lookup completes
// — see the comment in runTuiSession for why that ordering matters.
function runTui(session, uid, hostsPromise) {
return new Promise((resolve) => {
let ptyInfo = null;
let settled = false;
@@ -262,9 +291,14 @@ function runTui(session, uid, hosts) {
session.on('pty', (accept, _reject, info) => { ptyInfo = info; accept && accept(); });
session.on('shell', (accept) => {
const channel = accept();
pickHost(channel, uid, hosts).then((host) => {
if (!host) { try { channel.write('\r\n Bye.\r\n'); channel.close(); } catch (_) {} }
finish({ host, channel, ptyInfo });
hostsPromise.then((hosts) => {
pickHost(channel, uid, hosts).then((host) => {
if (!host) { try { channel.write('\r\n Bye.\r\n'); channel.close(); } catch (_) {} }
finish({ host, channel, ptyInfo });
});
}).catch(() => {
try { channel.write('\r\n Could not reach the directory.\r\n'); channel.close(); } catch (_) {}
finish({ host: null });
});
});
session.on('exec', (accept) => {
@@ -156,6 +156,29 @@ test('shell bridges and echoes', async () => {
assert.match(out, /echo:ping/);
});
test('connectUpstream rejects with a specific, non-generic error when the target refuses the connection', async () => {
// Regression coverage for ssh_server.js's resolveAndConnect: it used to
// discard this error entirely (catch (_) { throw fail('upstream-unreachable') }),
// so the audit log recorded the same generic reason for a refused port, a
// timeout, or a bad key alike. Now the real message is threaded through as
// failDetail, so this must stay meaningful.
// Bind a server just to reserve a free port, then close it immediately so
// nothing is listening there — guarantees ECONNREFUSED rather than relying
// on a hardcoded port number that might be in use.
const closedPort = await new Promise((resolve) => {
const probe = require('net').createServer();
probe.listen(0, '127.0.0.1', () => { const p = probe.address().port; probe.close(() => resolve(p)); });
});
await assert.rejects(
connectUpstream({ host: '127.0.0.1', port: closedPort, username: 'test', privateKey: jumpKey, uid: 'test', justInjected: false }),
(err) => {
assert.ok(err.message && err.message.length > 0);
assert.notStrictEqual(err.message, 'upstream-unreachable');
return true;
},
);
});
test('sftp subsystem bytes pass through', async () => {
const { conn, ready } = connectJump();
await ready;
+47 -1
View File
@@ -28,6 +28,27 @@
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-12">
<div class="card shadow-sm">
<div class="card-header"><i class="fa-solid fa-terminal me-1"></i> Quick Jump</div>
<div class="card-body">
<p class="text-muted small mb-2">
Skip the picker: <code>ssh &lt;your-username&gt;_-_&lt;host-slug&gt;@&lt;this-jump-host&gt;</code>
connects straight to a host. Or just <code>ssh &lt;your-username&gt;@&lt;this-jump-host&gt;</code>
for the interactive picker.
</p>
<div class="input-group">
<input type="text" class="form-control font-monospace" id="quick-jump-cmd" readonly>
<button class="btn btn-outline-secondary" onclick="copySshCommand('#quick-jump-cmd')" title="Copy">
<i class="fa-solid fa-copy"></i>
</button>
</div>
</div>
</div>
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-12">
<div class="card shadow-sm">
@@ -79,14 +100,38 @@
$b.append('<tr><td>' + app.jump.esc(x.name) + '</td><td class="text-end">' + x.count + '</td></tr>');
});
}
// The web UI and the SSH front door share a hostname, just not a port.
var SSH_PORT = <%- JSON.stringify(sshPort) %>;
function sshCommand(target){
var uid = app.auth.user && app.auth.user.username;
if(!uid) return '';
var portFlag = SSH_PORT === 22 ? '' : ' -p ' + SSH_PORT;
return 'ssh ' + uid + (target ? '_-_' + target : '') + '@' + location.hostname + portFlag;
}
function copySshCommand(sel){
var $el = $(sel);
var text = $el.val();
if(!text) return;
navigator.clipboard.writeText(text).then(function(){
app.messages.toast('Copied to clipboard', 'success');
}, function(){
app.messages.toast('Could not copy — select and copy manually', 'danger');
});
}
function hostRows(sel, hosts){
var $b = $(sel).empty();
if(!hosts || !hosts.length){ $b.append('<tr><td class="text-muted">No hosts reachable.</td></tr>'); return; }
hosts.forEach(function(h){
var addr = (h.metadata && (h.metadata.ip || h.metadata.address)) || '';
var rowId = 'host-cmd-' + h.slug.replace(/[^a-zA-Z0-9_-]/g, '');
$b.append('<tr><td>' + app.jump.esc(h.displayName || h.name || h.slug) + '</td>'
+ '<td class="text-muted small">' + app.jump.esc(h.slug) + '</td>'
+ '<td class="text-end text-muted small">' + app.jump.esc(addr) + '</td></tr>');
+ '<td class="text-end text-muted small">' + app.jump.esc(addr) + '</td>'
+ '<td class="text-end">'
+ '<input type="hidden" id="' + rowId + '" value="' + app.jump.esc(sshCommand(h.slug)) + '">'
+ '<button class="btn btn-sm btn-outline-secondary" onclick="copySshCommand(\'#' + rowId + '\')" title="Copy quick-jump command"><i class="fa-solid fa-copy"></i></button>'
+ '</td></tr>');
});
}
function tokenRows(tokens){
@@ -185,6 +230,7 @@
});
await app.auth.loadUser();
if(app.auth.isAdmin()) $('#my-hosts-title').text('All hosts');
$('#quick-jump-cmd').val(sshCommand());
app.jump.hosts(function(error, data){
if(error) return hostRows('#my-hosts', []);
hostRows('#my-hosts', data && data.results);