Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f386a5f9c3 | |||
| 82318da484 | |||
| 14784266b3 | |||
| 362e77f3dd | |||
| dfafffe154 | |||
| bd4464ed19 | |||
| 061a044a70 | |||
| c6a4a3c841 | |||
| 0ef451e15d | |||
| 6771904932 | |||
| c002afe043 | |||
| 0a2c25ae75 | |||
| a15decb6f7 | |||
| 599136e4dc | |||
| a7d5efc764 | |||
| 8a76f71edd | |||
| e482f52f10 | |||
| a6af160627 | |||
| 8c9646b65c | |||
| 1d09f243dd | |||
| ec4ca97af4 | |||
| 21ef8960c4 | |||
| a5bef2980b | |||
| ab2ee0fed3 | |||
| ab9e04e007 | |||
| a3a6787776 | |||
| 0ead0199a3 | |||
| 0af2fc7e3d | |||
| bc2180116f | |||
| 1b70701795 | |||
| 98e1e0e279 | |||
| b03fcefaae | |||
| 3474482f6f | |||
| da361a8a86 | |||
| 4326d5588e | |||
| 4a70b5b27e | |||
| 43caac13d5 | |||
| 465f923393 | |||
| 782a829cb9 | |||
| fd863b89ca | |||
| 4fb4e77007 |
@@ -4,6 +4,79 @@ 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.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
|
||||||
|
- **"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
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Self-service API tokens (PATs)** — `models/api_token.js` + `routes/api_token.js` (mounted at `/api-token`), Bearer-token support in `middleware/auth.js`, and a create/list/rotate/revoke card on the dashboard. Ports proxy's `jmp_<id>_<secret>` pattern; unlike proxy's, a jump-host token carries no group claims, so it authenticates as its creator for non-admin routes only (never passes `requireAdmin`). jump-host previously had no PAT support at all.
|
||||||
|
|
||||||
|
## [1.6.0] - 2026-07-27
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Adopted `@simpleworkjs/frontend`'s `app.messages`, `app.modal`, and `app.validate` modules**, replacing the vendored `app.util.actionMessage`/`actionConfirm` in `public/lib/js/app-base.js` and the vendored `public/lib/js/val.js` (unused by any current view here, so this is dedup/future-proofing rather than a behavior change). `app.api`/`app.auth`/`app.pubsub`/`app.socket` are untouched.
|
||||||
|
|
||||||
|
## [1.5.0] - 2026-07-27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Web UI dashboard now lists the hosts you can reach** ("Hosts you can reach", or "All hosts" for admins) — previously the dashboard only showed usage metrics, with no way to see your actual access from the browser. Backed by a new `GET /api/user/hosts` endpoint (auth-only, not admin-gated): admins get the full inventory via `utils/access.js`'s new `allHosts()`, everyone else gets the same group-based resolution the SSH front door uses.
|
||||||
|
- `utils/access.js`'s `accessibleHosts()` now accepts a pre-resolved `groups` array on the user object, skipping the LDAP `getGroups(dn)` round-trip — the web UI's OIDC session already has its groups claim and has no LDAP `dn` to query with.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Bumped `@simpleworkjs/ldap` to 1.0.1**, which fixes `addSshKey` throwing `ObjectClassViolationError` (LDAP `0x41`) on accounts predating the `ldapPublicKey` auxiliary objectClass. This is the code path this jump host's key-injection (`utils/key_inject.js`) uses on every first connection for a user — on affected accounts it aborted the SSH connection entirely (`key-inject-failed`).
|
||||||
|
|
||||||
## [1.4.0] - 2026-07-26
|
## [1.4.0] - 2026-07-26
|
||||||
|
|
||||||
### 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
|
||||||
|
|||||||
+7
-2
@@ -6,7 +6,7 @@
|
|||||||
// values (LDAP creds, SSO API token) belong in the secrets file.
|
// values (LDAP creds, SSO API token) belong in the secrets file.
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
name: 'Jump Host',
|
name: 'SSO Manager',
|
||||||
logo: '/static/img/theta42.svg',
|
logo: '/static/img/theta42.svg',
|
||||||
|
|
||||||
// LDAP directory the users live in (same directory the SSO manages).
|
// LDAP directory the users live in (same directory the SSO manages).
|
||||||
@@ -80,7 +80,12 @@ module.exports = {
|
|||||||
|
|
||||||
auth: {
|
auth: {
|
||||||
// OIDC group memberships that grant web UI/API admin access.
|
// 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
|
// Local anti-lockout admin: the first name here is bootstrapped as a
|
||||||
// redis-backed user on first boot (password from localAdminPass, or 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.
|
// random one printed to the log once). Lets you in even with OIDC down.
|
||||||
|
|||||||
@@ -9,6 +9,24 @@ const { Auth } = require('../models');
|
|||||||
|
|
||||||
async function auth(req, res, next){
|
async function auth(req, res, next){
|
||||||
try{
|
try{
|
||||||
|
// API-only token: `Authorization: Bearer jmp_<id>_<secret>`. Carries no
|
||||||
|
// group claims (see models/api_token.js), so it authenticates as its
|
||||||
|
// creator but never passes requireAdmin below.
|
||||||
|
const authz = req.header('authorization') || '';
|
||||||
|
if(authz.slice(0, 7).toLowerCase() === 'bearer '){
|
||||||
|
const t = await Auth.checkApiToken(authz.slice(7));
|
||||||
|
req.token = {
|
||||||
|
user: {username: t.created_by},
|
||||||
|
created_by: t.created_by,
|
||||||
|
groupsArray: () => [],
|
||||||
|
check: () => true,
|
||||||
|
is_valid: true,
|
||||||
|
};
|
||||||
|
req.user = req.token.user;
|
||||||
|
req.groups = [];
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
req.token = await Auth.checkToken(req.header('auth-token'));
|
req.token = await Auth.checkToken(req.header('auth-token'));
|
||||||
req.user = req.token.user;
|
req.user = req.token.user;
|
||||||
req.groups = typeof req.token.groupsArray === 'function' ? req.token.groupsArray() : [];
|
req.groups = typeof req.token.groupsArray === 'function' ? req.token.groupsArray() : [];
|
||||||
@@ -38,6 +56,25 @@ async function requireAdmin(req, res, next){
|
|||||||
next(error);
|
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).
|
// Socket.IO handshake auth (app-base.js connects with the session token).
|
||||||
async function authIO(socket, next){
|
async function authIO(socket, next){
|
||||||
try{
|
try{
|
||||||
@@ -51,4 +88,4 @@ async function authIO(socket, next){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { auth, requireAdmin, authIO, isAdmin };
|
module.exports = { auth, requireAdmin, authIO, isAdmin, isJumpAdmin, requireJumpAdmin };
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const Table = require('.');
|
||||||
|
const bcrypt = require('bcrypt');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
// Self-service personal access token (PAT) for the jump host's own API.
|
||||||
|
// Format: jmp_<id>_<secret>
|
||||||
|
// id — 24-char hex, stored plaintext as the record key (O(1) lookup)
|
||||||
|
// secret — 48-char hex, stored only as a bcrypt hash (isPrivate); shown ONCE
|
||||||
|
//
|
||||||
|
// Authenticated via `Authorization: Bearer jmp_...`. Mirrors proxy's
|
||||||
|
// models/api_token.js — see that file for the fuller design notes. jump-host
|
||||||
|
// has no per-user group snapshot the way proxy/sso do (its authz is a single
|
||||||
|
// admin/non-admin bit off conf.auth.adminGroups/adminUsers), so a token
|
||||||
|
// authenticates as its creator only; the auth middleware re-derives
|
||||||
|
// admin-ness from that user's current groups, same as a live session.
|
||||||
|
//
|
||||||
|
// No `static _ttl`: records persist (lifetime is the optional expires_at field).
|
||||||
|
|
||||||
|
const PREFIX = 'jmp_';
|
||||||
|
const randomHex = (bytes) => crypto.randomBytes(bytes).toString('hex');
|
||||||
|
|
||||||
|
class ApiToken extends Table{
|
||||||
|
static _key = 'id';
|
||||||
|
static _keyMap = {
|
||||||
|
'id': {default: function(){ return randomHex(12) }, type: 'string'},
|
||||||
|
'secret_hash': {isRequired: true, type: 'string', isPrivate: true},
|
||||||
|
'name': {isRequired: true, type: 'string', min: 1, max: 255},
|
||||||
|
'description': {default: '', type: 'string'},
|
||||||
|
'created_by': {isRequired: true, type: 'string', min: 3, max: 500},
|
||||||
|
'created_on': {default: function(){return (new Date).getTime()}},
|
||||||
|
'updated_on': {default: function(){return (new Date).getTime()}, always: true},
|
||||||
|
'expires_at': {default: 0, type: 'number'}, // epoch ms; 0 = never
|
||||||
|
'last_used_on': {default: 0, type: 'number'},
|
||||||
|
'is_valid': {default: true, type: 'boolean'},
|
||||||
|
}
|
||||||
|
|
||||||
|
get isExpired() {
|
||||||
|
return this.expires_at > 0 && (new Date).getTime() > this.expires_at;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async add(data){
|
||||||
|
const id = randomHex(12);
|
||||||
|
const secret = randomHex(24);
|
||||||
|
data.id = id;
|
||||||
|
data.secret_hash = await bcrypt.hash(secret, 10);
|
||||||
|
const token = await this.create(data);
|
||||||
|
token._raw_token = `${PREFIX}${id}_${secret}`;
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
async rotate(){
|
||||||
|
const secret = randomHex(24);
|
||||||
|
await this.update({ secret_hash: await bcrypt.hash(secret, 10) });
|
||||||
|
return `${PREFIX}${this.id}_${secret}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate a raw `jmp_<id>_<secret>` string. Throws a generic Error on any
|
||||||
|
// failure so the caller (Auth.checkApiToken) can collapse every case into
|
||||||
|
// one 401 (no existence / wrong-secret / expired leak).
|
||||||
|
static async authenticate(raw){
|
||||||
|
const m = /^jmp_([0-9a-f]{24})_([0-9a-f]{48})$/i.exec(String(raw || ''));
|
||||||
|
if(!m) throw new Error('InvalidApiToken');
|
||||||
|
let token;
|
||||||
|
try{
|
||||||
|
token = await this.get(m[1]);
|
||||||
|
}catch(e){
|
||||||
|
throw new Error('InvalidApiToken');
|
||||||
|
}
|
||||||
|
if(!token) throw new Error('InvalidApiToken');
|
||||||
|
const ok = await bcrypt.compare(m[2], token.secret_hash);
|
||||||
|
if(!ok || !token.is_valid || token.isExpired) throw new Error('InvalidApiToken');
|
||||||
|
// Best-effort: stamp last use. Fire-and-forget so a Redis hiccup never
|
||||||
|
// fails an otherwise-valid request.
|
||||||
|
try{ await token.update({ last_used_on: (new Date).getTime() }); }catch(_){}
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ApiToken.register();
|
||||||
|
|
||||||
|
module.exports = {ApiToken};
|
||||||
@@ -32,14 +32,17 @@ async function getRedis() {
|
|||||||
|
|
||||||
module.exports.getRedis = getRedis;
|
module.exports.getRedis = getRedis;
|
||||||
|
|
||||||
// Register models (order matters: User before AuthToken's relation resolves).
|
// Register models (order matters: User before AuthToken's relation resolves,
|
||||||
|
// and before ApiToken so `require('.')`'s Table is already exporting User).
|
||||||
require('./user_redis'); // User (redis-backed local + OIDC JIT)
|
require('./user_redis'); // User (redis-backed local + OIDC JIT)
|
||||||
|
const { ApiToken } = require('./api_token');
|
||||||
|
module.exports.ApiToken = ApiToken;
|
||||||
|
|
||||||
// Shared OIDC client (authorization-code + PKCE): session models (Token,
|
// Shared OIDC client (authorization-code + PKCE): session models (Token,
|
||||||
// AuthToken, OidcState), the Auth service, and the /login /logout /oidc/start
|
// AuthToken, OidcState), the Auth service, and the /login /logout /oidc/start
|
||||||
// /oidc/callback router — all created on this app's Table/redis. jump-host has
|
// /oidc/callback router — all created on this app's Table/redis. checkApiToken
|
||||||
// no Bearer PATs, so checkApiToken is omitted (Auth.checkApiToken is absent).
|
// wraps ApiToken.authenticate, same wiring as proxy's models/index.js.
|
||||||
const oidcClient = createOidcClient({ Table });
|
const oidcClient = createOidcClient({ Table, checkApiToken: (raw) => ApiToken.authenticate(raw) });
|
||||||
module.exports.Token = oidcClient.Token;
|
module.exports.Token = oidcClient.Token;
|
||||||
module.exports.AuthToken = oidcClient.AuthToken;
|
module.exports.AuthToken = oidcClient.AuthToken;
|
||||||
module.exports.OidcState = oidcClient.OidcState;
|
module.exports.OidcState = oidcClient.OidcState;
|
||||||
|
|||||||
@@ -13,10 +13,34 @@ async function bump({ uid, hostSlug, success }) {
|
|||||||
const ops = [redis.incr(`${P()}total`), redis.incr(`${P()}day_${day}`)];
|
const ops = [redis.incr(`${P()}total`), redis.incr(`${P()}day_${day}`)];
|
||||||
if (!success) ops.push(redis.incr(`${P()}fail`));
|
if (!success) ops.push(redis.incr(`${P()}fail`));
|
||||||
if (uid) ops.push(redis.incr(`${P()}user_${uid}`));
|
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);
|
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() {
|
async function summary() {
|
||||||
const redis = await getRedis();
|
const redis = await getRedis();
|
||||||
const [total, fail] = await Promise.all([
|
const [total, fail] = await Promise.all([
|
||||||
@@ -37,4 +61,4 @@ async function summary() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { bump, summary };
|
module.exports = { bump, summary, lastForHosts };
|
||||||
|
|||||||
Generated
+16
-6
@@ -1,19 +1,20 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-jump-host",
|
"name": "t42-jump-host",
|
||||||
"version": "1.3.0",
|
"version": "1.9.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "t42-jump-host",
|
"name": "t42-jump-host",
|
||||||
"version": "1.3.0",
|
"version": "1.9.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||||
"@simpleworkjs/app-stack": "^1.0.0",
|
"@simpleworkjs/app-stack": "^1.0.0",
|
||||||
"@simpleworkjs/conf": "^1.2.0",
|
"@simpleworkjs/conf": "^1.2.0",
|
||||||
"@simpleworkjs/directory-schema": "^1.0.0",
|
"@simpleworkjs/directory-schema": "^1.0.0",
|
||||||
"@simpleworkjs/ldap": "^1.0.0",
|
"@simpleworkjs/frontend": "^0.2.6",
|
||||||
|
"@simpleworkjs/ldap": "^1.0.1",
|
||||||
"@simpleworkjs/oidc-client": "^1.0.0",
|
"@simpleworkjs/oidc-client": "^1.0.0",
|
||||||
"@simpleworkjs/orm": "^0.2.8",
|
"@simpleworkjs/orm": "^0.2.8",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
@@ -176,10 +177,19 @@
|
|||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@simpleworkjs/frontend": {
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@simpleworkjs/ldap": {
|
"node_modules/@simpleworkjs/ldap": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@simpleworkjs/ldap/-/ldap-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@simpleworkjs/ldap/-/ldap-1.0.1.tgz",
|
||||||
"integrity": "sha512-saDmwk+KJ6kIWj9/MF37d+BM9KQisy6DsI9umyt1FWNyx6+wnEEat/1RUTwXKBd4IKJK+zPT5lC/B6gfa2CuAA==",
|
"integrity": "sha512-1jz3WQ9ghwNHz2mI+H33qw8nIQpLkoNHEy8lgdgsE/nCLK8JGU1SIKEdbsdMeXEQ0seKqyjuyXwmhdotsx6Lww==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ldapts": "^8.1.8"
|
"ldapts": "^8.1.8"
|
||||||
|
|||||||
+4
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-jump-host",
|
"name": "t42-jump-host",
|
||||||
"version": "1.4.0",
|
"version": "1.11.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": [
|
||||||
{
|
{
|
||||||
@@ -20,10 +20,11 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||||
"@simpleworkjs/conf": "^1.2.0",
|
|
||||||
"@simpleworkjs/app-stack": "^1.0.0",
|
"@simpleworkjs/app-stack": "^1.0.0",
|
||||||
"@simpleworkjs/ldap": "^1.0.0",
|
"@simpleworkjs/conf": "^1.2.0",
|
||||||
"@simpleworkjs/directory-schema": "^1.0.0",
|
"@simpleworkjs/directory-schema": "^1.0.0",
|
||||||
|
"@simpleworkjs/frontend": "^0.2.6",
|
||||||
|
"@simpleworkjs/ldap": "^1.0.1",
|
||||||
"@simpleworkjs/oidc-client": "^1.0.0",
|
"@simpleworkjs/oidc-client": "^1.0.0",
|
||||||
"@simpleworkjs/orm": "^0.2.8",
|
"@simpleworkjs/orm": "^0.2.8",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 100vh;
|
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 {
|
#spa-shell {
|
||||||
|
|||||||
+15
-3
@@ -11,11 +11,23 @@ app.jump = (function(app){
|
|||||||
var qs = $.param(query || {});
|
var qs = $.param(query || {});
|
||||||
app.api.get('audit' + (qs ? '?' + qs : ''), cb);
|
app.api.get('audit' + (qs ? '?' + qs : ''), cb);
|
||||||
}
|
}
|
||||||
return {metrics: metrics, sessions: sessions, audit: audit};
|
function hosts(cb){ app.api.get('user/hosts', cb); }
|
||||||
|
return {metrics: metrics, sessions: sessions, audit: audit, hosts: hosts};
|
||||||
|
})(app);
|
||||||
|
|
||||||
|
// Self-service API token (PAT) management.
|
||||||
|
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, update: update, remove: remove, rotate: rotate};
|
||||||
})(app);
|
})(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>'; };
|
||||||
|
|||||||
@@ -363,7 +363,7 @@ app.auth = (function(app){
|
|||||||
}
|
}
|
||||||
|
|
||||||
if(requiredGroups && !await memberOf(requiredGroups, user)){
|
if(requiredGroups && !await memberOf(requiredGroups, user)){
|
||||||
app.util.actionMessage(
|
app.messages.action(
|
||||||
`<h1>
|
`<h1>
|
||||||
<i class="fa-solid fa-triangle-exclamation"></i>
|
<i class="fa-solid fa-triangle-exclamation"></i>
|
||||||
<b>You do not have permission to be here.</b>
|
<b>You do not have permission to be here.</b>
|
||||||
@@ -520,68 +520,15 @@ app.util = (function(app){
|
|||||||
return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' '));
|
return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' '));
|
||||||
};
|
};
|
||||||
|
|
||||||
function actionMessage(message, $targetPassed, type, callback){
|
// escapeHtml/actionMessage/actionConfirm moved to @simpleworkjs/frontend's
|
||||||
message = message || '';
|
// app.util.escapeHtml and app.messages.action/confirm.
|
||||||
|
function escapeHtml(s){
|
||||||
let $target = $targetPassed.closest('div.card').find('.actionMessage');
|
return String(s == null ? '' : s)
|
||||||
if(!$target.length) $target = $($targetPassed.find('.actionMessage')[0]);
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
type = type || 'info';
|
.replace(/>/g, '>')
|
||||||
callback = callback || function(){};
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
if($target.html() === message) return;
|
|
||||||
|
|
||||||
if($target.html()){
|
|
||||||
$target.slideUp('fast', function(){
|
|
||||||
$target.html('')
|
|
||||||
$target.removeClass (function(index, className){
|
|
||||||
return (className.match (/(^|\s)bg-\S+/g) || []).join(' ');
|
|
||||||
});
|
|
||||||
if(message) return actionMessage(message, $target, type, callback);
|
|
||||||
$target.hide()
|
|
||||||
})
|
|
||||||
}else{
|
|
||||||
if(type) $target.addClass('bg-' + type);
|
|
||||||
|
|
||||||
// Messages that bring their own buttons (actionConfirm) are left
|
|
||||||
// alone; everything else gets the standard dismiss button.
|
|
||||||
if(!message.includes('<button')) message = `
|
|
||||||
<span class="align-middle">${message}</span>
|
|
||||||
<button class="action-close btn btn-sm btn-outline-dark float-end">
|
|
||||||
<i class="fa-solid fa-xmark"></i>
|
|
||||||
</button>
|
|
||||||
`
|
|
||||||
$target.html(message).slideDown('fast');
|
|
||||||
}
|
|
||||||
setTimeout(callback,10)
|
|
||||||
}
|
|
||||||
|
|
||||||
function actionConfirm(message, $target, type, callback){
|
|
||||||
return new Promise((resolve, reject) =>{
|
|
||||||
let id = crypto.randomUUID();
|
|
||||||
message = `
|
|
||||||
<h4 class"align-middle" >
|
|
||||||
<i class="fa-solid fa-triangle-exclamation"></i>
|
|
||||||
<b>${message}</b>
|
|
||||||
<span class="float-end">
|
|
||||||
<button type="button" class="btn btn-success confirm-${id}" data-confirm="true">
|
|
||||||
<i class="fa-solid fa-circle-check"></i>
|
|
||||||
Confirm
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-danger confirm-${id}">
|
|
||||||
<i class="fa-solid fa-circle-stop"></i>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
</h4>
|
|
||||||
`
|
|
||||||
actionMessage(message, $target, type);
|
|
||||||
$("body").on('click', `.confirm-${id}`, function(){
|
|
||||||
actionMessage('', $target, type);
|
|
||||||
resolve(!!$(this).data('confirm'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$.fn.serializeObject = function() {
|
$.fn.serializeObject = function() {
|
||||||
@@ -637,11 +584,31 @@ app.util = (function(app){
|
|||||||
document.body.removeChild(element);
|
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 {
|
return {
|
||||||
downloadFile: downloadFile,
|
downloadFile: downloadFile,
|
||||||
getUrlParameter: getUrlParameter,
|
getUrlParameter: getUrlParameter,
|
||||||
actionMessage: actionMessage,
|
escapeHtml: escapeHtml,
|
||||||
actionConfirm,
|
revealItem: revealItem,
|
||||||
}
|
}
|
||||||
})(app);
|
})(app);
|
||||||
|
|
||||||
@@ -696,9 +663,9 @@ $( document ).ready(async function(){
|
|||||||
$(this).closest('.card').slideUp('fast');
|
$(this).closest('.card').slideUp('fast');
|
||||||
});
|
});
|
||||||
|
|
||||||
$('.actionMessage').on('click', 'button.action-close', function(event){
|
// action-close click handling is wired by @simpleworkjs/frontend's
|
||||||
app.util.actionMessage(null, $(this));
|
// app.messages.js (delegated on document, so it also covers messages
|
||||||
});
|
// rendered after this ready handler runs).
|
||||||
|
|
||||||
setInterval(()=>{
|
setInterval(()=>{
|
||||||
$('.momentFromNow').each((idx, el)=>{
|
$('.momentFromNow').each((idx, el)=>{
|
||||||
@@ -729,20 +696,17 @@ function formAJAX(btn){
|
|||||||
var method = ($form.attr('method') || 'post').toLowerCase();
|
var method = ($form.attr('method') || 'post').toLowerCase();
|
||||||
|
|
||||||
if($form.validate && !$form.validate()){
|
if($form.validate && !$form.validate()){
|
||||||
app.util.actionMessage('Please fix the form errors.', $form, 'danger')
|
app.messages.action('Please fix the form errors.', $form, 'danger')
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
app.util.actionMessage(
|
// 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.util.actionMessage(data.message, $form, error ? 'danger' : 'success'); //re-populate table
|
app.messages.action(data.message, $form, error ? 'danger' : 'success'); //re-populate table
|
||||||
$form.validateClear();
|
$form.validateClear();
|
||||||
if(!error){
|
if(!error){
|
||||||
$form.trigger("reset");
|
$form.trigger("reset");
|
||||||
@@ -750,7 +714,7 @@ function formAJAX(btn){
|
|||||||
}else{
|
}else{
|
||||||
console.log('formAJAX res error', error, data)
|
console.log('formAJAX res error', error, data)
|
||||||
if(data && data.name === 'ObjectValidateError'){
|
if(data && data.name === 'ObjectValidateError'){
|
||||||
app.util.actionMessage('Please fix the form errors', $form, 'danger'); //re-populate table
|
app.messages.action('Please fix the form errors', $form, 'danger'); //re-populate table
|
||||||
}
|
}
|
||||||
if(data && data.keys){
|
if(data && data.keys){
|
||||||
console.log('form key errors', data.keys)
|
console.log('form key errors', data.keys)
|
||||||
|
|||||||
@@ -1,201 +0,0 @@
|
|||||||
( function( $ ) {
|
|
||||||
var settings = {
|
|
||||||
rule: {
|
|
||||||
eq: function(value, options){
|
|
||||||
var compare = $('[name=' + options + ']').val();
|
|
||||||
|
|
||||||
if ( value != compare ) {
|
|
||||||
return "Miss-match";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
$.fn.validate = function(event) {
|
|
||||||
// let thisSettings = $.extend(true, settings, settingsObj);
|
|
||||||
let hasErrors = false;
|
|
||||||
|
|
||||||
if(this.is('[validate]')) return this.validateField(event);
|
|
||||||
|
|
||||||
if(!this.attr('isValid')){
|
|
||||||
console.log('adding reset event')
|
|
||||||
this.on('reset', function(){
|
|
||||||
$(this).attr('isValid', false);
|
|
||||||
$(this).validateClear();
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
this.find('[validate]').each(function(){
|
|
||||||
if(!$(this).validateField()) hasErrors = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
this.attr('isValid', !hasErrors);
|
|
||||||
|
|
||||||
if(hasErrors && event) event.preventDefault();
|
|
||||||
|
|
||||||
return !hasErrors;
|
|
||||||
};
|
|
||||||
|
|
||||||
$.fn.validateClear = function(){
|
|
||||||
$(this).find('input').each(function(){
|
|
||||||
$(this).removeClass('is-invalid');
|
|
||||||
$(this).removeClass('is-valid');
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
$.fn.validateField = function(){
|
|
||||||
var attr = this.attr('validate').split(':'); //array of params
|
|
||||||
var rule = attr[0];
|
|
||||||
var options = attr[1];
|
|
||||||
var value = this.val(); //link to input value
|
|
||||||
var message;
|
|
||||||
|
|
||||||
if(this.prop('disabled')) return true;
|
|
||||||
|
|
||||||
|
|
||||||
//checks if field is required, and length
|
|
||||||
if(!isNaN(options) && value.length < options){
|
|
||||||
message = `Must be ${options} characters`;
|
|
||||||
}
|
|
||||||
|
|
||||||
//checks if empty to stop processing
|
|
||||||
if(!isNaN(options) && value.length === 0) {
|
|
||||||
}else if(rule in settings.rule){
|
|
||||||
message = settings.rule[rule].apply(this, [value, options]);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.validateMessage(message)
|
|
||||||
return !message;
|
|
||||||
}
|
|
||||||
|
|
||||||
$.fn.validateMessage = function(message){
|
|
||||||
if(message && message !== true){
|
|
||||||
this.closest('.form-group').find('b.invalid-feedback').html(message);
|
|
||||||
this.addClass('is-invalid');
|
|
||||||
}else{
|
|
||||||
this.removeClass('is-invalid');
|
|
||||||
this.addClass('is-valid');
|
|
||||||
}
|
|
||||||
return this;
|
|
||||||
};
|
|
||||||
|
|
||||||
jQuery.extend({
|
|
||||||
validateSettings: function( settingsObj ) {
|
|
||||||
$.extend( true, settings, settingsObj );
|
|
||||||
},
|
|
||||||
|
|
||||||
validateInit: function( ettingsObj ) {
|
|
||||||
$( '[action]' ).on( 'submit', function ( event, settingsObj ){
|
|
||||||
$( this ).validate( settingsObj, event );
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
}( jQuery ));
|
|
||||||
|
|
||||||
// Host / target validation, mirrored from the backend (utils/hostname_validate.js):
|
|
||||||
// a bare hostname or IPv4 address, no protocol / "/" / ":" / whitespace. The
|
|
||||||
// incoming host may be a wildcard ("*.example.com"); the target may not.
|
|
||||||
(function(){
|
|
||||||
var LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i;
|
|
||||||
// Either one bare label (Docker service names, /etc/hosts entries) or a
|
|
||||||
// dotted hostname with an alphabetic TLD.
|
|
||||||
var HOSTNAME = /^(?=.{1,253}$)(?:(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}|[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)$/i;
|
|
||||||
var FORBIDDEN = /[\s/:]/;
|
|
||||||
|
|
||||||
function isIPv4( value ) {
|
|
||||||
var parts = value.split( '.' );
|
|
||||||
if ( parts.length !== 4 ) return false;
|
|
||||||
return parts.every( function( p ) {
|
|
||||||
return /^(0|[1-9]\d{0,2})$/.test( p ) && Number( p ) <= 255;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Incoming-host pattern: labels may be normal, "*" (one fragment), or "**"
|
|
||||||
// (any number of fragments, incl. a bare "**" global catch-all).
|
|
||||||
function isHostPattern( value ) {
|
|
||||||
if ( value.length > 253 ) return false;
|
|
||||||
return value.split( '.' ).every( function( l ) {
|
|
||||||
return l === '*' || l === '**' || LABEL.test( l );
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function forbidden( value ) {
|
|
||||||
return FORBIDDEN.test( value ) || value.includes( '://' );
|
|
||||||
}
|
|
||||||
|
|
||||||
// Incoming host: IPv4 or a wildcard host pattern.
|
|
||||||
function checkHost( value ) {
|
|
||||||
if ( typeof value !== 'string' || value.length === 0 ) return "Required";
|
|
||||||
if ( forbidden( value ) ) return 'No protocol, "/", or ":"';
|
|
||||||
if ( isIPv4( value ) || isHostPattern( value ) ) return;
|
|
||||||
return "Enter a valid host or wildcard (*, **)";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Downstream target: IPv4 or a strict hostname, no wildcard.
|
|
||||||
function checkTarget( value ) {
|
|
||||||
if ( typeof value !== 'string' || value.length === 0 ) return "Required";
|
|
||||||
if ( forbidden( value ) ) return 'No protocol, "/", or ":"';
|
|
||||||
if ( isIPv4( value ) || HOSTNAME.test( value ) ) return;
|
|
||||||
return "Enter a valid hostname or IP";
|
|
||||||
}
|
|
||||||
|
|
||||||
$.validateSettings({
|
|
||||||
rule:{
|
|
||||||
ip: function( value ) {
|
|
||||||
value = value.split( '.' );
|
|
||||||
|
|
||||||
if ( value.length != 4 ) {
|
|
||||||
return "Malformed IP";
|
|
||||||
}
|
|
||||||
|
|
||||||
$.each( value, function( key, value ) {
|
|
||||||
if( value > 255 || value < 0 ) {
|
|
||||||
return "Malformed IP";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
// Incoming host name — hostname, IPv4, or wildcard pattern (*, **).
|
|
||||||
host: function( value ) {
|
|
||||||
return checkHost( value );
|
|
||||||
},
|
|
||||||
|
|
||||||
// Downstream target — hostname or IPv4, no wildcard.
|
|
||||||
target: function( value ) {
|
|
||||||
return checkTarget( value );
|
|
||||||
},
|
|
||||||
|
|
||||||
// Back-compat alias (no wildcard).
|
|
||||||
hostname: function( value ) {
|
|
||||||
return checkTarget( value );
|
|
||||||
},
|
|
||||||
|
|
||||||
user: function( value ) {
|
|
||||||
var reg = /^[a-z0-9\_\-\@\.]{1,32}$/;
|
|
||||||
if ( reg.test( value ) === false ) {
|
|
||||||
return "Invalid";
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Mirrors utils/password_policy.js: >= 8 chars, and either 12+ chars
|
|
||||||
// or at least 3 of {lowercase, uppercase, number, symbol}.
|
|
||||||
password: function( value ) {
|
|
||||||
if ( typeof value !== 'string' || value.length < 8 ) {
|
|
||||||
return "Password must be at least 8 characters";
|
|
||||||
}
|
|
||||||
if ( value.length >= 12 ) return;
|
|
||||||
|
|
||||||
var classes = 0;
|
|
||||||
if ( /[a-z]/.test( value ) ) classes++;
|
|
||||||
if ( /[A-Z]/.test( value ) ) classes++;
|
|
||||||
if ( /[0-9]/.test( value ) ) classes++;
|
|
||||||
if ( /[^A-Za-z0-9]/.test( value ) ) classes++;
|
|
||||||
|
|
||||||
if ( classes < 3 ) {
|
|
||||||
return "Use 3 of: lowercase, uppercase, number, symbol (or 12+ chars)";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
@@ -9,7 +9,11 @@ router.use('/auth', require('../models').authRouter);
|
|||||||
// Who am I — needs a valid session but no admin gate (drives the login state).
|
// Who am I — needs a valid session but no admin gate (drives the login state).
|
||||||
router.use('/user', middleware.auth, require('./user'));
|
router.use('/user', middleware.auth, require('./user'));
|
||||||
|
|
||||||
// Jump-host data — admin only (audit log, active sessions, metrics).
|
// Self-service API token (PAT) management — any authenticated user, no
|
||||||
router.use('/', middleware.auth, middleware.requireAdmin, require('./jump'));
|
// 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 — jump admin only (audit log, active sessions, metrics).
|
||||||
|
router.use('/', middleware.auth, middleware.requireJumpAdmin, require('./jump'));
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Self-service API token (PAT) management. Every endpoint is owner-scoped: a
|
||||||
|
// user only sees / mutates tokens where created_by === req.user.username.
|
||||||
|
// Mirrors proxy's routes/api_token.js. Mounted under middleware.auth only
|
||||||
|
// (no requireAdmin) — any authenticated user may mint one, but the token
|
||||||
|
// itself carries no group claims (see models/api_token.js), so it can only
|
||||||
|
// reach non-admin routes (e.g. GET /api/user/hosts), never the admin-gated
|
||||||
|
// ones under routes/jump.js.
|
||||||
|
|
||||||
|
const router = require('express').Router();
|
||||||
|
const {ApiToken} = require('../models');
|
||||||
|
|
||||||
|
function forbidden(){
|
||||||
|
let error = new Error('Forbidden');
|
||||||
|
error.name = 'Forbidden';
|
||||||
|
error.message = 'You do not own this API token.';
|
||||||
|
error.status = 403;
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve a token the caller owns. Missing or not-yours both raise 403 (no
|
||||||
|
// existence leak; ids are unguessable random hex anyway).
|
||||||
|
async function getOwned(req, id){
|
||||||
|
let token;
|
||||||
|
try{
|
||||||
|
token = await ApiToken.get(id);
|
||||||
|
}catch(e){
|
||||||
|
throw forbidden();
|
||||||
|
}
|
||||||
|
if(!token || token.created_by !== req.user.username) throw forbidden();
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/', async function(req, res, next){
|
||||||
|
try{
|
||||||
|
return res.json({results: await ApiToken.listDetail({created_by: req.user.username})});
|
||||||
|
}catch(error){
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/', async function(req, res, next){
|
||||||
|
try{
|
||||||
|
const days = req.body.expires_in_days !== '' && req.body.expires_in_days !== undefined
|
||||||
|
? Number(req.body.expires_in_days) : 0;
|
||||||
|
|
||||||
|
const token = await ApiToken.add({
|
||||||
|
name: req.body.name,
|
||||||
|
description: req.body.description || '',
|
||||||
|
created_by: req.user.username,
|
||||||
|
expires_at: days > 0 ? (new Date).getTime() + days * 86400000 : 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
results: token,
|
||||||
|
token: token._raw_token,
|
||||||
|
message: `API token '${token.name}' created. Save it now — it will not be shown again.`,
|
||||||
|
});
|
||||||
|
}catch(error){
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/:id', async function(req, res, next){
|
||||||
|
try{
|
||||||
|
return res.json({results: await getOwned(req, req.params.id)});
|
||||||
|
}catch(error){
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/:id', async function(req, res, next){
|
||||||
|
try{
|
||||||
|
const token = await getOwned(req, req.params.id);
|
||||||
|
|
||||||
|
const update = {};
|
||||||
|
for(const k of ['name', 'description']){
|
||||||
|
if(req.body[k] !== undefined) update[k] = req.body[k];
|
||||||
|
}
|
||||||
|
if(req.body.expires_in_days !== undefined && req.body.expires_in_days !== ''){
|
||||||
|
const days = Number(req.body.expires_in_days);
|
||||||
|
update.expires_at = days > 0 ? (new Date).getTime() + days * 86400000 : 0;
|
||||||
|
}else if(req.body.expires_at !== undefined){
|
||||||
|
update.expires_at = Number(req.body.expires_at) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
results: await token.update(update),
|
||||||
|
message: `API token '${token.name}' updated.`,
|
||||||
|
});
|
||||||
|
}catch(error){
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:id', async function(req, res, next){
|
||||||
|
try{
|
||||||
|
const token = await getOwned(req, req.params.id);
|
||||||
|
await token.remove();
|
||||||
|
return res.json({id: req.params.id, message: `API token '${token.name}' revoked.`});
|
||||||
|
}catch(error){
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/rotate', async function(req, res, next){
|
||||||
|
try{
|
||||||
|
const token = await getOwned(req, req.params.id);
|
||||||
|
const raw = await token.rotate();
|
||||||
|
return res.json({
|
||||||
|
token: raw,
|
||||||
|
message: `API token '${token.name}' rotated. Save it — it will not be shown again.`,
|
||||||
|
});
|
||||||
|
}catch(error){
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -21,7 +25,7 @@ const values = {
|
|||||||
// as the sibling apps), and the app's own JS/CSS/img from public/.
|
// as the sibling apps), and the app's own JS/CSS/img from public/.
|
||||||
mountStaticModules(router, {
|
mountStaticModules(router, {
|
||||||
root: path.join(__dirname, '..'),
|
root: path.join(__dirname, '..'),
|
||||||
deps: ['bootstrap', 'mustache', 'jquery', '@fortawesome', 'moment', 'jq-repeat'],
|
deps: ['bootstrap', 'mustache', 'jquery', '@fortawesome', 'moment', 'jq-repeat', '@simpleworkjs/frontend'],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Liveness probe — no auth.
|
// Liveness probe — no auth.
|
||||||
|
|||||||
+30
-1
@@ -4,14 +4,43 @@
|
|||||||
// browser who it is and whether it's an admin (drives login state + nav).
|
// browser who it is and whether it's an admin (drives login state + nav).
|
||||||
|
|
||||||
const router = require('express').Router();
|
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) => {
|
router.get('/me', (req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
username: req.user && req.user.username,
|
username: req.user && req.user.username,
|
||||||
groups: req.groups || [],
|
groups: req.groups || [],
|
||||||
isAdmin: isAdmin(req),
|
isAdmin: isAdmin(req),
|
||||||
|
isJumpAdmin: isJumpAdmin(req),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The hosts this session can SSH to — every host for an admin, otherwise the
|
||||||
|
// same group-based resolution the SSH front door uses (accessibleHosts),
|
||||||
|
// fed the OIDC session's already-known groups instead of an LDAP lookup.
|
||||||
|
router.get('/hosts', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const hosts = isAdmin(req)
|
||||||
|
? await access.allHosts()
|
||||||
|
: await access.accessibleHosts({ uid: req.user && req.user.username, groups: req.groups || [] });
|
||||||
|
|
||||||
|
// 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); }
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -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, host ? host.slug : undefined); }
|
||||||
|
|
||||||
let upstream;
|
let upstream;
|
||||||
try {
|
try {
|
||||||
@@ -139,12 +139,18 @@ 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, host ? host.slug : undefined); }
|
||||||
|
|
||||||
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. 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) {
|
async function runGrammar(session, client, state) {
|
||||||
// Register session listeners IMMEDIATELY — before any async work.
|
// Register session listeners IMMEDIATELY — before any async work.
|
||||||
@@ -174,25 +180,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, hostSlug: err.hostSlug, 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, hostSlug) => {
|
||||||
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, hostSlug, 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 +229,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, tui.host.slug); }
|
||||||
|
|
||||||
let upstream;
|
let upstream;
|
||||||
try {
|
try {
|
||||||
@@ -210,9 +238,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, tui.host.slug);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 +281,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 +293,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) => {
|
||||||
|
|||||||
@@ -9,10 +9,29 @@ const ESC = '\x1b';
|
|||||||
const CLEAR = `${ESC}[2J${ESC}[H`;
|
const CLEAR = `${ESC}[2J${ESC}[H`;
|
||||||
const HIDE_CUR = `${ESC}[?25l`;
|
const HIDE_CUR = `${ESC}[?25l`;
|
||||||
const SHOW_CUR = `${ESC}[?25h`;
|
const SHOW_CUR = `${ESC}[?25h`;
|
||||||
const INV = `${ESC}[7m`;
|
|
||||||
|
// Basic styles
|
||||||
const RST = `${ESC}[0m`;
|
const RST = `${ESC}[0m`;
|
||||||
const DIM = `${ESC}[2m`;
|
|
||||||
const BOLD = `${ESC}[1m`;
|
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) {
|
function pickHost(channel, uid, hosts) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
@@ -35,18 +54,43 @@ function pickHost(channel, uid, hosts) {
|
|||||||
const list = visible();
|
const list = visible();
|
||||||
if (selected >= list.length) selected = Math.max(0, list.length - 1);
|
if (selected >= list.length) selected = Math.max(0, list.length - 1);
|
||||||
let out = CLEAR + HIDE_CUR;
|
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) {
|
if (!list.length) {
|
||||||
out += ` ${DIM}(no match for "${filter}")${RST}\r\n`;
|
out += ` ${YELLOW}⚠${RST} ${DIM}(no match for "${filter}")${RST}\r\n`;
|
||||||
} else {
|
} else {
|
||||||
list.forEach((h, i) => {
|
list.forEach((h, i) => {
|
||||||
const ip = (h.metadata && h.metadata.ip) || (h.metadata && h.metadata.address) || '';
|
const ip = (h.metadata && h.metadata.ip) || (h.metadata && h.metadata.address) || '';
|
||||||
const row = ` ${h.name} ${DIM}(${h.slug})${RST}${ip ? ` ${ip}` : ''}`;
|
const isProd = h.metadata && h.metadata.isProduction;
|
||||||
out += (i === selected ? `${INV}> ${h.name} (${h.slug})${ip ? ` ${ip}` : ''}${RST}` : row) + '\r\n';
|
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);
|
channel.write(out);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
const { test } = require('node:test');
|
const { test } = require('node:test');
|
||||||
const assert = require('node:assert');
|
const assert = require('node:assert');
|
||||||
const { accessibleHosts, clearCache } = require('../../utils/access');
|
const { accessibleHosts, allHosts, clearCache } = require('../../utils/access');
|
||||||
|
|
||||||
function stubLdap(groups) {
|
function stubLdap(groups) {
|
||||||
return { getGroups: async () => groups };
|
return { getGroups: async () => groups };
|
||||||
@@ -54,6 +54,32 @@ test('caches per uid', async () => {
|
|||||||
assert.strictEqual(calls, 1);
|
assert.strictEqual(calls, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('accepts pre-resolved groups (web UI/OIDC session) without calling ldap.getGroups', async () => {
|
||||||
|
clearCache();
|
||||||
|
let ldapCalled = false;
|
||||||
|
const user = { uid: 'erin', groups: ['host_web01_access'] };
|
||||||
|
const fetchImpl = stubFetch({
|
||||||
|
host_web01_access: [{ id: '5', kind: 'host', slug: 'host_web01' }],
|
||||||
|
});
|
||||||
|
const ldap = { getGroups: async () => { ldapCalled = true; return []; } };
|
||||||
|
const hosts = await accessibleHosts(user, { fetchImpl, ldap });
|
||||||
|
assert.deepStrictEqual(hosts.map((h) => h.id), ['5']);
|
||||||
|
assert.strictEqual(ldapCalled, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('allHosts fetches the whole host inventory with no group filter', async () => {
|
||||||
|
const fetchImpl = async (url) => {
|
||||||
|
assert.ok(!url.includes('group='), 'must not filter by group');
|
||||||
|
assert.ok(url.includes('kind=host'));
|
||||||
|
return { ok: true, json: async () => ({ results: [
|
||||||
|
{ id: '1', kind: 'host', slug: 'host_a' },
|
||||||
|
{ id: '2', kind: 'host', slug: 'host_b' },
|
||||||
|
] }) };
|
||||||
|
};
|
||||||
|
const hosts = await allHosts({ fetchImpl });
|
||||||
|
assert.deepStrictEqual(hosts.map((h) => h.id).sort(), ['1', '2']);
|
||||||
|
});
|
||||||
|
|
||||||
test('a bare-array response (envelope drift) is treated as a failed group, not silently []', async () => {
|
test('a bare-array response (envelope drift) is treated as a failed group, not silently []', async () => {
|
||||||
clearCache();
|
clearCache();
|
||||||
const user = { uid: 'dave', dn: 'd' };
|
const user = { uid: 'dave', dn: 'd' };
|
||||||
|
|||||||
@@ -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, []);
|
||||||
|
});
|
||||||
+15
-4
@@ -8,9 +8,10 @@
|
|||||||
const conf = require('@simpleworkjs/conf');
|
const conf = require('@simpleworkjs/conf');
|
||||||
|
|
||||||
if (conf.standalone && conf.standalone.enabled) {
|
if (conf.standalone && conf.standalone.enabled) {
|
||||||
// Standalone mode: use the ORM-backed host inventory.
|
// Standalone mode: use the ORM-backed host inventory. Every host is
|
||||||
|
// accessible to every user, so allHosts and accessibleHosts coincide.
|
||||||
const { accessibleHosts } = require('./hosts_file');
|
const { accessibleHosts } = require('./hosts_file');
|
||||||
module.exports = { accessibleHosts, clearCache: () => {} };
|
module.exports = { accessibleHosts, allHosts: () => accessibleHosts(), clearCache: () => {} };
|
||||||
} else {
|
} else {
|
||||||
// Production mode: LDAP groups + SSO API (unchanged).
|
// Production mode: LDAP groups + SSO API (unchanged).
|
||||||
|
|
||||||
@@ -48,11 +49,21 @@ if (conf.standalone && conf.standalone.enabled) {
|
|||||||
return directoryClient({ fetchImpl }).getResourcesByGroup(group);
|
return directoryClient({ fetchImpl }).getResourcesByGroup(group);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Every host in the inventory, unfiltered — for admins (the web UI's own
|
||||||
|
// account is already gated by requireAdmin before this is ever called).
|
||||||
|
async function allHosts({ fetchImpl = fetch } = {}) {
|
||||||
|
const resources = await directoryClient({ fetchImpl }).getResourcesByGroup(undefined, { kind: 'host' });
|
||||||
|
return resources.filter(r => r.kind === 'host');
|
||||||
|
}
|
||||||
|
|
||||||
async function accessibleHosts(user, { fetchImpl = fetch, ldap = userLdap } = {}) {
|
async function accessibleHosts(user, { fetchImpl = fetch, ldap = userLdap } = {}) {
|
||||||
const hit = cache.get(user.uid);
|
const hit = cache.get(user.uid);
|
||||||
if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.hosts;
|
if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.hosts;
|
||||||
|
|
||||||
const groups = await ldap.getGroups(user.dn);
|
// The SSH path passes an LDAP user ({dn, uid, ...}) with no .groups, so we
|
||||||
|
// look them up; the web UI already has the session's OIDC groups claim
|
||||||
|
// and passes it directly, skipping a redundant LDAP round-trip.
|
||||||
|
const groups = user.groups || await ldap.getGroups(user.dn);
|
||||||
|
|
||||||
const seen = new Map();
|
const seen = new Map();
|
||||||
for (const cn of groups) {
|
for (const cn of groups) {
|
||||||
@@ -80,5 +91,5 @@ if (conf.standalone && conf.standalone.enabled) {
|
|||||||
else cache.clear();
|
else cache.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { accessibleHosts, clearCache, fetchResourcesByGroup };
|
module.exports = { accessibleHosts, allHosts, clearCache, fetchResourcesByGroup };
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -37,6 +37,6 @@ module.exports = {
|
|||||||
nav: [
|
nav: [
|
||||||
{href: '/dashboard', icon: 'fa-solid fa-gauge-high', label: 'Dashboard', groups: []},
|
{href: '/dashboard', icon: 'fa-solid fa-gauge-high', label: 'Dashboard', groups: []},
|
||||||
{href: '/sessions', icon: 'fa-solid fa-plug-circle-bolt', label: 'Sessions', 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') %>
|
<%- 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 shadow-sm">
|
||||||
<div class="card-header"><i class="fa-solid fa-clipboard-list me-1"></i> Audit log</div>
|
<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>
|
<button class="btn btn-sm btn-outline-secondary" id="next" onclick="changePage(1)">next →</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script type="text/javascript">
|
<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;
|
var page = 0;
|
||||||
function filters(){ return {page: page, uid: $('#f-uid').val(), target: $('#f-target').val(), status: $('#f-status').val()}; }
|
function filters(){ return {page: page, uid: $('#f-uid').val(), target: $('#f-target').val(), status: $('#f-status').val()}; }
|
||||||
function applyFilters(){ page = 0; load(); }
|
function applyFilters(){ page = 0; load(); }
|
||||||
@@ -57,6 +118,9 @@
|
|||||||
$('#next').prop('disabled', (page + 1) * size >= total);
|
$('#next').prop('disabled', (page + 1) * size >= total);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
$(document).ready(load);
|
$(document).ready(function(){
|
||||||
|
loadMetrics();
|
||||||
|
load();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
<%- include('bottom') %>
|
<%- include('bottom') %>
|
||||||
|
|||||||
+262
-44
@@ -1,64 +1,282 @@
|
|||||||
<%- include('top') %>
|
<%- include('top') %>
|
||||||
<script type="text/javascript">app.auth.forceLogin();</script>
|
<script type="text/javascript">app.auth.forceLogin();</script>
|
||||||
|
|
||||||
|
<div class="container mt-4">
|
||||||
<div class="row g-3 mb-4">
|
<div class="row g-3 mb-4">
|
||||||
<div class="col-6 col-md-3">
|
<div class="col-12">
|
||||||
<div class="card shadow-sm text-center"><div class="card-body">
|
<div class="card shadow-sm">
|
||||||
<div class="display-6" id="stat-active">–</div>
|
<div class="card-header"><i class="fa-solid fa-terminal me-1"></i> Quick Jump</div>
|
||||||
<div class="text-muted small text-uppercase">Active sessions</div>
|
<div class="card-body">
|
||||||
</div></div>
|
<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="copyFieldValue('#quick-jump-cmd')" title="Copy">
|
||||||
|
<i class="fa-solid fa-copy"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6 col-md-3">
|
</div>
|
||||||
<div class="card shadow-sm text-center"><div class="card-body">
|
|
||||||
<div class="display-6" id="stat-total">–</div>
|
<div class="row g-3 mb-4">
|
||||||
<div class="text-muted small text-uppercase">Total connections</div>
|
<div class="col-12">
|
||||||
</div></div>
|
<div class="card shadow-sm">
|
||||||
</div>
|
<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>
|
||||||
<div class="col-6 col-md-3">
|
<div class="table-responsive">
|
||||||
<div class="card shadow-sm text-center"><div class="card-body">
|
<table class="table table-sm mb-0">
|
||||||
<div class="display-6 text-danger" id="stat-fail">–</div>
|
<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>
|
||||||
<div class="text-muted small text-uppercase">Failed</div>
|
<tbody id="my-hosts"></tbody>
|
||||||
</div></div>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6 col-md-3">
|
</div>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-md-6">
|
<div class="col-12">
|
||||||
<div class="card shadow-sm"><div class="card-header"><i class="fa-solid fa-server me-1"></i> Top hosts</div>
|
<div class="card shadow-sm">
|
||||||
<table class="table table-sm mb-0"><tbody id="top-hosts"></tbody></table>
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
</div>
|
<span><i class="fa-solid fa-key me-1"></i> API Tokens</span>
|
||||||
</div>
|
<button class="btn btn-sm btn-primary" onclick="createApiToken()"><i class="fa-solid fa-plus"></i> New token</button>
|
||||||
<div class="col-md-6">
|
</div>
|
||||||
<div class="card shadow-sm"><div class="card-header"><i class="fa-solid fa-user me-1"></i> Top users</div>
|
<div class="card-header actionMessage" style="display:none"></div>
|
||||||
<table class="table table-sm mb-0"><tbody id="top-users"></tbody></table>
|
<p class="text-muted small px-3 pt-3 mb-0">
|
||||||
|
Personal access tokens authenticate as you against this jump host's own API
|
||||||
|
(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>
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
function rows(sel, list){
|
// The web UI and the SSH front door share a hostname, just not a port.
|
||||||
var $b = $(sel).empty();
|
var SSH_PORT = <%- JSON.stringify(sshPort) %>;
|
||||||
if(!list || !list.length){ $b.append('<tr><td class="text-muted">No data.</td></tr>'); return; }
|
function sshCommand(target){
|
||||||
list.forEach(function(x){
|
var uid = app.auth.user && app.auth.user.username;
|
||||||
$b.append('<tr><td>' + app.jump.esc(x.name) + '</td><td class="text-end">' + x.count + '</td></tr>');
|
if(!uid) return '';
|
||||||
|
var portFlag = SSH_PORT === 22 ? '' : ' -p ' + SSH_PORT;
|
||||||
|
return 'ssh ' + uid + (target ? '_-_' + target : '') + '@' + location.hostname + portFlag;
|
||||||
|
}
|
||||||
|
function copyFieldValue(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');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
$(document).ready(function(){
|
|
||||||
app.jump.metrics(function(error, data){
|
function hostRows(sel, hosts){
|
||||||
if(error || !data) return;
|
var $b = $(sel).empty();
|
||||||
$('#stat-active').text(data.active);
|
if(!hosts || !hosts.length){ $b.append('<tr><td class="text-muted">No hosts reachable.</td></tr>'); return; }
|
||||||
$('#stat-total').text(data.total);
|
hosts.forEach(function(h){
|
||||||
$('#stat-fail').text(data.fail);
|
var addr = (h.metadata && (h.metadata.ip || h.metadata.address)) || '';
|
||||||
$('#stat-users').text((data.topUsers || []).length);
|
var rowId = 'host-cmd-' + h.slug.replace(/[^a-zA-Z0-9_-]/g, '');
|
||||||
rows('#top-hosts', data.topHosts);
|
// Green: a session to this host is live right now. Yellow: the most
|
||||||
rows('#top-users', data.topUsers);
|
// 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="copyFieldValue(\'#' + rowId + '\')" title="Copy quick-jump command"><i class="fa-solid fa-copy"></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){
|
||||||
|
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" 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>'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createApiToken(){
|
||||||
|
var $body = app.modal.open({title: 'New API Token', bodyHtml:
|
||||||
|
'<div class="mb-3">'
|
||||||
|
+ '<label class="form-label">Name</label>'
|
||||||
|
+ '<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>',
|
||||||
|
footer: {buttonsHtml: app.modal.footerButtons({onSave: 'submitApiToken()', saveLabel: 'Create'})},
|
||||||
|
});
|
||||||
|
$body.find('#new-token-name').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitApiToken(){
|
||||||
|
var name = $('#new-token-name').val().trim();
|
||||||
|
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', 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');
|
||||||
|
if(!ok) return;
|
||||||
|
app.apiToken.remove(id, function(error, data){
|
||||||
|
if(error) return app.messages.action((data && data.message) || 'Failed to revoke token', $card, 'danger');
|
||||||
|
loadApiTokens();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function rotateApiToken(id, btn){
|
||||||
|
var $card = $(btn).closest('.card');
|
||||||
|
var ok = await app.messages.confirm('Rotate this API token? The old token stops working immediately.', $card, 'warning');
|
||||||
|
if(!ok) return;
|
||||||
|
app.apiToken.rotate(id, function(error, data){
|
||||||
|
if(error) return app.messages.action((data && data.message) || 'Failed to rotate token', $card, 'danger');
|
||||||
|
showToken('API Token Rotated', data.token);
|
||||||
|
loadApiTokens();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$(document).ready(async function(){
|
||||||
|
await app.auth.loadUser();
|
||||||
|
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', []);
|
||||||
|
hostRows('#my-hosts', data && data.results);
|
||||||
|
});
|
||||||
|
loadApiTokens();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<%- include('bottom') %>
|
<%- include('bottom') %>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<%- include('top') %>
|
<%- include('top') %>
|
||||||
<script type="text/javascript">app.auth.forceLogin();</script>
|
<script type="text/javascript">app.auth.forceLogin();</script>
|
||||||
|
|
||||||
|
<div class="container mt-4">
|
||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm">
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
<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>
|
<span><i class="fa-solid fa-plug-circle-bolt me-1"></i> Active sessions</span>
|
||||||
@@ -13,6 +14,7 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
function loadSessions(){
|
function loadSessions(){
|
||||||
|
|||||||
+12
-2
@@ -21,9 +21,11 @@
|
|||||||
<script type="text/javascript" src="/static-modules/@fortawesome/fontawesome-free/js/all.min.js"></script>
|
<script type="text/javascript" src="/static-modules/@fortawesome/fontawesome-free/js/all.min.js"></script>
|
||||||
<script type="text/javascript" src='/static-modules/mustache/mustache.min.js'></script>
|
<script type="text/javascript" src='/static-modules/mustache/mustache.min.js'></script>
|
||||||
<script type="text/javascript" src='/static-modules/jq-repeat/dist/js/jq-repeat.js'></script>
|
<script type="text/javascript" src='/static-modules/jq-repeat/dist/js/jq-repeat.js'></script>
|
||||||
<script type="text/javascript" src='/static/lib/js/val.js'></script>
|
|
||||||
<script type="text/javascript" src="/static-modules/moment/moment.js"></script>
|
<script type="text/javascript" src="/static-modules/moment/moment.js"></script>
|
||||||
<script type="text/javascript" src="/static/lib/js/app-base.js"></script>
|
<script type="text/javascript" src="/static/lib/js/app-base.js"></script>
|
||||||
|
<script type="text/javascript" src="/static-modules/@simpleworkjs/frontend/lib/app.messages.js"></script>
|
||||||
|
<script type="text/javascript" src="/static-modules/@simpleworkjs/frontend/lib/app.modal.js"></script>
|
||||||
|
<script type="text/javascript" src="/static-modules/@simpleworkjs/frontend/lib/app.validate.js"></script>
|
||||||
<script type="text/javascript" src="/static/js/app.js"></script>
|
<script type="text/javascript" src="/static/js/app.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -80,16 +82,24 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script type="text/javascript">
|
<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(){
|
function showUpdateBanner(){
|
||||||
let $nav = $('nav.fixed-top');
|
let $nav = $('nav.fixed-top');
|
||||||
let $banner = $('#update-banner');
|
let $banner = $('#update-banner');
|
||||||
$banner.css('top', $nav.outerHeight() + 'px').show();
|
$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(){
|
function dismissUpdateBanner(){
|
||||||
$('#update-banner').hide();
|
$('#update-banner').hide();
|
||||||
$('#spa-shell').css('margin-top', '');
|
$('#spa-shell').css('margin-top', '');
|
||||||
|
document.documentElement.style.setProperty('--sw-content-offset', $('nav.fixed-top').outerHeight() + 'px');
|
||||||
sessionStorage.setItem('update-banner-dismissed', '1');
|
sessionStorage.setItem('update-banner-dismissed', '1');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user