Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a56a31421d | |||
| d33e324b31 | |||
| 4879769cc7 | |||
| da274a3ced | |||
| b9be0fe4e1 | |||
| 9db530565d | |||
| fe6306b7d3 | |||
| 240563e093 | |||
| 047e54ce50 | |||
| ee76088f86 | |||
| 8db46bd9d9 | |||
| 67e2fc54c2 |
@@ -4,6 +4,56 @@ All notable changes to this project are documented here. Format loosely
|
|||||||
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions
|
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions
|
||||||
correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
|
correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
|
||||||
|
|
||||||
|
## [1.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
|
## [1.1.0] - 2026-07-23
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -2,9 +2,13 @@
|
|||||||
|
|
||||||
An SSH jump host for the [theta42](https://github.com/theta42) self-hosted
|
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
|
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
|
entitled to — audited end to end.
|
||||||
the [SSO Manager](https://github.com/theta42/sso-manager-node)'s inventory
|
|
||||||
graph, 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
|
## Two ways to connect
|
||||||
|
|
||||||
@@ -44,8 +48,53 @@ bridged straight in.
|
|||||||
4. **Bridge** — shell, exec, and the SFTP subsystem are spliced to the
|
4. **Bridge** — shell, exec, and the SFTP subsystem are spliced to the
|
||||||
downstream sshd. Every session is audited.
|
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
|
## Requirements
|
||||||
|
|
||||||
|
*(default LDAP + SSO mode — see [Standalone mode](#standalone-mode) to skip
|
||||||
|
all of this)*
|
||||||
|
|
||||||
- The SSO Manager (OpenLDAP directory + `/api/discovery`).
|
- The SSO Manager (OpenLDAP directory + `/api/discovery`).
|
||||||
- Downstream hosts joined via ldap-client (SSSD + `AuthorizedKeysCommand`).
|
- Downstream hosts joined via ldap-client (SSSD + `AuthorizedKeysCommand`).
|
||||||
- An LDAP bind account with **write access to the `sshPublicKey` attribute** on
|
- An LDAP bind account with **write access to the `sshPublicKey` attribute** on
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
title: Jump Host
|
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"
|
url: "https://theta42.github.io"
|
||||||
baseurl: "/jump-host"
|
baseurl: "/jump-host"
|
||||||
logo: /assets/img/theta42.svg
|
logo: /assets/img/theta42.svg
|
||||||
|
|||||||
@@ -112,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,
|
mode (grammar/picker), target slug/address/port, channel type, client IP,
|
||||||
success + failure reason, downstream host-key fingerprint, timing, and bytes in/out.
|
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
|
## Where it sits in the stack
|
||||||
|
|
||||||
- **[SSO Manager](https://theta42.github.io/sso-manager-node/)** — provides the
|
- **[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
|
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.
|
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
|
## Authentication
|
||||||
|
|
||||||
The jump host authenticates **you** against the directory:
|
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
|
layout: default
|
||||||
title: Home
|
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
|
# 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
|
[Proxy](https://theta42.github.io/proxy/), composable with one command via
|
||||||
[theta-env](https://theta42.github.io/theta-env/).
|
[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
|
## Two ways to connect
|
||||||
|
|
||||||
**Direct (WinSCP/SFTP-friendly):**
|
**Direct (WinSCP/SFTP-friendly):**
|
||||||
@@ -79,6 +94,8 @@ This jump host answers both from your directory:
|
|||||||
audit log, per-user/per-host counters
|
audit log, per-user/per-host counters
|
||||||
- **Full audit trail** — who, target, method, result, bytes, duration, and the
|
- **Full audit trail** — who, target, method, result, bytes, duration, and the
|
||||||
downstream host-key fingerprint
|
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
|
- Packaged like the rest of the stack: one-command Docker, idempotent bare-metal
|
||||||
installer, or bundled in theta-env
|
installer, or bundled in theta-env
|
||||||
|
|
||||||
|
|||||||
+46
-1
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
layout: default
|
layout: default
|
||||||
title: Installation
|
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
|
# 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)
|
config through [@simpleworkjs/conf](https://www.npmjs.com/package/@simpleworkjs/conf)
|
||||||
(`conf/base.js` < `conf/<NODE_ENV>.js` < the `CONF_SECRETS` file < `app_*` env).
|
(`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
|
## Requirements
|
||||||
|
|
||||||
- The [SSO Manager](https://theta42.github.io/sso-manager-node/) (OpenLDAP
|
- The [SSO Manager](https://theta42.github.io/sso-manager-node/) (OpenLDAP
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ const app = express();
|
|||||||
app.set('view engine', 'ejs');
|
app.set('view engine', 'ejs');
|
||||||
app.set('views', require('path').join(__dirname, 'views'));
|
app.set('views', require('path').join(__dirname, 'views'));
|
||||||
|
|
||||||
|
// 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(compression());
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(express.urlencoded({extended: false}));
|
app.use(express.urlencoded({extended: false}));
|
||||||
|
|||||||
@@ -98,6 +98,21 @@ module.exports = {
|
|||||||
maxEvents: 50000,
|
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).
|
// Orchestrator-only keys (ignored by the app, read by theta-env).
|
||||||
stack: {},
|
stack: {},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,4 +4,12 @@ module.exports = {
|
|||||||
ssh: {
|
ssh: {
|
||||||
hostKeyPath: './data/keys',
|
hostKeyPath: './data/keys',
|
||||||
},
|
},
|
||||||
|
standalone: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
orm: {
|
||||||
|
dialect: 'sqlite',
|
||||||
|
storage: './data/standalone.sqlite',
|
||||||
|
logging: false,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
// callback. The token carries the group snapshot captured at login.
|
// callback. The token carries the group snapshot captured at login.
|
||||||
|
|
||||||
const conf = require('@simpleworkjs/conf');
|
const conf = require('@simpleworkjs/conf');
|
||||||
const { Auth } = require('../models/auth');
|
const { Auth } = require('../models');
|
||||||
|
|
||||||
async function auth(req, res, next){
|
async function auth(req, res, next){
|
||||||
try{
|
try{
|
||||||
|
|||||||
@@ -1,118 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
const Table = require('../models');
|
|
||||||
const {User, AuthToken} = Table.models;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Auth Model
|
|
||||||
*
|
|
||||||
* Handles authentication operations for the application.
|
|
||||||
* Manages user login, token validation, and logout processes.
|
|
||||||
*
|
|
||||||
* Dependencies:
|
|
||||||
* - User model: Validates user credentials
|
|
||||||
* - AuthToken model: Creates and manages authentication tokens
|
|
||||||
*
|
|
||||||
* All methods throw standardized login errors on failure to avoid
|
|
||||||
* leaking information about whether usernames exist or tokens are valid.
|
|
||||||
*/
|
|
||||||
class Auth{
|
|
||||||
/**
|
|
||||||
* Standardized error responses for authentication failures.
|
|
||||||
* Returns generic "Invalid Credentials" message for security.
|
|
||||||
*/
|
|
||||||
static errors = {
|
|
||||||
login: function(){
|
|
||||||
let error = new Error('LoginFailed');
|
|
||||||
error.name = 'LoginFailed';
|
|
||||||
error.message = `Invalid Credentials, login failed.`;
|
|
||||||
error.status = 401;
|
|
||||||
|
|
||||||
return error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Authenticate user and create session token.
|
|
||||||
*
|
|
||||||
* @param {Object} data - Login credentials {username, password}
|
|
||||||
* @returns {Object} {user, token} - User object and auth token
|
|
||||||
* @throws {Error} Generic login error on any failure
|
|
||||||
*
|
|
||||||
* Flow:
|
|
||||||
* 1. Validate credentials via User.login()
|
|
||||||
* 2. Create new AuthToken for the user
|
|
||||||
* 3. Return both user data and token
|
|
||||||
*/
|
|
||||||
static async login(data){
|
|
||||||
try{
|
|
||||||
let user = await User.login(data);
|
|
||||||
// Backends may attach group membership to the user (LDAP); default
|
|
||||||
// to none for local/redis users.
|
|
||||||
let groups = Array.isArray(user.groups) ? user.groups : [];
|
|
||||||
let token = await AuthToken.create({username: user.username, groups});
|
|
||||||
|
|
||||||
return {user, token}
|
|
||||||
}catch(error){
|
|
||||||
console.log('login error', error);
|
|
||||||
throw this.errors.login();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Establish a session for an OIDC-authenticated identity: JIT-provision the
|
|
||||||
* local user (redis-backed) and mint an AuthToken carrying the SSO groups.
|
|
||||||
*
|
|
||||||
* @param {Object} identity - {username, groups} from utils/oidc claims
|
|
||||||
* @returns {Object} {user, token}
|
|
||||||
*/
|
|
||||||
static async oidcSession(identity){
|
|
||||||
let user = typeof User.upsertOidc === 'function'
|
|
||||||
? await User.upsertOidc(identity)
|
|
||||||
: await User.get(identity.username);
|
|
||||||
let token = await AuthToken.create({
|
|
||||||
username: user.username,
|
|
||||||
groups: identity.groups || [],
|
|
||||||
});
|
|
||||||
|
|
||||||
return {user, token};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate an authentication token.
|
|
||||||
*
|
|
||||||
* @param {string} token - Token string to validate
|
|
||||||
* @returns {Object} Token object if valid
|
|
||||||
* @throws {Error} Generic login error if token invalid or expired
|
|
||||||
*
|
|
||||||
* Checks:
|
|
||||||
* 1. Token exists in database
|
|
||||||
* 2. Token has not expired (via token.check())
|
|
||||||
*/
|
|
||||||
static async checkToken(token){
|
|
||||||
try{
|
|
||||||
token = await AuthToken.get(token);
|
|
||||||
if(token && token.check()) return token;
|
|
||||||
|
|
||||||
throw this.errors.login();
|
|
||||||
}catch(error){
|
|
||||||
console.log('check error', error);
|
|
||||||
throw this.errors.login();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Destroy an authentication token (logout).
|
|
||||||
*
|
|
||||||
* @param {string} data - Token string to destroy
|
|
||||||
* @returns {void}
|
|
||||||
*
|
|
||||||
* Removes token from database, invalidating the session.
|
|
||||||
*/
|
|
||||||
static async logout(data){
|
|
||||||
let token = await AuthToken.get(data);
|
|
||||||
await token.destroy();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {Auth};
|
|
||||||
@@ -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 };
|
|
||||||
+31
-3
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
const conf = require('@simpleworkjs/conf');
|
const conf = require('@simpleworkjs/conf');
|
||||||
const { setUpTable } = require('model-redis');
|
const { setUpTable } = require('model-redis');
|
||||||
|
const { createOidcClient, bootstrapLocalAdmin } = require('@simpleworkjs/oidc-client');
|
||||||
|
|
||||||
const Table = setUpTable(conf.redis);
|
const Table = setUpTable(conf.redis);
|
||||||
|
|
||||||
@@ -32,7 +33,34 @@ async function getRedis() {
|
|||||||
module.exports.getRedis = getRedis;
|
module.exports.getRedis = getRedis;
|
||||||
|
|
||||||
// Register models (order matters: User before AuthToken's relation resolves).
|
// Register models (order matters: User before AuthToken's relation resolves).
|
||||||
require('./user_redis');
|
require('./user_redis'); // User (redis-backed local + OIDC JIT)
|
||||||
require('./token');
|
|
||||||
require('./oidc_state');
|
// Shared OIDC client (authorization-code + PKCE): session models (Token,
|
||||||
|
// AuthToken, OidcState), the Auth service, and the /login /logout /oidc/start
|
||||||
|
// /oidc/callback router — all created on this app's Table/redis. jump-host has
|
||||||
|
// no Bearer PATs, so checkApiToken is omitted (Auth.checkApiToken is absent).
|
||||||
|
const oidcClient = createOidcClient({ Table });
|
||||||
|
module.exports.Token = oidcClient.Token;
|
||||||
|
module.exports.AuthToken = oidcClient.AuthToken;
|
||||||
|
module.exports.OidcState = oidcClient.OidcState;
|
||||||
|
module.exports.Auth = oidcClient.Auth;
|
||||||
|
module.exports.authRouter = oidcClient.router;
|
||||||
|
|
||||||
require('./audit_event');
|
require('./audit_event');
|
||||||
|
|
||||||
|
// Idempotent anti-lockout local admin (was the IIFE in user_redis.js).
|
||||||
|
bootstrapLocalAdmin(Table.models.User, { defaultName: 'jumpadmin' });
|
||||||
|
|
||||||
|
// 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,31 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
const Table = require('.');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* OidcState
|
|
||||||
*
|
|
||||||
* Short-lived store for an in-flight OpenID Connect authorization request.
|
|
||||||
* Keyed by the random `state` value; holds the PKCE `code_verifier` and the
|
|
||||||
* post-login redirect target until the SSO calls us back.
|
|
||||||
*
|
|
||||||
* The record auto-expires via model-redis per-key TTL (static _ttl), so an
|
|
||||||
* abandoned login attempt leaves nothing behind and there is no cleanup job.
|
|
||||||
*/
|
|
||||||
class OidcState extends Table{
|
|
||||||
static _key = 'state';
|
|
||||||
|
|
||||||
// Auth round-trips are quick; 5 minutes is plenty and bounds replay.
|
|
||||||
static _ttl = 300;
|
|
||||||
|
|
||||||
static _keyMap = {
|
|
||||||
'created_on': {default: function(){return (new Date).getTime()}},
|
|
||||||
'state': {isRequired: true, type: 'string', min: 8, max: 500},
|
|
||||||
'codeVerifier': {isRequired: true, type: 'string', min: 8, max: 500},
|
|
||||||
'redirect': {default: '/', isRequired: false, type: 'string'},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
OidcState.register();
|
|
||||||
|
|
||||||
module.exports = {OidcState};
|
|
||||||
@@ -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;
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
const Table = require('.');
|
|
||||||
const UUID = function b(a){return a?(a^Math.random()*16>>a/4).toString(16):([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g,b)};
|
|
||||||
|
|
||||||
|
|
||||||
class Token extends Table{
|
|
||||||
static _key = 'token';
|
|
||||||
static _keyMap = {
|
|
||||||
'created_by': {isRequired: true, type: 'string', min: 3, max: 500},
|
|
||||||
'created_on': {default: function(){return (new Date).getTime()}},
|
|
||||||
'updated_on': {default: function(){return (new Date).getTime()}, always: true},
|
|
||||||
'token': {default: UUID, type: 'string', min: 36, max: 36, isPrivate: true},
|
|
||||||
'is_valid': {default: true, type: 'boolean'},
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(...args){
|
|
||||||
super(...args);
|
|
||||||
}
|
|
||||||
|
|
||||||
async check(){
|
|
||||||
try{
|
|
||||||
return this.is_valid;
|
|
||||||
}catch(error){
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Token.register();
|
|
||||||
|
|
||||||
class AuthToken extends Token{
|
|
||||||
static _keyMap = {
|
|
||||||
...super._keyMap,
|
|
||||||
user: {model: 'User', rel: 'one', localKey: 'created_by'},
|
|
||||||
// Group memberships captured at login (OIDC `groups` claim or LDAP
|
|
||||||
// group membership), stored as a JSON string. Drives authorization for
|
|
||||||
// the life of the session without re-querying the IdP on every request.
|
|
||||||
groups: {default: '[]', isRequired: false, type: 'string'},
|
|
||||||
}
|
|
||||||
|
|
||||||
static async create(data){
|
|
||||||
data.created_by = data.username;
|
|
||||||
if(Array.isArray(data.groups)){
|
|
||||||
data.groups = JSON.stringify(data.groups);
|
|
||||||
}
|
|
||||||
return super.create(data)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse the stored groups JSON back into an array, tolerating bad/missing
|
|
||||||
// data so authorization never crashes on a malformed token.
|
|
||||||
groupsArray(){
|
|
||||||
try{
|
|
||||||
let parsed = JSON.parse(this.groups);
|
|
||||||
return Array.isArray(parsed) ? parsed : [];
|
|
||||||
}catch(error){
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
AuthToken.register();
|
|
||||||
|
|
||||||
module.exports = {Token, AuthToken};
|
|
||||||
@@ -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';
|
'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
|
// getUser(uid) -> { dn, uid, sshPublicKeys: [] } or null
|
||||||
// getGroups(dn) -> [cn, ...] (groupOfNames membership)
|
// getGroups(dn) -> [cn, ...]
|
||||||
// checkPassword(dn, pw) -> bool (simple bind as the user)
|
// checkPassword(dn, pw) -> bool
|
||||||
// addSshKey(dn, keyLine) -> void (idempotent multi-value add)
|
// addSshKey(dn, keyLine) -> void (idempotent)
|
||||||
//
|
|
||||||
// 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).
|
|
||||||
|
|
||||||
const { Client, Change, Attribute } = require('ldapts');
|
|
||||||
const conf = require('@simpleworkjs/conf');
|
const conf = require('@simpleworkjs/conf');
|
||||||
|
|
||||||
function ldapConf() {
|
if (conf.standalone && conf.standalone.enabled) {
|
||||||
return conf.ldap || {};
|
// Standalone mode: use the ORM-backed user store.
|
||||||
}
|
module.exports = require('./user_file');
|
||||||
|
} else {
|
||||||
function makeClient() {
|
// Production mode: use the LDAP directory.
|
||||||
const c = ldapConf();
|
const { createLdapClient } = require('@simpleworkjs/ldap');
|
||||||
return new Client({
|
const ldapConf = conf.ldap || {};
|
||||||
url: c.url,
|
module.exports = createLdapClient({
|
||||||
tlsOptions: c.tlsOptions || { rejectUnauthorized: false },
|
...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 };
|
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
const Table = require('.');
|
const Table = require('.');
|
||||||
const bcrypt = require('bcrypt');
|
const bcrypt = require('bcrypt');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const conf = require('@simpleworkjs/conf');
|
|
||||||
const saltRounds = 10;
|
const saltRounds = 10;
|
||||||
|
|
||||||
class User extends Table{
|
class User extends Table{
|
||||||
@@ -86,38 +85,6 @@ class User extends Table{
|
|||||||
|
|
||||||
User.register();
|
User.register();
|
||||||
|
|
||||||
(async function(){
|
// Anti-lockout local-admin bootstrap moved to @simpleworkjs/oidc-client
|
||||||
// The anti-lockout local admin: the first entry in conf.auth.adminUsers
|
// (bootstrapLocalAdmin); invoked once from models/index.js after User is
|
||||||
// (default 'jumpadmin'). A local login that works even if the SSO/OIDC is
|
// registered. See the package lib/bootstrap.js for the original logic.
|
||||||
// unreachable — the whole point of "OIDC + internal users".
|
|
||||||
var defaultUser = (conf.auth && conf.auth.adminUsers && conf.auth.adminUsers[0]) || 'jumpadmin';
|
|
||||||
// Optional: an orchestrator (e.g. theta-env's setup.sh) can set
|
|
||||||
// auth.localAdminPass in jump-secrets.js to a generated password so this
|
|
||||||
// bootstrap account isn't left at a well-known default. Only used on first
|
|
||||||
// creation -- once the account exists this is never read again, so it's
|
|
||||||
// safe to leave set. If unset, a random password is generated and printed
|
|
||||||
// once; save it from the log or set auth.localAdminPass explicitly.
|
|
||||||
var defaultPass = (conf.auth && conf.auth.localAdminPass);
|
|
||||||
if (!defaultPass) {
|
|
||||||
defaultPass = crypto.randomBytes(16).toString('hex');
|
|
||||||
console.warn(`====================================================================`);
|
|
||||||
console.warn(`Bootstrap admin "${defaultUser}" created with random password:`);
|
|
||||||
console.warn(`${defaultPass}`);
|
|
||||||
console.warn(`Set auth.localAdminPass in your secrets file to make this deterministic.`);
|
|
||||||
console.warn(`====================================================================`);
|
|
||||||
}
|
|
||||||
try{
|
|
||||||
let user = await User.get(defaultUser);
|
|
||||||
}catch(error){
|
|
||||||
try{
|
|
||||||
let user = await User.create({
|
|
||||||
username:defaultUser,
|
|
||||||
password: defaultPass,
|
|
||||||
created_by: defaultUser
|
|
||||||
});
|
|
||||||
console.log(defaultUser, 'created');
|
|
||||||
}catch(error){
|
|
||||||
console.error(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|||||||
Generated
+962
-153
File diff suppressed because it is too large
Load Diff
+9
-4
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-jump-host",
|
"name": "t42-jump-host",
|
||||||
"version": "1.1.0",
|
"version": "1.4.0",
|
||||||
"description": "SSH jump host for the theta42 stack — LDAP-authenticated, directory-driven host bridging with audit and metrics",
|
"description": "SSH jump host for the theta42 stack — LDAP-authenticated, directory-driven host bridging with audit and metrics",
|
||||||
"author": [
|
"author": [
|
||||||
{
|
{
|
||||||
@@ -21,6 +21,11 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||||
"@simpleworkjs/conf": "^1.2.0",
|
"@simpleworkjs/conf": "^1.2.0",
|
||||||
|
"@simpleworkjs/app-stack": "^1.0.0",
|
||||||
|
"@simpleworkjs/ldap": "^1.0.0",
|
||||||
|
"@simpleworkjs/directory-schema": "^1.0.0",
|
||||||
|
"@simpleworkjs/oidc-client": "^1.0.0",
|
||||||
|
"@simpleworkjs/orm": "^0.2.8",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
"compression": "^1.8.1",
|
"compression": "^1.8.1",
|
||||||
@@ -28,12 +33,12 @@
|
|||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"express-rate-limit": "^8.5.2",
|
"express-rate-limit": "^8.5.2",
|
||||||
"jq-repeat": "^2.2.0",
|
"jq-repeat": "^2.2.0",
|
||||||
"jquery": "^3.7.1",
|
"jquery": "^4.0.0",
|
||||||
"ldapts": "^8.1.2",
|
"ldapts": "^8.1.8",
|
||||||
"model-redis": "^1.6.0",
|
"model-redis": "^1.6.0",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"mustache": "^4.2.0",
|
"mustache": "^4.2.0",
|
||||||
"redis": "^4.7.0",
|
"redis": "^6.1.0",
|
||||||
"socket.io": "^4.8.3",
|
"socket.io": "^4.8.3",
|
||||||
"ssh2": "^1.16.0"
|
"ssh2": "^1.16.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,3 +18,7 @@ body {
|
|||||||
.card-title{
|
.card-title{
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.group-required{
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,12 @@
|
|||||||
|
// 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 = {};
|
var app = {};
|
||||||
|
|
||||||
app.pubsub = (function(){
|
app.pubsub = (function(){
|
||||||
@@ -45,7 +54,7 @@ app.pubsub = (function(){
|
|||||||
app.socket = (function(app){
|
app.socket = (function(app){
|
||||||
// $.getScript('/socket.io/socket.io.js')
|
// $.getScript('/socket.io/socket.io.js')
|
||||||
// <script type="text/javascript" src="/socket.io/socket.io.js"></script>
|
// <script type="text/javascript" src="/socket.io/socket.io.js"></script>
|
||||||
|
|
||||||
var socket;
|
var socket;
|
||||||
$(document).ready(function(){
|
$(document).ready(function(){
|
||||||
socket = io({
|
socket = io({
|
||||||
@@ -75,10 +84,26 @@ app.socket = (function(app){
|
|||||||
app.api = (function(app){
|
app.api = (function(app){
|
||||||
var baseURL = '/api/'
|
var baseURL = '/api/'
|
||||||
|
|
||||||
function post(url, data, callback){
|
// post/put/delete are dual-mode: pass a callback for the node-style
|
||||||
if(typeof callback !== 'function') callback = callback2;
|
// (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({
|
return $.ajax({
|
||||||
type: 'POST',
|
type: method,
|
||||||
url: baseURL+url,
|
url: baseURL+url,
|
||||||
headers:{
|
headers:{
|
||||||
'auth-token': app.auth.getToken()
|
'auth-token': app.auth.getToken()
|
||||||
@@ -87,40 +112,44 @@ app.api = (function(app){
|
|||||||
contentType: "application/json; charset=utf-8",
|
contentType: "application/json; charset=utf-8",
|
||||||
dataType: "json",
|
dataType: "json",
|
||||||
complete: function(res, text){
|
complete: function(res, text){
|
||||||
callback ? callback(
|
callback(
|
||||||
text !== 'success' ? res.statusText : null,
|
text !== 'success' ? res.statusText : null,
|
||||||
JSON.parse(res.responseText),
|
JSON.parse(res.responseText),
|
||||||
res.status
|
res.status
|
||||||
) : function(){}
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function post(url, data, callback){
|
||||||
|
return body('POST', url, data, callback);
|
||||||
|
}
|
||||||
|
|
||||||
function put(url, data, callback){
|
function put(url, data, callback){
|
||||||
if(typeof callback !== 'function') callback = callback2;
|
return body('PUT', url, data, callback);
|
||||||
return $.ajax({
|
|
||||||
type: 'PUT',
|
|
||||||
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 ? callback(
|
|
||||||
text !== 'success' ? res.statusText : null,
|
|
||||||
JSON.parse(res.responseText),
|
|
||||||
res.status
|
|
||||||
) : function(){}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function remove(url, callback, callback2){
|
// Called both as (url, callback) and — from formAJAX, which always passes
|
||||||
if(typeof callback !== 'function') callback = callback2;
|
// 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({
|
return $.ajax({
|
||||||
type: 'delete',
|
type: 'DELETE',
|
||||||
url: baseURL+url,
|
url: baseURL+url,
|
||||||
headers:{
|
headers:{
|
||||||
'auth-token': app.auth.getToken()
|
'auth-token': app.auth.getToken()
|
||||||
@@ -128,11 +157,11 @@ app.api = (function(app){
|
|||||||
contentType: "application/json; charset=utf-8",
|
contentType: "application/json; charset=utf-8",
|
||||||
dataType: "json",
|
dataType: "json",
|
||||||
complete: function(res, text){
|
complete: function(res, text){
|
||||||
callback ? callback(
|
callback(
|
||||||
text !== 'success' ? res.statusText : null,
|
text !== 'success' ? res.statusText : null,
|
||||||
JSON.parse(res.responseText),
|
JSON.parse(res.responseText),
|
||||||
res.status
|
res.status
|
||||||
) : function(){}
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -179,7 +208,11 @@ app.api = (function(app){
|
|||||||
})(app)
|
})(app)
|
||||||
|
|
||||||
app.auth = (function(app){
|
app.auth = (function(app){
|
||||||
var user = {}
|
// 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){
|
function setToken(token){
|
||||||
localStorage.setItem('APIToken', token);
|
localStorage.setItem('APIToken', token);
|
||||||
}
|
}
|
||||||
@@ -188,18 +221,95 @@ app.auth = (function(app){
|
|||||||
return localStorage.getItem('APIToken');
|
return localStorage.getItem('APIToken');
|
||||||
}
|
}
|
||||||
|
|
||||||
function isLoggedIn(callback){
|
async function getUser(){
|
||||||
if(getToken()){
|
try{
|
||||||
return app.api.get('user/me', function(error, data){
|
return await app.api.get('user/me');
|
||||||
// data now carries effective rights (isAdmin, global, domains).
|
}catch(error){
|
||||||
if(!error) app.auth.user = app.auth.perms = data;
|
if(error && error.status === 401) return null;
|
||||||
return callback(error, data);
|
throw error;
|
||||||
});
|
|
||||||
}else{
|
|
||||||
callback(null, false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// Constrain a redirect target to a same-origin absolute path. Rejects
|
||||||
// absolute URLs (open redirect), protocol-relative "//host" and "/\host",
|
// absolute URLs (open redirect), protocol-relative "//host" and "/\host",
|
||||||
// and non-path schemes like "javascript:" (XSS). Falls back to "/".
|
// and non-path schemes like "javascript:" (XSS). Falls back to "/".
|
||||||
@@ -230,46 +340,68 @@ app.auth = (function(app){
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// True when the logged-in user is a global admin (per user/me).
|
// Page-level gate. jQuery 4 removed $.holdReady, so an unauthenticated or
|
||||||
function isAdmin(){
|
// unauthorised user is kept off the page by a redirect / an error panel
|
||||||
return !!(app.auth.perms && app.auth.perms.isAdmin);
|
// rather than by pausing document ready.
|
||||||
}
|
//
|
||||||
|
// `requiredGroups` is a group CN or an OR-list of them; the synthetic
|
||||||
function logIn(args, callback){
|
// `admin` group covers the OIDC clients' isAdmin flag.
|
||||||
app.api.post('auth/login', args, function(error, data){
|
async function forceLogin(requiredGroups){
|
||||||
if(data.login){
|
var user = await loadUser();
|
||||||
setToken(data.token);
|
|
||||||
}
|
if(!user){
|
||||||
callback(error, !!data.token);
|
logOut(function(){});
|
||||||
});
|
location.replace('/login?redirect=' + encodeURIComponent(
|
||||||
}
|
location.pathname + location.search
|
||||||
|
));
|
||||||
function logOut(callback){
|
return false;
|
||||||
localStorage.removeItem('APIToken');
|
}
|
||||||
callback();
|
|
||||||
}
|
if(user.onboardingRequired && location.pathname !== '/onboarding'){
|
||||||
|
location.replace('/onboarding');
|
||||||
function forceLogin(){
|
return false;
|
||||||
// jQuery 4 removed $.holdReady; rely on the redirect below to keep an
|
}
|
||||||
// unauthenticated user off the page instead of pausing document ready.
|
|
||||||
app.auth.isLoggedIn(function(error, isLoggedIn){
|
if(requiredGroups && !await memberOf(requiredGroups, user)){
|
||||||
if(error || !isLoggedIn){
|
app.util.actionMessage(
|
||||||
app.auth.logOut(function(){})
|
`<h1>
|
||||||
location.replace(`/login${location.href.replace(location.origin, '')}`);
|
<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(){
|
function logInRedirect(){
|
||||||
window.location.href = safeInternalPath(location.href.replace(location.origin+'/login', '') || '/')
|
var params = new URLSearchParams(location.search);
|
||||||
|
var target = params.get('redirect')
|
||||||
|
|| location.href.replace(location.origin + '/login', '')
|
||||||
|
|| '/';
|
||||||
|
window.location.href = safeInternalPath(target);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getToken: getToken,
|
getToken: getToken,
|
||||||
setToken: setToken,
|
setToken: setToken,
|
||||||
isLoggedIn: isLoggedIn,
|
getUser: getUser,
|
||||||
consumeTokenFragment: consumeTokenFragment,
|
loadUser: loadUser,
|
||||||
|
groupCNs: groupCNs,
|
||||||
|
memberOf: memberOf,
|
||||||
isAdmin: isAdmin,
|
isAdmin: isAdmin,
|
||||||
|
isLoggedIn: isLoggedIn,
|
||||||
|
safeInternalPath: safeInternalPath,
|
||||||
|
consumeTokenFragment: consumeTokenFragment,
|
||||||
|
user: null,
|
||||||
perms: null,
|
perms: null,
|
||||||
logIn: logIn,
|
logIn: logIn,
|
||||||
logOut: logOut,
|
logOut: logOut,
|
||||||
@@ -279,6 +411,11 @@ app.auth = (function(app){
|
|||||||
|
|
||||||
})(app);
|
})(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){
|
app.user = (function(app){
|
||||||
function list(callback){
|
function list(callback){
|
||||||
app.api.get('user/?detail=true', function(error, data){
|
app.api.get('user/?detail=true', function(error, data){
|
||||||
@@ -308,6 +445,8 @@ app.user = (function(app){
|
|||||||
|
|
||||||
})(app);
|
})(app);
|
||||||
|
|
||||||
|
// Local (app-managed) permissions and groups. Only the OIDC-client apps serve
|
||||||
|
// these endpoints; the calls are inert elsewhere.
|
||||||
app.permission = (function(app){
|
app.permission = (function(app){
|
||||||
function list(callback){
|
function list(callback){
|
||||||
app.api.get('permission/', function(error, data){
|
app.api.get('permission/', function(error, data){
|
||||||
@@ -381,9 +520,12 @@ app.util = (function(app){
|
|||||||
return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' '));
|
return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' '));
|
||||||
};
|
};
|
||||||
|
|
||||||
function actionMessage(message, $target, type, callback){
|
function actionMessage(message, $targetPassed, type, callback){
|
||||||
message = message || '';
|
message = message || '';
|
||||||
$target = $target.closest('div.card').find('.actionMessage');
|
|
||||||
|
let $target = $targetPassed.closest('div.card').find('.actionMessage');
|
||||||
|
if(!$target.length) $target = $($targetPassed.find('.actionMessage')[0]);
|
||||||
|
|
||||||
type = type || 'info';
|
type = type || 'info';
|
||||||
callback = callback || function(){};
|
callback = callback || function(){};
|
||||||
|
|
||||||
@@ -400,12 +542,48 @@ app.util = (function(app){
|
|||||||
})
|
})
|
||||||
}else{
|
}else{
|
||||||
if(type) $target.addClass('bg-' + type);
|
if(type) $target.addClass('bg-' + type);
|
||||||
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>'
|
|
||||||
|
// 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');
|
$target.html(message).slideDown('fast');
|
||||||
}
|
}
|
||||||
setTimeout(callback,10)
|
setTimeout(callback,10)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function actionConfirm(message, $target, type, callback){
|
||||||
|
return new Promise((resolve, reject) =>{
|
||||||
|
let id = crypto.randomUUID();
|
||||||
|
message = `
|
||||||
|
<h4 class"align-middle" >
|
||||||
|
<i class="fa-solid fa-triangle-exclamation"></i>
|
||||||
|
<b>${message}</b>
|
||||||
|
<span class="float-end">
|
||||||
|
<button type="button" class="btn btn-success confirm-${id}" data-confirm="true">
|
||||||
|
<i class="fa-solid fa-circle-check"></i>
|
||||||
|
Confirm
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-danger confirm-${id}">
|
||||||
|
<i class="fa-solid fa-circle-stop"></i>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</h4>
|
||||||
|
`
|
||||||
|
actionMessage(message, $target, type);
|
||||||
|
$("body").on('click', `.confirm-${id}`, function(){
|
||||||
|
actionMessage('', $target, type);
|
||||||
|
resolve(!!$(this).data('confirm'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
$.fn.serializeObject = function() {
|
$.fn.serializeObject = function() {
|
||||||
var obj = {};
|
var obj = {};
|
||||||
|
|
||||||
@@ -462,11 +640,42 @@ app.util = (function(app){
|
|||||||
return {
|
return {
|
||||||
downloadFile: downloadFile,
|
downloadFile: downloadFile,
|
||||||
getUrlParameter: getUrlParameter,
|
getUrlParameter: getUrlParameter,
|
||||||
actionMessage: actionMessage
|
actionMessage: actionMessage,
|
||||||
|
actionConfirm,
|
||||||
}
|
}
|
||||||
})(app);
|
})(app);
|
||||||
|
|
||||||
$( document ).ready(function(){
|
// 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
|
$('div.row').fadeIn('slow'); //show the page
|
||||||
|
|
||||||
//panel button's
|
//panel button's
|
||||||
@@ -520,12 +729,14 @@ function formAJAX(btn){
|
|||||||
var method = ($form.attr('method') || 'post').toLowerCase();
|
var method = ($form.attr('method') || 'post').toLowerCase();
|
||||||
|
|
||||||
if($form.validate && !$form.validate()){
|
if($form.validate && !$form.validate()){
|
||||||
app.util.actionMessage('Please fix the form errors.', $form, 'danger');
|
app.util.actionMessage('Please fix the form errors.', $form, 'danger')
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
app.util.actionMessage(
|
app.util.actionMessage(
|
||||||
'<div class="spinner-border" role="status"><span class="sr-only">Loading...</span></div>',
|
`<div class="spinner-border" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>`,
|
||||||
$form,
|
$form,
|
||||||
'info'
|
'info'
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const router = require('express').Router();
|
|||||||
const middleware = require('../middleware/auth');
|
const middleware = require('../middleware/auth');
|
||||||
|
|
||||||
// Authentication (local login + OIDC handshake). Unauthenticated by design.
|
// Authentication (local login + OIDC handshake). Unauthenticated by design.
|
||||||
router.use('/auth', require('./auth'));
|
router.use('/auth', require('../models').authRouter);
|
||||||
|
|
||||||
// Who am I — needs a valid session but no admin gate (drives the login state).
|
// Who am I — needs a valid session but no admin gate (drives the login state).
|
||||||
router.use('/user', middleware.auth, require('./user'));
|
router.use('/user', middleware.auth, require('./user'));
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
const router = require('express').Router();
|
|
||||||
const { rateLimit } = require('express-rate-limit');
|
|
||||||
const conf = require('@simpleworkjs/conf');
|
|
||||||
const { Auth } = require('../models/auth');
|
|
||||||
const { OidcState } = require('../models/oidc_state');
|
|
||||||
const oidc = require('../utils/oidc');
|
|
||||||
const { safeInternalPath } = require('../utils/safe_redirect');
|
|
||||||
|
|
||||||
// Throttle unauthenticated auth endpoints (credential login + the OIDC
|
|
||||||
// handshake) to blunt brute-force / callback abuse. Keyed per IP.
|
|
||||||
const authLimiter = rateLimit({
|
|
||||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
||||||
max: 60, // 60 attempts per IP per window
|
|
||||||
standardHeaders: true,
|
|
||||||
legacyHeaders: false,
|
|
||||||
message: {name: 'TooManyRequests', message: 'Too many attempts, please try again later.'},
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
router.post('/login', authLimiter, async function(req, res, next){
|
|
||||||
try{
|
|
||||||
let auth = await Auth.login(req.body);
|
|
||||||
return res.json({
|
|
||||||
login: true,
|
|
||||||
token: auth.token.token,
|
|
||||||
message:`${req.body.username} logged in!`,
|
|
||||||
});
|
|
||||||
}catch(error){
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.all('/logout', async function(req, res, next){
|
|
||||||
try{
|
|
||||||
if(req.user){
|
|
||||||
await req.user.logout();
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({message: 'Bye'})
|
|
||||||
}catch(error){
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* OIDC login start: create a PKCE + state challenge, persist it (auto-expiring
|
|
||||||
* via OidcState TTL), and redirect the browser to the SSO authorize endpoint.
|
|
||||||
*/
|
|
||||||
router.get('/oidc/start', authLimiter, async function(req, res, next){
|
|
||||||
try{
|
|
||||||
if(!conf.oidc || !conf.oidc.enabled){
|
|
||||||
let error = new Error('OidcDisabled');
|
|
||||||
error.status = 404;
|
|
||||||
error.message = 'OIDC login is not enabled.';
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {state, codeVerifier, codeChallenge} = oidc.createAuthRequest();
|
|
||||||
await OidcState.create({
|
|
||||||
state,
|
|
||||||
codeVerifier,
|
|
||||||
// Sanitize now so a hostile ?redirect= can't be stored and later
|
|
||||||
// reflected into the login page's navigation.
|
|
||||||
redirect: safeInternalPath(req.query.redirect || '/'),
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.redirect(oidc.buildAuthUrl(state, codeChallenge));
|
|
||||||
}catch(error){
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* OIDC callback: validate state (consuming the one-time record), exchange the
|
|
||||||
* code for tokens, read identity from userinfo, establish a session, and hand
|
|
||||||
* the app token back to the browser via a URL fragment for the login page to
|
|
||||||
* store in localStorage.
|
|
||||||
*/
|
|
||||||
router.get('/oidc/callback', authLimiter, async function(req, res, next){
|
|
||||||
try{
|
|
||||||
let {code, state} = req.query;
|
|
||||||
if(!code || !state){
|
|
||||||
let error = new Error('OidcCallbackInvalid');
|
|
||||||
error.status = 400;
|
|
||||||
error.message = 'Missing code or state.';
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
// get() throws if the state is unknown or has expired — this both binds
|
|
||||||
// the callback to our request and bounds replay.
|
|
||||||
let saved = await OidcState.get(state);
|
|
||||||
await saved.remove();
|
|
||||||
|
|
||||||
let tokens = await oidc.exchangeCode(code, saved.codeVerifier);
|
|
||||||
let claims = await oidc.fetchUserInfo(tokens.access_token);
|
|
||||||
let identity = oidc.claimsToIdentity(claims);
|
|
||||||
|
|
||||||
let {token} = await Auth.oidcSession(identity);
|
|
||||||
|
|
||||||
let redirect = safeInternalPath(saved.redirect || '/');
|
|
||||||
return res.redirect(
|
|
||||||
`/login#token=${encodeURIComponent(token.token)}&redirect=${encodeURIComponent(redirect)}`
|
|
||||||
);
|
|
||||||
}catch(error){
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
@@ -4,8 +4,10 @@ const path = require('path');
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = require('express').Router();
|
const router = require('express').Router();
|
||||||
const conf = require('@simpleworkjs/conf');
|
const conf = require('@simpleworkjs/conf');
|
||||||
const buildInfo = require('../models/build_info');
|
const buildInfo = require('../utils/build_info');
|
||||||
const registry = require('../services/session_registry');
|
const registry = require('../services/session_registry');
|
||||||
|
const { safeInternalPath } = require('@simpleworkjs/oidc-client');
|
||||||
|
const { mountStaticModules } = require('@simpleworkjs/app-stack');
|
||||||
|
|
||||||
const values = {
|
const values = {
|
||||||
title: conf.environment !== 'production' ? 'dev' : '',
|
title: conf.environment !== 'production' ? 'dev' : '',
|
||||||
@@ -17,15 +19,14 @@ const values = {
|
|||||||
|
|
||||||
// Serve front-end vendor libraries straight from node_modules (same convention
|
// 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/.
|
// as the sibling apps), and the app's own JS/CSS/img from public/.
|
||||||
const frontEndModules = ['bootstrap', 'mustache', 'jquery', '@fortawesome', 'moment', 'jq-repeat'];
|
mountStaticModules(router, {
|
||||||
frontEndModules.forEach(dep => {
|
root: path.join(__dirname, '..'),
|
||||||
router.use(`/static-modules/${dep}`, express.static(path.join(__dirname, `../node_modules/${dep}`), {maxAge: '7d'}));
|
deps: ['bootstrap', 'mustache', 'jquery', '@fortawesome', 'moment', 'jq-repeat'],
|
||||||
});
|
});
|
||||||
router.use('/static', express.static(path.join(__dirname, '../public'), {maxAge: '1h'}));
|
|
||||||
|
|
||||||
// Liveness probe — no auth.
|
// Liveness probe — no auth.
|
||||||
router.get('/health', (req, res) => {
|
router.get('/health', (req, res) => {
|
||||||
res.json({status: 'ok', activeSessions: registry.count(), version: buildInfo.version, commit: buildInfo.commit});
|
res.json({status: 'ok', activeSessions: registry.count(), buildVersion: buildInfo.buildVersion, buildHash: buildInfo.buildHash});
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/', (req, res) => res.redirect(302, '/dashboard'));
|
router.get('/', (req, res) => res.redirect(302, '/dashboard'));
|
||||||
@@ -36,7 +37,7 @@ router.get('/', (req, res) => res.redirect(302, '/dashboard'));
|
|||||||
// /login when there's no valid session.
|
// /login when there's no valid session.
|
||||||
router.get('/login', (req, res) => res.render('login', {
|
router.get('/login', (req, res) => res.render('login', {
|
||||||
...values,
|
...values,
|
||||||
redirect: '/',
|
redirect: safeInternalPath(req.query.redirect || '/'),
|
||||||
oidcEnabled: !!(conf.oidc && conf.oidc.enabled),
|
oidcEnabled: !!(conf.oidc && conf.oidc.enabled),
|
||||||
}));
|
}));
|
||||||
router.get('/dashboard', (req, res) => res.render('dashboard', {...values}));
|
router.get('/dashboard', (req, res) => res.render('dashboard', {...values}));
|
||||||
|
|||||||
@@ -147,12 +147,20 @@ async function resolveAndConnect(state, record, { onHostKey } = {}) {
|
|||||||
function fail(reason) { const e = new Error(reason); e.reason = reason; return e; }
|
function fail(reason) { const e = new Error(reason); e.reason = reason; return e; }
|
||||||
|
|
||||||
async function runGrammar(session, client, state) {
|
async function runGrammar(session, client, state) {
|
||||||
const record = await audit.create({ uid: state.uid, authMethod: state.authMethod, clientIp: state.clientIp, mode: 'grammar' });
|
// Register session listeners IMMEDIATELY — before any async work.
|
||||||
|
// The client sends exec/shell requests right after opening the session;
|
||||||
// Deferred upstream — attach the bridge NOW, resolve/reject after connect.
|
// if we await audit.create() first, those requests arrive before the
|
||||||
|
// listeners are registered and ssh2 rejects them with CHANNEL_FAILURE.
|
||||||
let resolveUp, rejectUp;
|
let resolveUp, rejectUp;
|
||||||
const upstreamPromise = new Promise((res, rej) => { resolveUp = res; rejectUp = rej; });
|
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 {
|
try {
|
||||||
const { upstream, host, endpoint } = await resolveAndConnect(state, record, {
|
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';
|
const host = (conf.ssh && conf.ssh.listenHost) || '0.0.0.0';
|
||||||
server.listen(port, host, () => {
|
server.listen(port, host, () => {
|
||||||
console.log(`[ssh] jump host listening on ${host}:${server.address().port}`);
|
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 });
|
await accessibleHosts(user, { fetchImpl, ldap });
|
||||||
assert.strictEqual(calls, 1);
|
assert.strictEqual(calls, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a bare-array response (envelope drift) is treated as a failed group, not silently []', async () => {
|
||||||
|
clearCache();
|
||||||
|
const user = { uid: 'dave', dn: 'd' };
|
||||||
|
// drift shape: a bare array instead of { results: [...] }. The shared client
|
||||||
|
// throws DirectoryEnvelopeViolation; access.js must catch + continue, so a
|
||||||
|
// good group alongside still yields its hosts.
|
||||||
|
const fetchImpl = async (url) => {
|
||||||
|
if (url.includes('drift')) return { ok: true, json: async () => [{ id: '7', kind: 'host' }] };
|
||||||
|
return { ok: true, json: async () => ({ results: [{ id: '8', kind: 'host' }] }) };
|
||||||
|
};
|
||||||
|
const hosts = await accessibleHosts(user, { fetchImpl, ldap: stubLdap(['drift_access', 'good_access']) });
|
||||||
|
assert.deepStrictEqual(hosts.map((h) => h.id), ['8']);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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';
|
'use strict';
|
||||||
|
|
||||||
// Which directory hosts may a user reach, and how do we dial them?
|
// Host discovery — SSO Manager API in production, ORM-backed inventory in
|
||||||
//
|
// standalone mode. Both export the same interface:
|
||||||
// v1 resolution (see directory_spec.md §9.2 in sso-manager-node): the SSO's
|
// accessibleHosts(user) -> [host resources]
|
||||||
// /api/discovery/me only answers for the API token's own user, and /graph
|
// clearCache(uid?) -> void
|
||||||
// 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.
|
|
||||||
|
|
||||||
const conf = require('@simpleworkjs/conf');
|
const conf = require('@simpleworkjs/conf');
|
||||||
const userLdap = require('../models/user_ldap');
|
|
||||||
|
|
||||||
const CACHE_TTL_MS = 30 * 1000;
|
if (conf.standalone && conf.standalone.enabled) {
|
||||||
const cache = new Map(); // uid -> {at, hosts}
|
// 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 } = {}) {
|
// Which directory hosts may a user reach, and how do we dial them?
|
||||||
const sso = conf.sso || {};
|
//
|
||||||
const url = `${sso.url}/api/discovery/resources?group=${encodeURIComponent(group)}`;
|
// v1 resolution (see directory_spec.md §9.2 in sso-manager-node): the SSO's
|
||||||
const res = await fetchImpl(url, {
|
// /api/discovery/me only answers for the API token's own user, and /graph
|
||||||
headers: { Authorization: `Bearer ${sso.apiToken}` },
|
// omits ResourceGroup links — so we combine the user's LDAP groups (queried
|
||||||
});
|
// directly) with per-group resource lookups:
|
||||||
if (!res.ok) throw new Error(`directory query failed (${res.status}) for group ${group}`);
|
//
|
||||||
const data = await res.json();
|
// 1. LDAP: groups the user's DN is a member of
|
||||||
return (data && data.results) || [];
|
// 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 { createDirectoryClient } = require('@simpleworkjs/directory-schema');
|
||||||
const hit = cache.get(user.uid);
|
const userLdap = require('../models/user_ldap');
|
||||||
if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.hosts;
|
|
||||||
|
|
||||||
const groups = await ldap.getGroups(user.dn);
|
const CACHE_TTL_MS = 30 * 1000;
|
||||||
|
const cache = new Map(); // uid -> {at, hosts}
|
||||||
|
|
||||||
const seen = new Map();
|
// Build a directory client bound to conf.sso. fetchImpl is injectable so the
|
||||||
for (const cn of groups) {
|
// unit tests can stub the transport; the shared client validates the
|
||||||
let resources;
|
// `{ results }` envelope on every call (turns the old bare-array drift into a
|
||||||
try {
|
// thrown error instead of a silent `[]`).
|
||||||
resources = await fetchResourcesByGroup(cn, { fetchImpl });
|
function directoryClient({ fetchImpl = fetch } = {}) {
|
||||||
} catch (error) {
|
const sso = conf.sso || {};
|
||||||
// One bad group must not hide the rest; the SSO being down
|
return createDirectoryClient({ baseUrl: sso.url, apiToken: sso.apiToken, fetch: fetchImpl });
|
||||||
// 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()];
|
async function fetchResourcesByGroup(group, { fetchImpl = fetch } = {}) {
|
||||||
cache.set(user.uid, { at: Date.now(), hosts });
|
return directoryClient({ fetchImpl }).getResourcesByGroup(group);
|
||||||
return hosts;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function clearCache(uid) {
|
async function accessibleHosts(user, { fetchImpl = fetch, ldap = userLdap } = {}) {
|
||||||
if (uid) cache.delete(uid);
|
const hit = cache.get(user.uid);
|
||||||
else cache.clear();
|
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 };
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
const crypto = require('crypto');
|
|
||||||
const conf = require('@simpleworkjs/conf');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Minimal OpenID Connect authorization-code + PKCE client.
|
|
||||||
*
|
|
||||||
* The SSO publishes no jwks_uri, so we do not verify ID-token signatures;
|
|
||||||
* instead we treat the flow as opaque and read identity from the userinfo
|
|
||||||
* endpoint (the access token is exchanged server-side over TLS). Uses Node's
|
|
||||||
* global fetch (Node 18+) and crypto — no external dependency.
|
|
||||||
*
|
|
||||||
* All endpoints and client config come from conf.oidc (+ clientSecret from
|
|
||||||
* secrets.js, deep-merged by @simpleworkjs/conf).
|
|
||||||
*/
|
|
||||||
|
|
||||||
const base64url = buf => buf.toString('base64')
|
|
||||||
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
||||||
|
|
||||||
// A high-entropy random string for `state` / PKCE verifier.
|
|
||||||
function randomToken(bytes = 32){
|
|
||||||
return base64url(crypto.randomBytes(bytes));
|
|
||||||
}
|
|
||||||
|
|
||||||
// PKCE S256 challenge derived from the verifier.
|
|
||||||
function codeChallengeS256(verifier){
|
|
||||||
return base64url(crypto.createHash('sha256').update(verifier).digest());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate the {state, codeVerifier, codeChallenge} triple for a new login.
|
|
||||||
function createAuthRequest(){
|
|
||||||
let state = randomToken(32);
|
|
||||||
let codeVerifier = randomToken(32);
|
|
||||||
let codeChallenge = codeChallengeS256(codeVerifier);
|
|
||||||
return {state, codeVerifier, codeChallenge};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the SSO authorize URL the browser is redirected to. `redirectUri`
|
|
||||||
// overrides conf.oidc.redirectUri (per-host SSO uses a per-host callback).
|
|
||||||
function buildAuthUrl(state, codeChallenge, redirectUri){
|
|
||||||
let o = conf.oidc;
|
|
||||||
let params = new URLSearchParams({
|
|
||||||
response_type: 'code',
|
|
||||||
client_id: o.clientId,
|
|
||||||
redirect_uri: redirectUri || o.redirectUri,
|
|
||||||
scope: (o.scopes || ['openid', 'profile', 'email', 'groups']).join(' '),
|
|
||||||
state,
|
|
||||||
code_challenge: codeChallenge,
|
|
||||||
code_challenge_method: 'S256',
|
|
||||||
});
|
|
||||||
return `${o.authorizationEndpoint}?${params.toString()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exchange an authorization code for tokens at the token endpoint. `redirectUri`
|
|
||||||
// must match the one used in buildAuthUrl (per-host for per-host SSO).
|
|
||||||
async function exchangeCode(code, codeVerifier, redirectUri){
|
|
||||||
let o = conf.oidc;
|
|
||||||
let body = new URLSearchParams({
|
|
||||||
grant_type: 'authorization_code',
|
|
||||||
code,
|
|
||||||
redirect_uri: redirectUri || o.redirectUri,
|
|
||||||
client_id: o.clientId,
|
|
||||||
client_secret: o.clientSecret,
|
|
||||||
code_verifier: codeVerifier,
|
|
||||||
});
|
|
||||||
|
|
||||||
let res = await fetch(o.tokenEndpoint, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
'Accept': 'application/json',
|
|
||||||
},
|
|
||||||
body: body.toString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
if(!res.ok){
|
|
||||||
let text = await res.text().catch(() => '');
|
|
||||||
let error = new Error('OidcTokenExchangeFailed');
|
|
||||||
error.name = 'OidcTokenExchangeFailed';
|
|
||||||
error.message = `Token exchange failed (${res.status}): ${text}`;
|
|
||||||
error.status = 502;
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch the userinfo claims for an access token.
|
|
||||||
async function fetchUserInfo(accessToken){
|
|
||||||
let o = conf.oidc;
|
|
||||||
let res = await fetch(o.userinfoEndpoint, {
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${accessToken}`,
|
|
||||||
'Accept': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if(!res.ok){
|
|
||||||
let error = new Error('OidcUserInfoFailed');
|
|
||||||
error.name = 'OidcUserInfoFailed';
|
|
||||||
error.message = `Userinfo request failed (${res.status})`;
|
|
||||||
error.status = 502;
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pull the app username and group list out of userinfo claims per conf.
|
|
||||||
function claimsToIdentity(claims){
|
|
||||||
let o = conf.oidc;
|
|
||||||
let username = claims[o.usernameClaim || 'preferred_username'] || claims.sub;
|
|
||||||
let groups = claims[o.groupsClaim || 'groups'] || [];
|
|
||||||
if(!Array.isArray(groups)) groups = [groups].filter(Boolean);
|
|
||||||
return {username, groups, claims};
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
randomToken,
|
|
||||||
codeChallengeS256,
|
|
||||||
createAuthRequest,
|
|
||||||
buildAuthUrl,
|
|
||||||
exchangeCode,
|
|
||||||
fetchUserInfo,
|
|
||||||
claimsToIdentity,
|
|
||||||
};
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Constrain a post-login redirect target to a same-origin path.
|
|
||||||
*
|
|
||||||
* Rejects anything that could leave the site or execute script:
|
|
||||||
* - absolute URLs ("https://evil.com") -> not a "/" path
|
|
||||||
* - protocol-relative ("//evil.com", "/\\evil.com") -> host takeover
|
|
||||||
* - scheme targets ("javascript:...", "data:...") -> XSS
|
|
||||||
* Anything not a plain "/path" falls back to "/".
|
|
||||||
*
|
|
||||||
* The browser has its own copy of this in public/lib/js/app-base.js; keep the
|
|
||||||
* two in sync.
|
|
||||||
*/
|
|
||||||
function safeInternalPath(path){
|
|
||||||
if(typeof path !== 'string' || path.charAt(0) !== '/'
|
|
||||||
|| path.charAt(1) === '/' || path.charAt(1) === '\\'){
|
|
||||||
return '/';
|
|
||||||
}
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {safeInternalPath};
|
|
||||||
@@ -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: []},
|
||||||
|
],
|
||||||
|
};
|
||||||
+27
-21
@@ -1,24 +1,30 @@
|
|||||||
</div>
|
</div><!-- end spa-shell -->
|
||||||
|
|
||||||
<footer class="py-2 bg-dark text-light mt-4">
|
<!-- Shared UI shell — byte-identical across sso-manager-node, proxy and
|
||||||
<div class="container-fluid d-flex flex-wrap justify-content-between align-items-center small gap-2">
|
jump-host. Everything per-app comes from `ui` (utils/ui.js, exposed via
|
||||||
<span class="d-flex align-items-center gap-2">
|
app.locals in app.js). Edit all three copies together. -->
|
||||||
<a href="https://theta42.com" target="_blank">
|
<footer class="py-2 bg-dark text-light mt-4">
|
||||||
<img width="64" src="/static/img/theta42.svg"/>
|
<div class="container-fluid d-flex flex-wrap justify-content-between align-items-center small gap-2">
|
||||||
</a>
|
<span class="d-flex align-items-center gap-2">
|
||||||
© <%- (new Date()).getFullYear() %> theta42 ·
|
<a href="https://theta42.com" target="_blank">
|
||||||
<a href="https://github.com/theta42/jump-host/blob/master/LICENSE" target="_blank" class="text-light">MIT License</a>
|
<img width="64" src="/static/img/theta42.svg"/>
|
||||||
</span>
|
</a>
|
||||||
<span class="d-flex align-items-center gap-3">
|
© <%- buildYear %> theta42 ·
|
||||||
<a href="https://theta42.github.io/jump-host/" target="_blank" class="text-light text-decoration-none">
|
<a href="<%- ui.licenseUrl %>" target="_blank" class="text-light">MIT License</a>
|
||||||
<i class="fa-solid fa-book"></i> Docs
|
</span>
|
||||||
</a>
|
<span class="d-flex align-items-center gap-3">
|
||||||
<a href="https://github.com/theta42/jump-host" target="_blank" class="text-light text-decoration-none">
|
<a href="<%- ui.docsUrl %>"<%- ui.docsExternal ? ' target="_blank"' : '' %> class="text-light text-decoration-none">
|
||||||
<i class="fa-brands fa-github"></i> GitHub
|
<i class="fa-solid fa-book"></i> Docs
|
||||||
</a>
|
</a>
|
||||||
</span>
|
<a href="<%- ui.repoUrl %>" target="_blank" class="text-light text-decoration-none">
|
||||||
<span>v<%- version %> (<%- commit %>)</span>
|
<i class="fa-brands fa-github"></i> GitHub
|
||||||
</div>
|
</a>
|
||||||
</footer>
|
<% 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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+12
-8
@@ -4,14 +4,18 @@
|
|||||||
// If we arrived from the OIDC callback with a token in the URL fragment,
|
// If we arrived from the OIDC callback with a token in the URL fragment,
|
||||||
// store it and forward on before doing anything else.
|
// store it and forward on before doing anything else.
|
||||||
if(!app.auth.consumeTokenFragment()){
|
if(!app.auth.consumeTokenFragment()){
|
||||||
app.auth.isLoggedIn(function(error, isLoggedIn){
|
// The reveal below touches an element further down this page, so wait
|
||||||
if(isLoggedIn){
|
// for the DOM — isLoggedIn can answer before the parser gets there.
|
||||||
app.auth.logInRedirect();
|
$(document).ready(function(){
|
||||||
}else{
|
app.auth.isLoggedIn(function(error, isLoggedIn){
|
||||||
// Reveal the login card once we know the user is not logged in.
|
if(isLoggedIn){
|
||||||
document.getElementById('login-card-row').style.display = '';
|
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>
|
</script>
|
||||||
|
|||||||
+94
-23
@@ -4,10 +4,17 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
<title><%- name %> <%- title %></title>
|
<title><%- name %> <%- title %></title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
<!-- 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/bootstrap/dist/css/bootstrap.min.css">
|
||||||
<link rel="stylesheet" href="/static-modules/@fortawesome/fontawesome-free/css/all.min.css">
|
<link rel="stylesheet" href="/static-modules/@fortawesome/fontawesome-free/css/all.min.css">
|
||||||
|
|
||||||
<link rel='stylesheet' href='/static/css/styles.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="/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/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/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
@@ -28,51 +35,115 @@
|
|||||||
</button>
|
</button>
|
||||||
<div class="collapse navbar-collapse justify-content-end" id="navbarSupportedContent">
|
<div class="collapse navbar-collapse justify-content-end" id="navbarSupportedContent">
|
||||||
<ul class="navbar-nav top-nav">
|
<ul class="navbar-nav top-nav">
|
||||||
<li class="nav-item">
|
<%# Items gated on a group start hidden (.group-required) and are
|
||||||
<a class="nav-link" href="/dashboard"><i class="fa-solid fa-gauge-high"></i> Dashboard</a>
|
revealed by app-base.js for the groups the user is in. %>
|
||||||
</li>
|
<% for(const item of ui.nav){ %>
|
||||||
<li class="nav-item">
|
<li class="nav-item<%- item.groups.length ? ' group-required' : '' %><%- item.groups.map(group => ' group-required-' + group).join('') %>">
|
||||||
<a class="nav-link" href="/sessions"><i class="fa-solid fa-plug-circle-bolt"></i> Sessions</a>
|
<a class="nav-link" href="<%- item.href %>"><i class="<%- item.icon %>"></i>
|
||||||
</li>
|
<%- item.label %>
|
||||||
<li class="nav-item">
|
</a>
|
||||||
<a class="nav-link" href="/audit"><i class="fa-solid fa-clipboard-list"></i> Audit</a>
|
|
||||||
</li>
|
</li>
|
||||||
|
<% } %>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="form-inline mt-2 mt-md-0">
|
<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;">
|
<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>
|
<i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
|
||||||
</span>
|
</span>
|
||||||
<a id="cl-login-button" class="btn btn-outline-danger my-2 my-sm-0" href="/login" style="display: none;">
|
<% } %>
|
||||||
<i class="fas fa-sign-in"></i> Login
|
<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>
|
</a>
|
||||||
<button id="cl-logout-button" class="btn btn-outline-danger my-2 my-sm-0" onclick="app.auth.logOut(function(){ window.location.href='/login'; })" style="display: none;">
|
|
||||||
<i class="fas fa-sign-out"></i> Log Out
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</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">
|
<script type="text/javascript">
|
||||||
$(document).ready(function(){
|
$(document).ready(function(){
|
||||||
$('.top-nav a').each(function(){
|
|
||||||
var $this = $(this);
|
// Set the correct link to active in the top nav bar
|
||||||
|
$('.top-nav a').each(function(index){
|
||||||
|
let $this = $(this);
|
||||||
$this.removeClass('active');
|
$this.removeClass('active');
|
||||||
if($this.attr('href').toLowerCase() === window.location.pathname.toLowerCase()){
|
if($this.attr('href').toLocaleLowerCase() === window.location.pathname.toLocaleLowerCase()){
|
||||||
$this.addClass('active');
|
$this.addClass('active')
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
app.auth.isLoggedIn(function(error, data){
|
|
||||||
if(data){
|
// 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();
|
$('#cl-logout-button').show();
|
||||||
if(data.username){
|
let username = me.uid || me.username;
|
||||||
$('#cl-username-text').text(data.username);
|
if(username){
|
||||||
|
$('#cl-username-text').text(username);
|
||||||
$('#cl-username').css('display', '');
|
$('#cl-username').css('display', '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<% if(ui.updateCheck){ %>
|
||||||
|
if(me.isAdmin) checkForUpdate();
|
||||||
|
<% } %>
|
||||||
}else{
|
}else{
|
||||||
$('#cl-login-button').show();
|
$('#cl-login-button').show();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div id="spa-shell" class="container-fluid" style="margin-top: 4.5rem;">
|
|
||||||
|
<!-- Container -->
|
||||||
|
<div id="spa-shell" class="container-fluid">
|
||||||
|
<div class="actionMessage" style="display:none;"></div>
|
||||||
|
|||||||
@@ -11,7 +11,24 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
name: 'My Org',
|
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).
|
// 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
|
// IMPORTANT: bindDN needs, beyond read on ou=people + ou=groups, WRITE on
|
||||||
// the sshPublicKey attribute of user entries — the jump host injects its
|
// 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
|
// SSO Manager directory (inventory) API. apiToken is a personal access
|
||||||
// token (sso_<id>_<secret>) of any user that can read /api/discovery/*.
|
// token (sso_<id>_<secret>) of any user that can read /api/discovery/*.
|
||||||
|
// Unused when standalone.enabled is true.
|
||||||
sso: {
|
sso: {
|
||||||
url: 'https://sso.example.com',
|
url: 'https://sso.example.com',
|
||||||
apiToken: 'sso_CHANGE_ME',
|
apiToken: 'sso_CHANGE_ME',
|
||||||
|
|||||||
Reference in New Issue
Block a user