Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a76f71edd | |||
| e482f52f10 | |||
| a6af160627 | |||
| 8c9646b65c | |||
| 1d09f243dd | |||
| ec4ca97af4 | |||
| 21ef8960c4 | |||
| a5bef2980b | |||
| ab2ee0fed3 | |||
| ab9e04e007 | |||
| a3a6787776 | |||
| 0ead0199a3 | |||
| 0af2fc7e3d | |||
| bc2180116f | |||
| 1b70701795 | |||
| 98e1e0e279 |
@@ -4,6 +4,32 @@ All notable changes to this project are documented here. Format loosely
|
|||||||
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions
|
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`.
|
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
|
||||||
|
- **Regression test**: a static check across all views/client-side scripts fails CI if any native `alert()`/`confirm()`/`prompt()` call appears — these block all further browser events on the page. This app has never had one; keeps it that way.
|
||||||
|
|
||||||
## [1.7.0] - 2026-07-27
|
## [1.7.0] - 2026-07-27
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+11
-3
@@ -12,9 +12,17 @@ if [[ -f /config/jump-secrets.js ]]; then
|
|||||||
info "Loaded config from /config/jump-secrets.js"
|
info "Loaded config from /config/jump-secrets.js"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Redis for audit/metrics/session storage (app connects to 127.0.0.1:6379).
|
# Redis for audit/metrics/session AND api-token storage (app connects to
|
||||||
info "Starting redis..."
|
# 127.0.0.1:6379). Persisted (AOF + periodic RDB) to /data, which the
|
||||||
redis-server --daemonize yes --save '' --appendonly no
|
# 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.
|
# Wait for redis to answer before starting the app.
|
||||||
for _ in $(seq 1 20); do
|
for _ in $(seq 1 20); do
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-jump-host",
|
"name": "t42-jump-host",
|
||||||
"version": "1.7.0",
|
"version": "1.9.0",
|
||||||
"description": "SSH jump host for the theta42 stack — LDAP-authenticated, directory-driven host bridging with audit and metrics",
|
"description": "SSH jump host for the theta42 stack — LDAP-authenticated, directory-driven host bridging with audit and metrics",
|
||||||
"author": [
|
"author": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -27,5 +27,6 @@ app.apiToken = (function(app){
|
|||||||
// Shared render helpers.
|
// Shared render helpers.
|
||||||
app.jump.fmtTime = function(ts){ return ts ? moment(Number(ts)).format('YYYY-MM-DD HH:mm:ss') : '—'; };
|
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.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>'
|
app.jump.result = function(e){ if (e.success) return '<span class="badge bg-success">ok</span>';
|
||||||
: '<span class="badge bg-danger">' + app.jump.esc(e.failReason || 'fail') + '</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>'; };
|
||||||
|
|||||||
@@ -679,13 +679,10 @@ function formAJAX(btn){
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
app.messages.action(
|
// Plain text: app.messages.action HTML-escapes its message (by design,
|
||||||
`<div class="spinner-border" role="status">
|
// see @simpleworkjs/frontend), so raw markup like a spinner <div> would
|
||||||
<span class="visually-hidden">Loading...</span>
|
// render literally instead of as an element.
|
||||||
</div>`,
|
app.messages.action('Saving…', $form, 'info');
|
||||||
$form,
|
|
||||||
'info'
|
|
||||||
);
|
|
||||||
|
|
||||||
app.api[method]($form.attr('action'), formData, function(error, data){
|
app.api[method]($form.attr('action'), formData, function(error, data){
|
||||||
app.messages.action(data.message, $form, error ? 'danger' : 'success'); //re-populate table
|
app.messages.action(data.message, $form, error ? 'danger' : 'success'); //re-populate table
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ const values = {
|
|||||||
titleIcon: conf.environment !== 'production' ? '<i class="fa-brands fa-dev"></i>' : '',
|
titleIcon: conf.environment !== 'production' ? '<i class="fa-brands fa-dev"></i>' : '',
|
||||||
name: conf.name,
|
name: conf.name,
|
||||||
logo: conf.logo,
|
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,
|
...buildInfo,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ async function resolveAndConnect(state, record, { onHostKey } = {}) {
|
|||||||
|
|
||||||
let justInjected = false;
|
let justInjected = false;
|
||||||
try { justInjected = await ensureKeyInjected(state.user, JUMP_KEYS.publicLine); }
|
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;
|
let upstream;
|
||||||
try {
|
try {
|
||||||
@@ -139,12 +139,16 @@ async function resolveAndConnect(state, record, { onHostKey } = {}) {
|
|||||||
username: state.uid, privateKey: JUMP_KEYS.clientKey,
|
username: state.uid, privateKey: JUMP_KEYS.clientKey,
|
||||||
uid: state.uid, justInjected, onHostKey,
|
uid: state.uid, justInjected, onHostKey,
|
||||||
});
|
});
|
||||||
} catch (_) { throw fail('upstream-unreachable'); }
|
} catch (err) { throw fail('upstream-unreachable', err.message); }
|
||||||
|
|
||||||
return { upstream, host, endpoint };
|
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) {
|
async function runGrammar(session, client, state) {
|
||||||
// Register session listeners IMMEDIATELY — before any async work.
|
// Register session listeners IMMEDIATELY — before any async work.
|
||||||
@@ -174,25 +178,47 @@ async function runGrammar(session, client, state) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
const reason = err.reason || 'error';
|
const reason = err.reason || 'error';
|
||||||
rejectUp(new Error(reasonMessage(reason)));
|
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 });
|
await metrics.bump({ uid: state.uid, success: false });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runTuiSession(session, client, state) {
|
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 record = await audit.create({ uid: state.uid, authMethod: state.authMethod, clientIp: state.clientIp, mode: 'tui' });
|
||||||
|
|
||||||
const finishFail = async (reason) => {
|
const finishFail = async (reason, detail) => {
|
||||||
await record.finish({ success: false, failReason: reason });
|
await record.finish({ success: false, failReason: reason, failDetail: detail });
|
||||||
await metrics.bump({ uid: state.uid, success: false });
|
await metrics.bump({ uid: state.uid, success: false });
|
||||||
try { client.end(); } catch (_) {}
|
try { client.end(); } catch (_) {}
|
||||||
};
|
};
|
||||||
|
|
||||||
let hosts;
|
let hosts;
|
||||||
try { hosts = await accessibleHosts(state.user); }
|
try {
|
||||||
catch (_) { return finishFail('directory-unreachable'); }
|
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');
|
if (!tui.host) return finishFail('cancelled');
|
||||||
state.target = tui.host.slug;
|
state.target = tui.host.slug;
|
||||||
|
|
||||||
@@ -201,7 +227,7 @@ async function runTuiSession(session, client, state) {
|
|||||||
|
|
||||||
let justInjected = false;
|
let justInjected = false;
|
||||||
try { justInjected = await ensureKeyInjected(state.user, JUMP_KEYS.publicLine); }
|
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;
|
let upstream;
|
||||||
try {
|
try {
|
||||||
@@ -210,9 +236,9 @@ async function runTuiSession(session, client, state) {
|
|||||||
username: state.uid, privateKey: JUMP_KEYS.clientKey,
|
username: state.uid, privateKey: JUMP_KEYS.clientKey,
|
||||||
uid: state.uid, justInjected, onHostKey: (fp) => record.patch({ hostKeyFp: fp }),
|
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 (_) {}
|
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 });
|
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 }.
|
// 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.
|
// 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) => {
|
return new Promise((resolve) => {
|
||||||
let ptyInfo = null;
|
let ptyInfo = null;
|
||||||
let settled = false;
|
let settled = false;
|
||||||
@@ -262,9 +291,14 @@ function runTui(session, uid, hosts) {
|
|||||||
session.on('pty', (accept, _reject, info) => { ptyInfo = info; accept && accept(); });
|
session.on('pty', (accept, _reject, info) => { ptyInfo = info; accept && accept(); });
|
||||||
session.on('shell', (accept) => {
|
session.on('shell', (accept) => {
|
||||||
const channel = accept();
|
const channel = accept();
|
||||||
pickHost(channel, uid, hosts).then((host) => {
|
hostsPromise.then((hosts) => {
|
||||||
if (!host) { try { channel.write('\r\n Bye.\r\n'); channel.close(); } catch (_) {} }
|
pickHost(channel, uid, hosts).then((host) => {
|
||||||
finish({ host, channel, ptyInfo });
|
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) => {
|
session.on('exec', (accept) => {
|
||||||
|
|||||||
@@ -156,6 +156,29 @@ test('shell bridges and echoes', async () => {
|
|||||||
assert.match(out, /echo:ping/);
|
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 () => {
|
test('sftp subsystem bytes pass through', async () => {
|
||||||
const { conn, ready } = connectJump();
|
const { conn, ready } = connectJump();
|
||||||
await ready;
|
await ready;
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Regression guard: native alert()/confirm()/prompt() calls block all further
|
||||||
|
// browser events on the page (found live, mid browser-automation testing, on
|
||||||
|
// sso-manager-node's equivalent secret-rotate flow) and are visually
|
||||||
|
// inconsistent with the rest of the UI. This app has no such call sites;
|
||||||
|
// keep it that way.
|
||||||
|
|
||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const ROOTS = ['views', 'public/js', 'public/lib/js'].map((d) => path.join(__dirname, '..', '..', d));
|
||||||
|
|
||||||
|
const NATIVE_DIALOG_RE = /(^|[^.\w$])(alert|confirm|prompt)\s*\(/g;
|
||||||
|
|
||||||
|
function walk(dir) {
|
||||||
|
let files = [];
|
||||||
|
if (!fs.existsSync(dir)) return files;
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const full = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) files = files.concat(walk(full));
|
||||||
|
else if (/\.(ejs|js)$/.test(entry.name)) files.push(full);
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('no view or client-side script calls native alert()/confirm()/prompt()', () => {
|
||||||
|
const offenders = [];
|
||||||
|
for (const root of ROOTS) {
|
||||||
|
for (const file of walk(root)) {
|
||||||
|
const src = fs.readFileSync(file, 'utf8');
|
||||||
|
let m;
|
||||||
|
NATIVE_DIALOG_RE.lastIndex = 0;
|
||||||
|
while ((m = NATIVE_DIALOG_RE.exec(src))) {
|
||||||
|
const line = src.slice(0, m.index).split('\n').length;
|
||||||
|
offenders.push(`${path.relative(path.join(__dirname, '..', '..'), file)}:${line} — ${m[2]}(`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.deepStrictEqual(offenders, []);
|
||||||
|
});
|
||||||
@@ -28,6 +28,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 <your-username>_-_<host-slug>@<this-jump-host></code>
|
||||||
|
connects straight to a host. Or just <code>ssh <your-username>@<this-jump-host></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="row g-3 mb-4">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="card shadow-sm">
|
<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>');
|
$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){
|
function hostRows(sel, hosts){
|
||||||
var $b = $(sel).empty();
|
var $b = $(sel).empty();
|
||||||
if(!hosts || !hosts.length){ $b.append('<tr><td class="text-muted">No hosts reachable.</td></tr>'); return; }
|
if(!hosts || !hosts.length){ $b.append('<tr><td class="text-muted">No hosts reachable.</td></tr>'); return; }
|
||||||
hosts.forEach(function(h){
|
hosts.forEach(function(h){
|
||||||
var addr = (h.metadata && (h.metadata.ip || h.metadata.address)) || '';
|
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>'
|
$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-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){
|
function tokenRows(tokens){
|
||||||
@@ -185,6 +230,7 @@
|
|||||||
});
|
});
|
||||||
await app.auth.loadUser();
|
await app.auth.loadUser();
|
||||||
if(app.auth.isAdmin()) $('#my-hosts-title').text('All hosts');
|
if(app.auth.isAdmin()) $('#my-hosts-title').text('All hosts');
|
||||||
|
$('#quick-jump-cmd').val(sshCommand());
|
||||||
app.jump.hosts(function(error, data){
|
app.jump.hosts(function(error, data){
|
||||||
if(error) return hostRows('#my-hosts', []);
|
if(error) return hostRows('#my-hosts', []);
|
||||||
hostRows('#my-hosts', data && data.results);
|
hostRows('#my-hosts', data && data.results);
|
||||||
|
|||||||
Reference in New Issue
Block a user