Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9db530565d | |||
| fe6306b7d3 | |||
| 240563e093 | |||
| 047e54ce50 | |||
| ee76088f86 | |||
| 8db46bd9d9 | |||
| 67e2fc54c2 | |||
| 23d99980ce | |||
| aa3b3ed515 |
@@ -4,6 +4,51 @@ 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.3.0] - 2026-07-26
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Unified the front-end UI shell across the three theta42 apps.** `views/top.ejs`, `views/bottom.ejs` and `public/lib/js/app-base.js` are now byte-identical in sso-manager-node, proxy and jump-host, so the apps look and behave the same and a shell change lands in one edit per repo instead of three divergent ones. Everything that differs between the apps moved into a new `nodejs/utils/ui.js`, exposed to every render as `ui` via `app.locals`: nav items and the groups that may see them, footer repo/license/docs/Terms links, favicon, the profile and post-logout targets, and whether the update banner exists at all.
|
||||||
|
- **One nav-gating model everywhere.** `app-base.js` reveals `.group-required-<cn>` elements for each group the current user is in, read from `GET /api/user/me`. sso-manager-node reports LDAP DNs in `memberOf` and the OIDC clients report CNs in `groups`; both normalise to CNs client-side, and the clients' effective-rights `isAdmin` flag is exposed as a synthetic `admin` group — so one gating model covers a group-based provider and boolean-admin clients without either app learning the other's response shape.
|
||||||
|
- **`GET /api/user/me` is fetched once per page load and cached** (`app.auth.loadUser`). The nav, per-view `forceLogin` and every group-gated element read that one promise instead of issuing their own request.
|
||||||
|
- `app.auth.isLoggedIn` is dual-mode: it returns a Promise **and** invokes an optional node-style callback, so the async and callback call styles both work against one shared `top.ejs`.
|
||||||
|
- `app.auth.forceLogin` no longer uses `$.holdReady` (removed in jQuery 4). An unauthenticated user is redirected to `/login?redirect=<path>`; group requirements are still enforced, and `logOut` now only clears the session, leaving the destination to the caller (`ui.logoutRedirect`).
|
||||||
|
- Dependency alignment across all three apps: `jquery` `^4.0.0` and `ejs` `^3.1.10`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **`app.api.delete` dropped its callback when called by `formAJAX`.** `formAJAX` always passes the serialized form as the second argument, so a DELETE-method form's callback landed in the data slot and never ran. `delete` now accepts both `(url, callback)` and `(url, data, callback)`.
|
||||||
|
- **`app.api.post`/`put` referenced an undefined `callback2`** and threw when handed a non-function callback. Both are now dual-mode Promise/callback.
|
||||||
|
- **The login page's "reveal the card once we know you're logged out" branch threw** (`Cannot read properties of null`) whenever the logged-in check answered before the parser reached that element — which it always did without a stored token. It now runs on DOM ready.
|
||||||
|
- **`logInRedirect` on the legacy `/login/<path>` form kept only the path.** The OIDC provider routes an unauthenticated authorization request through `/login/oauth/authorize?client_id=…&state=…`; dropping the query there loses the entire authorization request. The suffix form now preserves its query string.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `.group-required { display: none }` in `public/css/styles.css`, the base rule the shared gating model reveals against.
|
||||||
|
- `#spa-shell` dropped its inline `margin-top`; `styles.css` already sets it and the shared shell adjusts it when a banner is shown.
|
||||||
|
|
||||||
|
### Verified
|
||||||
|
- Browser-verified against a full theta-env stack (sso-manager + proxy + jump-host): every top-level page renders with a clean console; nav gating is correct for admin and non-admin; `forceLogin`'s onboarding and group gates fire; `val.js` blocks a weak password and accepts a strong one through a real form submit; the DELETE-method forms work; and the OIDC login round trip (authorize with PKCE -> login -> consent -> callback -> token fragment) completes on both OIDC clients.
|
||||||
|
|
||||||
|
## [1.2.0] - 2026-07-25
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Adopted the shared `@simpleworkjs/*` packages published under the simpleworkjs org, replacing this app's byte-identical forks of the same code so the theta42 apps share one codebase and API schema:
|
||||||
|
- `@simpleworkjs/oidc-client` — the OIDC client (session models, auth router, OIDC utils, safe-redirect, local-admin bootstrap). Deleted the local `utils/oidc.js`, `utils/safe_redirect.js`, `models/oidc_state.js`, `models/token.js`, `models/auth.js`, `routes/auth.js`; `models/index.js` wires the factory and the local-admin bootstrap.
|
||||||
|
- `@simpleworkjs/directory-schema` — the sso↔jump-host directory contract. `utils/access.js` now fetches reachable hosts through the shared `createDirectoryClient` (`getResourcesByGroup`).
|
||||||
|
- `@simpleworkjs/ldap` — `models/user_ldap.js` is now a thin wrapper over `createLdapClient`, preserving this app's loose TLS default (`rejectUnauthorized: false`) and the exact export shape.
|
||||||
|
- `@simpleworkjs/app-stack` — unified `build_info` (`{buildVersion, buildHash, buildYear}`) and the `static-modules` mounting helper. `build_info` moved from `models/` to `utils/`; `routes/render.js` uses `mountStaticModules`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Directory envelope drift was silently treated as "no reachable hosts".** `utils/access.js` previously read `data.results || []`, so if the SSO directory ever returned a bare array (envelope drift) every per-group query collapsed to `[]` and no user could bridge. The shared client now validates the `{ results }` envelope on every call and treats an envelope violation as a failed group fetch rather than silently returning `[]`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Dependency alignment: `ldapts` `^8.1.2` → `^8.1.8`, `redis` `^4.7` → `^6.1.0` (the direct `redis` dep is unused — only `model-redis` is used, which already brings `redis` ^6.1.0). The new `@simpleworkjs/*` deps resolve from the npm registry (`^1.0.0`); no `file:`/`link:` entries in the lockfile, so `npm ci` is clean in docker builds.
|
||||||
|
- `build_info` export shape changed from `{commit, version}` to `{buildVersion, buildHash, buildYear}` (the shared shape used by all three apps). The `/health` endpoint and footer now report `buildVersion`/`buildHash`.
|
||||||
|
|
||||||
|
## [1.1.0] - 2026-07-23
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Rebuilt the web UI on the shared theta42 app stack** so it looks and behaves like the SSO Manager and Proxy: Express + EJS with the same `top.ejs`/`bottom.ejs` shell, Bootstrap 5, jQuery, jq-repeat, FontAwesome, the shared `app-base.js` client framework, and Socket.IO — replacing the bespoke minimal theme. Dashboard, Sessions, and Audit pages now render in the common look/feel.
|
||||||
|
- **Web-UI auth is now OIDC + a local anti-lockout admin** (the proxy's model), replacing the direct LDAP-bind login. Normal users log in through the SSO ("Log in with SSO"); a local `auth.adminUsers` account (bootstrapped on first boot, password from `auth.localAdminPass`) still works if the SSO is unreachable. Admin access is gated by `auth.adminGroups` or the local admin account. New config: `oidc` block + `auth.adminUsers`/`localAdminPass`. **Note:** the SSH bridge and its own LDAP auth are unchanged — this only affects the web management UI.
|
||||||
|
|
||||||
## [1.0.1] - 2026-07-23
|
## [1.0.1] - 2026-07-23
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -91,9 +91,13 @@ The default SSH port is **2222** so the service needs no privilege. To listen on
|
|||||||
|
|
||||||
## Web UI / API
|
## Web UI / API
|
||||||
|
|
||||||
`https://jump.example.com/` (behind the proxy) — admin login uses your LDAP
|
`https://jump.example.com/` (behind the proxy) — built on the same
|
||||||
credentials and requires membership in `auth.adminGroups` (default
|
Express + EJS + Bootstrap stack as the [SSO Manager](https://theta42.github.io/sso-manager-node/)
|
||||||
`app_sso_admin`).
|
and [Proxy](https://theta42.github.io/proxy/), so it looks and behaves like the
|
||||||
|
rest of the stack. Login is **OIDC against the SSO** (the "Log in with SSO"
|
||||||
|
button) plus a **local anti-lockout admin** that works even if the SSO is
|
||||||
|
unreachable. Admin access requires membership in `auth.adminGroups` (default
|
||||||
|
`app_sso_admin`) or being the local `auth.adminUsers` account.
|
||||||
|
|
||||||
- `GET /health` — open; `{status, activeSessions, version}`
|
- `GET /health` — open; `{status, activeSessions, version}`
|
||||||
- `GET /api/sessions` — active sessions
|
- `GET /api/sessions` — active sessions
|
||||||
|
|||||||
@@ -98,8 +98,10 @@ Byte counts per direction are tallied cheaply for the audit record.
|
|||||||
|
|
||||||
## Web UI, API & audit
|
## Web UI, API & audit
|
||||||
|
|
||||||
A small Express app on `:3002` (admin login via LDAP, gated by
|
An Express + EJS + Bootstrap app on `:3002` — the same front-end stack and
|
||||||
`auth.adminGroups`) exposes:
|
look/feel as the SSO Manager and Proxy. Login is OIDC against the SSO plus a
|
||||||
|
local anti-lockout admin (`auth.adminUsers`), with admin access gated by
|
||||||
|
`auth.adminGroups`. It exposes:
|
||||||
|
|
||||||
- `GET /health` — open; `{status, activeSessions, version}`
|
- `GET /health` — open; `{status, activeSessions, version}`
|
||||||
- `GET /api/sessions` — active sessions
|
- `GET /api/sessions` — active sessions
|
||||||
|
|||||||
@@ -94,7 +94,8 @@ Every key is documented in
|
|||||||
[`secrets.js.example`](https://github.com/theta42/jump-host/blob/master/secrets.js.example):
|
[`secrets.js.example`](https://github.com/theta42/jump-host/blob/master/secrets.js.example):
|
||||||
`ldap` (bind + bases + TLS), `sso` (url + apiToken), `ssh`
|
`ldap` (bind + bases + TLS), `sso` (url + apiToken), `ssh`
|
||||||
(`listenPort`, `passwordAuth`, `allowRawIPs`, `keyComment`, timeouts,
|
(`listenPort`, `passwordAuth`, `allowRawIPs`, `keyComment`, timeouts,
|
||||||
`maxSessions`), `web.port`, `auth.adminGroups`, and `redis`.
|
`maxSessions`), `web.port`, `oidc` (web-UI SSO login), `auth`
|
||||||
|
(`adminGroups` / `adminUsers` / `localAdminPass`), and `redis`.
|
||||||
|
|
||||||
## Verifying
|
## Verifying
|
||||||
|
|
||||||
|
|||||||
+31
-21
@@ -1,37 +1,47 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const path = require('path');
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const conf = require('@simpleworkjs/conf');
|
const compression = require('compression');
|
||||||
|
|
||||||
const registry = require('./services/session_registry');
|
require('./models'); // wire model-redis + register models
|
||||||
const { requireAdmin } = require('./middleware/auth');
|
|
||||||
const buildInfo = require('./models/build_info');
|
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
app.set('view engine', 'ejs');
|
app.set('view engine', 'ejs');
|
||||||
app.set('views', path.join(__dirname, 'views'));
|
app.set('views', require('path').join(__dirname, 'views'));
|
||||||
app.use('/public', express.static(path.join(__dirname, 'public')));
|
|
||||||
|
|
||||||
// Open health check — no auth (used by Docker/compose + the proxy).
|
// Per-app values for the shared UI shell (views/top.ejs + views/bottom.ejs).
|
||||||
app.get('/health', (req, res) => {
|
// Set as an app local so every res.render has it, including routes that don't
|
||||||
res.json({ status: 'ok', activeSessions: registry.count(), version: buildInfo.version, commit: buildInfo.commit });
|
// spread the render router's `values` object.
|
||||||
|
app.locals.ui = require('./utils/ui');
|
||||||
|
|
||||||
|
app.use(compression());
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.urlencoded({extended: false}));
|
||||||
|
|
||||||
|
// Page shells + static assets + /health (mostly unauthenticated; the client
|
||||||
|
// gates itself on /api/user/me and redirects to /login).
|
||||||
|
app.use('/', require('./routes/render'));
|
||||||
|
|
||||||
|
// API — auth handled per-router inside (see routes/api.js).
|
||||||
|
app.use('/api', require('./routes/api'));
|
||||||
|
|
||||||
|
// 404
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
const error = new Error('Not Found');
|
||||||
|
error.status = 404;
|
||||||
|
next(error);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Login routes (no session required).
|
// Error handler — JSON for API, redirect to login for pages on 401.
|
||||||
app.use('/', require('./routes/auth'));
|
|
||||||
|
|
||||||
// Everything else requires an admin session.
|
|
||||||
app.use(requireAdmin);
|
|
||||||
app.use('/api', require('./routes/api'));
|
|
||||||
app.use('/', require('./routes/index'));
|
|
||||||
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
// eslint-disable-next-line no-unused-vars
|
||||||
app.use((err, req, res, next) => {
|
app.use((err, req, res, next) => {
|
||||||
console.error(err);
|
const status = err.status || 500;
|
||||||
if (req.path.startsWith('/api/')) return res.status(500).json({ error: err.message });
|
if(status >= 500) console.error(err);
|
||||||
res.status(500).render('login', { error: 'Internal error.', name: conf.name });
|
if(req.path.startsWith('/api/')){
|
||||||
|
return res.status(status).json({name: err.name || 'Error', message: err.message || 'Error'});
|
||||||
|
}
|
||||||
|
res.status(status).send(err.message || 'Error');
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = app;
|
module.exports = app;
|
||||||
|
|||||||
+13
-4
@@ -2,24 +2,33 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
// Boots BOTH faces of the jump host: the SSH front door (services/ssh_server)
|
// Boots BOTH faces of the jump host: the SSH front door (services/ssh_server)
|
||||||
// and the web UI/API (app.js). One process, one redis, shared audit store.
|
// and the web UI/API (app.js + Socket.IO). One process, one redis, shared
|
||||||
|
// audit store.
|
||||||
|
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
const conf = require('@simpleworkjs/conf');
|
const conf = require('@simpleworkjs/conf');
|
||||||
|
const { Server } = require('socket.io');
|
||||||
|
|
||||||
require('../models'); // wire model-redis + register models
|
require('../models');
|
||||||
|
|
||||||
const app = require('../app');
|
const app = require('../app');
|
||||||
|
const middleware = require('../middleware/auth');
|
||||||
const sshServer = require('../services/ssh_server');
|
const sshServer = require('../services/ssh_server');
|
||||||
|
|
||||||
// Web server
|
|
||||||
const webPort = (conf.web && conf.web.port) || 3002;
|
const webPort = (conf.web && conf.web.port) || 3002;
|
||||||
const server = http.createServer(app);
|
const server = http.createServer(app);
|
||||||
|
|
||||||
|
// Socket.IO — the client framework (app-base.js) opens an authenticated socket.
|
||||||
|
// We don't push anything yet, but serving /socket.io keeps the shared front-end
|
||||||
|
// working exactly as it does in the sibling apps.
|
||||||
|
const io = new Server(server);
|
||||||
|
io.use(middleware.authIO);
|
||||||
|
app.io = io;
|
||||||
|
|
||||||
server.listen(webPort, () => {
|
server.listen(webPort, () => {
|
||||||
console.log(`[web] jump-host UI/API on :${server.address().port}`);
|
console.log(`[web] jump-host UI/API on :${server.address().port}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// SSH server
|
|
||||||
sshServer.start();
|
sshServer.start();
|
||||||
|
|
||||||
function shutdown() {
|
function shutdown() {
|
||||||
|
|||||||
+25
-3
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
name: 'Jump Host',
|
name: 'Jump Host',
|
||||||
|
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).
|
||||||
// bindDN needs: read on ou=people (users + sshPublicKey) and ou=groups,
|
// bindDN needs: read on ou=people (users + sshPublicKey) and ou=groups,
|
||||||
@@ -59,11 +60,32 @@ module.exports = {
|
|||||||
port: 3002,
|
port: 3002,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Web UI/API login. Same model as the proxy: OIDC against the SSO for
|
||||||
|
// normal users, plus a local anti-lockout admin that works even if the SSO
|
||||||
|
// is unreachable. OIDC endpoints + clientId/clientSecret live in the
|
||||||
|
// secrets file; enabled:false hides the "Log in with SSO" button.
|
||||||
|
oidc: {
|
||||||
|
enabled: false,
|
||||||
|
issuer: '',
|
||||||
|
authorizationEndpoint: '',
|
||||||
|
tokenEndpoint: '',
|
||||||
|
userinfoEndpoint: '',
|
||||||
|
clientId: '',
|
||||||
|
clientSecret: '',
|
||||||
|
redirectUri: '',
|
||||||
|
scopes: ['openid', 'profile', 'email', 'groups'],
|
||||||
|
groupsClaim: 'groups',
|
||||||
|
usernameClaim: 'preferred_username',
|
||||||
|
},
|
||||||
|
|
||||||
auth: {
|
auth: {
|
||||||
// LDAP groups whose members may use the web UI/API.
|
// OIDC group memberships that grant web UI/API admin access.
|
||||||
adminGroups: ['app_sso_admin'],
|
adminGroups: ['app_sso_admin'],
|
||||||
// Web session lifetime (ms).
|
// Local anti-lockout admin: the first name here is bootstrapped as a
|
||||||
sessionTTLms: 12 * 60 * 60 * 1000,
|
// 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.
|
||||||
|
adminUsers: ['jumpadmin'],
|
||||||
|
localAdminPass: '',
|
||||||
},
|
},
|
||||||
|
|
||||||
redis: {
|
redis: {
|
||||||
|
|||||||
+45
-27
@@ -1,36 +1,54 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
// Web UI/API auth: a signed-in admin session (cookie) whose LDAP groups
|
// Web UI/API auth, mirroring the sibling apps: a browser session token
|
||||||
// intersect conf.auth.adminGroups. /health and the login routes are exempt
|
// (`auth-token: <AuthToken uuid>`) established via local login or the OIDC
|
||||||
// (mounted before this middleware).
|
// callback. The token carries the group snapshot captured at login.
|
||||||
|
|
||||||
const conf = require('@simpleworkjs/conf');
|
const conf = require('@simpleworkjs/conf');
|
||||||
const Session = require('../models/session');
|
const { Auth } = require('../models');
|
||||||
|
|
||||||
function parseCookies(header) {
|
async function auth(req, res, next){
|
||||||
const out = {};
|
try{
|
||||||
(header || '').split(';').forEach((p) => {
|
req.token = await Auth.checkToken(req.header('auth-token'));
|
||||||
const i = p.indexOf('=');
|
req.user = req.token.user;
|
||||||
if (i > -1) out[p.slice(0, i).trim()] = decodeURIComponent(p.slice(i + 1).trim());
|
req.groups = typeof req.token.groupsArray === 'function' ? req.token.groupsArray() : [];
|
||||||
});
|
return next();
|
||||||
return out;
|
}catch(error){
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requireAdmin(req, res, next) {
|
// Is the authenticated request an admin? Admin = a session whose OIDC groups
|
||||||
const token = parseCookies(req.headers.cookie).jump_session;
|
// intersect conf.auth.adminGroups, OR the local anti-lockout admin
|
||||||
const session = await Session.verify(token);
|
// (conf.auth.adminUsers). The whole web UI is admin-only (audit + metrics).
|
||||||
if (!session) {
|
function isAdmin(req){
|
||||||
if (req.path.startsWith('/api/')) return res.status(401).json({ error: 'unauthorized' });
|
const adminGroups = (conf.auth && conf.auth.adminGroups) || [];
|
||||||
return res.redirect('/login');
|
const adminUsers = (conf.auth && conf.auth.adminUsers) || [];
|
||||||
}
|
const username = req.user && req.user.username;
|
||||||
const groups = JSON.parse(session.groups || '[]');
|
if(username && adminUsers.includes(username)) return true;
|
||||||
const admin = (conf.auth.adminGroups || []).some((g) => groups.includes(g));
|
return (req.groups || []).some(g => adminGroups.includes(g));
|
||||||
if (!admin) {
|
|
||||||
if (req.path.startsWith('/api/')) return res.status(403).json({ error: 'forbidden' });
|
|
||||||
return res.status(403).render('login', { error: 'Your account is not a jump-host admin.', name: conf.name });
|
|
||||||
}
|
|
||||||
req.jumpUser = { uid: session.uid, groups };
|
|
||||||
next();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { requireAdmin, parseCookies };
|
async function requireAdmin(req, res, next){
|
||||||
|
if(isAdmin(req)) return next();
|
||||||
|
const error = new Error('Forbidden');
|
||||||
|
error.name = 'Forbidden';
|
||||||
|
error.status = 403;
|
||||||
|
error.message = 'Admin access required.';
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Socket.IO handshake auth (app-base.js connects with the session token).
|
||||||
|
async function authIO(socket, next){
|
||||||
|
try{
|
||||||
|
const tok = socket.handshake.auth && socket.handshake.auth.token;
|
||||||
|
if(!tok) return next(Auth.errors.login());
|
||||||
|
const token = await Auth.checkToken(tok);
|
||||||
|
socket.user = token.user;
|
||||||
|
next();
|
||||||
|
}catch(error){
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { auth, requireAdmin, authIO, isAdmin };
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
// Short git commit, baked into /app/.build_commit at image build time (see
|
|
||||||
// Dockerfile gitinfo stage) or resolved from git on bare metal.
|
|
||||||
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const { execSync } = require('child_process');
|
|
||||||
|
|
||||||
function resolve() {
|
|
||||||
try {
|
|
||||||
const baked = path.join(__dirname, '../../.build_commit');
|
|
||||||
if (fs.existsSync(baked)) return fs.readFileSync(baked, 'utf8').trim();
|
|
||||||
} catch (_) {}
|
|
||||||
try {
|
|
||||||
return execSync('git rev-parse --short HEAD', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
|
||||||
} catch (_) {}
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
let version = 'unknown';
|
|
||||||
try { version = require('../package.json').version; } catch (_) {}
|
|
||||||
|
|
||||||
module.exports = { commit: resolve(), version };
|
|
||||||
+21
-4
@@ -1,11 +1,12 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
// model-redis backing (same store the other stack apps use). Table is the
|
// model-redis backing (same store the sibling apps use). Table is the base
|
||||||
// base class; getRedis() exposes the underlying node-redis client for the
|
// class; getRedis() exposes the underlying node-redis client for the counters
|
||||||
// counters and sorted-set index in models/metrics.js and models/audit_event.js.
|
// and sorted-set index in models/metrics.js and models/audit_event.js.
|
||||||
|
|
||||||
const conf = require('@simpleworkjs/conf');
|
const conf = require('@simpleworkjs/conf');
|
||||||
const { setUpTable } = require('model-redis');
|
const { setUpTable } = require('model-redis');
|
||||||
|
const { createOidcClient, bootstrapLocalAdmin } = require('@simpleworkjs/oidc-client');
|
||||||
|
|
||||||
const Table = setUpTable(conf.redis);
|
const Table = setUpTable(conf.redis);
|
||||||
|
|
||||||
@@ -31,5 +32,21 @@ async function getRedis() {
|
|||||||
|
|
||||||
module.exports.getRedis = getRedis;
|
module.exports.getRedis = getRedis;
|
||||||
|
|
||||||
require('./session');
|
// Register models (order matters: User before AuthToken's relation resolves).
|
||||||
|
require('./user_redis'); // User (redis-backed local + OIDC JIT)
|
||||||
|
|
||||||
|
// Shared OIDC client (authorization-code + PKCE): session models (Token,
|
||||||
|
// 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
|
||||||
|
// no Bearer PATs, so checkApiToken is omitted (Auth.checkApiToken is absent).
|
||||||
|
const oidcClient = createOidcClient({ Table });
|
||||||
|
module.exports.Token = oidcClient.Token;
|
||||||
|
module.exports.AuthToken = oidcClient.AuthToken;
|
||||||
|
module.exports.OidcState = oidcClient.OidcState;
|
||||||
|
module.exports.Auth = oidcClient.Auth;
|
||||||
|
module.exports.authRouter = oidcClient.router;
|
||||||
|
|
||||||
require('./audit_event');
|
require('./audit_event');
|
||||||
|
|
||||||
|
// Idempotent anti-lockout local admin (was the IIFE in user_redis.js).
|
||||||
|
bootstrapLocalAdmin(Table.models.User, { defaultName: 'jumpadmin' });
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
// Web UI sessions — a signed-in admin's browser token. model-redis Table with
|
|
||||||
// a TTL so entries expire and survive restarts.
|
|
||||||
|
|
||||||
const crypto = require('crypto');
|
|
||||||
const Table = require('.');
|
|
||||||
|
|
||||||
class Session extends Table {
|
|
||||||
static _key = 'token';
|
|
||||||
static _keyMap = {
|
|
||||||
'token': {default: function(){ return crypto.randomUUID() }, type: 'string'},
|
|
||||||
'uid': {isRequired: true, type: 'string'},
|
|
||||||
'groups': {default: '[]', type: 'string'},
|
|
||||||
'created_on': {default: function(){ return (new Date).getTime() }},
|
|
||||||
'expires_at': {default: 0, type: 'number'},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Session.register();
|
|
||||||
|
|
||||||
Session.start = async function (uid, groups, ttlMs) {
|
|
||||||
return Session.create({
|
|
||||||
uid,
|
|
||||||
groups: JSON.stringify(groups || []),
|
|
||||||
expires_at: Date.now() + ttlMs,
|
|
||||||
}, { ttl: Math.ceil(ttlMs / 1000) });
|
|
||||||
};
|
|
||||||
|
|
||||||
Session.verify = async function (token) {
|
|
||||||
if (!token) return null;
|
|
||||||
let session;
|
|
||||||
try {
|
|
||||||
session = await Session.get(token);
|
|
||||||
} catch (_) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (!session || session.expires_at < Date.now()) return null;
|
|
||||||
return session;
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = Session;
|
|
||||||
+12
-99
@@ -1,109 +1,22 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
// Thin LDAP helpers — the jump host's entire LDAP surface:
|
// Thin LDAP helpers — the jump host's entire LDAP surface, now backed by the
|
||||||
|
// shared @simpleworkjs/ldap package:
|
||||||
// getUser(uid) -> { dn, uid, sshPublicKeys: [] } or null
|
// getUser(uid) -> { dn, uid, sshPublicKeys: [] } or null
|
||||||
// getGroups(dn) -> [cn, ...] (groupOfNames membership)
|
// getGroups(dn) -> [cn, ...] (groupOfNames membership)
|
||||||
// checkPassword(dn, pw) -> bool (simple bind as the user)
|
// checkPassword(dn, pw) -> bool (simple bind as the user)
|
||||||
// addSshKey(dn, keyLine) -> void (idempotent multi-value add)
|
// addSshKey(dn, keyLine) -> void (idempotent multi-value add)
|
||||||
//
|
//
|
||||||
// Mirrors the patterns in sso-manager-node/nodejs/models/user_ldap.js and
|
// Behavior is unchanged from the previous in-tree implementation: posixAccount
|
||||||
// group_ldap.js (ldapts, admin-bound search, bind-as-user password check,
|
// user filter, groupOfNames group filter, bind-as-user password check,
|
||||||
// TypeOrValueExists treated as success on key add).
|
// TypeOrValueExists treated as success on key add, and the same loose TLS
|
||||||
|
// default ({ rejectUnauthorized: false } when conf.ldap omits tlsOptions).
|
||||||
|
|
||||||
const { Client, Change, Attribute } = require('ldapts');
|
|
||||||
const conf = require('@simpleworkjs/conf');
|
const conf = require('@simpleworkjs/conf');
|
||||||
|
const { createLdapClient } = require('@simpleworkjs/ldap');
|
||||||
|
|
||||||
function ldapConf() {
|
const ldapConf = conf.ldap || {};
|
||||||
return conf.ldap || {};
|
module.exports = createLdapClient({
|
||||||
}
|
...ldapConf,
|
||||||
|
tlsOptions: ldapConf.tlsOptions || { rejectUnauthorized: false },
|
||||||
function makeClient() {
|
});
|
||||||
const c = ldapConf();
|
|
||||||
return new Client({
|
|
||||||
url: c.url,
|
|
||||||
tlsOptions: c.tlsOptions || { rejectUnauthorized: false },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Escape a value being interpolated into an LDAP filter (RFC 4515).
|
|
||||||
function escapeFilter(value) {
|
|
||||||
return String(value).replace(/[\\*()\0]/g, (ch) => ({
|
|
||||||
'\\': '\\5c', '*': '\\2a', '(': '\\28', ')': '\\29', '\0': '\\00',
|
|
||||||
}[ch]));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function withClient(fn) {
|
|
||||||
const c = ldapConf();
|
|
||||||
const client = makeClient();
|
|
||||||
try {
|
|
||||||
await client.bind(c.bindDN, c.bindPassword);
|
|
||||||
return await fn(client);
|
|
||||||
} finally {
|
|
||||||
await client.unbind().catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getUser(uid) {
|
|
||||||
const c = ldapConf();
|
|
||||||
const attr = c.userNameAttribute || 'uid';
|
|
||||||
return withClient(async (client) => {
|
|
||||||
const { searchEntries } = await client.search(c.userBase, {
|
|
||||||
scope: 'sub',
|
|
||||||
filter: `(&(objectClass=posixAccount)(${attr}=${escapeFilter(uid)}))`,
|
|
||||||
attributes: ['dn', attr, 'cn', 'sshPublicKey'],
|
|
||||||
});
|
|
||||||
if (!searchEntries.length) return null;
|
|
||||||
const e = searchEntries[0];
|
|
||||||
let keys = e.sshPublicKey || [];
|
|
||||||
if (!Array.isArray(keys)) keys = [keys];
|
|
||||||
return {
|
|
||||||
dn: e.dn,
|
|
||||||
uid: String(e[attr]),
|
|
||||||
sshPublicKeys: keys.map(String),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getGroups(dn) {
|
|
||||||
const c = ldapConf();
|
|
||||||
return withClient(async (client) => {
|
|
||||||
const { searchEntries } = await client.search(c.groupBase, {
|
|
||||||
scope: 'sub',
|
|
||||||
filter: `(&(objectClass=groupOfNames)(member=${escapeFilter(dn)}))`,
|
|
||||||
attributes: ['cn'],
|
|
||||||
});
|
|
||||||
return searchEntries.map((e) => String(e.cn));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function checkPassword(dn, password) {
|
|
||||||
if (!password) return false;
|
|
||||||
const client = makeClient();
|
|
||||||
try {
|
|
||||||
await client.bind(dn, password);
|
|
||||||
return true;
|
|
||||||
} catch (_) {
|
|
||||||
return false;
|
|
||||||
} finally {
|
|
||||||
await client.unbind().catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addSshKey(dn, keyLine) {
|
|
||||||
return withClient(async (client) => {
|
|
||||||
try {
|
|
||||||
await client.modify(dn, [
|
|
||||||
new Change({
|
|
||||||
operation: 'add',
|
|
||||||
modification: new Attribute({ type: 'sshPublicKey', values: [keyLine] }),
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
} catch (error) {
|
|
||||||
// Same de-dup semantics as the SSO's User.addSSHkey.
|
|
||||||
if (error.name === 'TypeOrValueExistsError') return;
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { getUser, getGroups, checkPassword, addSshKey, escapeFilter, makeClient };
|
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const Table = require('.');
|
||||||
|
const bcrypt = require('bcrypt');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const saltRounds = 10;
|
||||||
|
|
||||||
|
class User extends Table{
|
||||||
|
static _key = 'username';
|
||||||
|
static _keyMap = {
|
||||||
|
'created_by': {isRequired: true, type: 'string', min: 3, max: 500},
|
||||||
|
'created_on': {default: function(){return (new Date).getTime()}},
|
||||||
|
'updated_by': {default:"__NONE__", isRequired: false, type: 'string',},
|
||||||
|
'updated_on': {default: function(){return (new Date).getTime()}, always: true},
|
||||||
|
'username': {isRequired: true, type: 'string', min: 3, max: 500},
|
||||||
|
'password': {isRequired: true, type: 'string', min: 3, max: 500, isPrivate: true},
|
||||||
|
'backing': {default:"redis", isRequired: false, type: 'string',},
|
||||||
|
}
|
||||||
|
|
||||||
|
static backing = 'redis'
|
||||||
|
|
||||||
|
static async create(data) {
|
||||||
|
try{
|
||||||
|
data['password'] = await bcrypt.hash(data['password'], saltRounds);
|
||||||
|
data['backing'] = data['backing'] || 'redis';
|
||||||
|
|
||||||
|
return await super.create(data)
|
||||||
|
|
||||||
|
}catch(error){
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setPassword(data){
|
||||||
|
try{
|
||||||
|
data['password'] = await bcrypt.hash(data['password'], saltRounds);
|
||||||
|
|
||||||
|
return this.update(data);
|
||||||
|
}catch(error){
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Just-in-time provisioning for an OIDC-authenticated user. Creates the
|
||||||
|
* local user on first login so relations (tokens, created_by, grants) have
|
||||||
|
* something to point at. OIDC users get a random, unusable password — they
|
||||||
|
* authenticate through the SSO, never the local password form.
|
||||||
|
*
|
||||||
|
* @param {Object} data - {username, ...} from the OIDC userinfo claims
|
||||||
|
* @returns {User} the existing or newly created user
|
||||||
|
*/
|
||||||
|
static async upsertOidc(data){
|
||||||
|
try{
|
||||||
|
return await User.get(data.username);
|
||||||
|
}catch(error){
|
||||||
|
return await User.create({
|
||||||
|
username: data.username,
|
||||||
|
password: crypto.randomBytes(24).toString('hex'),
|
||||||
|
created_by: data.username,
|
||||||
|
backing: 'oidc',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async login(data){
|
||||||
|
try{
|
||||||
|
let user = await User.get(data);
|
||||||
|
let auth = await bcrypt.compare(data.password, user.password);
|
||||||
|
|
||||||
|
if(auth){
|
||||||
|
return user
|
||||||
|
}else{
|
||||||
|
throw this.errors.login();
|
||||||
|
}
|
||||||
|
}catch(error){
|
||||||
|
console.error('!!!!!!!!!!', error)
|
||||||
|
if (error == 'Authentication failure'){
|
||||||
|
throw this.errors.login()
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
User.register();
|
||||||
|
|
||||||
|
// Anti-lockout local-admin bootstrap moved to @simpleworkjs/oidc-client
|
||||||
|
// (bootstrapLocalAdmin); invoked once from models/index.js after User is
|
||||||
|
// registered. See the package lib/bootstrap.js for the original logic.
|
||||||
Generated
+589
-146
@@ -1,20 +1,34 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-jump-host",
|
"name": "t42-jump-host",
|
||||||
"version": "1.0.1",
|
"version": "1.3.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "t42-jump-host",
|
"name": "t42-jump-host",
|
||||||
"version": "1.0.1",
|
"version": "1.3.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||||
|
"@simpleworkjs/app-stack": "^1.0.0",
|
||||||
"@simpleworkjs/conf": "^1.2.0",
|
"@simpleworkjs/conf": "^1.2.0",
|
||||||
|
"@simpleworkjs/directory-schema": "^1.0.0",
|
||||||
|
"@simpleworkjs/ldap": "^1.0.0",
|
||||||
|
"@simpleworkjs/oidc-client": "^1.0.0",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
|
"bootstrap": "^5.3.8",
|
||||||
|
"compression": "^1.8.1",
|
||||||
"ejs": "^3.1.10",
|
"ejs": "^3.1.10",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"ldapts": "^8.1.2",
|
"express-rate-limit": "^8.5.2",
|
||||||
|
"jq-repeat": "^2.2.0",
|
||||||
|
"jquery": "^4.0.0",
|
||||||
|
"ldapts": "^8.1.8",
|
||||||
"model-redis": "^1.6.0",
|
"model-redis": "^1.6.0",
|
||||||
"redis": "^4.7.0",
|
"moment": "^2.30.1",
|
||||||
|
"mustache": "^4.2.0",
|
||||||
|
"redis": "^6.1.0",
|
||||||
|
"socket.io": "^4.8.3",
|
||||||
"ssh2": "^1.16.0"
|
"ssh2": "^1.16.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -24,63 +38,108 @@
|
|||||||
"node": ">=20.14"
|
"node": ">=20.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@redis/bloom": {
|
"node_modules/@fortawesome/fontawesome-free": {
|
||||||
"version": "1.2.0",
|
"version": "7.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-7.3.1.tgz",
|
||||||
"integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==",
|
"integrity": "sha512-wmglKKPDIkgV3aWlZzWECCPoGIkYCulzBwxG9+w7rc5BGapZ6cPMpoPOT8k36J0Ni7PPX6c/rsoMWfS4d1MUMg==",
|
||||||
|
"license": "(CC-BY-4.0 AND OFL-1.1 AND MIT)",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@popperjs/core": {
|
||||||
|
"version": "2.11.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
||||||
|
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/popperjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@redis/bloom": {
|
||||||
|
"version": "6.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-6.1.0.tgz",
|
||||||
|
"integrity": "sha512-Rzascjd9J9bJsM45T/Z9CTg1QY/B63B6YO8QorLVMeXnbBDsKiSCVR/+GQ061hYPk8FpTzWmPY8tAv2sT+JEtQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20.0.0"
|
||||||
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@redis/client": "^1.0.0"
|
"@redis/client": "^6.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@redis/client": {
|
"node_modules/@redis/client": {
|
||||||
"version": "1.6.1",
|
"version": "6.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/@redis/client/-/client-6.1.0.tgz",
|
||||||
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
|
"integrity": "sha512-7u1LefkezJF0HESlhO7ZFLEPfyY+NejP3SGv+Z4pGaT3oM5GVVLa0u3f4rDLUrcw+SRo8IlX9Y8JAONeDdg1Ag==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cluster-key-slot": "1.1.2",
|
"cluster-key-slot": "1.1.2"
|
||||||
"generic-pool": "3.9.0",
|
|
||||||
"yallist": "4.0.0"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14"
|
"node": ">= 20.0.0"
|
||||||
}
|
},
|
||||||
},
|
|
||||||
"node_modules/@redis/graph": {
|
|
||||||
"version": "1.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz",
|
|
||||||
"integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@redis/client": "^1.0.0"
|
"@node-rs/xxhash": "^1.1.0",
|
||||||
|
"@opentelemetry/api": ">=1 <2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@node-rs/xxhash": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@opentelemetry/api": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@redis/json": {
|
"node_modules/@redis/json": {
|
||||||
"version": "1.0.7",
|
"version": "6.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/@redis/json/-/json-6.1.0.tgz",
|
||||||
"integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==",
|
"integrity": "sha512-/GFjQA6bu5pG9ClCJAI5Xx4bNXe7UTpxBBlIupBNTrn1+nY860apGnYJuaSCDV2BmEbTidpa7O2qa28oxKx+rg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20.0.0"
|
||||||
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@redis/client": "^1.0.0"
|
"@redis/client": "^6.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@redis/search": {
|
"node_modules/@redis/search": {
|
||||||
"version": "1.2.0",
|
"version": "6.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@redis/search/-/search-6.1.0.tgz",
|
||||||
"integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==",
|
"integrity": "sha512-kS5agg+3yZbrdrt8omrew7FLCD8eOm7tarG1CROekPBRe+QGDR9aOpnHIQaYsYi6wPRTH70nQiF06AIjgURefQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20.0.0"
|
||||||
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@redis/client": "^1.0.0"
|
"@redis/client": "^6.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@redis/time-series": {
|
"node_modules/@redis/time-series": {
|
||||||
"version": "1.1.0",
|
"version": "6.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-6.1.0.tgz",
|
||||||
"integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==",
|
"integrity": "sha512-uIDBtV8MmG/xpJsRqbGSO4iX6ryj37MLMP82lRpFvI7ykAVe5GyqgxigEbU+uZNv9kDPNMKw3dvI/S/J1BNBzA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20.0.0"
|
||||||
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@redis/client": "^1.0.0"
|
"@redis/client": "^6.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@simpleworkjs/app-stack": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@simpleworkjs/app-stack/-/app-stack-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-Hg/mouA87WruKeZqhqtJgAaLabjHY8Z9POO6U+DB7sGGDhy1jgZXT31hyxLUDV+InByOPhz48NIkGiWNwoesXQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^5.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@simpleworkjs/conf": {
|
"node_modules/@simpleworkjs/conf": {
|
||||||
@@ -95,6 +154,74 @@
|
|||||||
"node": ">=16.0.0"
|
"node": ">=16.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@simpleworkjs/directory-schema": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@simpleworkjs/directory-schema/-/directory-schema-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-thZhPGNdDYlD8rlhXidnbCHTKjdSkj9ag1zE/gz1AwuclYypsKAP+v3BAvcZ/YDQP8RBDJNPXof5EpVheLovTg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@simpleworkjs/ldap": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@simpleworkjs/ldap/-/ldap-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-saDmwk+KJ6kIWj9/MF37d+BM9KQisy6DsI9umyt1FWNyx6+wnEEat/1RUTwXKBd4IKJK+zPT5lC/B6gfa2CuAA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ldapts": "^8.1.8"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@simpleworkjs/oidc-client": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@simpleworkjs/oidc-client/-/oidc-client-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-AzxIaE32p4yKDlp0mWvZp1wmXi8tMFckcPwMiQyZrDgEC0IybGhbjppPa+vNGx1AoVLp64vRL/zR3yXb/19NPg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@simpleworkjs/conf": "^1.2.0",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"express-rate-limit": "^8.5.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@socket.io/component-emitter": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/cors": {
|
||||||
|
"version": "2.8.19",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
|
||||||
|
"integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "26.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
|
||||||
|
"integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~8.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/ws": {
|
||||||
|
"version": "8.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||||
|
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/accepts": {
|
"node_modules/accepts": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||||
@@ -143,6 +270,29 @@
|
|||||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/base64id": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^4.5.0 || >= 5.9"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/bcrypt": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"node-addon-api": "^8.3.0",
|
||||||
|
"node-gyp-build": "^4.8.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/bcrypt-pbkdf": {
|
"node_modules/bcrypt-pbkdf": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
|
||||||
@@ -202,6 +352,25 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bootstrap": {
|
||||||
|
"version": "5.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz",
|
||||||
|
"integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/twbs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/bootstrap"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@popperjs/core": "^2.11.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
|
||||||
@@ -305,6 +474,60 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/compressible": {
|
||||||
|
"version": "2.0.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
|
||||||
|
"integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-db": ">= 1.43.0 < 2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/compression": {
|
||||||
|
"version": "1.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz",
|
||||||
|
"integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bytes": "3.1.2",
|
||||||
|
"compressible": "~2.0.18",
|
||||||
|
"debug": "2.6.9",
|
||||||
|
"negotiator": "~0.6.4",
|
||||||
|
"on-headers": "~1.1.0",
|
||||||
|
"safe-buffer": "5.2.1",
|
||||||
|
"vary": "~1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/compression/node_modules/debug": {
|
||||||
|
"version": "2.6.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||||
|
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/compression/node_modules/ms": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/compression/node_modules/negotiator": {
|
||||||
|
"version": "0.6.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
|
||||||
|
"integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/content-disposition": {
|
"node_modules/content-disposition": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
|
||||||
@@ -345,6 +568,23 @@
|
|||||||
"node": ">=6.6.0"
|
"node": ">=6.6.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cors": {
|
||||||
|
"version": "2.8.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
|
||||||
|
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"object-assign": "^4",
|
||||||
|
"vary": "^1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cpu-features": {
|
"node_modules/cpu-features": {
|
||||||
"version": "0.0.10",
|
"version": "0.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz",
|
||||||
@@ -429,6 +669,79 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/engine.io": {
|
||||||
|
"version": "6.6.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.9.tgz",
|
||||||
|
"integrity": "sha512-clKkw4C7nJ22mGgoVcCg6V/W/TxdNyIOTr89k2ONZu81qqkddPFDF0LXcbAwhzPD8DjkiRCjzuiO6Y+fkpD4vg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/cors": "^2.8.12",
|
||||||
|
"@types/node": ">=10.0.0",
|
||||||
|
"@types/ws": "^8.5.12",
|
||||||
|
"accepts": "~1.3.4",
|
||||||
|
"base64id": "2.0.0",
|
||||||
|
"cookie": "~0.7.2",
|
||||||
|
"cors": "~2.8.5",
|
||||||
|
"debug": "~4.4.1",
|
||||||
|
"engine.io-parser": "~5.2.1",
|
||||||
|
"ws": "~8.21.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/engine.io-parser": {
|
||||||
|
"version": "5.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
|
||||||
|
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/engine.io/node_modules/accepts": {
|
||||||
|
"version": "1.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||||
|
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-types": "~2.1.34",
|
||||||
|
"negotiator": "0.6.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/engine.io/node_modules/mime-db": {
|
||||||
|
"version": "1.52.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||||
|
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/engine.io/node_modules/mime-types": {
|
||||||
|
"version": "2.1.35",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||||
|
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-db": "1.52.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/engine.io/node_modules/negotiator": {
|
||||||
|
"version": "0.6.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||||
|
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/es-define-property": {
|
"node_modules/es-define-property": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||||
@@ -517,6 +830,25 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/express-rate-limit": {
|
||||||
|
"version": "8.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.0.tgz",
|
||||||
|
"integrity": "sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "^4.4.3",
|
||||||
|
"ip-address": "^10.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/express-rate-limit"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"express": ">= 4.11"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/extend": {
|
"node_modules/extend": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||||
@@ -608,15 +940,6 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/generic-pool": {
|
|
||||||
"version": "3.9.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz",
|
|
||||||
"integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/get-intrinsic": {
|
"node_modules/get-intrinsic": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
@@ -762,6 +1085,15 @@
|
|||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/ip-address": {
|
||||||
|
"version": "10.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||||
|
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ipaddr.js": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
@@ -840,6 +1172,25 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jq-repeat": {
|
||||||
|
"version": "2.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jq-repeat/-/jq-repeat-2.2.1.tgz",
|
||||||
|
"integrity": "sha512-1M0jRo7rJKO2mHbeENNjUx9YMOCobUuG4XElKt2NWDc4+j22wrZELqt7Twy95BBEkZQuwDteoPTd1etupZZPtQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"jquery": ">=3.0.0",
|
||||||
|
"mustache": ">=4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jquery": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/jquery/-/jquery-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-TXCHVR3Lb6TZdtw1l3RTLf8RBWVGexdxL6AC8/e0xZKEpBflBsjh9/8LXw+dkNFuOyW9B7iB3O1sP7hS0Kiacg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ldapts": {
|
"node_modules/ldapts": {
|
||||||
"version": "8.2.0",
|
"version": "8.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/ldapts/-/ldapts-8.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/ldapts/-/ldapts-8.2.0.tgz",
|
||||||
@@ -928,92 +1279,13 @@
|
|||||||
"redis": "^6.1.0"
|
"redis": "^6.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/model-redis/node_modules/@redis/bloom": {
|
"node_modules/moment": {
|
||||||
"version": "6.1.0",
|
"version": "2.30.1",
|
||||||
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-6.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
|
||||||
"integrity": "sha512-Rzascjd9J9bJsM45T/Z9CTg1QY/B63B6YO8QorLVMeXnbBDsKiSCVR/+GQ061hYPk8FpTzWmPY8tAv2sT+JEtQ==",
|
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 20.0.0"
|
"node": "*"
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@redis/client": "^6.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/model-redis/node_modules/@redis/client": {
|
|
||||||
"version": "6.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@redis/client/-/client-6.1.0.tgz",
|
|
||||||
"integrity": "sha512-7u1LefkezJF0HESlhO7ZFLEPfyY+NejP3SGv+Z4pGaT3oM5GVVLa0u3f4rDLUrcw+SRo8IlX9Y8JAONeDdg1Ag==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"cluster-key-slot": "1.1.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 20.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@node-rs/xxhash": "^1.1.0",
|
|
||||||
"@opentelemetry/api": ">=1 <2"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@node-rs/xxhash": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"@opentelemetry/api": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/model-redis/node_modules/@redis/json": {
|
|
||||||
"version": "6.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@redis/json/-/json-6.1.0.tgz",
|
|
||||||
"integrity": "sha512-/GFjQA6bu5pG9ClCJAI5Xx4bNXe7UTpxBBlIupBNTrn1+nY860apGnYJuaSCDV2BmEbTidpa7O2qa28oxKx+rg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 20.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@redis/client": "^6.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/model-redis/node_modules/@redis/search": {
|
|
||||||
"version": "6.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@redis/search/-/search-6.1.0.tgz",
|
|
||||||
"integrity": "sha512-kS5agg+3yZbrdrt8omrew7FLCD8eOm7tarG1CROekPBRe+QGDR9aOpnHIQaYsYi6wPRTH70nQiF06AIjgURefQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 20.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@redis/client": "^6.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/model-redis/node_modules/@redis/time-series": {
|
|
||||||
"version": "6.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-6.1.0.tgz",
|
|
||||||
"integrity": "sha512-uIDBtV8MmG/xpJsRqbGSO4iX6ryj37MLMP82lRpFvI7ykAVe5GyqgxigEbU+uZNv9kDPNMKw3dvI/S/J1BNBzA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 20.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@redis/client": "^6.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/model-redis/node_modules/redis": {
|
|
||||||
"version": "6.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/redis/-/redis-6.1.0.tgz",
|
|
||||||
"integrity": "sha512-0kvUPM8RHP/ZMa0xYaDTcG5e8tIGW6kz6MToVT0V8iOnk6bkXp2jncGRGe2bZEk41lZwiDspUqjZCSk5ohjcKw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@redis/bloom": "6.1.0",
|
|
||||||
"@redis/client": "6.1.0",
|
|
||||||
"@redis/json": "6.1.0",
|
|
||||||
"@redis/search": "6.1.0",
|
|
||||||
"@redis/time-series": "6.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 20.0.0"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
@@ -1022,6 +1294,15 @@
|
|||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/mustache": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"mustache": "bin/mustache"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/nan": {
|
"node_modules/nan": {
|
||||||
"version": "2.28.0",
|
"version": "2.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz",
|
||||||
@@ -1038,6 +1319,26 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-addon-api": {
|
||||||
|
"version": "8.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.0.tgz",
|
||||||
|
"integrity": "sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^18 || ^20 || >= 21"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/node-gyp-build": {
|
||||||
|
"version": "4.8.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
|
||||||
|
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"node-gyp-build": "bin.js",
|
||||||
|
"node-gyp-build-optional": "optional.js",
|
||||||
|
"node-gyp-build-test": "build-test.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/nodemon": {
|
"node_modules/nodemon": {
|
||||||
"version": "3.1.14",
|
"version": "3.1.14",
|
||||||
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz",
|
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz",
|
||||||
@@ -1116,6 +1417,15 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/object-assign": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/object-inspect": {
|
"node_modules/object-inspect": {
|
||||||
"version": "1.13.4",
|
"version": "1.13.4",
|
||||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||||
@@ -1140,6 +1450,15 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/on-headers": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/once": {
|
"node_modules/once": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||||
@@ -1265,20 +1584,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/redis": {
|
"node_modules/redis": {
|
||||||
"version": "4.7.1",
|
"version": "6.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/redis/-/redis-6.1.0.tgz",
|
||||||
"integrity": "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==",
|
"integrity": "sha512-0kvUPM8RHP/ZMa0xYaDTcG5e8tIGW6kz6MToVT0V8iOnk6bkXp2jncGRGe2bZEk41lZwiDspUqjZCSk5ohjcKw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"workspaces": [
|
|
||||||
"./packages/*"
|
|
||||||
],
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@redis/bloom": "1.2.0",
|
"@redis/bloom": "6.1.0",
|
||||||
"@redis/client": "1.6.1",
|
"@redis/client": "6.1.0",
|
||||||
"@redis/graph": "1.1.1",
|
"@redis/json": "6.1.0",
|
||||||
"@redis/json": "1.0.7",
|
"@redis/search": "6.1.0",
|
||||||
"@redis/search": "1.2.0",
|
"@redis/time-series": "6.1.0"
|
||||||
"@redis/time-series": "1.1.0"
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/router": {
|
"node_modules/router": {
|
||||||
@@ -1297,6 +1615,26 @@
|
|||||||
"node": ">= 18"
|
"node": ">= 18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/safe-buffer": {
|
||||||
|
"version": "5.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
|
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/safer-buffer": {
|
"node_modules/safer-buffer": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
@@ -1452,6 +1790,90 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/socket.io": {
|
||||||
|
"version": "4.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz",
|
||||||
|
"integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"accepts": "~1.3.4",
|
||||||
|
"base64id": "~2.0.0",
|
||||||
|
"cors": "~2.8.5",
|
||||||
|
"debug": "~4.4.1",
|
||||||
|
"engine.io": "~6.6.0",
|
||||||
|
"socket.io-adapter": "~2.5.2",
|
||||||
|
"socket.io-parser": "~4.2.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/socket.io-adapter": {
|
||||||
|
"version": "2.5.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.8.tgz",
|
||||||
|
"integrity": "sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "~4.4.1",
|
||||||
|
"ws": "~8.21.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/socket.io-parser": {
|
||||||
|
"version": "4.2.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.7.tgz",
|
||||||
|
"integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@socket.io/component-emitter": "~3.1.0",
|
||||||
|
"debug": "~4.4.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/socket.io/node_modules/accepts": {
|
||||||
|
"version": "1.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||||
|
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-types": "~2.1.34",
|
||||||
|
"negotiator": "0.6.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/socket.io/node_modules/mime-db": {
|
||||||
|
"version": "1.52.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||||
|
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/socket.io/node_modules/mime-types": {
|
||||||
|
"version": "2.1.35",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||||
|
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-db": "1.52.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/socket.io/node_modules/negotiator": {
|
||||||
|
"version": "0.6.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||||
|
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ssh2": {
|
"node_modules/ssh2": {
|
||||||
"version": "1.17.0",
|
"version": "1.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz",
|
||||||
@@ -1573,6 +1995,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "8.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
||||||
|
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/unpipe": {
|
"node_modules/unpipe": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||||
@@ -1597,11 +2025,26 @@
|
|||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/yallist": {
|
"node_modules/ws": {
|
||||||
"version": "4.0.0",
|
"version": "8.21.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
|
||||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
|
||||||
"license": "ISC"
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-jump-host",
|
"name": "t42-jump-host",
|
||||||
"version": "1.0.1",
|
"version": "1.3.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": [
|
||||||
{
|
{
|
||||||
@@ -19,12 +19,26 @@
|
|||||||
"test:integration": "NODE_ENV=test node --test --test-force-exit test/integration/*.test.js"
|
"test:integration": "NODE_ENV=test node --test --test-force-exit test/integration/*.test.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||||
"@simpleworkjs/conf": "^1.2.0",
|
"@simpleworkjs/conf": "^1.2.0",
|
||||||
|
"@simpleworkjs/app-stack": "^1.0.0",
|
||||||
|
"@simpleworkjs/ldap": "^1.0.0",
|
||||||
|
"@simpleworkjs/directory-schema": "^1.0.0",
|
||||||
|
"@simpleworkjs/oidc-client": "^1.0.0",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
|
"bootstrap": "^5.3.8",
|
||||||
|
"compression": "^1.8.1",
|
||||||
"ejs": "^3.1.10",
|
"ejs": "^3.1.10",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"ldapts": "^8.1.2",
|
"express-rate-limit": "^8.5.2",
|
||||||
|
"jq-repeat": "^2.2.0",
|
||||||
|
"jquery": "^4.0.0",
|
||||||
|
"ldapts": "^8.1.8",
|
||||||
"model-redis": "^1.6.0",
|
"model-redis": "^1.6.0",
|
||||||
"redis": "^4.7.0",
|
"moment": "^2.30.1",
|
||||||
|
"mustache": "^4.2.0",
|
||||||
|
"redis": "^6.1.0",
|
||||||
|
"socket.io": "^4.8.3",
|
||||||
"ssh2": "^1.16.0"
|
"ssh2": "^1.16.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
:root { --bg:#0f1115; --panel:#181b22; --line:#272b34; --fg:#e6e8ec; --mut:#8b93a1; --acc:#4f9cf9; --bad:#ff6b6b; --ok:#4ec9a5; }
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
body { margin:0; font:14px/1.5 -apple-system,Segoe UI,Roboto,sans-serif; background:var(--bg); color:var(--fg); }
|
|
||||||
a { color:var(--acc); text-decoration:none; } a:hover { text-decoration:underline; }
|
|
||||||
.nav { display:flex; align-items:center; gap:16px; padding:12px 20px; background:var(--panel); border-bottom:1px solid var(--line); }
|
|
||||||
.brand { font-weight:600; } .brand small { color:var(--mut); font-weight:400; }
|
|
||||||
.nav .spacer { flex:1; } .nav .who { color:var(--mut); }
|
|
||||||
.wrap { max-width:1100px; margin:0 auto; padding:24px 20px; }
|
|
||||||
h1 { font-size:20px; margin:0 0 16px; } h2 { font-size:15px; margin:24px 0 8px; }
|
|
||||||
.tiles { display:flex; gap:16px; flex-wrap:wrap; }
|
|
||||||
.tile { background:var(--panel); border:1px solid var(--line); border-radius:10px; padding:16px 20px; min-width:150px; }
|
|
||||||
.tile .n { display:block; font-size:28px; font-weight:600; } .tile .l { color:var(--mut); }
|
|
||||||
.cols { display:grid; grid-template-columns:2fr 1fr; gap:24px; }
|
|
||||||
@media (max-width:800px){ .cols { grid-template-columns:1fr; } }
|
|
||||||
table { width:100%; border-collapse:collapse; margin-top:8px; }
|
|
||||||
th,td { text-align:left; padding:7px 10px; border-bottom:1px solid var(--line); }
|
|
||||||
th { color:var(--mut); font-weight:500; font-size:12px; text-transform:uppercase; letter-spacing:.03em; }
|
|
||||||
td.r,th.r { text-align:right; }
|
|
||||||
tr.bad td { color:var(--bad); }
|
|
||||||
.muted { color:var(--mut); }
|
|
||||||
.more { font-size:12px; font-weight:400; margin-left:8px; }
|
|
||||||
.foot { max-width:1100px; margin:0 auto; padding:16px 20px; color:var(--mut); font-size:12px; }
|
|
||||||
.filters { display:flex; gap:8px; margin-bottom:12px; flex-wrap:wrap; }
|
|
||||||
.filters input,.filters select,.login input { background:#0c0e12; border:1px solid var(--line); color:var(--fg); border-radius:7px; padding:7px 10px; }
|
|
||||||
button { background:var(--acc); color:#fff; border:0; border-radius:7px; padding:8px 14px; cursor:pointer; font:inherit; }
|
|
||||||
button.link { background:none; color:var(--acc); padding:0; }
|
|
||||||
.inline { display:inline; } .pager { display:flex; gap:16px; align-items:center; margin-top:16px; color:var(--mut); }
|
|
||||||
.center { display:grid; place-items:center; min-height:100vh; }
|
|
||||||
.card.login { background:var(--panel); border:1px solid var(--line); border-radius:12px; padding:28px; width:320px; display:flex; flex-direction:column; gap:12px; }
|
|
||||||
.card.login h1 { margin:0 0 8px; } .card.login label { display:flex; flex-direction:column; gap:4px; font-size:13px; color:var(--mut); }
|
|
||||||
.card.login .hint { color:var(--mut); font-size:12px; margin:4px 0 0; } .err { color:var(--bad); margin:0; }
|
|
||||||
Executable
+24
@@ -0,0 +1,24 @@
|
|||||||
|
nav.navbar{
|
||||||
|
padding-left: 1em;
|
||||||
|
padding-right: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
#spa-shell {
|
||||||
|
margin-top: 4.5rem;
|
||||||
|
padding-bottom: 1em;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title{
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-required{
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||||
|
<!-- Background circle -->
|
||||||
|
<circle cx="50" cy="50" r="48" fill="#1a1a1a" stroke="#4a9eff" stroke-width="3"/>
|
||||||
|
|
||||||
|
<!-- Network nodes -->
|
||||||
|
<circle cx="30" cy="30" r="8" fill="#4a9eff"/>
|
||||||
|
<circle cx="70" cy="30" r="8" fill="#4a9eff"/>
|
||||||
|
<circle cx="50" cy="50" r="10" fill="#66b3ff"/>
|
||||||
|
<circle cx="30" cy="70" r="8" fill="#4a9eff"/>
|
||||||
|
<circle cx="70" cy="70" r="8" fill="#4a9eff"/>
|
||||||
|
|
||||||
|
<!-- Connection lines -->
|
||||||
|
<line x1="30" y1="30" x2="50" y2="50" stroke="#4a9eff" stroke-width="2"/>
|
||||||
|
<line x1="70" y1="30" x2="50" y2="50" stroke="#4a9eff" stroke-width="2"/>
|
||||||
|
<line x1="30" y1="70" x2="50" y2="50" stroke="#4a9eff" stroke-width="2"/>
|
||||||
|
<line x1="70" y1="70" x2="50" y2="50" stroke="#4a9eff" stroke-width="2"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 788 B |
@@ -0,0 +1,51 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" width="100%" height="100%">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="gold-grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#C59341" />
|
||||||
|
<stop offset="20%" stop-color="#E4B869" />
|
||||||
|
<stop offset="40%" stop-color="#FBF0B9" />
|
||||||
|
<stop offset="60%" stop-color="#DFB260" />
|
||||||
|
<stop offset="80%" stop-color="#BC8837" />
|
||||||
|
<stop offset="100%" stop-color="#A36F28" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<linearGradient id="text-grad" x1="0%" y1="100%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stop-color="#FFFFFF" />
|
||||||
|
<stop offset="40%" stop-color="#F5E3B5" />
|
||||||
|
<stop offset="70%" stop-color="#D4A343" />
|
||||||
|
<stop offset="100%" stop-color="#8A5A16" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<filter id="drop-shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feDropShadow dx="0" dy="8" stdDeviation="6" flood-color="#000000" flood-opacity="0.4"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<g filter="url(#drop-shadow)">
|
||||||
|
<g fill="url(#gold-grad)">
|
||||||
|
<path d="M 200,40
|
||||||
|
C 290,40 350,110 350,200
|
||||||
|
C 350,290 290,360 200,360
|
||||||
|
C 110,360 50,290 50,200
|
||||||
|
C 50,110 110,40 200,40 Z
|
||||||
|
M 200,75
|
||||||
|
C 130,75 88,130 88,200
|
||||||
|
C 88,270 130,325 200,325
|
||||||
|
C 270,325 312,270 312,200
|
||||||
|
C 312,130 270,75 200,75 Z"
|
||||||
|
fill-rule="evenodd" />
|
||||||
|
|
||||||
|
<path d="M 88,190 L 140,190 C 140,190 142,210 140,210 L 88,210 Z" />
|
||||||
|
|
||||||
|
<path d="M 260,190 L 312,190 C 312,190 310,210 260,210 Z" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<text x="200" y="222"
|
||||||
|
font-family="system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
|
||||||
|
font-size="78"
|
||||||
|
font-weight="900"
|
||||||
|
fill="url(#text-grad)"
|
||||||
|
text-anchor="middle"
|
||||||
|
letter-spacing="-2">42</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,21 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Jump-host page controllers. app.api / app.auth come from app-base.js (the
|
||||||
|
// shared client framework); this adds the jump-host data calls and the small
|
||||||
|
// render helpers each page uses.
|
||||||
|
|
||||||
|
app.jump = (function(app){
|
||||||
|
function metrics(cb){ app.api.get('metrics', cb); }
|
||||||
|
function sessions(cb){ app.api.get('sessions', cb); }
|
||||||
|
function audit(query, cb){
|
||||||
|
var qs = $.param(query || {});
|
||||||
|
app.api.get('audit' + (qs ? '?' + qs : ''), cb);
|
||||||
|
}
|
||||||
|
return {metrics: metrics, sessions: sessions, audit: audit};
|
||||||
|
})(app);
|
||||||
|
|
||||||
|
// Shared render helpers.
|
||||||
|
app.jump.fmtTime = function(ts){ return ts ? moment(Number(ts)).format('YYYY-MM-DD HH:mm:ss') : '—'; };
|
||||||
|
app.jump.esc = function(s){ return $('<div>').text(s == null ? '' : String(s)).html(); };
|
||||||
|
app.jump.result = function(e){ return e.success ? '<span class="badge bg-success">ok</span>'
|
||||||
|
: '<span class="badge bg-danger">' + app.jump.esc(e.failReason || 'fail') + '</span>'; };
|
||||||
@@ -0,0 +1,763 @@
|
|||||||
|
// Shared client framework for the theta42 apps.
|
||||||
|
//
|
||||||
|
// This file is byte-identical across sso-manager-node, proxy and jump-host —
|
||||||
|
// per-app behaviour comes from the server (the `ui` locals in views/top.ejs and
|
||||||
|
// the /api/user/me response), never from edits to this file. Edit all three
|
||||||
|
// copies together.
|
||||||
|
//
|
||||||
|
// jQuery 4 safe: no $.isFunction, no $.holdReady.
|
||||||
|
|
||||||
|
var app = {};
|
||||||
|
|
||||||
|
app.pubsub = (function(){
|
||||||
|
app.topics = {};
|
||||||
|
|
||||||
|
app.subscribe = function(topic, listener){
|
||||||
|
if(topic instanceof RegExp){
|
||||||
|
listener.match = topic;
|
||||||
|
topic = "__REGEX__";
|
||||||
|
}
|
||||||
|
|
||||||
|
// create the topic if not yet created
|
||||||
|
if(!app.topics[topic]) app.topics[topic] = [];
|
||||||
|
|
||||||
|
// add the listener
|
||||||
|
app.topics[topic].push(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.matchTopics = function(topic){
|
||||||
|
topic = topic || '';
|
||||||
|
var matches = [... app.topics[topic] ? app.topics[topic] : []];
|
||||||
|
|
||||||
|
if(!app.topics['__REGEX__']) return matches;
|
||||||
|
|
||||||
|
for(var listener of app.topics['__REGEX__']){
|
||||||
|
if(topic.match(listener.match)) matches.push(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
app.publish = function(topic, data){
|
||||||
|
|
||||||
|
// send the event to all listeners
|
||||||
|
app.matchTopics(topic).forEach(function(listener){
|
||||||
|
setTimeout(function(data, topic){
|
||||||
|
listener(data || {}, topic);
|
||||||
|
}, 0, data, topic);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return this;
|
||||||
|
})(app);
|
||||||
|
|
||||||
|
app.socket = (function(app){
|
||||||
|
// $.getScript('/socket.io/socket.io.js')
|
||||||
|
// <script type="text/javascript" src="/socket.io/socket.io.js"></script>
|
||||||
|
|
||||||
|
var socket;
|
||||||
|
$(document).ready(function(){
|
||||||
|
socket = io({
|
||||||
|
auth: {
|
||||||
|
token: app.auth.getToken()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// socket.emit('chat message', $('#m').val());
|
||||||
|
socket.on('P2PSub', function(msg){
|
||||||
|
msg.data.__noSocket = true;
|
||||||
|
app.publish(msg.topic, msg.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.subscribe(/./g, function(data, topic){
|
||||||
|
// console.log('local_pubs', data, topic)
|
||||||
|
if(data.__noSocket) return;
|
||||||
|
// console.log('local_pubs 2', data, topic)
|
||||||
|
|
||||||
|
socket.emit('P2PSub', { topic, data });
|
||||||
|
});
|
||||||
|
})
|
||||||
|
|
||||||
|
return socket;
|
||||||
|
|
||||||
|
})(app);
|
||||||
|
|
||||||
|
app.api = (function(app){
|
||||||
|
var baseURL = '/api/'
|
||||||
|
|
||||||
|
// post/put/delete are dual-mode: pass a callback for the node-style
|
||||||
|
// (error, data, status) form, or omit it to get a Promise that resolves
|
||||||
|
// with the parsed body and rejects with the error body. get/options return
|
||||||
|
// the jqXHR, which is itself thenable, so `await app.api.get(...)` works.
|
||||||
|
|
||||||
|
function body(method, url, data, callback){
|
||||||
|
if(typeof callback !== 'function'){
|
||||||
|
return new Promise(function(resolve, reject){
|
||||||
|
$.ajax({
|
||||||
|
type: method,
|
||||||
|
url: baseURL+url,
|
||||||
|
headers: { 'auth-token': app.auth.getToken() },
|
||||||
|
data: JSON.stringify(data),
|
||||||
|
contentType: 'application/json; charset=utf-8',
|
||||||
|
dataType: 'json',
|
||||||
|
}).done(resolve).fail(function(xhr){ reject(xhr.responseJSON || {}); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return $.ajax({
|
||||||
|
type: method,
|
||||||
|
url: baseURL+url,
|
||||||
|
headers:{
|
||||||
|
'auth-token': app.auth.getToken()
|
||||||
|
},
|
||||||
|
data: JSON.stringify(data),
|
||||||
|
contentType: "application/json; charset=utf-8",
|
||||||
|
dataType: "json",
|
||||||
|
complete: function(res, text){
|
||||||
|
callback(
|
||||||
|
text !== 'success' ? res.statusText : null,
|
||||||
|
JSON.parse(res.responseText),
|
||||||
|
res.status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function post(url, data, callback){
|
||||||
|
return body('POST', url, data, callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
function put(url, data, callback){
|
||||||
|
return body('PUT', url, data, callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called both as (url, callback) and — from formAJAX, which always passes
|
||||||
|
// the serialized form as the second argument — as (url, data, callback).
|
||||||
|
// No request body is sent either way.
|
||||||
|
function remove(url, data, callback){
|
||||||
|
if(typeof data === 'function'){
|
||||||
|
callback = data;
|
||||||
|
data = undefined;
|
||||||
|
}
|
||||||
|
if(typeof callback !== 'function'){
|
||||||
|
return new Promise(function(resolve, reject){
|
||||||
|
$.ajax({
|
||||||
|
type: 'DELETE',
|
||||||
|
url: baseURL+url,
|
||||||
|
headers: { 'auth-token': app.auth.getToken() },
|
||||||
|
contentType: 'application/json; charset=utf-8',
|
||||||
|
dataType: 'json',
|
||||||
|
}).done(resolve).fail(function(xhr){ reject(xhr.responseJSON || {}); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return $.ajax({
|
||||||
|
type: 'DELETE',
|
||||||
|
url: baseURL+url,
|
||||||
|
headers:{
|
||||||
|
'auth-token': app.auth.getToken()
|
||||||
|
},
|
||||||
|
contentType: "application/json; charset=utf-8",
|
||||||
|
dataType: "json",
|
||||||
|
complete: function(res, text){
|
||||||
|
callback(
|
||||||
|
text !== 'success' ? res.statusText : null,
|
||||||
|
JSON.parse(res.responseText),
|
||||||
|
res.status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function options(url, callback){
|
||||||
|
return $.ajax({
|
||||||
|
type: 'OPTIONS',
|
||||||
|
url: baseURL+url,
|
||||||
|
headers:{
|
||||||
|
'auth-token': app.auth.getToken()
|
||||||
|
},
|
||||||
|
contentType: "application/json; charset=utf-8",
|
||||||
|
dataType: "json",
|
||||||
|
complete: function(res, text){
|
||||||
|
callback ? callback(
|
||||||
|
text !== 'success' ? res.statusText : null,
|
||||||
|
JSON.parse(res.responseText),
|
||||||
|
res.status
|
||||||
|
) : function(){}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function get(url, callback){
|
||||||
|
return $.ajax({
|
||||||
|
type: 'GET',
|
||||||
|
url: baseURL+url,
|
||||||
|
headers:{
|
||||||
|
'auth-token': app.auth.getToken()
|
||||||
|
},
|
||||||
|
contentType: "application/json; charset=utf-8",
|
||||||
|
dataType: "json",
|
||||||
|
complete: function(res, text){
|
||||||
|
callback ? callback(
|
||||||
|
text !== 'success' ? res.statusText : null,
|
||||||
|
JSON.parse(res.responseText),
|
||||||
|
res.status
|
||||||
|
) : function(){}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {post: post, get: get, put: put, delete: remove, options: options,}
|
||||||
|
})(app)
|
||||||
|
|
||||||
|
app.auth = (function(app){
|
||||||
|
// One in-flight/cached GET /api/user/me per page load. Every gating
|
||||||
|
// decision (nav items, per-view forceLogin, group-required elements) reads
|
||||||
|
// this same promise instead of re-fetching.
|
||||||
|
var userPromise = null;
|
||||||
|
|
||||||
|
function setToken(token){
|
||||||
|
localStorage.setItem('APIToken', token);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToken(){
|
||||||
|
return localStorage.getItem('APIToken');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getUser(){
|
||||||
|
try{
|
||||||
|
return await app.api.get('user/me');
|
||||||
|
}catch(error){
|
||||||
|
if(error && error.status === 401) return null;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cached current user, or false when there's no token at all. Callers that
|
||||||
|
// need a fresh copy (after a login or a profile change) pass force.
|
||||||
|
function loadUser(force){
|
||||||
|
if(force || !userPromise){
|
||||||
|
userPromise = getToken() ? getUser() : Promise.resolve(null);
|
||||||
|
userPromise = userPromise.then(function(user){
|
||||||
|
app.auth.user = app.auth.perms = user || null;
|
||||||
|
return user;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return userPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The apps report group membership two ways: sso-manager-node returns LDAP
|
||||||
|
// DNs in `memberOf`, the OIDC clients return plain CNs in `groups`. Both
|
||||||
|
// normalise to a list of CNs. `isAdmin` (the clients' effective-rights flag)
|
||||||
|
// is exposed as a synthetic `admin` group so one gating model covers both.
|
||||||
|
function groupCNs(user){
|
||||||
|
var raw = (user && (user.memberOf || user.groups)) || [];
|
||||||
|
if(!Array.isArray(raw)) raw = [raw];
|
||||||
|
var names = raw.map(function(group){
|
||||||
|
return String(group).split(',')[0].replace(/^cn=/i, '');
|
||||||
|
});
|
||||||
|
if(user && user.isAdmin && names.indexOf('admin') === -1) names.push('admin');
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function memberOf(groupNameToFind, user){
|
||||||
|
user = user || await loadUser();
|
||||||
|
if(!user) return false;
|
||||||
|
groupNameToFind = Array.isArray(groupNameToFind) ? groupNameToFind : [groupNameToFind];
|
||||||
|
|
||||||
|
return groupCNs(user).some(function(group){
|
||||||
|
return groupNameToFind.includes(group);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// True when the logged-in user is a global admin (per user/me). Sync — only
|
||||||
|
// meaningful once isLoggedIn/forceLogin has resolved.
|
||||||
|
function isAdmin(){
|
||||||
|
return !!(app.auth.perms && app.auth.perms.isAdmin);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dual-mode: returns a Promise resolving to the user (or false), and calls
|
||||||
|
// an optional node-style callback with the same result.
|
||||||
|
function isLoggedIn(callback){
|
||||||
|
var promise = loadUser().then(function(user){
|
||||||
|
return user || false;
|
||||||
|
});
|
||||||
|
|
||||||
|
if(typeof callback === 'function'){
|
||||||
|
promise.then(function(user){
|
||||||
|
callback(null, user);
|
||||||
|
}, function(error){
|
||||||
|
callback(error, false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function logIn(args, callback){
|
||||||
|
app.api.post('auth/login', args, function(error, data){
|
||||||
|
if(data.login){
|
||||||
|
setToken(data.token);
|
||||||
|
}
|
||||||
|
loadUser(true);
|
||||||
|
callback(error, !!data.token);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clears the session only — the caller decides where to go next (the nav's
|
||||||
|
// Log Out button uses ui.logoutRedirect).
|
||||||
|
function logOut(callback){
|
||||||
|
localStorage.removeItem('APIToken');
|
||||||
|
userPromise = null;
|
||||||
|
app.auth.user = app.auth.perms = null;
|
||||||
|
if(typeof callback === 'function') callback();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Constrain a redirect target to a same-origin absolute path. Rejects
|
||||||
|
// absolute URLs (open redirect), protocol-relative "//host" and "/\host",
|
||||||
|
// and non-path schemes like "javascript:" (XSS). Falls back to "/".
|
||||||
|
function safeInternalPath(path){
|
||||||
|
if(typeof path !== 'string' || path.charAt(0) !== '/'
|
||||||
|
|| path.charAt(1) === '/' || path.charAt(1) === '\\'){
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consume an app token handed back by the OIDC callback via the URL
|
||||||
|
// fragment (#token=…&redirect=…). Stores it, strips the fragment, and
|
||||||
|
// forwards to the intended page. Returns true if a token was consumed.
|
||||||
|
function consumeTokenFragment(){
|
||||||
|
if(!location.hash) return false;
|
||||||
|
var params = new URLSearchParams(location.hash.replace(/^#/, ''));
|
||||||
|
var token = params.get('token');
|
||||||
|
if(!token) return false;
|
||||||
|
|
||||||
|
setToken(token);
|
||||||
|
// redirect comes from the URL fragment (attacker-controllable); only
|
||||||
|
// allow a same-origin path so it can't become an open redirect / XSS.
|
||||||
|
var redirect = safeInternalPath(params.get('redirect') || '/');
|
||||||
|
// Drop the token from the address bar before navigating on.
|
||||||
|
history.replaceState(null, '', location.pathname + location.search);
|
||||||
|
window.location.href = redirect;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page-level gate. jQuery 4 removed $.holdReady, so an unauthenticated or
|
||||||
|
// unauthorised user is kept off the page by a redirect / an error panel
|
||||||
|
// rather than by pausing document ready.
|
||||||
|
//
|
||||||
|
// `requiredGroups` is a group CN or an OR-list of them; the synthetic
|
||||||
|
// `admin` group covers the OIDC clients' isAdmin flag.
|
||||||
|
async function forceLogin(requiredGroups){
|
||||||
|
var user = await loadUser();
|
||||||
|
|
||||||
|
if(!user){
|
||||||
|
logOut(function(){});
|
||||||
|
location.replace('/login?redirect=' + encodeURIComponent(
|
||||||
|
location.pathname + location.search
|
||||||
|
));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(user.onboardingRequired && location.pathname !== '/onboarding'){
|
||||||
|
location.replace('/onboarding');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(requiredGroups && !await memberOf(requiredGroups, user)){
|
||||||
|
app.util.actionMessage(
|
||||||
|
`<h1>
|
||||||
|
<i class="fa-solid fa-triangle-exclamation"></i>
|
||||||
|
<b>You do not have permission to be here.</b>
|
||||||
|
<i class="fa-solid fa-triangle-exclamation"></i>
|
||||||
|
</h1>`,
|
||||||
|
$('#spa-shell'),
|
||||||
|
'danger',
|
||||||
|
);
|
||||||
|
throw new Error("User does not have permission");
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Where to go after a successful login: the ?redirect= query param, or the
|
||||||
|
// legacy /login/<path> suffix form, constrained to a same-origin path. The
|
||||||
|
// suffix form keeps its query string — /login/oauth/authorize?client_id=…
|
||||||
|
// is how the OIDC provider sends an unauthenticated user through login.
|
||||||
|
function logInRedirect(){
|
||||||
|
var params = new URLSearchParams(location.search);
|
||||||
|
var target = params.get('redirect')
|
||||||
|
|| location.href.replace(location.origin + '/login', '')
|
||||||
|
|| '/';
|
||||||
|
window.location.href = safeInternalPath(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getToken: getToken,
|
||||||
|
setToken: setToken,
|
||||||
|
getUser: getUser,
|
||||||
|
loadUser: loadUser,
|
||||||
|
groupCNs: groupCNs,
|
||||||
|
memberOf: memberOf,
|
||||||
|
isAdmin: isAdmin,
|
||||||
|
isLoggedIn: isLoggedIn,
|
||||||
|
safeInternalPath: safeInternalPath,
|
||||||
|
consumeTokenFragment: consumeTokenFragment,
|
||||||
|
user: null,
|
||||||
|
perms: null,
|
||||||
|
logIn: logIn,
|
||||||
|
logOut: logOut,
|
||||||
|
forceLogin,
|
||||||
|
logInRedirect,
|
||||||
|
}
|
||||||
|
|
||||||
|
})(app);
|
||||||
|
|
||||||
|
// Back-compat alias for views that awaited the cached user directly.
|
||||||
|
Object.defineProperty(app.auth, 'asyncUser', {
|
||||||
|
get: function(){ return app.auth.loadUser(); },
|
||||||
|
});
|
||||||
|
|
||||||
|
app.user = (function(app){
|
||||||
|
function list(callback){
|
||||||
|
app.api.get('user/?detail=true', function(error, data){
|
||||||
|
callback(error, data);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function add(args, callback){
|
||||||
|
app.api.post('user/', args, function(error, data){
|
||||||
|
callback(error, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(args, callback){
|
||||||
|
app.api.delete('user/'+ args.username, function(error, data){
|
||||||
|
callback(error, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function changePassword(args, callback){
|
||||||
|
app.api.put('users/'+ arg.username || '', args, function(error, data){
|
||||||
|
callback(error, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {list, remove};
|
||||||
|
|
||||||
|
})(app);
|
||||||
|
|
||||||
|
// Local (app-managed) permissions and groups. Only the OIDC-client apps serve
|
||||||
|
// these endpoints; the calls are inert elsewhere.
|
||||||
|
app.permission = (function(app){
|
||||||
|
function list(callback){
|
||||||
|
app.api.get('permission/', function(error, data){
|
||||||
|
callback(error, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function subjects(callback){
|
||||||
|
app.api.get('permission/subjects', function(error, data){
|
||||||
|
callback(error, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function add(args, callback){
|
||||||
|
app.api.post('permission/', args, function(error, data){
|
||||||
|
callback(error, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(id, callback){
|
||||||
|
app.api.delete('permission/' + encodeURIComponent(id), function(error, data){
|
||||||
|
callback(error, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {list, subjects, add, remove};
|
||||||
|
|
||||||
|
})(app);
|
||||||
|
|
||||||
|
app.group = (function(app){
|
||||||
|
function list(callback){
|
||||||
|
app.api.get('group/', function(error, data){
|
||||||
|
callback(error, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function add(args, callback){
|
||||||
|
app.api.post('group/', args, function(error, data){
|
||||||
|
callback(error, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(name, callback){
|
||||||
|
app.api.delete('group/' + encodeURIComponent(name), function(error, data){
|
||||||
|
callback(error, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMember(name, username, callback){
|
||||||
|
app.api.post('group/' + encodeURIComponent(name) + '/members', {username}, function(error, data){
|
||||||
|
callback(error, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeMember(name, username, callback){
|
||||||
|
app.api.delete('group/' + encodeURIComponent(name) + '/members/' + encodeURIComponent(username), function(error, data){
|
||||||
|
callback(error, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {list, add, remove, addMember, removeMember};
|
||||||
|
|
||||||
|
})(app);
|
||||||
|
|
||||||
|
app.util = (function(app){
|
||||||
|
|
||||||
|
function getUrlParameter(name){
|
||||||
|
name = name.replace(/[\[]/, '\\[').replace(/[\]]/, '\\]');
|
||||||
|
var regex = new RegExp('[\\?&]' + name + '=([^&#]*)');
|
||||||
|
var results = regex.exec(location.search);
|
||||||
|
return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' '));
|
||||||
|
};
|
||||||
|
|
||||||
|
function actionMessage(message, $targetPassed, type, callback){
|
||||||
|
message = message || '';
|
||||||
|
|
||||||
|
let $target = $targetPassed.closest('div.card').find('.actionMessage');
|
||||||
|
if(!$target.length) $target = $($targetPassed.find('.actionMessage')[0]);
|
||||||
|
|
||||||
|
type = type || 'info';
|
||||||
|
callback = callback || function(){};
|
||||||
|
|
||||||
|
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() {
|
||||||
|
var obj = {};
|
||||||
|
|
||||||
|
// Get the form values and work over them
|
||||||
|
for (let {name, value} of $(this).serializeArray()) {
|
||||||
|
console.log(name, value)
|
||||||
|
if (obj[name] === undefined) {
|
||||||
|
if (!value
|
||||||
|
&& !$(this).parent().find(`[name="${name}"]`).attr('value')
|
||||||
|
// Keep empty <textarea>s so a cleared field is submitted (and
|
||||||
|
// can reset a list, e.g. the per-host IP/header controls).
|
||||||
|
&& !$(this).filter(`textarea[name="${name}"]`).length
|
||||||
|
){
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
obj[name] = value;
|
||||||
|
|
||||||
|
let type = $(this).parent().find(`[name="${name}"]`).attr('type');
|
||||||
|
if (['number', 'range'].includes(type)) {
|
||||||
|
obj[name] = Number(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['radio'].includes(type) && ['true', 'false'].includes(value)) {
|
||||||
|
obj[name] = value == 'true' ? true : false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!(obj[name] instanceof Array)) {
|
||||||
|
obj[name] = [obj[name]];
|
||||||
|
}
|
||||||
|
obj[name].push(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
return obj;
|
||||||
|
};
|
||||||
|
|
||||||
|
function downloadFile(filename, text){
|
||||||
|
// https://stackoverflow.com/a/18197341
|
||||||
|
|
||||||
|
var element = document.createElement('a');
|
||||||
|
element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(text));
|
||||||
|
element.setAttribute('download', filename);
|
||||||
|
|
||||||
|
element.style.display = 'none';
|
||||||
|
document.body.appendChild(element);
|
||||||
|
|
||||||
|
element.click();
|
||||||
|
|
||||||
|
document.body.removeChild(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
downloadFile: downloadFile,
|
||||||
|
getUrlParameter: getUrlParameter,
|
||||||
|
actionMessage: actionMessage,
|
||||||
|
actionConfirm,
|
||||||
|
}
|
||||||
|
})(app);
|
||||||
|
|
||||||
|
// Reveal every .group-required-<cn> element the current user's groups entitle
|
||||||
|
// them to. Elements carrying .group-required start hidden (styles.css), so a
|
||||||
|
// user who is in no groups — or who isn't logged in — simply never sees them.
|
||||||
|
app.auth.applyGroupVisibility = function(user){
|
||||||
|
var groups = app.auth.groupCNs(user);
|
||||||
|
if(!groups.length) return;
|
||||||
|
|
||||||
|
var style = document.getElementById('group-required-rules');
|
||||||
|
if(!style){
|
||||||
|
style = document.createElement('style');
|
||||||
|
style.id = 'group-required-rules';
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
for(var group of groups){
|
||||||
|
try{
|
||||||
|
style.sheet.insertRule(
|
||||||
|
`.group-required-${CSS.escape(group)} { display: revert !important; }`,
|
||||||
|
style.sheet.cssRules.length
|
||||||
|
);
|
||||||
|
}catch(error){
|
||||||
|
// A group whose CN isn't a usable CSS identifier just gates nothing.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$( document ).ready(async function(){
|
||||||
|
|
||||||
|
// Show content the user's groups entitle them to.
|
||||||
|
app.auth.applyGroupVisibility(await app.auth.loadUser());
|
||||||
|
|
||||||
|
$('div.row').fadeIn('slow'); //show the page
|
||||||
|
|
||||||
|
//panel button's
|
||||||
|
$('.fa-arrows-v').click(function(){
|
||||||
|
$(this).closest('.card').find('.card-body').slideToggle('fast');
|
||||||
|
});
|
||||||
|
|
||||||
|
$('.fa-circle-minus').click(function(){
|
||||||
|
let $body = $(this).closest('.card').find('.card-body');
|
||||||
|
if($body.hasClass('d-none')){
|
||||||
|
$body.removeClass("d-none").removeClass('d-md-block');
|
||||||
|
if($body.is(":visible")) $body.hide();
|
||||||
|
}
|
||||||
|
$body.slideToggle('fast');
|
||||||
|
});
|
||||||
|
|
||||||
|
$('.fa-circle-xmark').click(function(){
|
||||||
|
$(this).closest('.card').slideUp('fast');
|
||||||
|
});
|
||||||
|
|
||||||
|
$('.actionMessage').on('click', 'button.action-close', function(event){
|
||||||
|
app.util.actionMessage(null, $(this));
|
||||||
|
});
|
||||||
|
|
||||||
|
setInterval(()=>{
|
||||||
|
$('.momentFromNow').each((idx, el)=>{
|
||||||
|
var $el = $(el);
|
||||||
|
try{
|
||||||
|
$el.html(moment($(el).data('date')).fromNow());
|
||||||
|
}catch{}
|
||||||
|
})
|
||||||
|
}, 30000,);
|
||||||
|
});
|
||||||
|
|
||||||
|
(function($){
|
||||||
|
$.fn.scrollTo = function(){
|
||||||
|
const yOffset = Number($('#spa-shell').css('margin-top').replace('px', ''));
|
||||||
|
const y = this[0].getBoundingClientRect().top + window.scrollY - yOffset;
|
||||||
|
|
||||||
|
console.log('y', y)
|
||||||
|
window.scrollTo({top: y, behavior: 'smooth'});
|
||||||
|
};
|
||||||
|
|
||||||
|
})(jQuery);
|
||||||
|
|
||||||
|
//ajax form submit
|
||||||
|
function formAJAX(btn){
|
||||||
|
event.preventDefault(btn); // avoid to execute the actual submit of the form.
|
||||||
|
var $form = $(btn || event.target).closest('[action]'); // gets the 'form' parent
|
||||||
|
var formData = $form.find('[name]').serializeObject(); // builds query formDataing
|
||||||
|
var method = ($form.attr('method') || 'post').toLowerCase();
|
||||||
|
|
||||||
|
if($form.validate && !$form.validate()){
|
||||||
|
app.util.actionMessage('Please fix the form errors.', $form, 'danger')
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
app.util.actionMessage(
|
||||||
|
`<div class="spinner-border" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>`,
|
||||||
|
$form,
|
||||||
|
'info'
|
||||||
|
);
|
||||||
|
|
||||||
|
app.api[method]($form.attr('action'), formData, function(error, data){
|
||||||
|
app.util.actionMessage(data.message, $form, error ? 'danger' : 'success'); //re-populate table
|
||||||
|
$form.validateClear();
|
||||||
|
if(!error){
|
||||||
|
$form.trigger("reset");
|
||||||
|
eval($form.attr('evalAJAX')); //gets JS to run after completion
|
||||||
|
}else{
|
||||||
|
console.log('formAJAX res error', error, data)
|
||||||
|
if(data && data.name === 'ObjectValidateError'){
|
||||||
|
app.util.actionMessage('Please fix the form errors', $form, 'danger'); //re-populate table
|
||||||
|
}
|
||||||
|
if(data && data.keys){
|
||||||
|
console.log('form key errors', data.keys)
|
||||||
|
for(let keyError of data.keys){
|
||||||
|
$form.find(`[name=${keyError.key}]`).validateMessage(keyError.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
Executable
+201
@@ -0,0 +1,201 @@
|
|||||||
|
( 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)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
+8
-29
@@ -1,36 +1,15 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
// Auditing + metrics API (admin-gated by middleware/auth in app.js).
|
const router = require('express').Router();
|
||||||
|
const middleware = require('../middleware/auth');
|
||||||
|
|
||||||
const express = require('express');
|
// Authentication (local login + OIDC handshake). Unauthenticated by design.
|
||||||
const audit = require('../models/audit_event');
|
router.use('/auth', require('../models').authRouter);
|
||||||
const metrics = require('../models/metrics');
|
|
||||||
const registry = require('../services/session_registry');
|
|
||||||
|
|
||||||
const router = express.Router();
|
// Who am I — needs a valid session but no admin gate (drives the login state).
|
||||||
|
router.use('/user', middleware.auth, require('./user'));
|
||||||
|
|
||||||
router.get('/sessions', (req, res) => {
|
// Jump-host data — admin only (audit log, active sessions, metrics).
|
||||||
res.json({ results: registry.list(), active: registry.count() });
|
router.use('/', middleware.auth, middleware.requireAdmin, require('./jump'));
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/audit', async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
const page = Math.max(0, parseInt(req.query.page, 10) || 0);
|
|
||||||
const data = await audit.list({
|
|
||||||
page,
|
|
||||||
pageSize: Math.min(200, parseInt(req.query.pageSize, 10) || 50),
|
|
||||||
uid: req.query.uid || undefined,
|
|
||||||
target: req.query.target || undefined,
|
|
||||||
status: req.query.status || undefined,
|
|
||||||
});
|
|
||||||
res.json(data);
|
|
||||||
} catch (err) { next(err); }
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/metrics', async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
res.json({ ...(await metrics.summary()), active: registry.count() });
|
|
||||||
} catch (err) { next(err); }
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
// Web login: LDAP bind as the user, require an adminGroups membership, mint a
|
|
||||||
// session cookie. (OIDC against the SSO is a follow-up.)
|
|
||||||
|
|
||||||
const express = require('express');
|
|
||||||
const conf = require('@simpleworkjs/conf');
|
|
||||||
const userLdap = require('../models/user_ldap');
|
|
||||||
const Session = require('../models/session');
|
|
||||||
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
router.get('/login', (req, res) => {
|
|
||||||
res.render('login', { error: null, name: conf.name });
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/login', express.urlencoded({ extended: false }), async (req, res) => {
|
|
||||||
const { uid, password } = req.body || {};
|
|
||||||
const fail = (msg) => res.status(401).render('login', { error: msg, name: conf.name });
|
|
||||||
try {
|
|
||||||
const user = await userLdap.getUser(uid);
|
|
||||||
if (!user) return fail('Invalid credentials.');
|
|
||||||
const ok = await userLdap.checkPassword(user.dn, password);
|
|
||||||
if (!ok) return fail('Invalid credentials.');
|
|
||||||
const groups = await userLdap.getGroups(user.dn);
|
|
||||||
const admin = (conf.auth.adminGroups || []).some((g) => groups.includes(g));
|
|
||||||
if (!admin) return fail('Your account is not a jump-host admin.');
|
|
||||||
|
|
||||||
const session = await Session.start(user.uid, groups, conf.auth.sessionTTLms);
|
|
||||||
res.setHeader('Set-Cookie', `jump_session=${session.token}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${Math.floor(conf.auth.sessionTTLms / 1000)}`);
|
|
||||||
res.redirect('/');
|
|
||||||
} catch (err) {
|
|
||||||
return fail('Login failed.');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/logout', (req, res) => {
|
|
||||||
res.setHeader('Set-Cookie', 'jump_session=; HttpOnly; Path=/; Max-Age=0');
|
|
||||||
res.redirect('/login');
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
const express = require('express');
|
|
||||||
const audit = require('../models/audit_event');
|
|
||||||
const metrics = require('../models/metrics');
|
|
||||||
const registry = require('../services/session_registry');
|
|
||||||
const buildInfo = require('../models/build_info');
|
|
||||||
const conf = require('@simpleworkjs/conf');
|
|
||||||
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
router.get('/', async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
const [m, recent] = await Promise.all([
|
|
||||||
metrics.summary(),
|
|
||||||
audit.list({ page: 0, pageSize: 10 }),
|
|
||||||
]);
|
|
||||||
res.render('dashboard', {
|
|
||||||
name: conf.name, buildInfo, user: req.jumpUser,
|
|
||||||
metrics: { ...m, active: registry.count() },
|
|
||||||
active: registry.list(),
|
|
||||||
recent: recent.results,
|
|
||||||
});
|
|
||||||
} catch (err) { next(err); }
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/sessions', (req, res) => {
|
|
||||||
res.render('sessions', { name: conf.name, buildInfo, user: req.jumpUser, active: registry.list() });
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/audit', async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
const page = Math.max(0, parseInt(req.query.page, 10) || 0);
|
|
||||||
const data = await audit.list({ page, pageSize: 50, uid: req.query.uid, target: req.query.target, status: req.query.status });
|
|
||||||
res.render('audit', { name: conf.name, buildInfo, user: req.jumpUser, data, query: req.query });
|
|
||||||
} catch (err) { next(err); }
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Jump-host data API: active sessions, the audit log, and metrics. Admin-gated
|
||||||
|
// (mounted behind middleware.auth + requireAdmin in routes/api.js).
|
||||||
|
|
||||||
|
const router = require('express').Router();
|
||||||
|
const audit = require('../models/audit_event');
|
||||||
|
const metrics = require('../models/metrics');
|
||||||
|
const registry = require('../services/session_registry');
|
||||||
|
|
||||||
|
router.get('/sessions', (req, res) => {
|
||||||
|
res.json({results: registry.list(), active: registry.count()});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/audit', async (req, res, next) => {
|
||||||
|
try{
|
||||||
|
const page = Math.max(0, parseInt(req.query.page, 10) || 0);
|
||||||
|
const data = await audit.list({
|
||||||
|
page,
|
||||||
|
pageSize: Math.min(200, parseInt(req.query.pageSize, 10) || 50),
|
||||||
|
uid: req.query.uid || undefined,
|
||||||
|
target: req.query.target || undefined,
|
||||||
|
status: req.query.status || undefined,
|
||||||
|
});
|
||||||
|
res.json(data);
|
||||||
|
}catch(error){ next(error); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/metrics', async (req, res, next) => {
|
||||||
|
try{
|
||||||
|
res.json({...(await metrics.summary()), active: registry.count()});
|
||||||
|
}catch(error){ next(error); }
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const express = require('express');
|
||||||
|
const router = require('express').Router();
|
||||||
|
const conf = require('@simpleworkjs/conf');
|
||||||
|
const buildInfo = require('../utils/build_info');
|
||||||
|
const registry = require('../services/session_registry');
|
||||||
|
const { safeInternalPath } = require('@simpleworkjs/oidc-client');
|
||||||
|
const { mountStaticModules } = require('@simpleworkjs/app-stack');
|
||||||
|
|
||||||
|
const values = {
|
||||||
|
title: conf.environment !== 'production' ? 'dev' : '',
|
||||||
|
titleIcon: conf.environment !== 'production' ? '<i class="fa-brands fa-dev"></i>' : '',
|
||||||
|
name: conf.name,
|
||||||
|
logo: conf.logo,
|
||||||
|
...buildInfo,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Serve front-end vendor libraries straight from node_modules (same convention
|
||||||
|
// as the sibling apps), and the app's own JS/CSS/img from public/.
|
||||||
|
mountStaticModules(router, {
|
||||||
|
root: path.join(__dirname, '..'),
|
||||||
|
deps: ['bootstrap', 'mustache', 'jquery', '@fortawesome', 'moment', 'jq-repeat'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Liveness probe — no auth.
|
||||||
|
router.get('/health', (req, res) => {
|
||||||
|
res.json({status: 'ok', activeSessions: registry.count(), buildVersion: buildInfo.buildVersion, buildHash: buildInfo.buildHash});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/', (req, res) => res.redirect(302, '/dashboard'));
|
||||||
|
|
||||||
|
// Page shells. The client framework (app-base.js + app.js) loads data via the
|
||||||
|
// authenticated /api/* endpoints and gates the UI on /api/user/me, so these
|
||||||
|
// render unauthenticated (like the sibling apps) and the client redirects to
|
||||||
|
// /login when there's no valid session.
|
||||||
|
router.get('/login', (req, res) => res.render('login', {
|
||||||
|
...values,
|
||||||
|
redirect: safeInternalPath(req.query.redirect || '/'),
|
||||||
|
oidcEnabled: !!(conf.oidc && conf.oidc.enabled),
|
||||||
|
}));
|
||||||
|
router.get('/dashboard', (req, res) => res.render('dashboard', {...values}));
|
||||||
|
router.get('/sessions', (req, res) => res.render('sessions', {...values}));
|
||||||
|
router.get('/audit', (req, res) => res.render('audit', {...values}));
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Minimal user endpoint the client framework needs: GET /api/user/me tells the
|
||||||
|
// browser who it is and whether it's an admin (drives login state + nav).
|
||||||
|
|
||||||
|
const router = require('express').Router();
|
||||||
|
const { isAdmin } = require('../middleware/auth');
|
||||||
|
|
||||||
|
router.get('/me', (req, res) => {
|
||||||
|
res.json({
|
||||||
|
username: req.user && req.user.username,
|
||||||
|
groups: req.groups || [],
|
||||||
|
isAdmin: isAdmin(req),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -53,3 +53,17 @@ test('caches per uid', async () => {
|
|||||||
await accessibleHosts(user, { fetchImpl, ldap });
|
await accessibleHosts(user, { fetchImpl, ldap });
|
||||||
assert.strictEqual(calls, 1);
|
assert.strictEqual(calls, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a bare-array response (envelope drift) is treated as a failed group, not silently []', async () => {
|
||||||
|
clearCache();
|
||||||
|
const user = { uid: 'dave', dn: 'd' };
|
||||||
|
// drift shape: a bare array instead of { results: [...] }. The shared client
|
||||||
|
// throws DirectoryEnvelopeViolation; access.js must catch + continue, so a
|
||||||
|
// good group alongside still yields its hosts.
|
||||||
|
const fetchImpl = async (url) => {
|
||||||
|
if (url.includes('drift')) return { ok: true, json: async () => [{ id: '7', kind: 'host' }] };
|
||||||
|
return { ok: true, json: async () => ({ results: [{ id: '8', kind: 'host' }] }) };
|
||||||
|
};
|
||||||
|
const hosts = await accessibleHosts(user, { fetchImpl, ldap: stubLdap(['drift_access', 'good_access']) });
|
||||||
|
assert.deepStrictEqual(hosts.map((h) => h.id), ['8']);
|
||||||
|
});
|
||||||
|
|||||||
+11
-8
@@ -16,20 +16,23 @@
|
|||||||
// unit testing.
|
// unit testing.
|
||||||
|
|
||||||
const conf = require('@simpleworkjs/conf');
|
const conf = require('@simpleworkjs/conf');
|
||||||
|
const { createDirectoryClient } = require('@simpleworkjs/directory-schema');
|
||||||
const userLdap = require('../models/user_ldap');
|
const userLdap = require('../models/user_ldap');
|
||||||
|
|
||||||
const CACHE_TTL_MS = 30 * 1000;
|
const CACHE_TTL_MS = 30 * 1000;
|
||||||
const cache = new Map(); // uid -> {at, hosts}
|
const cache = new Map(); // uid -> {at, hosts}
|
||||||
|
|
||||||
async function fetchResourcesByGroup(group, { fetchImpl = fetch } = {}) {
|
// Build a directory client bound to conf.sso. fetchImpl is injectable so the
|
||||||
|
// unit tests can stub the transport; the shared client validates the
|
||||||
|
// `{ results }` envelope on every call (turns the old bare-array drift into a
|
||||||
|
// thrown error instead of a silent `[]`).
|
||||||
|
function directoryClient({ fetchImpl = fetch } = {}) {
|
||||||
const sso = conf.sso || {};
|
const sso = conf.sso || {};
|
||||||
const url = `${sso.url}/api/discovery/resources?group=${encodeURIComponent(group)}`;
|
return createDirectoryClient({ baseUrl: sso.url, apiToken: sso.apiToken, fetch: fetchImpl });
|
||||||
const res = await fetchImpl(url, {
|
}
|
||||||
headers: { Authorization: `Bearer ${sso.apiToken}` },
|
|
||||||
});
|
async function fetchResourcesByGroup(group, { fetchImpl = fetch } = {}) {
|
||||||
if (!res.ok) throw new Error(`directory query failed (${res.status}) for group ${group}`);
|
return directoryClient({ fetchImpl }).getResourcesByGroup(group);
|
||||||
const data = await res.json();
|
|
||||||
return (data && data.results) || [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function accessibleHosts(user, { fetchImpl = fetch, ldap = userLdap } = {}) {
|
async function accessibleHosts(user, { fetchImpl = fetch, ldap = userLdap } = {}) {
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Unified build-info shape ({ buildVersion, buildHash, buildYear }) via the
|
||||||
|
// shared @simpleworkjs/app-stack. Previously this lived in models/build_info.js
|
||||||
|
// and exported { commit, version }; the shape is now aligned with sso + proxy.
|
||||||
|
//
|
||||||
|
// The baked commit file lives at the jump-host repo root (../../ from here in
|
||||||
|
// utils/), matching the Dockerfile gitinfo stage. cwd is utils/ for the
|
||||||
|
// bare-metal git fallback.
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const { createBuildInfo } = require('@simpleworkjs/app-stack');
|
||||||
|
const { version } = require('../package.json');
|
||||||
|
|
||||||
|
module.exports = createBuildInfo({
|
||||||
|
version,
|
||||||
|
buildCommitPath: path.join(__dirname, '../../.build_commit'),
|
||||||
|
cwd: __dirname,
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Per-app values for the shared UI shell (views/top.ejs + views/bottom.ejs).
|
||||||
|
//
|
||||||
|
// Those two partials are byte-identical across sso-manager-node, proxy and
|
||||||
|
// jump-host — everything that differs between the apps lives here and is
|
||||||
|
// exposed to every render as `ui` via app.locals (see app.js). Keep the key set
|
||||||
|
// in sync across the three apps; a missing key is a render-time ReferenceError,
|
||||||
|
// not a silent fallback.
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
// --- footer -------------------------------------------------------------
|
||||||
|
repoUrl: 'https://github.com/theta42/jump-host',
|
||||||
|
licenseUrl: 'https://github.com/theta42/jump-host/blob/master/LICENSE',
|
||||||
|
// No in-app /docs route here — point at the published docs site.
|
||||||
|
docsUrl: 'https://theta42.github.io/jump-host/',
|
||||||
|
docsExternal: true,
|
||||||
|
// Only sso-manager-node serves a Terms of Service page; null hides the link.
|
||||||
|
tosUrl: null,
|
||||||
|
|
||||||
|
// --- header / nav -------------------------------------------------------
|
||||||
|
faviconUrl: '/static/favicon.svg',
|
||||||
|
// Where the current-user chip links. null renders it as a plain span (for
|
||||||
|
// apps with no profile page).
|
||||||
|
profileUrl: null,
|
||||||
|
// Where "Log Out" lands.
|
||||||
|
logoutRedirect: '/login',
|
||||||
|
// Admin-only "a newer release is available" banner, backed by
|
||||||
|
// GET /api/update-check. Apps without that endpoint set false.
|
||||||
|
updateCheck: false,
|
||||||
|
updateLabel: 'the jump host',
|
||||||
|
|
||||||
|
// Nav items, in order. `groups` is an OR-list of group CNs that may see the
|
||||||
|
// item; an empty list means "always visible". Gating is done client-side by
|
||||||
|
// app-base.js, which reveals .group-required-<cn> for each group the user is
|
||||||
|
// in (plus the synthetic `admin` group when user/me reports isAdmin).
|
||||||
|
nav: [
|
||||||
|
{href: '/dashboard', icon: 'fa-solid fa-gauge-high', label: 'Dashboard', groups: []},
|
||||||
|
{href: '/sessions', icon: 'fa-solid fa-plug-circle-bolt', label: 'Sessions', groups: []},
|
||||||
|
{href: '/audit', icon: 'fa-solid fa-clipboard-list', label: 'Audit', groups: []},
|
||||||
|
],
|
||||||
|
};
|
||||||
+58
-35
@@ -1,39 +1,62 @@
|
|||||||
<%- include('top') %>
|
<%- include('top') %>
|
||||||
<h1>Audit log</h1>
|
<script type="text/javascript">app.auth.forceLogin();</script>
|
||||||
<form class="filters" method="get">
|
|
||||||
<input name="uid" placeholder="user" value="<%= query.uid || '' %>">
|
|
||||||
<input name="target" placeholder="target" value="<%= query.target || '' %>">
|
|
||||||
<select name="status">
|
|
||||||
<option value="">any</option>
|
|
||||||
<option value="success" <%= query.status === 'success' ? 'selected' : '' %>>success</option>
|
|
||||||
<option value="fail" <%= query.status === 'fail' ? 'selected' : '' %>>fail</option>
|
|
||||||
</select>
|
|
||||||
<button>Filter</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<table>
|
<div class="card shadow-sm">
|
||||||
<thead><tr><th>Time</th><th>User</th><th>Method</th><th>Mode</th><th>Target</th><th>Chan</th><th>Client</th><th>Result</th><th>Bytes</th></tr></thead>
|
<div class="card-header"><i class="fa-solid fa-clipboard-list me-1"></i> Audit log</div>
|
||||||
<tbody>
|
<div class="card-body pb-0">
|
||||||
<% data.results.forEach(e => { %>
|
<form class="row g-2 mb-2" onsubmit="applyFilters(); return false;">
|
||||||
<tr class="<%= e.success ? '' : 'bad' %>">
|
<div class="col-auto"><input class="form-control form-control-sm" id="f-uid" placeholder="user"></div>
|
||||||
<td><%= new Date(e.ts).toLocaleString() %></td>
|
<div class="col-auto"><input class="form-control form-control-sm" id="f-target" placeholder="target"></div>
|
||||||
<td><%= e.uid %></td>
|
<div class="col-auto">
|
||||||
<td><%= e.authMethod %></td>
|
<select class="form-select form-select-sm" id="f-status">
|
||||||
<td><%= e.mode %></td>
|
<option value="">any result</option>
|
||||||
<td><%= e.targetSlug || e.targetAddr || '—' %></td>
|
<option value="success">success</option>
|
||||||
<td><%= e.channel || '—' %></td>
|
<option value="fail">fail</option>
|
||||||
<td><%= e.clientIp %></td>
|
</select>
|
||||||
<td><%= e.success ? '✓' : '✗ ' + e.failReason %></td>
|
</div>
|
||||||
<td class="r"><%= (e.bytesIn + e.bytesOut) || 0 %></td>
|
<div class="col-auto"><button class="btn btn-sm btn-primary">Filter</button></div>
|
||||||
</tr>
|
</form>
|
||||||
<% }) %>
|
</div>
|
||||||
</tbody>
|
<div class="table-responsive">
|
||||||
</table>
|
<table class="table table-striped table-sm mb-0">
|
||||||
|
<thead><tr><th>Time</th><th>User</th><th>Method</th><th>Mode</th><th>Target</th><th>Chan</th><th>Client</th><th>Result</th><th class="text-end">Bytes</th></tr></thead>
|
||||||
<div class="pager">
|
<tbody id="audit-body"></tbody>
|
||||||
<% const p = data.page; %>
|
</table>
|
||||||
<% if (p > 0) { %><a href="?page=<%= p-1 %>&uid=<%= query.uid||'' %>&target=<%= query.target||'' %>&status=<%= query.status||'' %>">← prev</a><% } %>
|
</div>
|
||||||
<span><%= data.total %> events</span>
|
<div class="card-footer d-flex justify-content-between align-items-center">
|
||||||
<% if ((p+1) * data.pageSize < data.total) { %><a href="?page=<%= p+1 %>&uid=<%= query.uid||'' %>&target=<%= query.target||'' %>&status=<%= query.status||'' %>">next →</a><% } %>
|
<button class="btn btn-sm btn-outline-secondary" id="prev" onclick="changePage(-1)">← prev</button>
|
||||||
|
<span class="text-muted small" id="page-info"></span>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" id="next" onclick="changePage(1)">next →</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
var page = 0;
|
||||||
|
function filters(){ return {page: page, uid: $('#f-uid').val(), target: $('#f-target').val(), status: $('#f-status').val()}; }
|
||||||
|
function applyFilters(){ page = 0; load(); }
|
||||||
|
function changePage(d){ page = Math.max(0, page + d); load(); }
|
||||||
|
function load(){
|
||||||
|
app.jump.audit(filters(), function(error, data){
|
||||||
|
var $b = $('#audit-body').empty();
|
||||||
|
if(error || !data || !data.results.length){ $b.append('<tr><td colspan="9" class="text-muted">No events.</td></tr>'); }
|
||||||
|
else data.results.forEach(function(e){
|
||||||
|
$b.append('<tr class="' + (e.success ? '' : 'table-danger') + '">' +
|
||||||
|
'<td>' + app.jump.fmtTime(e.ts) + '</td>' +
|
||||||
|
'<td>' + app.jump.esc(e.uid) + '</td>' +
|
||||||
|
'<td>' + app.jump.esc(e.authMethod) + '</td>' +
|
||||||
|
'<td>' + app.jump.esc(e.mode) + '</td>' +
|
||||||
|
'<td>' + app.jump.esc(e.targetSlug || e.targetAddr || '—') + '</td>' +
|
||||||
|
'<td>' + app.jump.esc(e.channel || '—') + '</td>' +
|
||||||
|
'<td>' + app.jump.esc(e.clientIp) + '</td>' +
|
||||||
|
'<td>' + app.jump.result(e) + '</td>' +
|
||||||
|
'<td class="text-end">' + ((e.bytesIn + e.bytesOut) || 0) + '</td></tr>');
|
||||||
|
});
|
||||||
|
var total = data ? data.total : 0, size = data ? data.pageSize : 50;
|
||||||
|
$('#page-info').text(total + ' events · page ' + (page + 1));
|
||||||
|
$('#prev').prop('disabled', page === 0);
|
||||||
|
$('#next').prop('disabled', (page + 1) * size >= total);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
$(document).ready(load);
|
||||||
|
</script>
|
||||||
<%- include('bottom') %>
|
<%- include('bottom') %>
|
||||||
|
|||||||
+29
-5
@@ -1,6 +1,30 @@
|
|||||||
</main>
|
</div><!-- end spa-shell -->
|
||||||
<footer class="foot">
|
|
||||||
<% if (typeof buildInfo !== 'undefined') { %><span>v<%= buildInfo.version %> · <%= buildInfo.commit %></span><% } %>
|
<!-- Shared UI shell — byte-identical across sso-manager-node, proxy and
|
||||||
</footer>
|
jump-host. Everything per-app comes from `ui` (utils/ui.js, exposed via
|
||||||
</body>
|
app.locals in app.js). Edit all three copies together. -->
|
||||||
|
<footer class="py-2 bg-dark text-light mt-4">
|
||||||
|
<div class="container-fluid d-flex flex-wrap justify-content-between align-items-center small gap-2">
|
||||||
|
<span class="d-flex align-items-center gap-2">
|
||||||
|
<a href="https://theta42.com" target="_blank">
|
||||||
|
<img width="64" src="/static/img/theta42.svg"/>
|
||||||
|
</a>
|
||||||
|
© <%- buildYear %> theta42 ·
|
||||||
|
<a href="<%- ui.licenseUrl %>" target="_blank" class="text-light">MIT License</a>
|
||||||
|
</span>
|
||||||
|
<span class="d-flex align-items-center gap-3">
|
||||||
|
<a href="<%- ui.docsUrl %>"<%- ui.docsExternal ? ' target="_blank"' : '' %> class="text-light text-decoration-none">
|
||||||
|
<i class="fa-solid fa-book"></i> Docs
|
||||||
|
</a>
|
||||||
|
<a href="<%- ui.repoUrl %>" target="_blank" class="text-light text-decoration-none">
|
||||||
|
<i class="fa-brands fa-github"></i> GitHub
|
||||||
|
</a>
|
||||||
|
<% if(ui.tosUrl){ %>
|
||||||
|
<a href="<%- ui.tosUrl %>" class="text-light text-decoration-none">Terms of Service</a>
|
||||||
|
<% } %>
|
||||||
|
</span>
|
||||||
|
<span>v<%- buildVersion %> (<%- buildHash %>)</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+58
-45
@@ -1,51 +1,64 @@
|
|||||||
<%- include('top') %>
|
<%- include('top') %>
|
||||||
<h1>Dashboard</h1>
|
<script type="text/javascript">app.auth.forceLogin();</script>
|
||||||
<div class="tiles">
|
|
||||||
<div class="tile"><span class="n"><%= metrics.active %></span><span class="l">active sessions</span></div>
|
<div class="row g-3 mb-4">
|
||||||
<div class="tile"><span class="n"><%= metrics.total %></span><span class="l">total connections</span></div>
|
<div class="col-6 col-md-3">
|
||||||
<div class="tile"><span class="n"><%= metrics.fail %></span><span class="l">failed</span></div>
|
<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>
|
||||||
|
|
||||||
<div class="cols">
|
<div class="row g-3">
|
||||||
<section>
|
<div class="col-md-6">
|
||||||
<h2>Active sessions</h2>
|
<div class="card shadow-sm"><div class="card-header"><i class="fa-solid fa-server me-1"></i> Top hosts</div>
|
||||||
<% if (!active.length) { %><p class="muted">None right now.</p><% } else { %>
|
<table class="table table-sm mb-0"><tbody id="top-hosts"></tbody></table>
|
||||||
<table>
|
</div>
|
||||||
<thead><tr><th>User</th><th>Target</th><th>Since</th></tr></thead>
|
</div>
|
||||||
<tbody>
|
<div class="col-md-6">
|
||||||
<% active.forEach(s => { %>
|
<div class="card shadow-sm"><div class="card-header"><i class="fa-solid fa-user me-1"></i> Top users</div>
|
||||||
<tr><td><%= s.uid %></td><td><%= s.slug || s.target %></td><td><%= new Date(s.startedAt).toLocaleTimeString() %></td></tr>
|
<table class="table table-sm mb-0"><tbody id="top-users"></tbody></table>
|
||||||
<% }) %>
|
</div>
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
|
||||||
<% } %>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2>Top hosts</h2>
|
|
||||||
<% if (!metrics.topHosts.length) { %><p class="muted">No data.</p><% } else { %>
|
|
||||||
<table><tbody>
|
|
||||||
<% metrics.topHosts.forEach(h => { %><tr><td><%= h.name %></td><td class="r"><%= h.count %></td></tr><% }) %>
|
|
||||||
</tbody></table>
|
|
||||||
<% } %>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section>
|
<script type="text/javascript">
|
||||||
<h2>Recent connections <a class="more" href="/audit">view all →</a></h2>
|
function rows(sel, list){
|
||||||
<table>
|
var $b = $(sel).empty();
|
||||||
<thead><tr><th>Time</th><th>User</th><th>Target</th><th>Method</th><th>Result</th></tr></thead>
|
if(!list || !list.length){ $b.append('<tr><td class="text-muted">No data.</td></tr>'); return; }
|
||||||
<tbody>
|
list.forEach(function(x){
|
||||||
<% recent.forEach(e => { %>
|
$b.append('<tr><td>' + app.jump.esc(x.name) + '</td><td class="text-end">' + x.count + '</td></tr>');
|
||||||
<tr>
|
});
|
||||||
<td><%= new Date(e.ts).toLocaleString() %></td>
|
}
|
||||||
<td><%= e.uid %></td>
|
$(document).ready(function(){
|
||||||
<td><%= e.targetSlug || e.targetAddr || '—' %></td>
|
app.jump.metrics(function(error, data){
|
||||||
<td><%= e.authMethod %> / <%= e.mode %></td>
|
if(error || !data) return;
|
||||||
<td><%= e.success ? '✓' : '✗ ' + e.failReason %></td>
|
$('#stat-active').text(data.active);
|
||||||
</tr>
|
$('#stat-total').text(data.total);
|
||||||
<% }) %>
|
$('#stat-fail').text(data.fail);
|
||||||
</tbody>
|
$('#stat-users').text((data.topUsers || []).length);
|
||||||
</table>
|
rows('#top-hosts', data.topHosts);
|
||||||
</section>
|
rows('#top-users', data.topUsers);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
<%- include('bottom') %>
|
<%- include('bottom') %>
|
||||||
|
|||||||
Regular → Executable
+101
-19
@@ -1,19 +1,101 @@
|
|||||||
<!doctype html>
|
<%- include('top') %>
|
||||||
<html lang="en">
|
<script type="text/javascript">
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
// If we arrived from the OIDC callback with a token in the URL fragment,
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
// store it and forward on before doing anything else.
|
||||||
<title><%= name %> — Jump Host login</title>
|
if(!app.auth.consumeTokenFragment()){
|
||||||
<link rel="stylesheet" href="/public/css/app.css">
|
// The reveal below touches an element further down this page, so wait
|
||||||
</head>
|
// for the DOM — isLoggedIn can answer before the parser gets there.
|
||||||
<body class="center">
|
$(document).ready(function(){
|
||||||
<form method="post" action="/login" class="card login">
|
app.auth.isLoggedIn(function(error, isLoggedIn){
|
||||||
<h1><%= name %> <small>jump host</small></h1>
|
if(isLoggedIn){
|
||||||
<% if (error) { %><p class="err"><%= error %></p><% } %>
|
app.auth.logInRedirect();
|
||||||
<label>Username <input name="uid" autofocus autocomplete="username"></label>
|
}else{
|
||||||
<label>Password <input name="password" type="password" autocomplete="current-password"></label>
|
// Reveal the login card once we know the user is not logged in.
|
||||||
<button type="submit">Sign in</button>
|
document.getElementById('login-card-row').style.display = '';
|
||||||
<p class="hint">Admin group required. Uses your directory (LDAP) credentials.</p>
|
}
|
||||||
</form>
|
});
|
||||||
</body>
|
});
|
||||||
</html>
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div id="login-card-row" class="row" style="display: none;">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="shadow-lg card">
|
||||||
|
|
||||||
|
<div class="card-header text-center">
|
||||||
|
<span class="card-icon float-start">
|
||||||
|
<i class="fa-solid fa-lock"></i>
|
||||||
|
</span>
|
||||||
|
<span class="card-title">
|
||||||
|
User Login
|
||||||
|
</span>
|
||||||
|
<span class="float-end">
|
||||||
|
<i class="fa-solid fa-circle-minus"></i>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div class="card-header shadow actionMessage" style="display:none"></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form action="auth/login" onsubmit="formAJAX(this)" evalAJAX="
|
||||||
|
app.auth.setToken(data.token);
|
||||||
|
app.auth.logInRedirect();
|
||||||
|
">
|
||||||
|
<input type="hidden" name="redirect" value="<%= redirect %>">
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label></label>
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text" id="addon-wrapping"><i class="fa-solid fa-user-tie"></i></span>
|
||||||
|
<input type="text" name="username" class="form-control" placeholder="jsmith" aria-label="Username" aria-describedby="addon-wrapping">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text" id="addon-wrapping"><i class="fa-solid fa-key"></i></span>
|
||||||
|
<input type="password" name="password" class="form-control" placeholder="huunteR!23" aria-label="Username" aria-describedby="addon-wrapping">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- <div class="group">
|
||||||
|
<label class="control-label">User name</label>
|
||||||
|
<div class="input-group mb-3 shadow">
|
||||||
|
<div class="input-group-prepend">
|
||||||
|
<span class="input-group-text" ><i class="fa-solid fa-user-tie"></i></span>
|
||||||
|
</div>
|
||||||
|
<input type="text" name="username" class="input-control" placeholder="jsmith" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="group">
|
||||||
|
<label class="control-label">Password</label>
|
||||||
|
<div class="input-group mb-3 shadow">
|
||||||
|
<div class="input-group-prepend">
|
||||||
|
<span class="input-group-text" ><i class="fa-solid fa-key"></i></span>
|
||||||
|
</div>
|
||||||
|
<input type="password" name="password" class="input-control" placeholder="hunter123!"/>
|
||||||
|
</div>
|
||||||
|
</div> -->
|
||||||
|
<hr />
|
||||||
|
<button type="submit" class="btn btn-outline-dark"><i class="fa-solid fa-right-to-bracket"></i> Log in</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<% if (typeof oidcEnabled === 'undefined' || oidcEnabled) { %>
|
||||||
|
<hr />
|
||||||
|
<div class="d-grid">
|
||||||
|
<a href="/api/auth/oidc/start" class="btn btn-outline-primary">
|
||||||
|
<i class="fa-solid fa-id-badge"></i> Log in with SSO
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<%- include('bottom') %>
|
||||||
|
|||||||
+28
-11
@@ -1,13 +1,30 @@
|
|||||||
<%- include('top') %>
|
<%- include('top') %>
|
||||||
<h1>Active sessions</h1>
|
<script type="text/javascript">app.auth.forceLogin();</script>
|
||||||
<% if (!active.length) { %><p class="muted">No active sessions.</p><% } else { %>
|
|
||||||
<table>
|
<div class="card shadow-sm">
|
||||||
<thead><tr><th>User</th><th>Target host</th><th>Address</th><th>Started</th></tr></thead>
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
<tbody>
|
<span><i class="fa-solid fa-plug-circle-bolt me-1"></i> Active sessions</span>
|
||||||
<% active.forEach(s => { %>
|
<button class="btn btn-sm btn-outline-secondary" onclick="loadSessions()"><i class="fa-solid fa-rotate"></i></button>
|
||||||
<tr><td><%= s.uid %></td><td><%= s.slug || '—' %></td><td><%= s.target %></td><td><%= new Date(s.startedAt).toLocaleString() %></td></tr>
|
</div>
|
||||||
<% }) %>
|
<div class="table-responsive">
|
||||||
</tbody>
|
<table class="table table-striped mb-0">
|
||||||
</table>
|
<thead><tr><th>User</th><th>Target host</th><th>Address</th><th>Started</th></tr></thead>
|
||||||
<% } %>
|
<tbody id="sessions-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
function loadSessions(){
|
||||||
|
app.jump.sessions(function(error, data){
|
||||||
|
var $b = $('#sessions-body').empty();
|
||||||
|
if(error || !data || !data.results.length){ $b.append('<tr><td colspan="4" class="text-muted">No active sessions.</td></tr>'); return; }
|
||||||
|
data.results.forEach(function(s){
|
||||||
|
$b.append('<tr><td>' + app.jump.esc(s.uid) + '</td><td>' + app.jump.esc(s.slug || '—') +
|
||||||
|
'</td><td>' + app.jump.esc(s.target) + '</td><td>' + app.jump.fmtTime(s.startedAt) + '</td></tr>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
$(document).ready(loadSessions);
|
||||||
|
</script>
|
||||||
<%- include('bottom') %>
|
<%- include('bottom') %>
|
||||||
|
|||||||
+147
-19
@@ -1,21 +1,149 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
<title><%= name %> — Jump Host</title>
|
<title><%- name %> <%- title %></title>
|
||||||
<link rel="stylesheet" href="/public/css/app.css">
|
<!-- Shared UI shell — byte-identical across sso-manager-node, proxy and
|
||||||
</head>
|
jump-host. Everything per-app comes from `ui` (utils/ui.js, exposed
|
||||||
<body>
|
via app.locals in app.js). Edit all three copies together. -->
|
||||||
<nav class="nav">
|
<!-- Favicon -->
|
||||||
<span class="brand"><%= name %> <small>jump host</small></span>
|
<link rel="icon" type="image/svg+xml" href="<%- ui.faviconUrl %>">
|
||||||
<% if (typeof user !== 'undefined' && user) { %>
|
<!-- CSS are placed here -->
|
||||||
<span class="spacer"></span>
|
<link rel="stylesheet" href="/static-modules/bootstrap/dist/css/bootstrap.min.css">
|
||||||
<a href="/">Dashboard</a>
|
<link rel="stylesheet" href="/static-modules/@fortawesome/fontawesome-free/css/all.min.css">
|
||||||
<a href="/sessions">Sessions</a>
|
|
||||||
<a href="/audit">Audit</a>
|
<link rel='stylesheet' href='/static/css/styles.css' />
|
||||||
<span class="who"><%= user.uid %></span>
|
<!-- Scripts are placed here -->
|
||||||
<form method="post" action="/logout" class="inline"><button class="link">logout</button></form>
|
<script type="text/javascript" src="/socket.io/socket.io.js"></script>
|
||||||
<% } %>
|
<script type="text/javascript" src='/static-modules/jquery/dist/jquery.js'></script>
|
||||||
</nav>
|
<script type="text/javascript" src="/static-modules/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
<main class="wrap">
|
<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/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/lib/js/app-base.js"></script>
|
||||||
|
<script type="text/javascript" src="/static/js/app.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<nav class="navbar navbar-expand-md navbar-dark fixed-top bg-dark">
|
||||||
|
<a class="navbar-brand" href="/"><img src="<%- logo %>" height="28" class="me-2" alt=""><%- name %> <%- titleIcon %></a>
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div class="collapse navbar-collapse justify-content-end" id="navbarSupportedContent">
|
||||||
|
<ul class="navbar-nav top-nav">
|
||||||
|
<%# Items gated on a group start hidden (.group-required) and are
|
||||||
|
revealed by app-base.js for the groups the user is in. %>
|
||||||
|
<% for(const item of ui.nav){ %>
|
||||||
|
<li class="nav-item<%- item.groups.length ? ' group-required' : '' %><%- item.groups.map(group => ' group-required-' + group).join('') %>">
|
||||||
|
<a class="nav-link" href="<%- item.href %>"><i class="<%- item.icon %>"></i>
|
||||||
|
<%- item.label %>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<% } %>
|
||||||
|
</ul>
|
||||||
|
<div class="form-inline mt-2 mt-md-0">
|
||||||
|
<% if(ui.profileUrl){ %>
|
||||||
|
<a id="cl-username" class="navbar-text text-light me-3" href="<%- ui.profileUrl %>" style="display: none;">
|
||||||
|
<i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
|
||||||
|
</a>
|
||||||
|
<% } else { %>
|
||||||
|
<span id="cl-username" class="navbar-text text-light me-3" style="display: none;">
|
||||||
|
<i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
|
||||||
|
</span>
|
||||||
|
<% } %>
|
||||||
|
<a id="cl-login-button" class="btn btn-outline-danger my-2 my-sm-0" onclick="app.auth.forceLogin()" style="display: none;">
|
||||||
|
<i class="fas fa-sign-in"></i>
|
||||||
|
Login
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<button id="cl-logout-button" class="btn btn-outline-danger my-2 my-sm-0" onclick="app.auth.logOut(function(){ window.location.href = '<%- ui.logoutRedirect %>'; })" style="display: none;">
|
||||||
|
<i class="fas fa-sign-out"></i>
|
||||||
|
Log Out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<% if(ui.updateCheck){ %>
|
||||||
|
<!-- Admin-only "a newer release is available" notice (services/update_check.js).
|
||||||
|
Dismissal is per-browser-session only (sessionStorage), not persisted server-side.
|
||||||
|
Fixed-positioned below the fixed navbar (a plain in-flow div here would render
|
||||||
|
UNDER the nav, since fixed elements are taken out of document flow) -- shown/hidden
|
||||||
|
dynamically, so #spa-shell's margin-top is adjusted in JS to make room for it. -->
|
||||||
|
<div id="update-banner" class="alert alert-info alert-dismissible mb-0 rounded-0 text-center" style="display:none; position:fixed; left:0; right:0; z-index:1029;">
|
||||||
|
<span id="update-banner-text"></span>
|
||||||
|
<button type="button" class="btn-close" onclick="dismissUpdateBanner()"></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
function showUpdateBanner(){
|
||||||
|
let $nav = $('nav.fixed-top');
|
||||||
|
let $banner = $('#update-banner');
|
||||||
|
$banner.css('top', $nav.outerHeight() + 'px').show();
|
||||||
|
$('#spa-shell').css('margin-top', ($nav.outerHeight() + $banner.outerHeight()) + 'px');
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismissUpdateBanner(){
|
||||||
|
$('#update-banner').hide();
|
||||||
|
$('#spa-shell').css('margin-top', '');
|
||||||
|
sessionStorage.setItem('update-banner-dismissed', '1');
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkForUpdate(){
|
||||||
|
if(sessionStorage.getItem('update-banner-dismissed')) return;
|
||||||
|
app.api.get('update-check', function(error, info){
|
||||||
|
if(error || !info || !info.updateAvailable) return;
|
||||||
|
$('#update-banner-text').html(
|
||||||
|
'A newer version of <%- ui.updateLabel %> is available: <b>v' + info.latestVersion + '</b> ' +
|
||||||
|
'(running v' + info.currentVersion + ') — ' +
|
||||||
|
'<a href="' + info.releaseUrl + '" target="_blank" class="alert-link">see what changed</a>.'
|
||||||
|
);
|
||||||
|
showUpdateBanner();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
$(document).ready(function(){
|
||||||
|
|
||||||
|
// Set the correct link to active in the top nav bar
|
||||||
|
$('.top-nav a').each(function(index){
|
||||||
|
let $this = $(this);
|
||||||
|
$this.removeClass('active');
|
||||||
|
if($this.attr('href').toLocaleLowerCase() === window.location.pathname.toLocaleLowerCase()){
|
||||||
|
$this.addClass('active')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Set the correct login/logout button, and reveal the current user's
|
||||||
|
// name once we know who they are. Group-gated nav items are revealed
|
||||||
|
// by app-base.js off the same cached user/me.
|
||||||
|
app.auth.isLoggedIn(function(error, me){
|
||||||
|
if(me){
|
||||||
|
$('#cl-logout-button').show();
|
||||||
|
let username = me.uid || me.username;
|
||||||
|
if(username){
|
||||||
|
$('#cl-username-text').text(username);
|
||||||
|
$('#cl-username').css('display', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
<% if(ui.updateCheck){ %>
|
||||||
|
if(me.isAdmin) checkForUpdate();
|
||||||
|
<% } %>
|
||||||
|
}else{
|
||||||
|
$('#cl-login-button').show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Container -->
|
||||||
|
<div id="spa-shell" class="container-fluid">
|
||||||
|
<div class="actionMessage" style="display:none;"></div>
|
||||||
|
|||||||
+27
-2
@@ -59,8 +59,33 @@ module.exports = {
|
|||||||
|
|
||||||
web: { port: 3002 },
|
web: { port: 3002 },
|
||||||
|
|
||||||
// LDAP groups whose members may use the web UI/API.
|
// Web UI/API login. Same model as the proxy: OIDC against the SSO for
|
||||||
auth: { adminGroups: ['app_sso_admin'] },
|
// normal users, plus a local anti-lockout admin. Set enabled:true and fill
|
||||||
|
// in the endpoints + client creds to turn on "Log in with SSO" (in the
|
||||||
|
// theta-env bundle these are provisioned for you).
|
||||||
|
oidc: {
|
||||||
|
enabled: false,
|
||||||
|
issuer: 'https://sso.example.com',
|
||||||
|
authorizationEndpoint: 'https://sso.example.com/oauth/authorize',
|
||||||
|
tokenEndpoint: 'http://sso-manager:3001/oauth/token',
|
||||||
|
userinfoEndpoint: 'http://sso-manager:3001/oauth/userinfo',
|
||||||
|
clientId: 'CHANGE_ME',
|
||||||
|
clientSecret: 'CHANGE_ME',
|
||||||
|
redirectUri: 'https://jump.example.com/api/auth/oidc/callback',
|
||||||
|
scopes: ['openid', 'profile', 'email', 'groups'],
|
||||||
|
groupsClaim: 'groups',
|
||||||
|
usernameClaim: 'preferred_username',
|
||||||
|
},
|
||||||
|
|
||||||
|
auth: {
|
||||||
|
// OIDC group memberships that grant web UI/API admin access.
|
||||||
|
adminGroups: ['app_sso_admin'],
|
||||||
|
// Local anti-lockout admin — the first name is bootstrapped as a
|
||||||
|
// redis-backed user on first boot (password from localAdminPass below,
|
||||||
|
// or a random one printed to the log once). Works even if OIDC is down.
|
||||||
|
adminUsers: ['jumpadmin'],
|
||||||
|
localAdminPass: 'CHANGE_ME',
|
||||||
|
},
|
||||||
|
|
||||||
redis: {
|
redis: {
|
||||||
prefix: 'jump_host_',
|
prefix: 'jump_host_',
|
||||||
|
|||||||
Reference in New Issue
Block a user