Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a56a31421d | |||
| d33e324b31 | |||
| 4879769cc7 | |||
| da274a3ced | |||
| b9be0fe4e1 | |||
| 9db530565d | |||
| fe6306b7d3 | |||
| 240563e093 | |||
| 047e54ce50 | |||
| ee76088f86 | |||
| 8db46bd9d9 | |||
| 67e2fc54c2 | |||
| 23d99980ce | |||
| aa3b3ed515 |
@@ -4,6 +4,62 @@ All notable changes to this project are documented here. Format loosely
|
||||
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions
|
||||
correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
|
||||
|
||||
## [1.4.0] - 2026-07-26
|
||||
|
||||
### Added
|
||||
- **Standalone mode** — run the jump host with no LDAP directory and no SSO Manager at all. Set `standalone.enabled: true` and user authentication and host discovery switch to `@simpleworkjs/orm`-backed stores (Sequelize; SQLite by default, any Sequelize-supported dialect via `conf.orm`) instead of the directory services. `models/user_ldap.js` and `utils/access.js` become conditional facades that pick their backend at require time — `ssh_server.js`, `bridge.js`, `key_inject.js`, `tui_picker.js`, and the web UI are unchanged either way.
|
||||
- New ORM models: `StandaloneUser` (`uid`, `passwordHash`, `sshPublicKeys`, `groups`) and `StandaloneHost` (`slug`, `displayName`, `kind`, `metadata`), plus `models/user_file.js` and `utils/hosts_file.js`, which implement the same interfaces as the LDAP client and `accessibleHosts()` respectively. There's no admin UI for standalone users/hosts yet — see the README's "Standalone mode" section for the ORM-model seeding snippet. In standalone mode every stored host is reachable by every stored user; there's no group-based authorization yet.
|
||||
- 47 tests pass (24 existing + 15 new unit + 3 existing integration + 5 new standalone integration).
|
||||
|
||||
### Fixed
|
||||
- **`services/ssh_server.js` used `|| 2222` for the listen port**, so an explicit `listenPort: 0` (ephemeral port, used by the test suite) was silently overridden back to 2222. Changed to `?? 2222`.
|
||||
- **`services/ssh_server.js` awaited `audit.create()` before registering session listeners.** A client that sends `exec`/`shell` immediately after connecting could have its request dropped because nothing was listening yet. Listener registration now happens first.
|
||||
|
||||
## [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
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -2,9 +2,13 @@
|
||||
|
||||
An SSH jump host for the [theta42](https://github.com/theta42) self-hosted
|
||||
stack. Users SSH into one public host and land on any downstream host they're
|
||||
entitled to — authenticated against the shared LDAP directory, authorized from
|
||||
the [SSO Manager](https://github.com/theta42/sso-manager-node)'s inventory
|
||||
graph, audited end to end.
|
||||
entitled to — audited end to end.
|
||||
|
||||
Two backends, same SSH front door and audit trail: the default mode
|
||||
authenticates against the shared LDAP directory and authorizes from the
|
||||
[SSO Manager](https://github.com/theta42/sso-manager-node)'s inventory graph;
|
||||
**standalone mode** (below) runs with no LDAP or SSO at all, storing users and
|
||||
hosts in a local SQL database instead.
|
||||
|
||||
## Two ways to connect
|
||||
|
||||
@@ -44,8 +48,53 @@ bridged straight in.
|
||||
4. **Bridge** — shell, exec, and the SFTP subsystem are spliced to the
|
||||
downstream sshd. Every session is audited.
|
||||
|
||||
## Standalone mode
|
||||
|
||||
Run without LDAP or the SSO Manager at all. Set `standalone.enabled: true` and
|
||||
the jump host stores users and hosts itself, via
|
||||
[@simpleworkjs/orm](https://www.npmjs.com/package/@simpleworkjs/orm)
|
||||
(Sequelize under the hood — defaults to a local SQLite file, but any
|
||||
Sequelize-supported dialect works via `conf.orm`):
|
||||
|
||||
```js
|
||||
standalone: { enabled: true },
|
||||
orm: { dialect: 'sqlite', storage: './data/standalone.sqlite', logging: false },
|
||||
```
|
||||
|
||||
Everything else — the SSH front door, key injection, bridging, the web UI and
|
||||
audit trail — is unchanged; only where users/hosts live and how passwords are
|
||||
checked differs. There's no admin UI for standalone users/hosts yet — add them
|
||||
with the ORM models directly:
|
||||
|
||||
```js
|
||||
const StandaloneUser = require('./models/standalone_user');
|
||||
const StandaloneHost = require('./models/standalone_host');
|
||||
const bcrypt = require('bcrypt');
|
||||
|
||||
await StandaloneUser.create({
|
||||
uid: 'alice',
|
||||
passwordHash: await bcrypt.hash('a real password', 10),
|
||||
sshPublicKeys: ['ssh-ed25519 AAAA... alice@laptop'],
|
||||
groups: [],
|
||||
});
|
||||
|
||||
await StandaloneHost.create({
|
||||
slug: 'host_web01',
|
||||
displayName: 'web01',
|
||||
kind: 'host',
|
||||
metadata: { ip: '10.0.0.5', sshPort: 22 },
|
||||
});
|
||||
```
|
||||
|
||||
In standalone mode every stored host is reachable by every stored user — there
|
||||
is no group-based authorization (the `groups` field on `StandaloneUser` is
|
||||
accepted for interface parity but not yet enforced).
|
||||
|
||||
## Requirements
|
||||
|
||||
*(default LDAP + SSO mode — see [Standalone mode](#standalone-mode) to skip
|
||||
all of this)*
|
||||
|
||||
- The SSO Manager (OpenLDAP directory + `/api/discovery`).
|
||||
- Downstream hosts joined via ldap-client (SSSD + `AuthorizedKeysCommand`).
|
||||
- An LDAP bind account with **write access to the `sshPublicKey` attribute** on
|
||||
@@ -91,9 +140,13 @@ The default SSH port is **2222** so the service needs no privilege. To listen on
|
||||
|
||||
## Web UI / API
|
||||
|
||||
`https://jump.example.com/` (behind the proxy) — admin login uses your LDAP
|
||||
credentials and requires membership in `auth.adminGroups` (default
|
||||
`app_sso_admin`).
|
||||
`https://jump.example.com/` (behind the proxy) — built on the same
|
||||
Express + EJS + Bootstrap stack as the [SSO Manager](https://theta42.github.io/sso-manager-node/)
|
||||
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 /api/sessions` — active sessions
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
title: Jump Host
|
||||
description: An SSH jump host for the theta42 stack — LDAP-authenticated, directory-driven host bridging with audit and metrics.
|
||||
description: An SSH jump host for the theta42 stack — directory-driven host bridging with audit and metrics; LDAP + SSO Manager by default, or fully standalone.
|
||||
url: "https://theta42.github.io"
|
||||
baseurl: "/jump-host"
|
||||
logo: /assets/img/theta42.svg
|
||||
|
||||
+26
-2
@@ -98,8 +98,10 @@ Byte counts per direction are tallied cheaply for the audit record.
|
||||
|
||||
## Web UI, API & audit
|
||||
|
||||
A small Express app on `:3002` (admin login via LDAP, gated by
|
||||
`auth.adminGroups`) exposes:
|
||||
An Express + EJS + Bootstrap app on `:3002` — the same front-end stack and
|
||||
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 /api/sessions` — active sessions
|
||||
@@ -110,6 +112,28 @@ Audit events and counters live in redis. Each event captures: user, auth method,
|
||||
mode (grammar/picker), target slug/address/port, channel type, client IP,
|
||||
success + failure reason, downstream host-key fingerprint, timing, and bytes in/out.
|
||||
|
||||
## Standalone mode
|
||||
|
||||
Everything above describes the default backend. Set `standalone.enabled: true`
|
||||
and two modules become conditional facades, swapping their entire
|
||||
implementation at `require` time based on that flag — nothing else in the
|
||||
codebase (`ssh_server.js`, `bridge.js`, `key_inject.js`, `tui_picker.js`, the
|
||||
web UI) changes or even knows which mode it's running in:
|
||||
|
||||
- **`models/user_ldap.js`** — LDAP client, or `models/user_file.js` (an
|
||||
[@simpleworkjs/orm](https://www.npmjs.com/package/@simpleworkjs/orm)-backed
|
||||
store implementing the same `getUser` / `getGroups` / `checkPassword` /
|
||||
`addSshKey` interface).
|
||||
- **`utils/access.js`** — LDAP groups + SSO `/api/discovery`, or
|
||||
`utils/hosts_file.js` (same ORM package, same `accessibleHosts()` interface).
|
||||
In standalone mode there's no group-based authorization: every stored host
|
||||
is accessible to every stored user.
|
||||
|
||||
The ORM is Sequelize underneath, defaulting to a local SQLite file but
|
||||
accepting any Sequelize-supported dialect via `conf.orm`. See
|
||||
[Installation](installation.html#standalone-mode) for config and how to add
|
||||
users/hosts (there's no admin UI for standalone data yet).
|
||||
|
||||
## Where it sits in the stack
|
||||
|
||||
- **[SSO Manager](https://theta42.github.io/sso-manager-node/)** — provides the
|
||||
|
||||
@@ -74,6 +74,10 @@ changes.
|
||||
Targets that don't resolve to a host you're allowed to reach are refused (and
|
||||
audited). Raw IPs that aren't a known directory host are denied by default.
|
||||
|
||||
> On a [standalone](architecture.html#standalone-mode) jump host (no LDAP/SSO),
|
||||
> every registered host is reachable by every registered user — there's no
|
||||
> group-based restriction to ask an admin about.
|
||||
|
||||
## Authentication
|
||||
|
||||
The jump host authenticates **you** against the directory:
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 83 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 78 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
+18
-1
@@ -1,7 +1,7 @@
|
||||
---
|
||||
layout: default
|
||||
title: Home
|
||||
description: An SSH jump host for the theta42 stack — one public host, LDAP login, and directory-driven access to every downstream machine you're entitled to.
|
||||
description: An SSH jump host for the theta42 stack — one public host and directory-driven access to every downstream machine you're entitled to; LDAP by default, or fully standalone.
|
||||
---
|
||||
|
||||
# Jump Host
|
||||
@@ -21,6 +21,21 @@ Part of the theta42 self-hosted identity stack, alongside
|
||||
[Proxy](https://theta42.github.io/proxy/), composable with one command via
|
||||
[theta-env](https://theta42.github.io/theta-env/).
|
||||
|
||||
## Screenshots
|
||||
|
||||
<a href="images/login.png" target="_blank"><img src="images/login.png" alt="Login" width="49%"></a>
|
||||
<a href="images/dashboard.png" target="_blank"><img src="images/dashboard.png" alt="Dashboard" width="49%"></a>
|
||||
<a href="images/sessions.png" target="_blank"><img src="images/sessions.png" alt="Active sessions" width="49%"></a>
|
||||
<a href="images/audit.png" target="_blank"><img src="images/audit.png" alt="Audit log" width="49%"></a>
|
||||
|
||||
*(click any screenshot to view full size)*
|
||||
|
||||
Don't want to run LDAP or the SSO Manager? **Standalone mode** stores users
|
||||
and hosts in a local SQL database instead (SQLite by default, any
|
||||
Sequelize-supported dialect if you want something else) — same SSH front door,
|
||||
key injection, and audit trail. See
|
||||
[Installation](installation.html#standalone-mode) to get started.
|
||||
|
||||
## Two ways to connect
|
||||
|
||||
**Direct (WinSCP/SFTP-friendly):**
|
||||
@@ -79,6 +94,8 @@ This jump host answers both from your directory:
|
||||
audit log, per-user/per-host counters
|
||||
- **Full audit trail** — who, target, method, result, bytes, duration, and the
|
||||
downstream host-key fingerprint
|
||||
- **Standalone mode** — no LDAP, no SSO Manager; users and hosts live in a
|
||||
local SQL database (Sequelize, any dialect — SQLite by default)
|
||||
- Packaged like the rest of the stack: one-command Docker, idempotent bare-metal
|
||||
installer, or bundled in theta-env
|
||||
|
||||
|
||||
+48
-2
@@ -1,7 +1,7 @@
|
||||
---
|
||||
layout: default
|
||||
title: Installation
|
||||
description: Install the jump host three ways — bundled in the theta-env stack, standalone Docker, or bare metal — plus the required LDAP write-ACL and port-22 options.
|
||||
description: Install the jump host three ways — bundled in the theta-env stack, standalone Docker, or bare metal — plus standalone mode (no LDAP/SSO), the LDAP write-ACL, and port-22 options.
|
||||
---
|
||||
|
||||
# Installation
|
||||
@@ -10,6 +10,51 @@ Three ways to run the jump host, in increasing manual effort. All read their
|
||||
config through [@simpleworkjs/conf](https://www.npmjs.com/package/@simpleworkjs/conf)
|
||||
(`conf/base.js` < `conf/<NODE_ENV>.js` < the `CONF_SECRETS` file < `app_*` env).
|
||||
|
||||
## Standalone mode (no LDAP/SSO) {#standalone-mode}
|
||||
|
||||
Skip LDAP and the SSO Manager entirely. Not to be confused with "Standalone
|
||||
Docker" below, which is still LDAP + SSO, just run outside theta-env. Set in your secrets/config:
|
||||
|
||||
```js
|
||||
standalone: { enabled: true },
|
||||
orm: { dialect: 'sqlite', storage: './data/standalone.sqlite', logging: false },
|
||||
```
|
||||
|
||||
`orm` is passed straight to Sequelize, so any supported dialect works — SQLite
|
||||
is just the zero-dependency default. Everything downstream of auth (bridging,
|
||||
key injection, the web UI, audit) is unchanged.
|
||||
|
||||
There's no admin UI for standalone users/hosts yet, so add them directly with
|
||||
the ORM models:
|
||||
|
||||
```js
|
||||
const StandaloneUser = require('./models/standalone_user');
|
||||
const StandaloneHost = require('./models/standalone_host');
|
||||
const bcrypt = require('bcrypt');
|
||||
|
||||
await StandaloneUser.create({
|
||||
uid: 'alice',
|
||||
passwordHash: await bcrypt.hash('a real password', 10),
|
||||
sshPublicKeys: ['ssh-ed25519 AAAA... alice@laptop'],
|
||||
groups: [],
|
||||
});
|
||||
|
||||
await StandaloneHost.create({
|
||||
slug: 'host_web01',
|
||||
displayName: 'web01',
|
||||
kind: 'host',
|
||||
metadata: { ip: '10.0.0.5', sshPort: 22 },
|
||||
});
|
||||
```
|
||||
|
||||
Every host in the standalone inventory is reachable by every standalone user —
|
||||
there's no group-based authorization yet (`groups` on `StandaloneUser` is
|
||||
accepted for interface parity with the LDAP path, not enforced).
|
||||
|
||||
The rest of this page (requirements, the LDAP write-ACL, the three install
|
||||
paths) describes the default LDAP + SSO mode — skip it if you're running
|
||||
standalone.
|
||||
|
||||
## Requirements
|
||||
|
||||
- The [SSO Manager](https://theta42.github.io/sso-manager-node/) (OpenLDAP
|
||||
@@ -94,7 +139,8 @@ Every key is documented in
|
||||
[`secrets.js.example`](https://github.com/theta42/jump-host/blob/master/secrets.js.example):
|
||||
`ldap` (bind + bases + TLS), `sso` (url + apiToken), `ssh`
|
||||
(`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
|
||||
|
||||
|
||||
+31
-21
@@ -1,37 +1,47 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
const express = require('express');
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
const compression = require('compression');
|
||||
|
||||
const registry = require('./services/session_registry');
|
||||
const { requireAdmin } = require('./middleware/auth');
|
||||
const buildInfo = require('./models/build_info');
|
||||
require('./models'); // wire model-redis + register models
|
||||
|
||||
const app = express();
|
||||
|
||||
app.set('view engine', 'ejs');
|
||||
app.set('views', path.join(__dirname, 'views'));
|
||||
app.use('/public', express.static(path.join(__dirname, 'public')));
|
||||
app.set('views', require('path').join(__dirname, 'views'));
|
||||
|
||||
// Open health check — no auth (used by Docker/compose + the proxy).
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', activeSessions: registry.count(), version: buildInfo.version, commit: buildInfo.commit });
|
||||
// Per-app values for the shared UI shell (views/top.ejs + views/bottom.ejs).
|
||||
// Set as an app local so every res.render has it, including routes that don't
|
||||
// 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).
|
||||
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'));
|
||||
|
||||
// Error handler — JSON for API, redirect to login for pages on 401.
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
app.use((err, req, res, next) => {
|
||||
console.error(err);
|
||||
if (req.path.startsWith('/api/')) return res.status(500).json({ error: err.message });
|
||||
res.status(500).render('login', { error: 'Internal error.', name: conf.name });
|
||||
const status = err.status || 500;
|
||||
if(status >= 500) console.error(err);
|
||||
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;
|
||||
|
||||
+13
-4
@@ -2,24 +2,33 @@
|
||||
'use strict';
|
||||
|
||||
// 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 conf = require('@simpleworkjs/conf');
|
||||
const { Server } = require('socket.io');
|
||||
|
||||
require('../models'); // wire model-redis + register models
|
||||
require('../models');
|
||||
|
||||
const app = require('../app');
|
||||
const middleware = require('../middleware/auth');
|
||||
const sshServer = require('../services/ssh_server');
|
||||
|
||||
// Web server
|
||||
const webPort = (conf.web && conf.web.port) || 3002;
|
||||
const server = http.createServer(app);
|
||||
|
||||
// Socket.IO — the client framework (app-base.js) opens an authenticated socket.
|
||||
// We don't push anything yet, but serving /socket.io keeps the shared front-end
|
||||
// working exactly as it does in the sibling apps.
|
||||
const io = new Server(server);
|
||||
io.use(middleware.authIO);
|
||||
app.io = io;
|
||||
|
||||
server.listen(webPort, () => {
|
||||
console.log(`[web] jump-host UI/API on :${server.address().port}`);
|
||||
});
|
||||
|
||||
// SSH server
|
||||
sshServer.start();
|
||||
|
||||
function shutdown() {
|
||||
|
||||
+40
-3
@@ -7,6 +7,7 @@
|
||||
|
||||
module.exports = {
|
||||
name: 'Jump Host',
|
||||
logo: '/static/img/theta42.svg',
|
||||
|
||||
// LDAP directory the users live in (same directory the SSO manages).
|
||||
// bindDN needs: read on ou=people (users + sshPublicKey) and ou=groups,
|
||||
@@ -59,11 +60,32 @@ module.exports = {
|
||||
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: {
|
||||
// LDAP groups whose members may use the web UI/API.
|
||||
// OIDC group memberships that grant web UI/API admin access.
|
||||
adminGroups: ['app_sso_admin'],
|
||||
// Web session lifetime (ms).
|
||||
sessionTTLms: 12 * 60 * 60 * 1000,
|
||||
// Local anti-lockout admin: the first name here is bootstrapped as a
|
||||
// redis-backed user on first boot (password from localAdminPass, or a
|
||||
// random one printed to the log once). Lets you in even with OIDC down.
|
||||
adminUsers: ['jumpadmin'],
|
||||
localAdminPass: '',
|
||||
},
|
||||
|
||||
redis: {
|
||||
@@ -76,6 +98,21 @@ module.exports = {
|
||||
maxEvents: 50000,
|
||||
},
|
||||
|
||||
// Standalone mode: run without LDAP or SSO Manager. When enabled, user
|
||||
// authentication and host discovery use @simpleworkjs/orm-backed stores
|
||||
// (Sequelize, defaulting to SQLite) instead of the directory services.
|
||||
standalone: {
|
||||
enabled: false,
|
||||
},
|
||||
|
||||
// ORM config for standalone mode. Passed through to Sequelize — any dialect
|
||||
// works. Defaults to SQLite for zero-dependency local dev.
|
||||
orm: {
|
||||
dialect: 'sqlite',
|
||||
storage: './data/standalone.sqlite',
|
||||
logging: false,
|
||||
},
|
||||
|
||||
// Orchestrator-only keys (ignored by the app, read by theta-env).
|
||||
stack: {},
|
||||
};
|
||||
|
||||
@@ -4,4 +4,12 @@ module.exports = {
|
||||
ssh: {
|
||||
hostKeyPath: './data/keys',
|
||||
},
|
||||
standalone: {
|
||||
enabled: true,
|
||||
},
|
||||
orm: {
|
||||
dialect: 'sqlite',
|
||||
storage: './data/standalone.sqlite',
|
||||
logging: false,
|
||||
},
|
||||
};
|
||||
|
||||
+45
-27
@@ -1,36 +1,54 @@
|
||||
'use strict';
|
||||
|
||||
// Web UI/API auth: a signed-in admin session (cookie) whose LDAP groups
|
||||
// intersect conf.auth.adminGroups. /health and the login routes are exempt
|
||||
// (mounted before this middleware).
|
||||
// Web UI/API auth, mirroring the sibling apps: a browser session token
|
||||
// (`auth-token: <AuthToken uuid>`) established via local login or the OIDC
|
||||
// callback. The token carries the group snapshot captured at login.
|
||||
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
const Session = require('../models/session');
|
||||
const { Auth } = require('../models');
|
||||
|
||||
function parseCookies(header) {
|
||||
const out = {};
|
||||
(header || '').split(';').forEach((p) => {
|
||||
const i = p.indexOf('=');
|
||||
if (i > -1) out[p.slice(0, i).trim()] = decodeURIComponent(p.slice(i + 1).trim());
|
||||
});
|
||||
return out;
|
||||
async function auth(req, res, next){
|
||||
try{
|
||||
req.token = await Auth.checkToken(req.header('auth-token'));
|
||||
req.user = req.token.user;
|
||||
req.groups = typeof req.token.groupsArray === 'function' ? req.token.groupsArray() : [];
|
||||
return next();
|
||||
}catch(error){
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function requireAdmin(req, res, next) {
|
||||
const token = parseCookies(req.headers.cookie).jump_session;
|
||||
const session = await Session.verify(token);
|
||||
if (!session) {
|
||||
if (req.path.startsWith('/api/')) return res.status(401).json({ error: 'unauthorized' });
|
||||
return res.redirect('/login');
|
||||
}
|
||||
const groups = JSON.parse(session.groups || '[]');
|
||||
const admin = (conf.auth.adminGroups || []).some((g) => groups.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();
|
||||
// Is the authenticated request an admin? Admin = a session whose OIDC groups
|
||||
// intersect conf.auth.adminGroups, OR the local anti-lockout admin
|
||||
// (conf.auth.adminUsers). The whole web UI is admin-only (audit + metrics).
|
||||
function isAdmin(req){
|
||||
const adminGroups = (conf.auth && conf.auth.adminGroups) || [];
|
||||
const adminUsers = (conf.auth && conf.auth.adminUsers) || [];
|
||||
const username = req.user && req.user.username;
|
||||
if(username && adminUsers.includes(username)) return true;
|
||||
return (req.groups || []).some(g => adminGroups.includes(g));
|
||||
}
|
||||
|
||||
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 };
|
||||
+35
-4
@@ -1,11 +1,12 @@
|
||||
'use strict';
|
||||
|
||||
// model-redis backing (same store the other stack apps use). Table is the
|
||||
// base class; getRedis() exposes the underlying node-redis client for the
|
||||
// counters and sorted-set index in models/metrics.js and models/audit_event.js.
|
||||
// model-redis backing (same store the sibling apps use). Table is the base
|
||||
// class; getRedis() exposes the underlying node-redis client for the counters
|
||||
// and sorted-set index in models/metrics.js and models/audit_event.js.
|
||||
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
const { setUpTable } = require('model-redis');
|
||||
const { createOidcClient, bootstrapLocalAdmin } = require('@simpleworkjs/oidc-client');
|
||||
|
||||
const Table = setUpTable(conf.redis);
|
||||
|
||||
@@ -31,5 +32,35 @@ async function 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');
|
||||
|
||||
// Idempotent anti-lockout local admin (was the IIFE in user_redis.js).
|
||||
bootstrapLocalAdmin(Table.models.User, { defaultName: 'jumpadmin' });
|
||||
|
||||
// Standalone mode: initialize @simpleworkjs/orm for local user/host stores.
|
||||
// The ORM must be loaded before any code calls user_ldap or access — both of
|
||||
// which check conf.standalone.enabled at require time and may delegate to the
|
||||
// ORM-backed wrappers. Model registration is synchronous; table sync is async
|
||||
// but the first query will implicitly wait (Sequelize.sync is in-flight).
|
||||
// Export the promise so integration tests can await it before seeding data.
|
||||
let ormReady = Promise.resolve();
|
||||
if (conf.standalone && conf.standalone.enabled) {
|
||||
const { init } = require('@simpleworkjs/orm');
|
||||
const ormConf = conf.orm || { dialect: 'sqlite', storage: './data/standalone.sqlite', logging: false };
|
||||
ormReady = init({ conf: { orm: ormConf }, models: [require('./standalone_user'), require('./standalone_host')] });
|
||||
}
|
||||
module.exports.ormReady = ormReady;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,30 @@
|
||||
'use strict';
|
||||
|
||||
// ORM model for standalone-mode hosts. Stored in the configured SQL database
|
||||
// (default SQLite) when conf.standalone.enabled is true. The hosts_file.js
|
||||
// wrapper translates between this model and the accessibleHosts() interface
|
||||
// that ssh_server.js expects.
|
||||
|
||||
const { Model, fields } = require('@simpleworkjs/orm');
|
||||
|
||||
// Patch StringField.toSequelize() to pass through primaryKey (same fix as in
|
||||
// standalone_user.js — see that file for details).
|
||||
if (!fields.StringField.prototype.toSequelize.toString().includes('primaryKey')) {
|
||||
const orig = fields.StringField.prototype.toSequelize;
|
||||
fields.StringField.prototype.toSequelize = function () {
|
||||
const def = orig.call(this);
|
||||
if (this.primaryKey) def.primaryKey = true;
|
||||
return def;
|
||||
};
|
||||
}
|
||||
|
||||
class StandaloneHost extends Model {
|
||||
static fields = {
|
||||
slug: { type: 'string', primaryKey: true },
|
||||
displayName: { type: 'string' },
|
||||
kind: { type: 'string', default: 'host' },
|
||||
metadata: { type: 'json', default: {} },
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = StandaloneHost;
|
||||
@@ -0,0 +1,36 @@
|
||||
'use strict';
|
||||
|
||||
// ORM model for standalone-mode users. Stored in the configured SQL database
|
||||
// (default SQLite) when conf.standalone.enabled is true. The user_file.js
|
||||
// wrapper translates between this model and the LDAP-client interface that
|
||||
// ssh_server.js and key_inject.js expect.
|
||||
|
||||
const { Model, fields } = require('@simpleworkjs/orm');
|
||||
|
||||
// Patch: StringField.toSequelize() and IntegerField.toSequelize() don't pass
|
||||
// through primaryKey / autoIncrement (unlike UUIDField which does). Fix them
|
||||
// so string and int primary keys work.
|
||||
const origStringToSeq = fields.StringField.prototype.toSequelize;
|
||||
fields.StringField.prototype.toSequelize = function () {
|
||||
const def = origStringToSeq.call(this);
|
||||
if (this.primaryKey) def.primaryKey = true;
|
||||
return def;
|
||||
};
|
||||
const origIntToSeq = fields.IntegerField.prototype.toSequelize;
|
||||
fields.IntegerField.prototype.toSequelize = function () {
|
||||
const def = origIntToSeq.call(this);
|
||||
if (this.primaryKey) def.primaryKey = true;
|
||||
if (this.autoIncrement) def.autoIncrement = true;
|
||||
return def;
|
||||
};
|
||||
|
||||
class StandaloneUser extends Model {
|
||||
static fields = {
|
||||
uid: { type: 'string', primaryKey: true },
|
||||
passwordHash: { type: 'string', isPrivate: true },
|
||||
sshPublicKeys: { type: 'json', default: [] },
|
||||
groups: { type: 'json', default: [] },
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = StandaloneUser;
|
||||
@@ -0,0 +1,65 @@
|
||||
'use strict';
|
||||
|
||||
// ORM-backed user store for standalone mode. Implements the same interface as
|
||||
// the @simpleworkjs/ldap client so ssh_server.js and key_inject.js work
|
||||
// unchanged: getUser(uid), getGroups(dn), checkPassword(dn, pw), addSshKey(dn, keyLine).
|
||||
//
|
||||
// Users are stored via the StandaloneUser ORM model (Sequelize, any dialect).
|
||||
// DNs are synthetic: uid=<uid>,ou=people,dc=standalone,dc=local — the real
|
||||
// identity is the uid; the DN exists only for interface compatibility with
|
||||
// callers that thread user.dn through to checkPassword / addSshKey.
|
||||
|
||||
const bcrypt = require('bcrypt');
|
||||
const StandaloneUser = require('./standalone_user');
|
||||
|
||||
const DN_PREFIX = 'uid=';
|
||||
const DN_SUFFIX = ',ou=people,dc=standalone,dc=local';
|
||||
|
||||
function dnFor(uid) {
|
||||
return `${DN_PREFIX}${uid}${DN_SUFFIX}`;
|
||||
}
|
||||
|
||||
function uidFromDn(dn) {
|
||||
if (!dn || typeof dn !== 'string') return null;
|
||||
const m = dn.match(/^uid=([^,]+)/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
async function getUser(uid) {
|
||||
const user = await StandaloneUser.get(uid);
|
||||
if (!user) return null;
|
||||
return {
|
||||
dn: dnFor(user.uid),
|
||||
uid: user.uid,
|
||||
sshPublicKeys: user.sshPublicKeys || [],
|
||||
};
|
||||
}
|
||||
|
||||
async function getGroups(dn) {
|
||||
const uid = uidFromDn(dn);
|
||||
if (!uid) return [];
|
||||
const user = await StandaloneUser.get(uid);
|
||||
if (!user) return [];
|
||||
return user.groups || [];
|
||||
}
|
||||
|
||||
async function checkPassword(dn, pw) {
|
||||
const uid = uidFromDn(dn);
|
||||
if (!uid) return false;
|
||||
const user = await StandaloneUser.get(uid);
|
||||
if (!user || !user.passwordHash) return false;
|
||||
return bcrypt.compare(pw, user.passwordHash);
|
||||
}
|
||||
|
||||
async function addSshKey(dn, keyLine) {
|
||||
const uid = uidFromDn(dn);
|
||||
if (!uid) return;
|
||||
const user = await StandaloneUser.get(uid);
|
||||
if (!user) return;
|
||||
const keys = [...(user.sshPublicKeys || [])];
|
||||
if (keys.includes(keyLine)) return; // idempotent
|
||||
keys.push(keyLine);
|
||||
await user.update({ sshPublicKeys: keys });
|
||||
}
|
||||
|
||||
module.exports = { getUser, getGroups, checkPassword, addSshKey };
|
||||
+16
-102
@@ -1,109 +1,23 @@
|
||||
'use strict';
|
||||
|
||||
// Thin LDAP helpers — the jump host's entire LDAP surface:
|
||||
// User authentication backend — LDAP in production, ORM-backed file store in
|
||||
// standalone mode. Both export the same interface:
|
||||
// getUser(uid) -> { dn, uid, sshPublicKeys: [] } or null
|
||||
// getGroups(dn) -> [cn, ...] (groupOfNames membership)
|
||||
// checkPassword(dn, pw) -> bool (simple bind as the user)
|
||||
// addSshKey(dn, keyLine) -> void (idempotent multi-value add)
|
||||
//
|
||||
// Mirrors the patterns in sso-manager-node/nodejs/models/user_ldap.js and
|
||||
// group_ldap.js (ldapts, admin-bound search, bind-as-user password check,
|
||||
// TypeOrValueExists treated as success on key add).
|
||||
// getGroups(dn) -> [cn, ...]
|
||||
// checkPassword(dn, pw) -> bool
|
||||
// addSshKey(dn, keyLine) -> void (idempotent)
|
||||
|
||||
const { Client, Change, Attribute } = require('ldapts');
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
|
||||
function ldapConf() {
|
||||
return conf.ldap || {};
|
||||
}
|
||||
|
||||
function makeClient() {
|
||||
const c = ldapConf();
|
||||
return new Client({
|
||||
url: c.url,
|
||||
tlsOptions: c.tlsOptions || { rejectUnauthorized: false },
|
||||
if (conf.standalone && conf.standalone.enabled) {
|
||||
// Standalone mode: use the ORM-backed user store.
|
||||
module.exports = require('./user_file');
|
||||
} else {
|
||||
// Production mode: use the LDAP directory.
|
||||
const { createLdapClient } = require('@simpleworkjs/ldap');
|
||||
const ldapConf = conf.ldap || {};
|
||||
module.exports = createLdapClient({
|
||||
...ldapConf,
|
||||
tlsOptions: ldapConf.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
+1432
-140
File diff suppressed because it is too large
Load Diff
+18
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "t42-jump-host",
|
||||
"version": "1.0.1",
|
||||
"version": "1.4.0",
|
||||
"description": "SSH jump host for the theta42 stack — LDAP-authenticated, directory-driven host bridging with audit and metrics",
|
||||
"author": [
|
||||
{
|
||||
@@ -19,12 +19,27 @@
|
||||
"test:integration": "NODE_ENV=test node --test --test-force-exit test/integration/*.test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^7.3.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",
|
||||
"@simpleworkjs/orm": "^0.2.8",
|
||||
"bcrypt": "^6.0.0",
|
||||
"bootstrap": "^5.3.8",
|
||||
"compression": "^1.8.1",
|
||||
"ejs": "^3.1.10",
|
||||
"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",
|
||||
"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"
|
||||
},
|
||||
"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';
|
||||
|
||||
// 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');
|
||||
const audit = require('../models/audit_event');
|
||||
const metrics = require('../models/metrics');
|
||||
const registry = require('../services/session_registry');
|
||||
// Authentication (local login + OIDC handshake). Unauthenticated by design.
|
||||
router.use('/auth', require('../models').authRouter);
|
||||
|
||||
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) => {
|
||||
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 (err) { next(err); }
|
||||
});
|
||||
|
||||
router.get('/metrics', async (req, res, next) => {
|
||||
try {
|
||||
res.json({ ...(await metrics.summary()), active: registry.count() });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
// Jump-host data — admin only (audit log, active sessions, metrics).
|
||||
router.use('/', middleware.auth, middleware.requireAdmin, require('./jump'));
|
||||
|
||||
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;
|
||||
@@ -147,12 +147,20 @@ async function resolveAndConnect(state, record, { onHostKey } = {}) {
|
||||
function fail(reason) { const e = new Error(reason); e.reason = reason; return e; }
|
||||
|
||||
async function runGrammar(session, client, state) {
|
||||
const record = await audit.create({ uid: state.uid, authMethod: state.authMethod, clientIp: state.clientIp, mode: 'grammar' });
|
||||
|
||||
// Deferred upstream — attach the bridge NOW, resolve/reject after connect.
|
||||
// Register session listeners IMMEDIATELY — before any async work.
|
||||
// The client sends exec/shell requests right after opening the session;
|
||||
// if we await audit.create() first, those requests arrive before the
|
||||
// listeners are registered and ssh2 rejects them with CHANNEL_FAILURE.
|
||||
let resolveUp, rejectUp;
|
||||
const upstreamPromise = new Promise((res, rej) => { resolveUp = res; rejectUp = rej; });
|
||||
attachSession(session, upstreamPromise, record);
|
||||
const dummyAudit = { patch() {}, finish() {}, event: {} };
|
||||
attachSession(session, upstreamPromise, dummyAudit);
|
||||
|
||||
const record = await audit.create({ uid: state.uid, authMethod: state.authMethod, clientIp: state.clientIp, mode: 'grammar' });
|
||||
// Wire the real audit record into the already-attached session.
|
||||
dummyAudit.patch = (...a) => record.patch(...a);
|
||||
dummyAudit.finish = (...a) => record.finish(...a);
|
||||
Object.defineProperty(dummyAudit, 'event', { get: () => record.event });
|
||||
|
||||
try {
|
||||
const { upstream, host, endpoint } = await resolveAndConnect(state, record, {
|
||||
@@ -280,7 +288,7 @@ function start() {
|
||||
}
|
||||
);
|
||||
|
||||
const port = (conf.ssh && conf.ssh.listenPort) || 2222;
|
||||
const port = (conf.ssh && conf.ssh.listenPort) ?? 2222;
|
||||
const host = (conf.ssh && conf.ssh.listenHost) || '0.0.0.0';
|
||||
server.listen(port, host, () => {
|
||||
console.log(`[ssh] jump host listening on ${host}:${server.address().port}`);
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
'use strict';
|
||||
|
||||
// End-to-end standalone SSH test: a real downstream sshd, the full jump host
|
||||
// SSH server (ssh_server.js), and an SSH client. Authentication and host
|
||||
// discovery use the ORM-backed standalone stores (temp file SQLite).
|
||||
//
|
||||
// Follows the same hermetic pattern as ssh_bridge.test.js but exercises the
|
||||
// full stack: conf → ORM → user_ldap facade → ssh_server → bridge.
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
const { test, before, after } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { Server, Client, utils } = require('ssh2');
|
||||
const bcrypt = require('bcrypt');
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
|
||||
// ── Conf must be set BEFORE any module that checks conf.standalone.enabled ──
|
||||
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jump-host-standalone-'));
|
||||
const dbPath = path.join(tmpDir, 'test.sqlite');
|
||||
|
||||
conf.standalone = { enabled: true };
|
||||
conf.orm = { dialect: 'sqlite', storage: dbPath, logging: false };
|
||||
conf.ssh = {
|
||||
listenHost: '127.0.0.1',
|
||||
listenPort: 0,
|
||||
hostKeyPath: path.join(tmpDir, 'keys'),
|
||||
passwordAuth: 'all',
|
||||
keyComment: 'jump-host-test',
|
||||
defaultPort: 22,
|
||||
connectTimeoutMs: 5000,
|
||||
maxSessions: 10,
|
||||
};
|
||||
conf.redis = { prefix: 'jump_host_test_standalone_' };
|
||||
conf.audit = { maxEvents: 100 };
|
||||
|
||||
// ── Require models/index FIRST so it initializes the ORM exactly once.
|
||||
// This also registers the standalone models. We await ormReady before
|
||||
// seeding data, then start the SSH server. ──
|
||||
|
||||
const models = require('../../models');
|
||||
const StandaloneUser = require('../../models/standalone_user');
|
||||
const StandaloneHost = require('../../models/standalone_host');
|
||||
|
||||
let downstream, downstreamPort, jump, jumpPort;
|
||||
let testUserKey;
|
||||
|
||||
function startDownstream() {
|
||||
return new Promise((resolve) => {
|
||||
const { private: hostKey } = utils.generateKeyPairSync('ed25519');
|
||||
const srv = new Server({ hostKeys: [hostKey] }, (client) => {
|
||||
client.on('authentication', (ctx) => ctx.accept());
|
||||
client.on('ready', () => {
|
||||
client.on('session', (accept) => {
|
||||
const session = accept();
|
||||
session.on('pty', (a) => a && a());
|
||||
session.on('shell', (a) => {
|
||||
const ch = a();
|
||||
ch.write('downstream-shell-ready\n');
|
||||
ch.on('data', (d) => ch.write('echo:' + d));
|
||||
});
|
||||
session.on('exec', (a, r, info) => {
|
||||
const ch = a();
|
||||
ch.write(`ran:${info.command}`);
|
||||
ch.exit(0);
|
||||
ch.end();
|
||||
});
|
||||
session.on('subsystem', (a, r, info) => {
|
||||
if (info.name !== 'sftp') return r && r();
|
||||
const ch = a();
|
||||
ch.on('data', (d) => ch.write(Buffer.concat([Buffer.from('sftp:'), d])));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
srv.listen(0, '127.0.0.1', () => resolve(srv));
|
||||
});
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
// 1. Start downstream.
|
||||
downstream = await startDownstream();
|
||||
downstreamPort = downstream.address().port;
|
||||
|
||||
// 2. Wait for the ORM to finish syncing tables (init was called by models/index
|
||||
// at require time — we just need the tables to exist before seeding).
|
||||
await models.ormReady;
|
||||
|
||||
// 3. Seed test data.
|
||||
const userKeyPair = utils.generateKeyPairSync('ed25519');
|
||||
testUserKey = userKeyPair.private;
|
||||
const userPubKey = utils.parseKey(userKeyPair.private);
|
||||
const userPubLine = `${userPubKey.type} ${userPubKey.getPublicSSH().toString('base64')} testuser@test`;
|
||||
|
||||
const passwordHash = await bcrypt.hash('testpass', 4);
|
||||
|
||||
await StandaloneUser.create({
|
||||
uid: 'testuser',
|
||||
passwordHash,
|
||||
sshPublicKeys: [userPubLine],
|
||||
groups: ['admin'],
|
||||
});
|
||||
|
||||
await StandaloneHost.create({
|
||||
slug: 'host_test',
|
||||
displayName: 'Test Downstream',
|
||||
kind: 'host',
|
||||
metadata: { address: `ssh://127.0.0.1:${downstreamPort}`, ip: '127.0.0.1', sshPort: downstreamPort },
|
||||
});
|
||||
|
||||
// 4. Start the jump host SSH server.
|
||||
const sshServer = require('../../services/ssh_server');
|
||||
jump = sshServer.start();
|
||||
await new Promise((resolve) => {
|
||||
const check = () => {
|
||||
const addr = jump.address();
|
||||
if (addr) { jumpPort = addr.port; resolve(); }
|
||||
else setTimeout(check, 10);
|
||||
};
|
||||
check();
|
||||
});
|
||||
});
|
||||
|
||||
after(() => {
|
||||
try { downstream && downstream.close(); } catch (_) {}
|
||||
try { jump && jump.close(); } catch (_) {}
|
||||
try { models.redisClient.destroy(); } catch (_) {}
|
||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', () => {});
|
||||
|
||||
function connectJump(opts = {}) {
|
||||
const conn = new Client();
|
||||
const connectOpts = {
|
||||
host: '127.0.0.1',
|
||||
port: jumpPort,
|
||||
username: opts.username || 'testuser_-_host_test',
|
||||
...opts,
|
||||
};
|
||||
return {
|
||||
conn,
|
||||
ready: new Promise((res, rej) => {
|
||||
conn.on('ready', res).on('error', rej).connect(connectOpts);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Tests ──
|
||||
|
||||
test('public key auth + grammar mode exec', async () => {
|
||||
const { conn, ready } = connectJump({ privateKey: testUserKey });
|
||||
await ready;
|
||||
const out = await new Promise((resolve, reject) => {
|
||||
conn.exec('hello-world', (err, stream) => {
|
||||
if (err) return reject(err);
|
||||
let buf = '';
|
||||
stream.on('data', (d) => { buf += d; }).on('close', () => resolve(buf));
|
||||
});
|
||||
});
|
||||
conn.end();
|
||||
assert.match(out, /ran:hello-world/);
|
||||
});
|
||||
|
||||
test('public key auth + grammar mode shell', async () => {
|
||||
const { conn, ready } = connectJump({ privateKey: testUserKey });
|
||||
await ready;
|
||||
const out = await new Promise((resolve, reject) => {
|
||||
conn.shell((err, stream) => {
|
||||
if (err) return reject(err);
|
||||
let buf = '';
|
||||
stream.on('data', (d) => {
|
||||
buf += d;
|
||||
if (buf.includes('echo:ping')) resolve(buf);
|
||||
});
|
||||
setTimeout(() => stream.write('ping'), 150);
|
||||
setTimeout(() => resolve(buf), 5000);
|
||||
});
|
||||
});
|
||||
conn.end();
|
||||
assert.match(out, /downstream-shell-ready/);
|
||||
assert.match(out, /echo:ping/);
|
||||
});
|
||||
|
||||
test('password auth + grammar mode exec', async () => {
|
||||
const { conn, ready } = connectJump({
|
||||
username: 'testuser_-_host_test',
|
||||
password: 'testpass',
|
||||
});
|
||||
await ready;
|
||||
const out = await new Promise((resolve, reject) => {
|
||||
conn.exec('pw-test', (err, stream) => {
|
||||
if (err) return reject(err);
|
||||
let buf = '';
|
||||
stream.on('data', (d) => { buf += d; }).on('close', () => resolve(buf));
|
||||
});
|
||||
});
|
||||
conn.end();
|
||||
assert.match(out, /ran:pw-test/);
|
||||
});
|
||||
|
||||
test('password auth denied with wrong password', async () => {
|
||||
const conn = new Client();
|
||||
const result = await new Promise((resolve) => {
|
||||
conn.on('ready', () => resolve('unexpected-ready'));
|
||||
conn.on('error', () => resolve('auth-failed'));
|
||||
conn.connect({
|
||||
host: '127.0.0.1', port: jumpPort,
|
||||
username: 'testuser_-_host_test',
|
||||
password: 'wrongpass',
|
||||
});
|
||||
});
|
||||
assert.strictEqual(result, 'auth-failed');
|
||||
});
|
||||
|
||||
test('unknown user rejected', async () => {
|
||||
const conn = new Client();
|
||||
const result = await new Promise((resolve) => {
|
||||
conn.on('ready', () => resolve('unexpected-ready'));
|
||||
conn.on('error', () => resolve('auth-failed'));
|
||||
conn.connect({
|
||||
host: '127.0.0.1', port: jumpPort,
|
||||
username: 'nobody_-_host_test',
|
||||
password: 'testpass',
|
||||
});
|
||||
});
|
||||
assert.strictEqual(result, 'auth-failed');
|
||||
});
|
||||
@@ -53,3 +53,17 @@ test('caches per uid', async () => {
|
||||
await accessibleHosts(user, { fetchImpl, ldap });
|
||||
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']);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
'use strict';
|
||||
|
||||
// Unit tests for the ORM-backed host inventory (utils/hosts_file.js).
|
||||
// Uses a temp file SQLite database — no external services needed.
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
const { test, before, after } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
const { init } = require('@simpleworkjs/orm');
|
||||
const StandaloneHost = require('../../models/standalone_host');
|
||||
|
||||
let tmpDir;
|
||||
let hostsFile; // required after ORM init
|
||||
|
||||
before(async () => {
|
||||
// Unique temp DB so this test file doesn't collide with other ORM tests.
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jump-host-test-hostfile-'));
|
||||
const dbPath = path.join(tmpDir, 'test.sqlite');
|
||||
|
||||
conf.standalone = { enabled: true };
|
||||
conf.orm = { dialect: 'sqlite', storage: dbPath, logging: false };
|
||||
|
||||
await init({ conf: { orm: conf.orm }, models: [StandaloneHost] });
|
||||
|
||||
await StandaloneHost.create({
|
||||
slug: 'host_web01',
|
||||
displayName: 'Web Server 01',
|
||||
kind: 'host',
|
||||
metadata: { address: 'ssh://10.0.0.10:22', ip: '10.0.0.10', sshPort: 22 },
|
||||
});
|
||||
await StandaloneHost.create({
|
||||
slug: 'host_db',
|
||||
displayName: 'Database Server',
|
||||
kind: 'host',
|
||||
metadata: { address: 'ssh://10.0.0.20:22', ip: '10.0.0.20', sshPort: 22 },
|
||||
});
|
||||
await StandaloneHost.create({
|
||||
slug: 'app_gitea',
|
||||
displayName: 'Gitea',
|
||||
kind: 'service',
|
||||
metadata: { url: 'https://gitea.internal' },
|
||||
});
|
||||
|
||||
hostsFile = require('../../utils/hosts_file');
|
||||
});
|
||||
|
||||
after(() => {
|
||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
|
||||
});
|
||||
|
||||
test('accessibleHosts returns all hosts', async () => {
|
||||
const hosts = await hostsFile.accessibleHosts({ uid: 'alice' });
|
||||
assert.strictEqual(hosts.length, 2);
|
||||
const slugs = hosts.map((h) => h.slug).sort();
|
||||
assert.deepStrictEqual(slugs, ['host_db', 'host_web01']);
|
||||
});
|
||||
|
||||
test('accessibleHosts filters to kind=host', async () => {
|
||||
const hosts = await hostsFile.accessibleHosts({ uid: 'alice' });
|
||||
const kinds = [...new Set(hosts.map((h) => h.kind))];
|
||||
assert.deepStrictEqual(kinds, ['host']);
|
||||
});
|
||||
|
||||
test('accessibleHosts returns host resources with expected shape', async () => {
|
||||
const hosts = await hostsFile.accessibleHosts({ uid: 'alice' });
|
||||
const web = hosts.find((h) => h.slug === 'host_web01');
|
||||
assert.ok(web);
|
||||
assert.strictEqual(web.id, 'host_web01');
|
||||
assert.strictEqual(web.displayName, 'Web Server 01');
|
||||
assert.strictEqual(web.metadata.ip, '10.0.0.10');
|
||||
assert.strictEqual(web.metadata.sshPort, 22);
|
||||
});
|
||||
|
||||
test('accessibleHosts returns empty array when no hosts exist', async () => {
|
||||
// Delete all hosts and verify empty result.
|
||||
const all = await StandaloneHost.list();
|
||||
for (const h of all) {
|
||||
await h.delete({ force: true });
|
||||
}
|
||||
const hosts = await hostsFile.accessibleHosts({ uid: 'alice' });
|
||||
assert.deepStrictEqual(hosts, []);
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
'use strict';
|
||||
|
||||
// Unit tests for the ORM-backed user store (models/user_file.js).
|
||||
// Uses a temp file SQLite database — no external services needed.
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
const { test, before, after } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const bcrypt = require('bcrypt');
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
const { init } = require('@simpleworkjs/orm');
|
||||
const StandaloneUser = require('../../models/standalone_user');
|
||||
|
||||
let testPasswordHash;
|
||||
let tmpDir;
|
||||
let userFile; // required after ORM init
|
||||
|
||||
before(async () => {
|
||||
// Unique temp DB so this test file doesn't collide with other ORM tests.
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jump-host-test-userfile-'));
|
||||
const dbPath = path.join(tmpDir, 'test.sqlite');
|
||||
|
||||
conf.standalone = { enabled: true };
|
||||
conf.orm = { dialect: 'sqlite', storage: dbPath, logging: false };
|
||||
|
||||
await init({ conf: { orm: conf.orm }, models: [StandaloneUser] });
|
||||
|
||||
testPasswordHash = await bcrypt.hash('testpass', 4);
|
||||
|
||||
await StandaloneUser.create({
|
||||
uid: 'alice',
|
||||
passwordHash: testPasswordHash,
|
||||
sshPublicKeys: ['ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... alice@laptop'],
|
||||
groups: ['admin', 'developers'],
|
||||
});
|
||||
|
||||
// Now that the ORM is initialized and conf.standalone is set, require the
|
||||
// facade. It checks conf.standalone.enabled at require time.
|
||||
userFile = require('../../models/user_file');
|
||||
});
|
||||
|
||||
after(() => {
|
||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
|
||||
});
|
||||
|
||||
test('getUser returns user with synthesized dn and keys', async () => {
|
||||
const user = await userFile.getUser('alice');
|
||||
assert.ok(user);
|
||||
assert.strictEqual(user.uid, 'alice');
|
||||
assert.strictEqual(user.dn, 'uid=alice,ou=people,dc=standalone,dc=local');
|
||||
assert.deepStrictEqual(user.sshPublicKeys, ['ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... alice@laptop']);
|
||||
});
|
||||
|
||||
test('getUser returns null for unknown uid', async () => {
|
||||
const user = await userFile.getUser('nobody');
|
||||
assert.strictEqual(user, null);
|
||||
});
|
||||
|
||||
test('getGroups returns user groups', async () => {
|
||||
const groups = await userFile.getGroups('uid=alice,ou=people,dc=standalone,dc=local');
|
||||
assert.deepStrictEqual(groups, ['admin', 'developers']);
|
||||
});
|
||||
|
||||
test('getGroups returns empty array for unknown dn', async () => {
|
||||
const groups = await userFile.getGroups('uid=nobody,ou=people,dc=standalone,dc=local');
|
||||
assert.deepStrictEqual(groups, []);
|
||||
});
|
||||
|
||||
test('getGroups returns empty array for malformed dn', async () => {
|
||||
const groups = await userFile.getGroups('not-a-dn');
|
||||
assert.deepStrictEqual(groups, []);
|
||||
});
|
||||
|
||||
test('checkPassword returns true for correct password', async () => {
|
||||
const ok = await userFile.checkPassword('uid=alice,ou=people,dc=standalone,dc=local', 'testpass');
|
||||
assert.strictEqual(ok, true);
|
||||
});
|
||||
|
||||
test('checkPassword returns false for wrong password', async () => {
|
||||
const ok = await userFile.checkPassword('uid=alice,ou=people,dc=standalone,dc=local', 'wrongpass');
|
||||
assert.strictEqual(ok, false);
|
||||
});
|
||||
|
||||
test('checkPassword returns false for unknown user', async () => {
|
||||
const ok = await userFile.checkPassword('uid=nobody,ou=people,dc=standalone,dc=local', 'testpass');
|
||||
assert.strictEqual(ok, false);
|
||||
});
|
||||
|
||||
test('addSshKey appends a new key', async () => {
|
||||
const newKey = 'ssh-rsa AAAAB3NzaC1yc2E... bob@desktop';
|
||||
await userFile.addSshKey('uid=alice,ou=people,dc=standalone,dc=local', newKey);
|
||||
|
||||
const user = await StandaloneUser.get('alice');
|
||||
assert.ok(user.sshPublicKeys.includes(newKey));
|
||||
});
|
||||
|
||||
test('addSshKey is idempotent', async () => {
|
||||
const key = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... alice@laptop';
|
||||
const userBefore = await StandaloneUser.get('alice');
|
||||
const countBefore = userBefore.sshPublicKeys.length;
|
||||
await userFile.addSshKey('uid=alice,ou=people,dc=standalone,dc=local', key);
|
||||
const userAfter = await StandaloneUser.get('alice');
|
||||
assert.strictEqual(userAfter.sshPublicKeys.length, countBefore);
|
||||
});
|
||||
|
||||
test('addSshKey is a no-op for unknown user', async () => {
|
||||
// Should not throw.
|
||||
await userFile.addSshKey('uid=nobody,ou=people,dc=standalone,dc=local', 'ssh-rsa AAA...');
|
||||
});
|
||||
+71
-54
@@ -1,67 +1,84 @@
|
||||
'use strict';
|
||||
|
||||
// Which directory hosts may a user reach, and how do we dial them?
|
||||
//
|
||||
// v1 resolution (see directory_spec.md §9.2 in sso-manager-node): the SSO's
|
||||
// /api/discovery/me only answers for the API token's own user, and /graph
|
||||
// omits ResourceGroup links — so we combine the user's LDAP groups (queried
|
||||
// directly) with per-group resource lookups:
|
||||
//
|
||||
// 1. LDAP: groups the user's DN is a member of
|
||||
// 2. SSO: GET /api/discovery/resources?group=<cn> per group (ApiToken)
|
||||
// 3. union, keep kind === 'host'
|
||||
//
|
||||
// Results are cached per-uid for a short TTL — the TUI picker and the
|
||||
// username-grammar path share the cache. Dependency-injected fetch/ldap for
|
||||
// unit testing.
|
||||
// Host discovery — SSO Manager API in production, ORM-backed inventory in
|
||||
// standalone mode. Both export the same interface:
|
||||
// accessibleHosts(user) -> [host resources]
|
||||
// clearCache(uid?) -> void
|
||||
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
const userLdap = require('../models/user_ldap');
|
||||
|
||||
const CACHE_TTL_MS = 30 * 1000;
|
||||
const cache = new Map(); // uid -> {at, hosts}
|
||||
if (conf.standalone && conf.standalone.enabled) {
|
||||
// Standalone mode: use the ORM-backed host inventory.
|
||||
const { accessibleHosts } = require('./hosts_file');
|
||||
module.exports = { accessibleHosts, clearCache: () => {} };
|
||||
} else {
|
||||
// Production mode: LDAP groups + SSO API (unchanged).
|
||||
|
||||
async function fetchResourcesByGroup(group, { fetchImpl = fetch } = {}) {
|
||||
const sso = conf.sso || {};
|
||||
const url = `${sso.url}/api/discovery/resources?group=${encodeURIComponent(group)}`;
|
||||
const res = await fetchImpl(url, {
|
||||
headers: { Authorization: `Bearer ${sso.apiToken}` },
|
||||
});
|
||||
if (!res.ok) throw new Error(`directory query failed (${res.status}) for group ${group}`);
|
||||
const data = await res.json();
|
||||
return (data && data.results) || [];
|
||||
}
|
||||
// Which directory hosts may a user reach, and how do we dial them?
|
||||
//
|
||||
// v1 resolution (see directory_spec.md §9.2 in sso-manager-node): the SSO's
|
||||
// /api/discovery/me only answers for the API token's own user, and /graph
|
||||
// omits ResourceGroup links — so we combine the user's LDAP groups (queried
|
||||
// directly) with per-group resource lookups:
|
||||
//
|
||||
// 1. LDAP: groups the user's DN is a member of
|
||||
// 2. SSO: GET /api/discovery/resources?group=<cn> per group (ApiToken)
|
||||
// 3. union, keep kind === 'host'
|
||||
//
|
||||
// Results are cached per-uid for a short TTL — the TUI picker and the
|
||||
// username-grammar path share the cache. Dependency-injected fetch/ldap for
|
||||
// unit testing.
|
||||
|
||||
async function accessibleHosts(user, { fetchImpl = fetch, ldap = userLdap } = {}) {
|
||||
const hit = cache.get(user.uid);
|
||||
if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.hosts;
|
||||
const { createDirectoryClient } = require('@simpleworkjs/directory-schema');
|
||||
const userLdap = require('../models/user_ldap');
|
||||
|
||||
const groups = await ldap.getGroups(user.dn);
|
||||
const CACHE_TTL_MS = 30 * 1000;
|
||||
const cache = new Map(); // uid -> {at, hosts}
|
||||
|
||||
const seen = new Map();
|
||||
for (const cn of groups) {
|
||||
let resources;
|
||||
try {
|
||||
resources = await fetchResourcesByGroup(cn, { fetchImpl });
|
||||
} catch (error) {
|
||||
// One bad group must not hide the rest; the SSO being down
|
||||
// surfaces as an empty list + log line, not a crash.
|
||||
console.error(`[access] ${error.message}`);
|
||||
continue;
|
||||
}
|
||||
for (const r of resources) {
|
||||
if (r.kind === 'host' && !seen.has(r.id)) seen.set(r.id, r);
|
||||
}
|
||||
// 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 || {};
|
||||
return createDirectoryClient({ baseUrl: sso.url, apiToken: sso.apiToken, fetch: fetchImpl });
|
||||
}
|
||||
|
||||
const hosts = [...seen.values()];
|
||||
cache.set(user.uid, { at: Date.now(), hosts });
|
||||
return hosts;
|
||||
}
|
||||
async function fetchResourcesByGroup(group, { fetchImpl = fetch } = {}) {
|
||||
return directoryClient({ fetchImpl }).getResourcesByGroup(group);
|
||||
}
|
||||
|
||||
function clearCache(uid) {
|
||||
if (uid) cache.delete(uid);
|
||||
else cache.clear();
|
||||
}
|
||||
async function accessibleHosts(user, { fetchImpl = fetch, ldap = userLdap } = {}) {
|
||||
const hit = cache.get(user.uid);
|
||||
if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.hosts;
|
||||
|
||||
module.exports = { accessibleHosts, clearCache, fetchResourcesByGroup };
|
||||
const groups = await ldap.getGroups(user.dn);
|
||||
|
||||
const seen = new Map();
|
||||
for (const cn of groups) {
|
||||
let resources;
|
||||
try {
|
||||
resources = await fetchResourcesByGroup(cn, { fetchImpl });
|
||||
} catch (error) {
|
||||
// One bad group must not hide the rest; the SSO being down
|
||||
// surfaces as an empty list + log line, not a crash.
|
||||
console.error(`[access] ${error.message}`);
|
||||
continue;
|
||||
}
|
||||
for (const r of resources) {
|
||||
if (r.kind === 'host' && !seen.has(r.id)) seen.set(r.id, r);
|
||||
}
|
||||
}
|
||||
|
||||
const hosts = [...seen.values()];
|
||||
cache.set(user.uid, { at: Date.now(), hosts });
|
||||
return hosts;
|
||||
}
|
||||
|
||||
function clearCache(uid) {
|
||||
if (uid) cache.delete(uid);
|
||||
else cache.clear();
|
||||
}
|
||||
|
||||
module.exports = { accessibleHosts, clearCache, fetchResourcesByGroup };
|
||||
}
|
||||
|
||||
@@ -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,30 @@
|
||||
'use strict';
|
||||
|
||||
// ORM-backed host inventory for standalone mode. Implements the same interface
|
||||
// as utils/access.js so ssh_server.js works unchanged: accessibleHosts(user)
|
||||
// returns an array of host resources the user may reach.
|
||||
//
|
||||
// In standalone mode all hosts in the inventory are accessible to every
|
||||
// authenticated user — there is no group-based filtering. The _user parameter
|
||||
// is accepted for interface compatibility but ignored.
|
||||
|
||||
const StandaloneHost = require('../models/standalone_host');
|
||||
|
||||
async function accessibleHosts(_user) {
|
||||
const hosts = await StandaloneHost.list({ where: { kind: 'host' } });
|
||||
// The ORM returns model instances; map to plain objects matching the shape
|
||||
// that target_match.js and tui_picker.js expect.
|
||||
return hosts.map((h) => ({
|
||||
id: h.slug, // slug doubles as the stable id in standalone mode
|
||||
kind: h.kind,
|
||||
slug: h.slug,
|
||||
displayName: h.displayName,
|
||||
metadata: h.metadata || {},
|
||||
}));
|
||||
}
|
||||
|
||||
function clearCache() {
|
||||
// No cache in standalone mode — every call reads from the DB.
|
||||
}
|
||||
|
||||
module.exports = { accessibleHosts, clearCache };
|
||||
@@ -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') %>
|
||||
<h1>Audit log</h1>
|
||||
<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>
|
||||
<script type="text/javascript">app.auth.forceLogin();</script>
|
||||
|
||||
<table>
|
||||
<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>
|
||||
<tbody>
|
||||
<% data.results.forEach(e => { %>
|
||||
<tr class="<%= e.success ? '' : 'bad' %>">
|
||||
<td><%= new Date(e.ts).toLocaleString() %></td>
|
||||
<td><%= e.uid %></td>
|
||||
<td><%= e.authMethod %></td>
|
||||
<td><%= e.mode %></td>
|
||||
<td><%= e.targetSlug || e.targetAddr || '—' %></td>
|
||||
<td><%= e.channel || '—' %></td>
|
||||
<td><%= e.clientIp %></td>
|
||||
<td><%= e.success ? '✓' : '✗ ' + e.failReason %></td>
|
||||
<td class="r"><%= (e.bytesIn + e.bytesOut) || 0 %></td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="pager">
|
||||
<% const p = data.page; %>
|
||||
<% if (p > 0) { %><a href="?page=<%= p-1 %>&uid=<%= query.uid||'' %>&target=<%= query.target||'' %>&status=<%= query.status||'' %>">← prev</a><% } %>
|
||||
<span><%= data.total %> events</span>
|
||||
<% if ((p+1) * data.pageSize < data.total) { %><a href="?page=<%= p+1 %>&uid=<%= query.uid||'' %>&target=<%= query.target||'' %>&status=<%= query.status||'' %>">next →</a><% } %>
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header"><i class="fa-solid fa-clipboard-list me-1"></i> Audit log</div>
|
||||
<div class="card-body pb-0">
|
||||
<form class="row g-2 mb-2" onsubmit="applyFilters(); return false;">
|
||||
<div class="col-auto"><input class="form-control form-control-sm" id="f-uid" placeholder="user"></div>
|
||||
<div class="col-auto"><input class="form-control form-control-sm" id="f-target" placeholder="target"></div>
|
||||
<div class="col-auto">
|
||||
<select class="form-select form-select-sm" id="f-status">
|
||||
<option value="">any result</option>
|
||||
<option value="success">success</option>
|
||||
<option value="fail">fail</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto"><button class="btn btn-sm btn-primary">Filter</button></div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<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>
|
||||
<tbody id="audit-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-between align-items-center">
|
||||
<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>
|
||||
|
||||
<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') %>
|
||||
|
||||
+29
-5
@@ -1,6 +1,30 @@
|
||||
</main>
|
||||
<footer class="foot">
|
||||
<% if (typeof buildInfo !== 'undefined') { %><span>v<%= buildInfo.version %> · <%= buildInfo.commit %></span><% } %>
|
||||
</footer>
|
||||
</body>
|
||||
</div><!-- end spa-shell -->
|
||||
|
||||
<!-- Shared UI shell — byte-identical across sso-manager-node, proxy and
|
||||
jump-host. Everything per-app comes from `ui` (utils/ui.js, exposed via
|
||||
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>
|
||||
|
||||
+58
-45
@@ -1,51 +1,64 @@
|
||||
<%- include('top') %>
|
||||
<h1>Dashboard</h1>
|
||||
<div class="tiles">
|
||||
<div class="tile"><span class="n"><%= metrics.active %></span><span class="l">active sessions</span></div>
|
||||
<div class="tile"><span class="n"><%= metrics.total %></span><span class="l">total connections</span></div>
|
||||
<div class="tile"><span class="n"><%= metrics.fail %></span><span class="l">failed</span></div>
|
||||
<script type="text/javascript">app.auth.forceLogin();</script>
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card shadow-sm text-center"><div class="card-body">
|
||||
<div class="display-6" id="stat-active">–</div>
|
||||
<div class="text-muted small text-uppercase">Active sessions</div>
|
||||
</div></div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card shadow-sm text-center"><div class="card-body">
|
||||
<div class="display-6" id="stat-total">–</div>
|
||||
<div class="text-muted small text-uppercase">Total connections</div>
|
||||
</div></div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card shadow-sm text-center"><div class="card-body">
|
||||
<div class="display-6 text-danger" id="stat-fail">–</div>
|
||||
<div class="text-muted small text-uppercase">Failed</div>
|
||||
</div></div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card shadow-sm text-center"><div class="card-body">
|
||||
<div class="display-6" id="stat-users">–</div>
|
||||
<div class="text-muted small text-uppercase">Users seen</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cols">
|
||||
<section>
|
||||
<h2>Active sessions</h2>
|
||||
<% if (!active.length) { %><p class="muted">None right now.</p><% } else { %>
|
||||
<table>
|
||||
<thead><tr><th>User</th><th>Target</th><th>Since</th></tr></thead>
|
||||
<tbody>
|
||||
<% active.forEach(s => { %>
|
||||
<tr><td><%= s.uid %></td><td><%= s.slug || s.target %></td><td><%= new Date(s.startedAt).toLocaleTimeString() %></td></tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</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 class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm"><div class="card-header"><i class="fa-solid fa-server me-1"></i> Top hosts</div>
|
||||
<table class="table table-sm mb-0"><tbody id="top-hosts"></tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm"><div class="card-header"><i class="fa-solid fa-user me-1"></i> Top users</div>
|
||||
<table class="table table-sm mb-0"><tbody id="top-users"></tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h2>Recent connections <a class="more" href="/audit">view all →</a></h2>
|
||||
<table>
|
||||
<thead><tr><th>Time</th><th>User</th><th>Target</th><th>Method</th><th>Result</th></tr></thead>
|
||||
<tbody>
|
||||
<% recent.forEach(e => { %>
|
||||
<tr>
|
||||
<td><%= new Date(e.ts).toLocaleString() %></td>
|
||||
<td><%= e.uid %></td>
|
||||
<td><%= e.targetSlug || e.targetAddr || '—' %></td>
|
||||
<td><%= e.authMethod %> / <%= e.mode %></td>
|
||||
<td><%= e.success ? '✓' : '✗ ' + e.failReason %></td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<script type="text/javascript">
|
||||
function rows(sel, list){
|
||||
var $b = $(sel).empty();
|
||||
if(!list || !list.length){ $b.append('<tr><td class="text-muted">No data.</td></tr>'); return; }
|
||||
list.forEach(function(x){
|
||||
$b.append('<tr><td>' + app.jump.esc(x.name) + '</td><td class="text-end">' + x.count + '</td></tr>');
|
||||
});
|
||||
}
|
||||
$(document).ready(function(){
|
||||
app.jump.metrics(function(error, data){
|
||||
if(error || !data) return;
|
||||
$('#stat-active').text(data.active);
|
||||
$('#stat-total').text(data.total);
|
||||
$('#stat-fail').text(data.fail);
|
||||
$('#stat-users').text((data.topUsers || []).length);
|
||||
rows('#top-hosts', data.topHosts);
|
||||
rows('#top-users', data.topUsers);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<%- include('bottom') %>
|
||||
|
||||
Regular → Executable
+101
-19
@@ -1,19 +1,101 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><%= name %> — Jump Host login</title>
|
||||
<link rel="stylesheet" href="/public/css/app.css">
|
||||
</head>
|
||||
<body class="center">
|
||||
<form method="post" action="/login" class="card login">
|
||||
<h1><%= name %> <small>jump host</small></h1>
|
||||
<% if (error) { %><p class="err"><%= error %></p><% } %>
|
||||
<label>Username <input name="uid" autofocus autocomplete="username"></label>
|
||||
<label>Password <input name="password" type="password" autocomplete="current-password"></label>
|
||||
<button type="submit">Sign in</button>
|
||||
<p class="hint">Admin group required. Uses your directory (LDAP) credentials.</p>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
<%- include('top') %>
|
||||
<script type="text/javascript">
|
||||
|
||||
// If we arrived from the OIDC callback with a token in the URL fragment,
|
||||
// store it and forward on before doing anything else.
|
||||
if(!app.auth.consumeTokenFragment()){
|
||||
// The reveal below touches an element further down this page, so wait
|
||||
// for the DOM — isLoggedIn can answer before the parser gets there.
|
||||
$(document).ready(function(){
|
||||
app.auth.isLoggedIn(function(error, isLoggedIn){
|
||||
if(isLoggedIn){
|
||||
app.auth.logInRedirect();
|
||||
}else{
|
||||
// Reveal the login card once we know the user is not logged in.
|
||||
document.getElementById('login-card-row').style.display = '';
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
</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') %>
|
||||
<h1>Active sessions</h1>
|
||||
<% if (!active.length) { %><p class="muted">No active sessions.</p><% } else { %>
|
||||
<table>
|
||||
<thead><tr><th>User</th><th>Target host</th><th>Address</th><th>Started</th></tr></thead>
|
||||
<tbody>
|
||||
<% active.forEach(s => { %>
|
||||
<tr><td><%= s.uid %></td><td><%= s.slug || '—' %></td><td><%= s.target %></td><td><%= new Date(s.startedAt).toLocaleString() %></td></tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% } %>
|
||||
<script type="text/javascript">app.auth.forceLogin();</script>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span><i class="fa-solid fa-plug-circle-bolt me-1"></i> Active sessions</span>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="loadSessions()"><i class="fa-solid fa-rotate"></i></button>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped mb-0">
|
||||
<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') %>
|
||||
|
||||
+147
-19
@@ -1,21 +1,149 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><%= name %> — Jump Host</title>
|
||||
<link rel="stylesheet" href="/public/css/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="nav">
|
||||
<span class="brand"><%= name %> <small>jump host</small></span>
|
||||
<% if (typeof user !== 'undefined' && user) { %>
|
||||
<span class="spacer"></span>
|
||||
<a href="/">Dashboard</a>
|
||||
<a href="/sessions">Sessions</a>
|
||||
<a href="/audit">Audit</a>
|
||||
<span class="who"><%= user.uid %></span>
|
||||
<form method="post" action="/logout" class="inline"><button class="link">logout</button></form>
|
||||
<% } %>
|
||||
</nav>
|
||||
<main class="wrap">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<title><%- name %> <%- title %></title>
|
||||
<!-- Shared UI shell — byte-identical across sso-manager-node, proxy and
|
||||
jump-host. Everything per-app comes from `ui` (utils/ui.js, exposed
|
||||
via app.locals in app.js). Edit all three copies together. -->
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/svg+xml" href="<%- ui.faviconUrl %>">
|
||||
<!-- CSS are placed here -->
|
||||
<link rel="stylesheet" href="/static-modules/bootstrap/dist/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="/static-modules/@fortawesome/fontawesome-free/css/all.min.css">
|
||||
|
||||
<link rel='stylesheet' href='/static/css/styles.css' />
|
||||
<!-- Scripts are placed here -->
|
||||
<script type="text/javascript" src="/socket.io/socket.io.js"></script>
|
||||
<script type="text/javascript" src='/static-modules/jquery/dist/jquery.js'></script>
|
||||
<script type="text/javascript" src="/static-modules/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script type="text/javascript" src="/static-modules/@fortawesome/fontawesome-free/js/all.min.js"></script>
|
||||
<script type="text/javascript" src='/static-modules/mustache/mustache.min.js'></script>
|
||||
<script type="text/javascript" src='/static-modules/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>
|
||||
|
||||
+45
-2
@@ -11,7 +11,24 @@
|
||||
module.exports = {
|
||||
name: 'My Org',
|
||||
|
||||
// Standalone mode: run with no LDAP directory and no SSO Manager. When
|
||||
// enabled, user auth and host discovery use the ORM-backed stores below
|
||||
// instead of `ldap` + `sso` (both become unused). See the README's
|
||||
// "Standalone mode" section for how to add users/hosts.
|
||||
standalone: {
|
||||
enabled: false,
|
||||
},
|
||||
|
||||
// ORM config for standalone mode (Sequelize — any dialect works, not just
|
||||
// sqlite). Ignored unless standalone.enabled is true.
|
||||
orm: {
|
||||
dialect: 'sqlite',
|
||||
storage: './data/standalone.sqlite',
|
||||
logging: false,
|
||||
},
|
||||
|
||||
// The directory the users live in (the SSO Manager's OpenLDAP).
|
||||
// Unused when standalone.enabled is true.
|
||||
//
|
||||
// IMPORTANT: bindDN needs, beyond read on ou=people + ou=groups, WRITE on
|
||||
// the sshPublicKey attribute of user entries — the jump host injects its
|
||||
@@ -36,6 +53,7 @@ module.exports = {
|
||||
|
||||
// SSO Manager directory (inventory) API. apiToken is a personal access
|
||||
// token (sso_<id>_<secret>) of any user that can read /api/discovery/*.
|
||||
// Unused when standalone.enabled is true.
|
||||
sso: {
|
||||
url: 'https://sso.example.com',
|
||||
apiToken: 'sso_CHANGE_ME',
|
||||
@@ -59,8 +77,33 @@ module.exports = {
|
||||
|
||||
web: { port: 3002 },
|
||||
|
||||
// LDAP groups whose members may use the web UI/API.
|
||||
auth: { adminGroups: ['app_sso_admin'] },
|
||||
// Web UI/API login. Same model as the proxy: OIDC against the SSO for
|
||||
// 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: {
|
||||
prefix: 'jump_host_',
|
||||
|
||||
Reference in New Issue
Block a user