Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 49bf0fa1d3 | |||
| 1b4764e328 | |||
| db3333e26d | |||
| 1092031a9f | |||
| f386a5f9c3 | |||
| 82318da484 | |||
| 14784266b3 | |||
| 362e77f3dd | |||
| dfafffe154 | |||
| bd4464ed19 | |||
| 061a044a70 | |||
| c6a4a3c841 | |||
| 0ef451e15d | |||
| 6771904932 | |||
| c002afe043 | |||
| 0a2c25ae75 | |||
| a15decb6f7 | |||
| 599136e4dc | |||
| a7d5efc764 |
@@ -1,9 +1,68 @@
|
||||
# v1.15.0
|
||||
- feat: Rename SSO Manager to Jump in UI
|
||||
|
||||
# Changelog
|
||||
|
||||
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.14.1] - 2026-08-01
|
||||
|
||||
### Fixed
|
||||
- **Bumped `@simpleworkjs/bao-conf` to 1.0.1** so standalone/no-OpenBao boots
|
||||
don't crash. bao-conf 1.0.0's `init()` threw when `VAULT_TOKEN` was unset,
|
||||
which — combined with `bin/www`'s `.catch(() => process.exit(1))` — made the
|
||||
jump host exit at boot in any deployment without an OpenBao sidecar
|
||||
(standalone Docker, bare metal). 1.0.1 makes `init()` fail-soft on a missing
|
||||
token (warn + continue from `CONF_SECRETS`), matching the documented
|
||||
contract. The theta-env stack is unaffected (it always sets a scoped
|
||||
`VAULT_TOKEN`).
|
||||
|
||||
## [1.14.0] - 2026-08-01
|
||||
|
||||
### Changed
|
||||
- **Secrets now load from OpenBao at boot** via
|
||||
[@simpleworkjs/bao-conf](https://simpleworkjs.github.io/bao-conf/), which
|
||||
deep-merges `secret/jump-host/conf` over the file-loaded config. The jump
|
||||
host authenticates to OpenBao with a scoped `VAULT_TOKEN` (policy
|
||||
`jump-host` — read-only on its own path), never the root token. Because the
|
||||
OIDC `clientSecret` is captured at require time inside `createOidcClient`
|
||||
(during `require('../models')`), `bin/www` now runs `bao-conf.init()`
|
||||
**before** `require('../models')`. Fail-soft: if OpenBao is unreachable,
|
||||
boot continues from `CONF_SECRETS`. The `config/jump-secrets.js` file is now
|
||||
an operator-edit seed artifact (gitignored); OpenBao is authoritative. See
|
||||
theta-env's [Secrets docs](https://theta42.github.io/theta-env/secrets/).
|
||||
- Bumped package version to track the release tag.
|
||||
|
||||
## [1.11.0] - 2026-07-30
|
||||
|
||||
### Added
|
||||
- **`app_super_admin` (cross-app) and `app_jump_admin` groups**: super admins are full admins here same as `app_sso_admin`; jump admins get audit page/data access without other admin rights. The Audit page/API is now actually admin-gated server-side (previously the page shell rendered for any logged-in user, only its data was gated).
|
||||
- **Host list adds Last connection/Last failed connection columns** and highlights rows green (a session is live right now) or yellow (the most recent attempt failed), backed by new per-host last-success/last-fail timestamps in `models/metrics.js`. `services/ssh_server.js` now attributes grammar/TUI connect failures to the resolved host when one was found, not just aggregate counters.
|
||||
|
||||
### Changed
|
||||
- **Dashboard's stat boxes and Top hosts/Top users cards moved to the Audit page** (audit is now the admin-facing metrics home; dashboard stays focused on "hosts I can reach"). "All hosts" renamed to "My hosts".
|
||||
|
||||
## [1.10.2] - 2026-07-30
|
||||
|
||||
### Changed
|
||||
- **Dashboard, Sessions, and Audit pages now match sso-manager-node/proxy's page width**, wrapping content in a standard container instead of rendering full-bleed inside the fluid shell.
|
||||
- **Audit's nav entry is now admin-gated** (`groups: ['admin']` in `utils/ui.js`), reusing the existing synthetic-admin-group nav-gating convention — the API route was already server-side admin-gated; this hides the nav link for non-admins too.
|
||||
|
||||
## [1.10.1] - 2026-07-28
|
||||
|
||||
### Fixed
|
||||
- **The API-token reveal modal silently didn't show after creating a token** — `submitApiToken()` called `app.modal.close()` immediately before `showToken()`'s `app.modal.open()` in the same tick, colliding with Bootstrap's hide-transition guard on the singleton modal. Same root cause as the OAuth-secret-reveal race fixed in sso-manager-node (v1.8.2) and the create-token race fixed in proxy (v1.7.0).
|
||||
|
||||
## [1.10.0] - 2026-07-28
|
||||
|
||||
### Added
|
||||
- **API-token UI unified with sso-manager-node/proxy**: card grid replacing the bare table, a new Edit modal (footer shows real created-by/on data), and a Description field on both the create and edit flows — the model and API already fully supported all of this, it just wasn't exposed anywhere in the dashboard.
|
||||
|
||||
### Changed
|
||||
- `@simpleworkjs/frontend` bumped to `^0.2.6` (this app was still on `^0.2.5`).
|
||||
|
||||
## [1.9.0] - 2026-07-28
|
||||
|
||||
### Added
|
||||
|
||||
@@ -159,6 +159,22 @@ Config layers via [@simpleworkjs/conf](https://www.npmjs.com/package/@simplework
|
||||
`conf/base.js` < `conf/<NODE_ENV>.js` < the `CONF_SECRETS` file < `app_*` env.
|
||||
See `secrets.js.example` for every key.
|
||||
|
||||
## Secrets
|
||||
|
||||
At boot, [@simpleworkjs/bao-conf](https://simpleworkjs.github.io/bao-conf/)
|
||||
deep-merges `secret/jump-host/conf` from **OpenBao** over the file-loaded
|
||||
config. The jump host's OIDC `clientSecret` is captured at require time
|
||||
(inside `createOidcClient` during `require('../models')`), so `bin/www` runs
|
||||
`bao-conf.init()` **before** `require('../models')`. Fail-soft: if OpenBao is
|
||||
unreachable, boot continues from `CONF_SECRETS`. The jump host authenticates to
|
||||
OpenBao with the scoped `VAULT_TOKEN` (env, policy `jump-host` — read only
|
||||
`secret/jump-host/conf`), never the root token.
|
||||
|
||||
The `config/jump-secrets.js` file is an operator-edit seed artifact
|
||||
(gitignored); the bootstrap writes the generated API token + OAuth client
|
||||
into OpenBao, which is authoritative. For the full architecture see
|
||||
theta-env's **[Secrets docs](https://theta42.github.io/theta-env/secrets/)**.
|
||||
|
||||
## Development
|
||||
|
||||
```
|
||||
|
||||
+34
-23
@@ -9,32 +9,43 @@ const http = require('http');
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
const { Server } = require('socket.io');
|
||||
|
||||
require('../models');
|
||||
// @simpleworkjs/conf loads ./config/jump-secrets.js synchronously, then
|
||||
// @simpleworkjs/bao-conf deep-merges secret/jump-host/conf from OpenBao over
|
||||
// it. The OIDC clientSecret is captured at require time inside models (via
|
||||
// createOidcClient), so the fetch MUST resolve before require('../models').
|
||||
// Fail-soft: if OpenBao is unreachable, init() leaves conf as the file-loaded
|
||||
// fallback and boot continues from ./config/jump-secrets.js.
|
||||
require('@simpleworkjs/bao-conf').init({ path: 'jump-host', conf }).then(() => {
|
||||
require('../models');
|
||||
|
||||
const app = require('../app');
|
||||
const middleware = require('../middleware/auth');
|
||||
const sshServer = require('../services/ssh_server');
|
||||
const app = require('../app');
|
||||
const middleware = require('../middleware/auth');
|
||||
const sshServer = require('../services/ssh_server');
|
||||
|
||||
const webPort = (conf.web && conf.web.port) || 3002;
|
||||
const server = http.createServer(app);
|
||||
const webPort = (conf.web && conf.web.port) || 3002;
|
||||
const server = http.createServer(app);
|
||||
|
||||
// Socket.IO — the client framework (app-base.js) opens an authenticated socket.
|
||||
// We don't push anything yet, but serving /socket.io keeps the shared front-end
|
||||
// working exactly as it does in the sibling apps.
|
||||
const io = new Server(server);
|
||||
io.use(middleware.authIO);
|
||||
app.io = io;
|
||||
// Socket.IO — the client framework (app-base.js) opens an authenticated socket.
|
||||
// We don't push anything yet, but serving /socket.io keeps the shared front-end
|
||||
// working exactly as it does in the sibling apps.
|
||||
const io = new Server(server);
|
||||
io.use(middleware.authIO);
|
||||
app.io = io;
|
||||
|
||||
server.listen(webPort, () => {
|
||||
console.log(`[web] jump-host UI/API on :${server.address().port}`);
|
||||
});
|
||||
server.listen(webPort, () => {
|
||||
console.log(`[web] jump-host UI/API on :${server.address().port}`);
|
||||
});
|
||||
|
||||
sshServer.start();
|
||||
sshServer.start();
|
||||
|
||||
function shutdown() {
|
||||
console.log('[jump-host] shutting down');
|
||||
server.close();
|
||||
process.exit(0);
|
||||
}
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
function shutdown() {
|
||||
console.log('[jump-host] shutting down');
|
||||
server.close();
|
||||
process.exit(0);
|
||||
}
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
}).catch(err => {
|
||||
console.error('boot failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
+7
-2
@@ -6,7 +6,7 @@
|
||||
// values (LDAP creds, SSO API token) belong in the secrets file.
|
||||
|
||||
module.exports = {
|
||||
name: 'Jump Host',
|
||||
name: 'Jump',
|
||||
logo: '/static/img/theta42.svg',
|
||||
|
||||
// LDAP directory the users live in (same directory the SSO manages).
|
||||
@@ -80,7 +80,12 @@ module.exports = {
|
||||
|
||||
auth: {
|
||||
// OIDC group memberships that grant web UI/API admin access.
|
||||
adminGroups: ['app_sso_admin'],
|
||||
// app_super_admin is the cross-app super admin group (sso, proxy, jump-host).
|
||||
adminGroups: ['app_sso_admin', 'app_super_admin'],
|
||||
// OIDC group memberships that grant jump admin access (the audit page
|
||||
// and its data), without granting other admin-only rights. Full admins
|
||||
// (adminGroups/adminUsers) always have jump admin access too.
|
||||
jumpAdminGroups: ['app_jump_admin'],
|
||||
// Local anti-lockout admin: the first name here is bootstrapped as a
|
||||
// redis-backed user on first boot (password from localAdminPass, or a
|
||||
// random one printed to the log once). Lets you in even with OIDC down.
|
||||
|
||||
@@ -56,6 +56,25 @@ async function requireAdmin(req, res, next){
|
||||
next(error);
|
||||
}
|
||||
|
||||
// Jump admin = access to the audit page/data. A narrower grant than full
|
||||
// jump-host admin: full admins (isAdmin) always qualify, plus anyone in
|
||||
// conf.auth.jumpAdminGroups (e.g. a dedicated app_jump_admin LDAP group) can
|
||||
// be granted audit access without also getting other admin-only rights.
|
||||
function isJumpAdmin(req){
|
||||
if(isAdmin(req)) return true;
|
||||
const jumpAdminGroups = (conf.auth && conf.auth.jumpAdminGroups) || [];
|
||||
return (req.groups || []).some(g => jumpAdminGroups.includes(g));
|
||||
}
|
||||
|
||||
async function requireJumpAdmin(req, res, next){
|
||||
if(isJumpAdmin(req)) return next();
|
||||
const error = new Error('Forbidden');
|
||||
error.name = 'Forbidden';
|
||||
error.status = 403;
|
||||
error.message = 'Jump admin access required.';
|
||||
next(error);
|
||||
}
|
||||
|
||||
// Socket.IO handshake auth (app-base.js connects with the session token).
|
||||
async function authIO(socket, next){
|
||||
try{
|
||||
@@ -69,4 +88,4 @@ async function authIO(socket, next){
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { auth, requireAdmin, authIO, isAdmin };
|
||||
module.exports = { auth, requireAdmin, authIO, isAdmin, isJumpAdmin, requireJumpAdmin };
|
||||
|
||||
@@ -13,10 +13,34 @@ async function bump({ uid, hostSlug, success }) {
|
||||
const ops = [redis.incr(`${P()}total`), redis.incr(`${P()}day_${day}`)];
|
||||
if (!success) ops.push(redis.incr(`${P()}fail`));
|
||||
if (uid) ops.push(redis.incr(`${P()}user_${uid}`));
|
||||
if (hostSlug) ops.push(redis.incr(`${P()}host_${hostSlug}`));
|
||||
if (hostSlug) {
|
||||
ops.push(redis.incr(`${P()}host_${hostSlug}`));
|
||||
// Last-attempt timestamp per host, split by outcome -- drives the
|
||||
// dashboard's "Last connection"/"Last failed connection" columns and
|
||||
// row highlighting (see lastForHosts below).
|
||||
ops.push(redis.set(`${P()}host_last_${success ? 'success' : 'fail'}_${hostSlug}`, Date.now()));
|
||||
}
|
||||
await Promise.all(ops);
|
||||
}
|
||||
|
||||
// Per-host last-success/last-fail timestamps for a given list of slugs (e.g.
|
||||
// the hosts a session can reach), for the dashboard's host list.
|
||||
async function lastForHosts(slugs) {
|
||||
const redis = await getRedis();
|
||||
const result = {};
|
||||
await Promise.all((slugs || []).map(async (slug) => {
|
||||
const [lastSuccess, lastFail] = await Promise.all([
|
||||
redis.get(`${P()}host_last_success_${slug}`),
|
||||
redis.get(`${P()}host_last_fail_${slug}`),
|
||||
]);
|
||||
result[slug] = {
|
||||
lastConnected: lastSuccess ? Number(lastSuccess) : null,
|
||||
lastFailed: lastFail ? Number(lastFail) : null,
|
||||
};
|
||||
}));
|
||||
return result;
|
||||
}
|
||||
|
||||
async function summary() {
|
||||
const redis = await getRedis();
|
||||
const [total, fail] = await Promise.all([
|
||||
@@ -37,4 +61,4 @@ async function summary() {
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { bump, summary };
|
||||
module.exports = { bump, summary, lastForHosts };
|
||||
|
||||
Generated
+19
-6
@@ -1,19 +1,20 @@
|
||||
{
|
||||
"name": "t42-jump-host",
|
||||
"version": "1.5.0",
|
||||
"version": "1.15.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "t42-jump-host",
|
||||
"version": "1.5.0",
|
||||
"version": "1.15.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||
"@simpleworkjs/app-stack": "^1.0.0",
|
||||
"@simpleworkjs/bao-conf": "^1.0.0",
|
||||
"@simpleworkjs/conf": "^1.2.0",
|
||||
"@simpleworkjs/directory-schema": "^1.0.0",
|
||||
"@simpleworkjs/frontend": "^0.2.5",
|
||||
"@simpleworkjs/frontend": "^0.2.6",
|
||||
"@simpleworkjs/ldap": "^1.0.1",
|
||||
"@simpleworkjs/oidc-client": "^1.0.0",
|
||||
"@simpleworkjs/orm": "^0.2.8",
|
||||
@@ -156,6 +157,18 @@
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@simpleworkjs/bao-conf": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@simpleworkjs/bao-conf/-/bao-conf-1.0.1.tgz",
|
||||
"integrity": "sha512-mcay5NQ/w9ShpIAolMP/3f9TfXSLE+d5jrA4dTPOUHDjTkdsP7pe4hMmQUmwnniR59U1bGoRIVdXjvDbX3I5nw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"extend": "^3.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@simpleworkjs/conf": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@simpleworkjs/conf/-/conf-1.2.0.tgz",
|
||||
@@ -178,9 +191,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@simpleworkjs/frontend": {
|
||||
"version": "0.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@simpleworkjs/frontend/-/frontend-0.2.5.tgz",
|
||||
"integrity": "sha512-PxR7UVPv3gRpdF0WsuAZplF1vYvKsEJQevVPhz9d72U+69vP/OH3tlaAXjtO/apMHfhT1viOPw2gMVOrPSxYZw==",
|
||||
"version": "0.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@simpleworkjs/frontend/-/frontend-0.2.6.tgz",
|
||||
"integrity": "sha512-2uqvEjxyZ2LE+sfhP6rJcEMmqdViazJ3ZkitWJXInPMWF6DiEZuP5MYqBqJvfDko63CCHEt1/ChFQd7Ry85Pzg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "t42-jump-host",
|
||||
"version": "1.9.0",
|
||||
"version": "1.15.0",
|
||||
"description": "SSH jump host for the theta42 stack — LDAP-authenticated, directory-driven host bridging with audit and metrics",
|
||||
"author": [
|
||||
{
|
||||
@@ -21,9 +21,10 @@
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||
"@simpleworkjs/app-stack": "^1.0.0",
|
||||
"@simpleworkjs/bao-conf": "^1.0.0",
|
||||
"@simpleworkjs/conf": "^1.2.0",
|
||||
"@simpleworkjs/directory-schema": "^1.0.0",
|
||||
"@simpleworkjs/frontend": "^0.2.5",
|
||||
"@simpleworkjs/frontend": "^0.2.6",
|
||||
"@simpleworkjs/ldap": "^1.0.1",
|
||||
"@simpleworkjs/oidc-client": "^1.0.0",
|
||||
"@simpleworkjs/orm": "^0.2.8",
|
||||
|
||||
@@ -7,6 +7,12 @@ body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
/* Height of the fixed navbar (plus the update banner, while shown --
|
||||
see top.ejs's showUpdateBanner/dismissUpdateBanner). Lets an in-page
|
||||
sticky element offset itself below both fixed elements via
|
||||
`top: var(--sw-content-offset)` instead of colliding with them at the
|
||||
viewport's true top:0. */
|
||||
--sw-content-offset: 4.5rem;
|
||||
}
|
||||
|
||||
#spa-shell {
|
||||
|
||||
@@ -19,9 +19,10 @@ app.jump = (function(app){
|
||||
app.apiToken = (function(app){
|
||||
function list(cb){ app.api.get('api-token/', cb); }
|
||||
function add(args, cb){ app.api.post('api-token/', args, cb); }
|
||||
function update(args, cb){ app.api.put('api-token/' + args.id, args, cb); }
|
||||
function remove(id, cb){ app.api.delete('api-token/' + id, cb); }
|
||||
function rotate(id, cb){ app.api.post('api-token/' + id + '/rotate', {}, cb); }
|
||||
return {list: list, add: add, remove: remove, rotate: rotate};
|
||||
return {list: list, add: add, update: update, remove: remove, rotate: rotate};
|
||||
})(app);
|
||||
|
||||
// Shared render helpers.
|
||||
|
||||
@@ -584,10 +584,31 @@ app.util = (function(app){
|
||||
document.body.removeChild(element);
|
||||
}
|
||||
|
||||
// Scroll a just-added/-edited element into view and flash its
|
||||
// background, so the user's eye lands on the row that changed instead of
|
||||
// it silently appearing/updating somewhere off-screen. Takes a jQuery
|
||||
// object or a raw DOM node (e.g. jq-repeat's `item.__jq_$el`).
|
||||
function revealItem(el){
|
||||
var node = el && el.jquery ? el[0] : el;
|
||||
if (!node) return;
|
||||
if (typeof node.scrollIntoView === 'function') {
|
||||
node.scrollIntoView({behavior: 'smooth', block: 'center'});
|
||||
}
|
||||
var prevTransition = node.style.transition;
|
||||
var prevBg = node.style.backgroundColor;
|
||||
node.style.transition = 'background-color 1.5s ease';
|
||||
node.style.backgroundColor = 'var(--bs-success-bg-subtle, #d1e7dd)';
|
||||
setTimeout(function(){
|
||||
node.style.backgroundColor = prevBg;
|
||||
setTimeout(function(){ node.style.transition = prevTransition; }, 1500);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
return {
|
||||
downloadFile: downloadFile,
|
||||
getUrlParameter: getUrlParameter,
|
||||
escapeHtml: escapeHtml,
|
||||
revealItem: revealItem,
|
||||
}
|
||||
})(app);
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ router.use('/user', middleware.auth, require('./user'));
|
||||
// admin gate (see routes/api_token.js for why a token can't reach admin routes).
|
||||
router.use('/api-token', middleware.auth, require('./api_token'));
|
||||
|
||||
// Jump-host data — admin only (audit log, active sessions, metrics).
|
||||
router.use('/', middleware.auth, middleware.requireAdmin, require('./jump'));
|
||||
// Jump-host data — jump admin only (audit log, active sessions, metrics).
|
||||
router.use('/', middleware.auth, middleware.requireJumpAdmin, require('./jump'));
|
||||
|
||||
module.exports = router;
|
||||
|
||||
+18
-2
@@ -4,14 +4,17 @@
|
||||
// browser who it is and whether it's an admin (drives login state + nav).
|
||||
|
||||
const router = require('express').Router();
|
||||
const { isAdmin } = require('../middleware/auth');
|
||||
const { isAdmin, isJumpAdmin } = require('../middleware/auth');
|
||||
const access = require('../utils/access');
|
||||
const metrics = require('../models/metrics');
|
||||
const registry = require('../services/session_registry');
|
||||
|
||||
router.get('/me', (req, res) => {
|
||||
res.json({
|
||||
username: req.user && req.user.username,
|
||||
groups: req.groups || [],
|
||||
isAdmin: isAdmin(req),
|
||||
isJumpAdmin: isJumpAdmin(req),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,7 +26,20 @@ router.get('/hosts', async (req, res, next) => {
|
||||
const hosts = isAdmin(req)
|
||||
? await access.allHosts()
|
||||
: await access.accessibleHosts({ uid: req.user && req.user.username, groups: req.groups || [] });
|
||||
res.json({ results: hosts });
|
||||
|
||||
// Enrich with connection state for the dashboard's host list: whether a
|
||||
// session is live right now (active bridges, session_registry), plus the
|
||||
// last successful/failed connection times (models/metrics).
|
||||
const connectedSlugs = new Set(registry.list().map((s) => s.slug));
|
||||
const last = await metrics.lastForHosts(hosts.map((h) => h.slug));
|
||||
const enriched = hosts.map((h) => ({
|
||||
...h,
|
||||
connected: connectedSlugs.has(h.slug),
|
||||
lastConnected: (last[h.slug] && last[h.slug].lastConnected) || null,
|
||||
lastFailed: (last[h.slug] && last[h.slug].lastFailed) || null,
|
||||
}));
|
||||
|
||||
res.json({ results: enriched });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
|
||||
@@ -130,7 +130,7 @@ async function resolveAndConnect(state, record, { onHostKey } = {}) {
|
||||
|
||||
let justInjected = false;
|
||||
try { justInjected = await ensureKeyInjected(state.user, JUMP_KEYS.publicLine); }
|
||||
catch (err) { throw fail('key-inject-failed', err.message); }
|
||||
catch (err) { throw fail('key-inject-failed', err.message, host ? host.slug : undefined); }
|
||||
|
||||
let upstream;
|
||||
try {
|
||||
@@ -139,7 +139,7 @@ async function resolveAndConnect(state, record, { onHostKey } = {}) {
|
||||
username: state.uid, privateKey: JUMP_KEYS.clientKey,
|
||||
uid: state.uid, justInjected, onHostKey,
|
||||
});
|
||||
} catch (err) { throw fail('upstream-unreachable', err.message); }
|
||||
} catch (err) { throw fail('upstream-unreachable', err.message, host ? host.slug : undefined); }
|
||||
|
||||
return { upstream, host, endpoint };
|
||||
}
|
||||
@@ -147,8 +147,10 @@ async function resolveAndConnect(state, record, { onHostKey } = {}) {
|
||||
// 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; }
|
||||
// SSH auth failure both looked identical in the audit log. hostSlug (when the
|
||||
// target was already resolved to a known host) lets callers attribute the
|
||||
// failure to that host for per-host "last failed connection" tracking.
|
||||
function fail(reason, detail, hostSlug) { const e = new Error(reason); e.reason = reason; e.detail = detail; e.hostSlug = hostSlug; return e; }
|
||||
|
||||
async function runGrammar(session, client, state) {
|
||||
// Register session listeners IMMEDIATELY — before any async work.
|
||||
@@ -179,7 +181,7 @@ async function runGrammar(session, client, state) {
|
||||
const reason = err.reason || 'error';
|
||||
rejectUp(new Error(reasonMessage(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, hostSlug: err.hostSlug, success: false });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,9 +205,9 @@ async function runTuiSession(session, client, state) {
|
||||
|
||||
const record = await audit.create({ uid: state.uid, authMethod: state.authMethod, clientIp: state.clientIp, mode: 'tui' });
|
||||
|
||||
const finishFail = async (reason, detail) => {
|
||||
const finishFail = async (reason, detail, hostSlug) => {
|
||||
await record.finish({ success: false, failReason: reason, failDetail: detail });
|
||||
await metrics.bump({ uid: state.uid, success: false });
|
||||
await metrics.bump({ uid: state.uid, hostSlug, success: false });
|
||||
try { client.end(); } catch (_) {}
|
||||
};
|
||||
|
||||
@@ -227,7 +229,7 @@ async function runTuiSession(session, client, state) {
|
||||
|
||||
let justInjected = false;
|
||||
try { justInjected = await ensureKeyInjected(state.user, JUMP_KEYS.publicLine); }
|
||||
catch (err) { return finishFail('key-inject-failed', err.message); }
|
||||
catch (err) { return finishFail('key-inject-failed', err.message, tui.host.slug); }
|
||||
|
||||
let upstream;
|
||||
try {
|
||||
@@ -238,7 +240,7 @@ async function runTuiSession(session, client, state) {
|
||||
});
|
||||
} catch (err) {
|
||||
try { tui.channel.write(`\r\n Could not reach ${endpoint.address}.\r\n`); tui.channel.close(); } catch (_) {}
|
||||
return finishFail('upstream-unreachable', err.message);
|
||||
return finishFail('upstream-unreachable', err.message, tui.host.slug);
|
||||
}
|
||||
|
||||
registry.add(record.id, { uid: state.uid, target: endpoint.address, slug: tui.host.slug });
|
||||
|
||||
@@ -9,10 +9,29 @@ const ESC = '\x1b';
|
||||
const CLEAR = `${ESC}[2J${ESC}[H`;
|
||||
const HIDE_CUR = `${ESC}[?25l`;
|
||||
const SHOW_CUR = `${ESC}[?25h`;
|
||||
const INV = `${ESC}[7m`;
|
||||
|
||||
// Basic styles
|
||||
const RST = `${ESC}[0m`;
|
||||
const DIM = `${ESC}[2m`;
|
||||
const BOLD = `${ESC}[1m`;
|
||||
const DIM = `${ESC}[2m`;
|
||||
|
||||
// Colors (30-37: standard, 90-97: bright)
|
||||
const RED = `${ESC}[31m`;
|
||||
const BRIGHT_RED = `${ESC}[91m`;
|
||||
const CYAN = `${ESC}[36m`;
|
||||
const BRIGHT_CYAN = `${ESC}[96m`;
|
||||
const GREEN = `${ESC}[32m`;
|
||||
const BRIGHT_GREEN = `${ESC}[92m`;
|
||||
const YELLOW = `${ESC}[33m`;
|
||||
const BRIGHT_YELLOW = `${ESC}[93m`;
|
||||
const MAGENTA = `${ESC}[35m`;
|
||||
const BRIGHT_MAGENTA = `${ESC}[95m`;
|
||||
const BLUE = `${ESC}[34m`;
|
||||
const BRIGHT_BLUE = `${ESC}[94m`;
|
||||
|
||||
// Inverted selection with color
|
||||
const INV_GREEN = `${ESC}[42m${ESC}[30m`; // Green bg, black text
|
||||
const INV = `${ESC}[7m`;
|
||||
|
||||
function pickHost(channel, uid, hosts) {
|
||||
return new Promise((resolve) => {
|
||||
@@ -35,18 +54,43 @@ function pickHost(channel, uid, hosts) {
|
||||
const list = visible();
|
||||
if (selected >= list.length) selected = Math.max(0, list.length - 1);
|
||||
let out = CLEAR + HIDE_CUR;
|
||||
out += `${BOLD} Theta42 Jump — hosts for ${uid}${RST}\r\n`;
|
||||
out += `${DIM} ↑/↓ move · Enter connect · type to filter · q quit${RST}\r\n\r\n`;
|
||||
|
||||
// Header with gradient-style color
|
||||
out += `\r\n ${BOLD}${BRIGHT_CYAN}╔════════════════════════════════════════════════════════╗${RST}\r\n`;
|
||||
out += ` ${BOLD}${BRIGHT_CYAN}║${RST} ${BOLD}${BRIGHT_MAGENTA}Theta42 Jump${RST} ${DIM}·${RST} ${BRIGHT_GREEN}hosts for ${uid}${RST} ${BOLD}${BRIGHT_CYAN}║${RST}\r\n`;
|
||||
out += ` ${BOLD}${BRIGHT_CYAN}╚════════════════════════════════════════════════════════╝${RST}\r\n`;
|
||||
out += `\r\n`;
|
||||
out += ` ${DIM}↑/↓ move · Enter connect · type to filter · q quit${RST}\r\n`;
|
||||
out += `\r\n`;
|
||||
|
||||
if (!list.length) {
|
||||
out += ` ${DIM}(no match for "${filter}")${RST}\r\n`;
|
||||
out += ` ${YELLOW}⚠${RST} ${DIM}(no match for "${filter}")${RST}\r\n`;
|
||||
} else {
|
||||
list.forEach((h, i) => {
|
||||
const ip = (h.metadata && h.metadata.ip) || (h.metadata && h.metadata.address) || '';
|
||||
const row = ` ${h.name} ${DIM}(${h.slug})${RST}${ip ? ` ${ip}` : ''}`;
|
||||
out += (i === selected ? `${INV}> ${h.name} (${h.slug})${ip ? ` ${ip}` : ''}${RST}` : row) + '\r\n';
|
||||
const isProd = h.metadata && h.metadata.isProduction;
|
||||
const envBadge = isProd ? `${BOLD}${RED}PROD${RST} ` : `${DIM}DEV${RST} `;
|
||||
|
||||
if (i === selected) {
|
||||
// Selected row with green inverse background
|
||||
const selRow = `${INV_GREEN} ${h.name} ${DIM}(${h.slug})${RST}${ip ? ` ${CYAN}${ip}${RST}` : ''} ${envBadge} ${BOLD}${BRIGHT_GREEN}◄ SELECTED ►${RST}${INV_GREEN}${RST}`;
|
||||
out += selRow + '\r\n';
|
||||
} else {
|
||||
// Normal row with subtle coloring
|
||||
const nameColor = i % 2 === 0 ? BRIGHT_CYAN : CYAN;
|
||||
out += ` ${nameColor}${h.name}${RST} ${DIM}(${h.slug})${RST}${ip ? ` ${BLUE}${ip}${RST}` : ''} ${envBadge}\r\n`;
|
||||
}
|
||||
});
|
||||
}
|
||||
if (filter) out += `\r\n ${DIM}filter:${RST} ${filter}`;
|
||||
|
||||
if (filter) {
|
||||
out += `\r\n ${DIM}filter: ${BRIGHT_YELLOW}${filter}${RST}`;
|
||||
}
|
||||
|
||||
// Footer
|
||||
out += `\r\n\r\n ${DIM}────────────────────────────────────────────────────────${RST}\r\n`;
|
||||
out += ` ${DIM}Press${RST} ${BOLD}1-9${RST} ${DIM}to quick-select · ${BOLD}q${RST} ${DIM}to quit${RST}\r\n`;
|
||||
|
||||
channel.write(out);
|
||||
};
|
||||
|
||||
|
||||
+1
-1
@@ -37,6 +37,6 @@ module.exports = {
|
||||
nav: [
|
||||
{href: '/dashboard', icon: 'fa-solid fa-gauge-high', label: 'Dashboard', groups: []},
|
||||
{href: '/sessions', icon: 'fa-solid fa-plug-circle-bolt', label: 'Sessions', groups: []},
|
||||
{href: '/audit', icon: 'fa-solid fa-clipboard-list', label: 'Audit', groups: []},
|
||||
{href: '/audit', icon: 'fa-solid fa-clipboard-list', label: 'Audit', groups: ['admin', 'app_jump_admin']},
|
||||
],
|
||||
};
|
||||
|
||||
+66
-2
@@ -1,5 +1,46 @@
|
||||
<%- include('top') %>
|
||||
<script type="text/javascript">app.auth.forceLogin();</script>
|
||||
<script type="text/javascript">app.auth.forceLogin(['admin', 'app_jump_admin']);</script>
|
||||
|
||||
<div class="container mt-4">
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card shadow-sm text-center"><div class="card-body">
|
||||
<div class="display-6" id="stat-active">–</div>
|
||||
<div class="text-muted small text-uppercase">Active sessions</div>
|
||||
</div></div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card shadow-sm text-center"><div class="card-body">
|
||||
<div class="display-6" id="stat-total">–</div>
|
||||
<div class="text-muted small text-uppercase">Total connections</div>
|
||||
</div></div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card shadow-sm text-center"><div class="card-body">
|
||||
<div class="display-6 text-danger" id="stat-fail">–</div>
|
||||
<div class="text-muted small text-uppercase">Failed</div>
|
||||
</div></div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card shadow-sm text-center"><div class="card-body">
|
||||
<div class="display-6" id="stat-users">–</div>
|
||||
<div class="text-muted small text-uppercase">Users seen</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm"><div class="card-header"><i class="fa-solid fa-server me-1"></i> Top hosts</div>
|
||||
<table class="table table-sm mb-0"><tbody id="top-hosts"></tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm"><div class="card-header"><i class="fa-solid fa-user me-1"></i> Top users</div>
|
||||
<table class="table table-sm mb-0"><tbody id="top-users"></tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header"><i class="fa-solid fa-clipboard-list me-1"></i> Audit log</div>
|
||||
@@ -29,8 +70,28 @@
|
||||
<button class="btn btn-sm btn-outline-secondary" id="next" onclick="changePage(1)">next →</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
function rows(sel, list){
|
||||
var $b = $(sel).empty();
|
||||
if(!list || !list.length){ $b.append('<tr><td class="text-muted">No data.</td></tr>'); return; }
|
||||
list.forEach(function(x){
|
||||
$b.append('<tr><td>' + app.jump.esc(x.name) + '</td><td class="text-end">' + x.count + '</td></tr>');
|
||||
});
|
||||
}
|
||||
function loadMetrics(){
|
||||
app.jump.metrics(function(error, data){
|
||||
if(error || !data) return;
|
||||
$('#stat-active').text(data.active);
|
||||
$('#stat-total').text(data.total);
|
||||
$('#stat-fail').text(data.fail);
|
||||
$('#stat-users').text((data.topUsers || []).length);
|
||||
rows('#top-hosts', data.topHosts);
|
||||
rows('#top-users', data.topUsers);
|
||||
});
|
||||
}
|
||||
|
||||
var page = 0;
|
||||
function filters(){ return {page: page, uid: $('#f-uid').val(), target: $('#f-target').val(), status: $('#f-status').val()}; }
|
||||
function applyFilters(){ page = 0; load(); }
|
||||
@@ -57,6 +118,9 @@
|
||||
$('#next').prop('disabled', (page + 1) * size >= total);
|
||||
});
|
||||
}
|
||||
$(document).ready(load);
|
||||
$(document).ready(function(){
|
||||
loadMetrics();
|
||||
load();
|
||||
});
|
||||
</script>
|
||||
<%- include('bottom') %>
|
||||
|
||||
+134
-93
@@ -1,33 +1,7 @@
|
||||
<%- include('top') %>
|
||||
<script type="text/javascript">app.auth.forceLogin();</script>
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card shadow-sm text-center"><div class="card-body">
|
||||
<div class="display-6" id="stat-active">–</div>
|
||||
<div class="text-muted small text-uppercase">Active sessions</div>
|
||||
</div></div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card shadow-sm text-center"><div class="card-body">
|
||||
<div class="display-6" id="stat-total">–</div>
|
||||
<div class="text-muted small text-uppercase">Total connections</div>
|
||||
</div></div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card shadow-sm text-center"><div class="card-body">
|
||||
<div class="display-6 text-danger" id="stat-fail">–</div>
|
||||
<div class="text-muted small text-uppercase">Failed</div>
|
||||
</div></div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card shadow-sm text-center"><div class="card-body">
|
||||
<div class="display-6" id="stat-users">–</div>
|
||||
<div class="text-muted small text-uppercase">Users seen</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container mt-4">
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card shadow-sm">
|
||||
@@ -40,7 +14,7 @@
|
||||
</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">
|
||||
<button class="btn btn-outline-secondary" onclick="copyFieldValue('#quick-jump-cmd')" title="Copy">
|
||||
<i class="fa-solid fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -53,20 +27,12 @@
|
||||
<div class="col-12">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header"><i class="fa-solid fa-network-wired me-1"></i> <span id="my-hosts-title">Hosts you can reach</span></div>
|
||||
<table class="table table-sm mb-0"><tbody id="my-hosts"></tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm"><div class="card-header"><i class="fa-solid fa-server me-1"></i> Top hosts</div>
|
||||
<table class="table table-sm mb-0"><tbody id="top-hosts"></tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm"><div class="card-header"><i class="fa-solid fa-user me-1"></i> Top users</div>
|
||||
<table class="table table-sm mb-0"><tbody id="top-users"></tbody></table>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead><tr><th>Host</th><th>Slug</th><th class="text-end">Address</th><th>Last connection</th><th>Last failed connection</th><th></th></tr></thead>
|
||||
<tbody id="my-hosts"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -84,22 +50,42 @@
|
||||
(e.g. <code>GET /api/user/hosts</code>) — not for SSH login. A token carries
|
||||
no group claims, so it can't reach admin-only endpoints.
|
||||
</p>
|
||||
<table class="table table-sm mb-0">
|
||||
<thead><tr><th>Name</th><th>Created</th><th>Last used</th><th>Expires</th><th></th></tr></thead>
|
||||
<tbody id="api-tokens"></tbody>
|
||||
</table>
|
||||
<div class="card-body">
|
||||
<p id="api-tokens-empty" class="text-muted mb-0" style="display:none">No API tokens.</p>
|
||||
<div id="api-tokens">
|
||||
<div jq-repeat="apiTokenCard" jq-index-key="id" id="apitoken-card-{{id}}" class="card shadow-sm mb-3">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0"><i class="fa-solid fa-key"></i> {{name}}</h6>
|
||||
<small class="text-muted font-monospace">{{id_short}}</small>
|
||||
</div>
|
||||
<div class="card-header actionMessage" style="display:none"></div>
|
||||
<div class="card-body">
|
||||
{{#description}}<p>{{description}}</p>{{/description}}
|
||||
<dl class="row mb-0 small">
|
||||
<dt class="col-sm-3">Token ID</dt>
|
||||
<dd class="col-sm-9"><code>{{id_short}}</code></dd>
|
||||
<dt class="col-sm-3">Created</dt>
|
||||
<dd class="col-sm-9">{{{created_display}}}</dd>
|
||||
<dt class="col-sm-3">Last used</dt>
|
||||
<dd class="col-sm-9">{{{last_used_display}}}</dd>
|
||||
<dt class="col-sm-3">Expires</dt>
|
||||
<dd class="col-sm-9">{{{expires_display}}}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<button type="button" onclick="editToken('{{id}}')" class="btn btn-primary btn-sm"><i class="fa-solid fa-pen-to-square"></i> Edit</button>
|
||||
<button type="button" onclick="rotateApiToken('{{id}}', this)" class="btn btn-warning btn-sm"><i class="fa-solid fa-arrows-rotate"></i> Rotate</button>
|
||||
<button type="button" onclick="revokeApiToken('{{id}}', this)" class="btn btn-danger btn-sm float-end"><i class="fa-solid fa-trash"></i> Revoke</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
function rows(sel, list){
|
||||
var $b = $(sel).empty();
|
||||
if(!list || !list.length){ $b.append('<tr><td class="text-muted">No data.</td></tr>'); return; }
|
||||
list.forEach(function(x){
|
||||
$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){
|
||||
@@ -108,7 +94,7 @@
|
||||
var portFlag = SSH_PORT === 22 ? '' : ' -p ' + SSH_PORT;
|
||||
return 'ssh ' + uid + (target ? '_-_' + target : '') + '@' + location.hostname + portFlag;
|
||||
}
|
||||
function copySshCommand(sel){
|
||||
function copyFieldValue(sel){
|
||||
var $el = $(sel);
|
||||
var text = $el.val();
|
||||
if(!text) return;
|
||||
@@ -125,47 +111,61 @@
|
||||
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>'
|
||||
// Green: a session to this host is live right now. Yellow: the most
|
||||
// recent attempt to this host failed (and none is currently live).
|
||||
var rowClass = h.connected ? 'table-success'
|
||||
: (h.lastFailed && (!h.lastConnected || h.lastFailed > h.lastConnected)) ? 'table-warning'
|
||||
: '';
|
||||
$b.append('<tr class="' + rowClass + '"><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>'
|
||||
+ '<td class="small">' + (h.lastConnected ? app.jump.fmtTime(h.lastConnected) : '—') + '</td>'
|
||||
+ '<td class="small">' + (h.lastFailed ? app.jump.fmtTime(h.lastFailed) : '—') + '</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>'
|
||||
+ '<button class="btn btn-sm btn-outline-secondary" onclick="copyFieldValue(\'#' + rowId + '\')" title="Copy quick-jump command"><i class="fa-solid fa-copy"></i></button>'
|
||||
+ '</td></tr>');
|
||||
});
|
||||
}
|
||||
function tokenRows(tokens){
|
||||
var $b = $('#api-tokens').empty();
|
||||
if(!tokens || !tokens.length){ $b.append('<tr><td colspan="5" class="text-muted">No API tokens.</td></tr>'); return; }
|
||||
tokens.forEach(function(t){
|
||||
var expires = t.expires_at ? app.jump.fmtTime(t.expires_at) : 'Never';
|
||||
var lastUsed = t.last_used_on ? app.jump.fmtTime(t.last_used_on) : 'Never';
|
||||
$b.append(
|
||||
'<tr>'
|
||||
+ '<td>' + app.jump.esc(t.name) + '</td>'
|
||||
+ '<td class="text-muted small">' + app.jump.fmtTime(t.created_on) + '</td>'
|
||||
+ '<td class="text-muted small">' + lastUsed + '</td>'
|
||||
+ '<td class="text-muted small">' + expires + '</td>'
|
||||
+ '<td class="text-end">'
|
||||
+ '<button class="btn btn-sm btn-outline-secondary" onclick="rotateApiToken(\'' + t.id + '\', this)" title="Rotate"><i class="fa-solid fa-rotate"></i></button> '
|
||||
+ '<button class="btn btn-sm btn-outline-danger" onclick="revokeApiToken(\'' + t.id + '\', this)" title="Revoke"><i class="fa-solid fa-trash"></i></button>'
|
||||
+ '</td>'
|
||||
+ '</tr>'
|
||||
);
|
||||
});
|
||||
// expires_at/created_on/last_used_on come back as redis-hash strings for
|
||||
// some fields and real numbers for others depending on the model's field
|
||||
// type -- fmtTime already handles both via moment(ms, 'x').
|
||||
function fmtExpiry(token){
|
||||
var exp = Number(token.expires_at);
|
||||
if(!exp) return '<span class="badge text-bg-secondary">never</span>';
|
||||
if(Date.now() > exp) return '<span class="badge text-bg-danger">expired</span>';
|
||||
return '<span class="badge text-bg-warning">' + moment(exp).fromNow() + '</span>';
|
||||
}
|
||||
|
||||
var tokensById = {};
|
||||
function processToken(token){
|
||||
tokensById[token.id] = token;
|
||||
token.id_short = token.id.slice(0, 12) + '…';
|
||||
token.expires_display = fmtExpiry(token);
|
||||
token.created_display = app.jump.fmtTime(token.created_on);
|
||||
token.last_used_display = token.last_used_on ? app.jump.fmtTime(token.last_used_on) : 'Never';
|
||||
return token;
|
||||
}
|
||||
|
||||
function loadApiTokens(){
|
||||
app.apiToken.list(function(error, data){
|
||||
if(error) return tokenRows([]);
|
||||
tokenRows(data && data.results);
|
||||
var tokens = (!error && data && data.results) || [];
|
||||
$.scope.apiTokenCard.empty();
|
||||
tokens.forEach(function(t){ $.scope.apiTokenCard.push(processToken(t)); });
|
||||
$('#api-tokens-empty').toggle(tokens.length === 0);
|
||||
});
|
||||
}
|
||||
|
||||
// Shared "reveal secret once" display -- also used by proxy/sso-manager-node.
|
||||
function showToken(title, token){
|
||||
app.modal.open({title: title, bodyHtml:
|
||||
'<p class="text-danger"><i class="fa-solid fa-triangle-exclamation"></i> Save this token now — it will <strong>not</strong> be shown again.</p>'
|
||||
+ '<div class="input-group"><input type="text" class="form-control font-monospace" readonly value="' + app.jump.esc(token) + '"></div>'
|
||||
+ '<div class="input-group"><input type="text" class="form-control font-monospace" id="revealed-token" readonly value="' + app.jump.esc(token) + '">'
|
||||
// Reuses the same copy-to-clipboard helper as the Quick Jump card
|
||||
// above (toast feedback -- FontAwesome replaces <i> icons with
|
||||
// inline <svg>, so a checkmark-flash-the-icon approach silently
|
||||
// no-ops; the toast doesn't have that problem).
|
||||
+ '<button class="btn btn-outline-secondary" onclick="copyFieldValue(\'#revealed-token\')" title="Copy"><i class="fa-solid fa-copy"></i></button></div>'
|
||||
+ '<p class="mt-3 mb-0 text-muted small">Use it as a bearer token:<br><code>Authorization: Bearer ' + app.jump.esc(token) + '</code></p>'
|
||||
});
|
||||
}
|
||||
@@ -177,27 +177,77 @@
|
||||
+ '<input type="text" class="form-control" id="new-token-name" placeholder="e.g. laptop-cron">'
|
||||
+ '</div>'
|
||||
+ '<div class="mb-3">'
|
||||
+ '<label class="form-label">Description</label>'
|
||||
+ '<input type="text" class="form-control" id="new-token-description" placeholder="optional">'
|
||||
+ '</div>'
|
||||
+ '<div class="mb-3">'
|
||||
+ '<label class="form-label">Expires in (days, blank = never)</label>'
|
||||
+ '<input type="number" class="form-control" id="new-token-days" min="1">'
|
||||
+ '</div>'
|
||||
+ '<button class="btn btn-primary" onclick="submitApiToken()"><i class="fa-solid fa-check"></i> Create</button>'
|
||||
+ '</div>',
|
||||
footer: {buttonsHtml: app.modal.footerButtons({onSave: 'submitApiToken()', saveLabel: 'Create'})},
|
||||
});
|
||||
$body.find('#new-token-name').focus();
|
||||
}
|
||||
|
||||
function submitApiToken(){
|
||||
var name = $('#new-token-name').val().trim();
|
||||
var $card = $('#api-tokens').closest('.card');
|
||||
if(!name) return app.messages.action('Name is required', $card, 'danger');
|
||||
if(!name) return app.messages.action('Name is required', app.modal.body(), 'danger');
|
||||
app.apiToken.add({
|
||||
name: name,
|
||||
description: $('#new-token-description').val(),
|
||||
expires_in_days: $('#new-token-days').val(),
|
||||
}, function(error, data){
|
||||
if(error) return app.messages.action((data && data.message) || 'Failed to create token', $card, 'danger');
|
||||
if(error) return app.messages.action((data && data.message) || 'Failed to create token', app.modal.body(), 'danger');
|
||||
// Deliberately no app.modal.close() here -- app.modal is a
|
||||
// singleton, and close() immediately followed by open() (inside
|
||||
// showToken) in the same tick collides with Bootstrap's
|
||||
// hide-transition guard, so the reveal modal silently never
|
||||
// shows. open() alone already overwrites the (already-visible)
|
||||
// modal's content in place.
|
||||
showToken('API Token Created', data.token);
|
||||
loadApiTokens();
|
||||
});
|
||||
}
|
||||
|
||||
function editToken(id){
|
||||
var t = tokensById[id]; if(!t) return;
|
||||
app.modal.open({
|
||||
title: 'Edit Token',
|
||||
bodyHtml:
|
||||
'<input type="hidden" id="edit-token-id" value="' + app.jump.esc(id) + '">'
|
||||
+ '<div class="mb-3">'
|
||||
+ '<label class="form-label">Name</label>'
|
||||
+ '<input type="text" class="form-control" id="edit-token-name" value="' + app.jump.esc(t.name || '') + '">'
|
||||
+ '</div>'
|
||||
+ '<div class="mb-3">'
|
||||
+ '<label class="form-label">Description</label>'
|
||||
+ '<input type="text" class="form-control" id="edit-token-description" value="' + app.jump.esc(t.description || '') + '">'
|
||||
+ '</div>'
|
||||
+ '<div class="mb-3">'
|
||||
+ '<label class="form-label">Expires in (days, blank = keep as-is, 0 = never)</label>'
|
||||
+ '<input type="number" class="form-control" id="edit-token-days" min="0">'
|
||||
+ '</div>',
|
||||
footer: {
|
||||
metaHtml: 'Created by ' + app.jump.esc(t.created_by || '—') + ' on ' + app.jump.fmtTime(t.created_on),
|
||||
buttonsHtml: app.modal.footerButtons({onSave: 'saveEditToken()', saveLabel: 'Save'}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function saveEditToken(){
|
||||
var payload = {
|
||||
id: $('#edit-token-id').val(),
|
||||
name: $('#edit-token-name').val(),
|
||||
description: $('#edit-token-description').val(),
|
||||
expires_in_days: $('#edit-token-days').val(),
|
||||
};
|
||||
app.apiToken.update(payload, function(error, data){
|
||||
if(error) return app.messages.action((data && data.message) || 'Failed to update token', app.modal.body(), 'danger');
|
||||
app.modal.close();
|
||||
loadApiTokens();
|
||||
});
|
||||
}
|
||||
|
||||
async function revokeApiToken(id, btn){
|
||||
var $card = $(btn).closest('.card');
|
||||
var ok = await app.messages.confirm('Revoke this API token? It stops working immediately.', $card, 'danger');
|
||||
@@ -219,17 +269,8 @@
|
||||
}
|
||||
|
||||
$(document).ready(async function(){
|
||||
app.jump.metrics(function(error, data){
|
||||
if(error || !data) return;
|
||||
$('#stat-active').text(data.active);
|
||||
$('#stat-total').text(data.total);
|
||||
$('#stat-fail').text(data.fail);
|
||||
$('#stat-users').text((data.topUsers || []).length);
|
||||
rows('#top-hosts', data.topHosts);
|
||||
rows('#top-users', data.topUsers);
|
||||
});
|
||||
await app.auth.loadUser();
|
||||
if(app.auth.isAdmin()) $('#my-hosts-title').text('All hosts');
|
||||
if(app.auth.isAdmin()) $('#my-hosts-title').text('My hosts');
|
||||
$('#quick-jump-cmd').val(sshCommand());
|
||||
app.jump.hosts(function(error, data){
|
||||
if(error) return hostRows('#my-hosts', []);
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
<hr />
|
||||
<div class="d-grid">
|
||||
<a href="/api/auth/oidc/start" class="btn btn-outline-primary">
|
||||
<i class="fa-solid fa-id-badge"></i> Log in with SSO
|
||||
<i class="fa-solid fa-id-badge"></i> Log in with Jump
|
||||
</a>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<%- include('top') %>
|
||||
<script type="text/javascript">app.auth.forceLogin();</script>
|
||||
|
||||
<div class="container mt-4">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span><i class="fa-solid fa-plug-circle-bolt me-1"></i> Active sessions</span>
|
||||
@@ -13,6 +14,7 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
function loadSessions(){
|
||||
|
||||
@@ -82,16 +82,24 @@
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
// --sw-content-offset tracks the same height as #spa-shell's margin-top
|
||||
// (fixed navbar, plus the update banner while it's shown), so any
|
||||
// in-page sticky element (e.g. a sticky search/sort bar) can offset
|
||||
// itself below both fixed elements via `top: var(--sw-content-offset)`
|
||||
// instead of colliding with them at the viewport's true top:0.
|
||||
function showUpdateBanner(){
|
||||
let $nav = $('nav.fixed-top');
|
||||
let $banner = $('#update-banner');
|
||||
$banner.css('top', $nav.outerHeight() + 'px').show();
|
||||
$('#spa-shell').css('margin-top', ($nav.outerHeight() + $banner.outerHeight()) + 'px');
|
||||
let offset = $nav.outerHeight() + $banner.outerHeight();
|
||||
$('#spa-shell').css('margin-top', offset + 'px');
|
||||
document.documentElement.style.setProperty('--sw-content-offset', offset + 'px');
|
||||
}
|
||||
|
||||
function dismissUpdateBanner(){
|
||||
$('#update-banner').hide();
|
||||
$('#spa-shell').css('margin-top', '');
|
||||
document.documentElement.style.setProperty('--sw-content-offset', $('nav.fixed-top').outerHeight() + 'px');
|
||||
sessionStorage.setItem('update-banner-dismissed', '1');
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user