Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a76f71edd | |||
| e482f52f10 | |||
| a6af160627 | |||
| 8c9646b65c | |||
| 1d09f243dd | |||
| ec4ca97af4 | |||
| 21ef8960c4 | |||
| a5bef2980b | |||
| ab2ee0fed3 | |||
| ab9e04e007 | |||
| a3a6787776 | |||
| 0ead0199a3 | |||
| 0af2fc7e3d | |||
| bc2180116f | |||
| 1b70701795 | |||
| 98e1e0e279 | |||
| b03fcefaae | |||
| 3474482f6f | |||
| da361a8a86 | |||
| 4326d5588e | |||
| 4a70b5b27e | |||
| 43caac13d5 | |||
| 465f923393 | |||
| 782a829cb9 | |||
| fd863b89ca | |||
| 4fb4e77007 | |||
| a56a31421d | |||
| d33e324b31 | |||
| 4879769cc7 | |||
| da274a3ced | |||
| b9be0fe4e1 |
@@ -4,6 +4,62 @@ All notable changes to this project are documented here. Format loosely
|
|||||||
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions
|
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.9.0] - 2026-07-28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **"Quick Jump" copy-to-clipboard section on the dashboard** — the `uid_-_target` grammar-mode SSH command was documented in the README but nowhere in the UI. A new card gives a one-click-copy command for interactive-picker mode, and every row in "Hosts you can reach" has its own copy button for the exact grammar-mode command to that host, ready to paste and run as-is (uses the logged-in user's own uid).
|
||||||
|
|
||||||
|
## [1.8.2] - 2026-07-28
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Audit records for a failed upstream connection only ever said `upstream-unreachable`** — `resolveAndConnect` discarded the real error from `connectUpstream` (ECONNREFUSED, ETIMEDOUT, an ssh2 auth-failure message, etc.) and replaced it with that one generic string, so there was no way to tell a network-layer failure from an auth failure from the audit log alone. This is what blocked root-causing the "Could not reach 192.168.1.206" (emby host) report — the real error is now captured and surfaced as a new `failDetail` field on the audit record, shown as a tooltip on the fail badge in the admin audit table.
|
||||||
|
|
||||||
|
## [1.8.1] - 2026-07-28
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Redis had zero persistence** (`--save '' --appendonly no`, no data-dir volume) — every container rebuild/recreation silently wiped all sessions, in-flight OAuth logins, and any admin-created API token. This is why re-running `setup.sh` appeared to "break OAuth with jump": the jump-host container gets recreated, and any token or in-flight login vanished with it. Now Redis persists (AOF + periodic RDB) to `/data`, mounted as a named volume (`jump-redis-data`) in theta-env's compose file. Verified live: minted a PAT, force-recreated the container, confirmed the same PAT still authenticated afterward.
|
||||||
|
|
||||||
|
## [1.8.0] - 2026-07-28
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **TUI-mode SSH connections (a bare `ssh user@host`, no target) could drop with "PTY allocation request failed" / "shell request failed"** — `runTuiSession` awaited two real round-trips (an audit-log write, then a directory API call) *before* attaching the session's pty/shell/exec listeners, so a client that sent those requests quickly enough got auto-rejected by ssh2 before anything was listening. `runGrammar` (the `uid_-_target` path) already had the equivalent fix; this ports it to the picker path.
|
||||||
|
- **`formAJAX`'s loading indicator showed literal HTML**, not a spinner — same fix as sso-manager-node/proxy's companion releases.
|
||||||
|
|
||||||
|
## [1.7.1] - 2026-07-28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Regression test**: a static check across all views/client-side scripts fails CI if any native `alert()`/`confirm()`/`prompt()` call appears — these block all further browser events on the page. This app has never had one; keeps it that way.
|
||||||
|
|
||||||
|
## [1.7.0] - 2026-07-27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Self-service API tokens (PATs)** — `models/api_token.js` + `routes/api_token.js` (mounted at `/api-token`), Bearer-token support in `middleware/auth.js`, and a create/list/rotate/revoke card on the dashboard. Ports proxy's `jmp_<id>_<secret>` pattern; unlike proxy's, a jump-host token carries no group claims, so it authenticates as its creator for non-admin routes only (never passes `requireAdmin`). jump-host previously had no PAT support at all.
|
||||||
|
|
||||||
|
## [1.6.0] - 2026-07-27
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Adopted `@simpleworkjs/frontend`'s `app.messages`, `app.modal`, and `app.validate` modules**, replacing the vendored `app.util.actionMessage`/`actionConfirm` in `public/lib/js/app-base.js` and the vendored `public/lib/js/val.js` (unused by any current view here, so this is dedup/future-proofing rather than a behavior change). `app.api`/`app.auth`/`app.pubsub`/`app.socket` are untouched.
|
||||||
|
|
||||||
|
## [1.5.0] - 2026-07-27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Web UI dashboard now lists the hosts you can reach** ("Hosts you can reach", or "All hosts" for admins) — previously the dashboard only showed usage metrics, with no way to see your actual access from the browser. Backed by a new `GET /api/user/hosts` endpoint (auth-only, not admin-gated): admins get the full inventory via `utils/access.js`'s new `allHosts()`, everyone else gets the same group-based resolution the SSH front door uses.
|
||||||
|
- `utils/access.js`'s `accessibleHosts()` now accepts a pre-resolved `groups` array on the user object, skipping the LDAP `getGroups(dn)` round-trip — the web UI's OIDC session already has its groups claim and has no LDAP `dn` to query with.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Bumped `@simpleworkjs/ldap` to 1.0.1**, which fixes `addSshKey` throwing `ObjectClassViolationError` (LDAP `0x41`) on accounts predating the `ldapPublicKey` auxiliary objectClass. This is the code path this jump host's key-injection (`utils/key_inject.js`) uses on every first connection for a user — on affected accounts it aborted the SSH connection entirely (`key-inject-failed`).
|
||||||
|
|
||||||
|
## [1.4.0] - 2026-07-26
|
||||||
|
|
||||||
|
### 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
|
## [1.3.0] - 2026-07-26
|
||||||
|
|
||||||
### 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
|
||||||
|
|||||||
+11
-3
@@ -12,9 +12,17 @@ if [[ -f /config/jump-secrets.js ]]; then
|
|||||||
info "Loaded config from /config/jump-secrets.js"
|
info "Loaded config from /config/jump-secrets.js"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Redis for audit/metrics/session storage (app connects to 127.0.0.1:6379).
|
# Redis for audit/metrics/session AND api-token storage (app connects to
|
||||||
info "Starting redis..."
|
# 127.0.0.1:6379). Persisted (AOF + periodic RDB) to /data, which the
|
||||||
redis-server --daemonize yes --save '' --appendonly no
|
# deployment should mount as a volume -- without this, every container
|
||||||
|
# recreation silently wiped every session, in-flight OAuth login, and any
|
||||||
|
# admin-created API token, which is especially bad for the last one since a
|
||||||
|
# PAT is meant to be a stable, long-lived credential, not session state.
|
||||||
|
REDIS_DATA_DIR="${REDIS_DATA_DIR:-/data}"
|
||||||
|
mkdir -p "$REDIS_DATA_DIR"
|
||||||
|
info "Starting redis (AOF persisted to $REDIS_DATA_DIR)..."
|
||||||
|
redis-server --daemonize yes --dir "$REDIS_DATA_DIR" --appendonly yes \
|
||||||
|
--appendfilename appendonly.aof --save 900 1 --save 300 10 --save 60 10000
|
||||||
|
|
||||||
# Wait for redis to answer before starting the app.
|
# Wait for redis to answer before starting the app.
|
||||||
for _ in $(seq 1 20); do
|
for _ in $(seq 1 20); do
|
||||||
|
|||||||
+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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,6 +9,24 @@ const { Auth } = require('../models');
|
|||||||
|
|
||||||
async function auth(req, res, next){
|
async function auth(req, res, next){
|
||||||
try{
|
try{
|
||||||
|
// API-only token: `Authorization: Bearer jmp_<id>_<secret>`. Carries no
|
||||||
|
// group claims (see models/api_token.js), so it authenticates as its
|
||||||
|
// creator but never passes requireAdmin below.
|
||||||
|
const authz = req.header('authorization') || '';
|
||||||
|
if(authz.slice(0, 7).toLowerCase() === 'bearer '){
|
||||||
|
const t = await Auth.checkApiToken(authz.slice(7));
|
||||||
|
req.token = {
|
||||||
|
user: {username: t.created_by},
|
||||||
|
created_by: t.created_by,
|
||||||
|
groupsArray: () => [],
|
||||||
|
check: () => true,
|
||||||
|
is_valid: true,
|
||||||
|
};
|
||||||
|
req.user = req.token.user;
|
||||||
|
req.groups = [];
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
req.token = await Auth.checkToken(req.header('auth-token'));
|
req.token = await Auth.checkToken(req.header('auth-token'));
|
||||||
req.user = req.token.user;
|
req.user = req.token.user;
|
||||||
req.groups = typeof req.token.groupsArray === 'function' ? req.token.groupsArray() : [];
|
req.groups = typeof req.token.groupsArray === 'function' ? req.token.groupsArray() : [];
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const Table = require('.');
|
||||||
|
const bcrypt = require('bcrypt');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
// Self-service personal access token (PAT) for the jump host's own API.
|
||||||
|
// Format: jmp_<id>_<secret>
|
||||||
|
// id — 24-char hex, stored plaintext as the record key (O(1) lookup)
|
||||||
|
// secret — 48-char hex, stored only as a bcrypt hash (isPrivate); shown ONCE
|
||||||
|
//
|
||||||
|
// Authenticated via `Authorization: Bearer jmp_...`. Mirrors proxy's
|
||||||
|
// models/api_token.js — see that file for the fuller design notes. jump-host
|
||||||
|
// has no per-user group snapshot the way proxy/sso do (its authz is a single
|
||||||
|
// admin/non-admin bit off conf.auth.adminGroups/adminUsers), so a token
|
||||||
|
// authenticates as its creator only; the auth middleware re-derives
|
||||||
|
// admin-ness from that user's current groups, same as a live session.
|
||||||
|
//
|
||||||
|
// No `static _ttl`: records persist (lifetime is the optional expires_at field).
|
||||||
|
|
||||||
|
const PREFIX = 'jmp_';
|
||||||
|
const randomHex = (bytes) => crypto.randomBytes(bytes).toString('hex');
|
||||||
|
|
||||||
|
class ApiToken extends Table{
|
||||||
|
static _key = 'id';
|
||||||
|
static _keyMap = {
|
||||||
|
'id': {default: function(){ return randomHex(12) }, type: 'string'},
|
||||||
|
'secret_hash': {isRequired: true, type: 'string', isPrivate: true},
|
||||||
|
'name': {isRequired: true, type: 'string', min: 1, max: 255},
|
||||||
|
'description': {default: '', type: 'string'},
|
||||||
|
'created_by': {isRequired: true, type: 'string', min: 3, max: 500},
|
||||||
|
'created_on': {default: function(){return (new Date).getTime()}},
|
||||||
|
'updated_on': {default: function(){return (new Date).getTime()}, always: true},
|
||||||
|
'expires_at': {default: 0, type: 'number'}, // epoch ms; 0 = never
|
||||||
|
'last_used_on': {default: 0, type: 'number'},
|
||||||
|
'is_valid': {default: true, type: 'boolean'},
|
||||||
|
}
|
||||||
|
|
||||||
|
get isExpired() {
|
||||||
|
return this.expires_at > 0 && (new Date).getTime() > this.expires_at;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async add(data){
|
||||||
|
const id = randomHex(12);
|
||||||
|
const secret = randomHex(24);
|
||||||
|
data.id = id;
|
||||||
|
data.secret_hash = await bcrypt.hash(secret, 10);
|
||||||
|
const token = await this.create(data);
|
||||||
|
token._raw_token = `${PREFIX}${id}_${secret}`;
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
async rotate(){
|
||||||
|
const secret = randomHex(24);
|
||||||
|
await this.update({ secret_hash: await bcrypt.hash(secret, 10) });
|
||||||
|
return `${PREFIX}${this.id}_${secret}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate a raw `jmp_<id>_<secret>` string. Throws a generic Error on any
|
||||||
|
// failure so the caller (Auth.checkApiToken) can collapse every case into
|
||||||
|
// one 401 (no existence / wrong-secret / expired leak).
|
||||||
|
static async authenticate(raw){
|
||||||
|
const m = /^jmp_([0-9a-f]{24})_([0-9a-f]{48})$/i.exec(String(raw || ''));
|
||||||
|
if(!m) throw new Error('InvalidApiToken');
|
||||||
|
let token;
|
||||||
|
try{
|
||||||
|
token = await this.get(m[1]);
|
||||||
|
}catch(e){
|
||||||
|
throw new Error('InvalidApiToken');
|
||||||
|
}
|
||||||
|
if(!token) throw new Error('InvalidApiToken');
|
||||||
|
const ok = await bcrypt.compare(m[2], token.secret_hash);
|
||||||
|
if(!ok || !token.is_valid || token.isExpired) throw new Error('InvalidApiToken');
|
||||||
|
// Best-effort: stamp last use. Fire-and-forget so a Redis hiccup never
|
||||||
|
// fails an otherwise-valid request.
|
||||||
|
try{ await token.update({ last_used_on: (new Date).getTime() }); }catch(_){}
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ApiToken.register();
|
||||||
|
|
||||||
|
module.exports = {ApiToken};
|
||||||
+21
-4
@@ -32,14 +32,17 @@ async function getRedis() {
|
|||||||
|
|
||||||
module.exports.getRedis = getRedis;
|
module.exports.getRedis = getRedis;
|
||||||
|
|
||||||
// Register models (order matters: User before AuthToken's relation resolves).
|
// Register models (order matters: User before AuthToken's relation resolves,
|
||||||
|
// and before ApiToken so `require('.')`'s Table is already exporting User).
|
||||||
require('./user_redis'); // User (redis-backed local + OIDC JIT)
|
require('./user_redis'); // User (redis-backed local + OIDC JIT)
|
||||||
|
const { ApiToken } = require('./api_token');
|
||||||
|
module.exports.ApiToken = ApiToken;
|
||||||
|
|
||||||
// Shared OIDC client (authorization-code + PKCE): session models (Token,
|
// Shared OIDC client (authorization-code + PKCE): session models (Token,
|
||||||
// AuthToken, OidcState), the Auth service, and the /login /logout /oidc/start
|
// AuthToken, OidcState), the Auth service, and the /login /logout /oidc/start
|
||||||
// /oidc/callback router — all created on this app's Table/redis. jump-host has
|
// /oidc/callback router — all created on this app's Table/redis. checkApiToken
|
||||||
// no Bearer PATs, so checkApiToken is omitted (Auth.checkApiToken is absent).
|
// wraps ApiToken.authenticate, same wiring as proxy's models/index.js.
|
||||||
const oidcClient = createOidcClient({ Table });
|
const oidcClient = createOidcClient({ Table, checkApiToken: (raw) => ApiToken.authenticate(raw) });
|
||||||
module.exports.Token = oidcClient.Token;
|
module.exports.Token = oidcClient.Token;
|
||||||
module.exports.AuthToken = oidcClient.AuthToken;
|
module.exports.AuthToken = oidcClient.AuthToken;
|
||||||
module.exports.OidcState = oidcClient.OidcState;
|
module.exports.OidcState = oidcClient.OidcState;
|
||||||
@@ -50,3 +53,17 @@ require('./audit_event');
|
|||||||
|
|
||||||
// Idempotent anti-lockout local admin (was the IIFE in user_redis.js).
|
// Idempotent anti-lockout local admin (was the IIFE in user_redis.js).
|
||||||
bootstrapLocalAdmin(Table.models.User, { defaultName: 'jumpadmin' });
|
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;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ORM model for standalone-mode hosts. Stored in the configured SQL database
|
||||||
|
// (default SQLite) when conf.standalone.enabled is true. The hosts_file.js
|
||||||
|
// wrapper translates between this model and the accessibleHosts() interface
|
||||||
|
// that ssh_server.js expects.
|
||||||
|
|
||||||
|
const { Model, fields } = require('@simpleworkjs/orm');
|
||||||
|
|
||||||
|
// Patch StringField.toSequelize() to pass through primaryKey (same fix as in
|
||||||
|
// standalone_user.js — see that file for details).
|
||||||
|
if (!fields.StringField.prototype.toSequelize.toString().includes('primaryKey')) {
|
||||||
|
const orig = fields.StringField.prototype.toSequelize;
|
||||||
|
fields.StringField.prototype.toSequelize = function () {
|
||||||
|
const def = orig.call(this);
|
||||||
|
if (this.primaryKey) def.primaryKey = true;
|
||||||
|
return def;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class StandaloneHost extends Model {
|
||||||
|
static fields = {
|
||||||
|
slug: { type: 'string', primaryKey: true },
|
||||||
|
displayName: { type: 'string' },
|
||||||
|
kind: { type: 'string', default: 'host' },
|
||||||
|
metadata: { type: 'json', default: {} },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = StandaloneHost;
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ORM model for standalone-mode users. Stored in the configured SQL database
|
||||||
|
// (default SQLite) when conf.standalone.enabled is true. The user_file.js
|
||||||
|
// wrapper translates between this model and the LDAP-client interface that
|
||||||
|
// ssh_server.js and key_inject.js expect.
|
||||||
|
|
||||||
|
const { Model, fields } = require('@simpleworkjs/orm');
|
||||||
|
|
||||||
|
// Patch: StringField.toSequelize() and IntegerField.toSequelize() don't pass
|
||||||
|
// through primaryKey / autoIncrement (unlike UUIDField which does). Fix them
|
||||||
|
// so string and int primary keys work.
|
||||||
|
const origStringToSeq = fields.StringField.prototype.toSequelize;
|
||||||
|
fields.StringField.prototype.toSequelize = function () {
|
||||||
|
const def = origStringToSeq.call(this);
|
||||||
|
if (this.primaryKey) def.primaryKey = true;
|
||||||
|
return def;
|
||||||
|
};
|
||||||
|
const origIntToSeq = fields.IntegerField.prototype.toSequelize;
|
||||||
|
fields.IntegerField.prototype.toSequelize = function () {
|
||||||
|
const def = origIntToSeq.call(this);
|
||||||
|
if (this.primaryKey) def.primaryKey = true;
|
||||||
|
if (this.autoIncrement) def.autoIncrement = true;
|
||||||
|
return def;
|
||||||
|
};
|
||||||
|
|
||||||
|
class StandaloneUser extends Model {
|
||||||
|
static fields = {
|
||||||
|
uid: { type: 'string', primaryKey: true },
|
||||||
|
passwordHash: { type: 'string', isPrivate: true },
|
||||||
|
sshPublicKeys: { type: 'json', default: [] },
|
||||||
|
groups: { type: 'json', default: [] },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = StandaloneUser;
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ORM-backed user store for standalone mode. Implements the same interface as
|
||||||
|
// the @simpleworkjs/ldap client so ssh_server.js and key_inject.js work
|
||||||
|
// unchanged: getUser(uid), getGroups(dn), checkPassword(dn, pw), addSshKey(dn, keyLine).
|
||||||
|
//
|
||||||
|
// Users are stored via the StandaloneUser ORM model (Sequelize, any dialect).
|
||||||
|
// DNs are synthetic: uid=<uid>,ou=people,dc=standalone,dc=local — the real
|
||||||
|
// identity is the uid; the DN exists only for interface compatibility with
|
||||||
|
// callers that thread user.dn through to checkPassword / addSshKey.
|
||||||
|
|
||||||
|
const bcrypt = require('bcrypt');
|
||||||
|
const StandaloneUser = require('./standalone_user');
|
||||||
|
|
||||||
|
const DN_PREFIX = 'uid=';
|
||||||
|
const DN_SUFFIX = ',ou=people,dc=standalone,dc=local';
|
||||||
|
|
||||||
|
function dnFor(uid) {
|
||||||
|
return `${DN_PREFIX}${uid}${DN_SUFFIX}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function uidFromDn(dn) {
|
||||||
|
if (!dn || typeof dn !== 'string') return null;
|
||||||
|
const m = dn.match(/^uid=([^,]+)/);
|
||||||
|
return m ? m[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getUser(uid) {
|
||||||
|
const user = await StandaloneUser.get(uid);
|
||||||
|
if (!user) return null;
|
||||||
|
return {
|
||||||
|
dn: dnFor(user.uid),
|
||||||
|
uid: user.uid,
|
||||||
|
sshPublicKeys: user.sshPublicKeys || [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getGroups(dn) {
|
||||||
|
const uid = uidFromDn(dn);
|
||||||
|
if (!uid) return [];
|
||||||
|
const user = await StandaloneUser.get(uid);
|
||||||
|
if (!user) return [];
|
||||||
|
return user.groups || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkPassword(dn, pw) {
|
||||||
|
const uid = uidFromDn(dn);
|
||||||
|
if (!uid) return false;
|
||||||
|
const user = await StandaloneUser.get(uid);
|
||||||
|
if (!user || !user.passwordHash) return false;
|
||||||
|
return bcrypt.compare(pw, user.passwordHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addSshKey(dn, keyLine) {
|
||||||
|
const uid = uidFromDn(dn);
|
||||||
|
if (!uid) return;
|
||||||
|
const user = await StandaloneUser.get(uid);
|
||||||
|
if (!user) return;
|
||||||
|
const keys = [...(user.sshPublicKeys || [])];
|
||||||
|
if (keys.includes(keyLine)) return; // idempotent
|
||||||
|
keys.push(keyLine);
|
||||||
|
await user.update({ sshPublicKeys: keys });
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getUser, getGroups, checkPassword, addSshKey };
|
||||||
+17
-16
@@ -1,22 +1,23 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
// Thin LDAP helpers — the jump host's entire LDAP surface, now backed by the
|
// User authentication backend — LDAP in production, ORM-backed file store in
|
||||||
// shared @simpleworkjs/ldap package:
|
// 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)
|
||||||
//
|
|
||||||
// Behavior is unchanged from the previous in-tree implementation: posixAccount
|
|
||||||
// user filter, groupOfNames group filter, bind-as-user password check,
|
|
||||||
// TypeOrValueExists treated as success on key add, and the same loose TLS
|
|
||||||
// default ({ rejectUnauthorized: false } when conf.ldap omits tlsOptions).
|
|
||||||
|
|
||||||
const conf = require('@simpleworkjs/conf');
|
const conf = require('@simpleworkjs/conf');
|
||||||
const { createLdapClient } = require('@simpleworkjs/ldap');
|
|
||||||
|
|
||||||
const ldapConf = conf.ldap || {};
|
if (conf.standalone && conf.standalone.enabled) {
|
||||||
module.exports = createLdapClient({
|
// Standalone mode: use the ORM-backed user store.
|
||||||
...ldapConf,
|
module.exports = require('./user_file');
|
||||||
tlsOptions: ldapConf.tlsOptions || { rejectUnauthorized: false },
|
} else {
|
||||||
});
|
// Production mode: use the LDAP directory.
|
||||||
|
const { createLdapClient } = require('@simpleworkjs/ldap');
|
||||||
|
const ldapConf = conf.ldap || {};
|
||||||
|
module.exports = createLdapClient({
|
||||||
|
...ldapConf,
|
||||||
|
tlsOptions: ldapConf.tlsOptions || { rejectUnauthorized: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
Generated
+866
-7
File diff suppressed because it is too large
Load Diff
+5
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-jump-host",
|
"name": "t42-jump-host",
|
||||||
"version": "1.3.0",
|
"version": "1.9.0",
|
||||||
"description": "SSH jump host for the theta42 stack — LDAP-authenticated, directory-driven host bridging with audit and metrics",
|
"description": "SSH jump host for the theta42 stack — LDAP-authenticated, directory-driven host bridging with audit and metrics",
|
||||||
"author": [
|
"author": [
|
||||||
{
|
{
|
||||||
@@ -20,11 +20,13 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||||
"@simpleworkjs/conf": "^1.2.0",
|
|
||||||
"@simpleworkjs/app-stack": "^1.0.0",
|
"@simpleworkjs/app-stack": "^1.0.0",
|
||||||
"@simpleworkjs/ldap": "^1.0.0",
|
"@simpleworkjs/conf": "^1.2.0",
|
||||||
"@simpleworkjs/directory-schema": "^1.0.0",
|
"@simpleworkjs/directory-schema": "^1.0.0",
|
||||||
|
"@simpleworkjs/frontend": "^0.2.5",
|
||||||
|
"@simpleworkjs/ldap": "^1.0.1",
|
||||||
"@simpleworkjs/oidc-client": "^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",
|
||||||
|
|||||||
+14
-3
@@ -11,11 +11,22 @@ app.jump = (function(app){
|
|||||||
var qs = $.param(query || {});
|
var qs = $.param(query || {});
|
||||||
app.api.get('audit' + (qs ? '?' + qs : ''), cb);
|
app.api.get('audit' + (qs ? '?' + qs : ''), cb);
|
||||||
}
|
}
|
||||||
return {metrics: metrics, sessions: sessions, audit: audit};
|
function hosts(cb){ app.api.get('user/hosts', cb); }
|
||||||
|
return {metrics: metrics, sessions: sessions, audit: audit, hosts: hosts};
|
||||||
|
})(app);
|
||||||
|
|
||||||
|
// Self-service API token (PAT) management.
|
||||||
|
app.apiToken = (function(app){
|
||||||
|
function list(cb){ app.api.get('api-token/', cb); }
|
||||||
|
function add(args, cb){ app.api.post('api-token/', args, cb); }
|
||||||
|
function remove(id, cb){ app.api.delete('api-token/' + id, cb); }
|
||||||
|
function rotate(id, cb){ app.api.post('api-token/' + id + '/rotate', {}, cb); }
|
||||||
|
return {list: list, add: add, remove: remove, rotate: rotate};
|
||||||
})(app);
|
})(app);
|
||||||
|
|
||||||
// Shared render helpers.
|
// Shared render helpers.
|
||||||
app.jump.fmtTime = function(ts){ return ts ? moment(Number(ts)).format('YYYY-MM-DD HH:mm:ss') : '—'; };
|
app.jump.fmtTime = function(ts){ return ts ? moment(Number(ts)).format('YYYY-MM-DD HH:mm:ss') : '—'; };
|
||||||
app.jump.esc = function(s){ return $('<div>').text(s == null ? '' : String(s)).html(); };
|
app.jump.esc = function(s){ return $('<div>').text(s == null ? '' : String(s)).html(); };
|
||||||
app.jump.result = function(e){ return e.success ? '<span class="badge bg-success">ok</span>'
|
app.jump.result = function(e){ if (e.success) return '<span class="badge bg-success">ok</span>';
|
||||||
: '<span class="badge bg-danger">' + app.jump.esc(e.failReason || 'fail') + '</span>'; };
|
var title = e.failDetail ? ' title="' + app.jump.esc(e.failDetail) + '"' : '';
|
||||||
|
return '<span class="badge bg-danger"' + title + '>' + app.jump.esc(e.failReason || 'fail') + '</span>'; };
|
||||||
|
|||||||
@@ -363,7 +363,7 @@ app.auth = (function(app){
|
|||||||
}
|
}
|
||||||
|
|
||||||
if(requiredGroups && !await memberOf(requiredGroups, user)){
|
if(requiredGroups && !await memberOf(requiredGroups, user)){
|
||||||
app.util.actionMessage(
|
app.messages.action(
|
||||||
`<h1>
|
`<h1>
|
||||||
<i class="fa-solid fa-triangle-exclamation"></i>
|
<i class="fa-solid fa-triangle-exclamation"></i>
|
||||||
<b>You do not have permission to be here.</b>
|
<b>You do not have permission to be here.</b>
|
||||||
@@ -520,68 +520,15 @@ app.util = (function(app){
|
|||||||
return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' '));
|
return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' '));
|
||||||
};
|
};
|
||||||
|
|
||||||
function actionMessage(message, $targetPassed, type, callback){
|
// escapeHtml/actionMessage/actionConfirm moved to @simpleworkjs/frontend's
|
||||||
message = message || '';
|
// app.util.escapeHtml and app.messages.action/confirm.
|
||||||
|
function escapeHtml(s){
|
||||||
let $target = $targetPassed.closest('div.card').find('.actionMessage');
|
return String(s == null ? '' : s)
|
||||||
if(!$target.length) $target = $($targetPassed.find('.actionMessage')[0]);
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
type = type || 'info';
|
.replace(/>/g, '>')
|
||||||
callback = callback || function(){};
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
if($target.html() === message) return;
|
|
||||||
|
|
||||||
if($target.html()){
|
|
||||||
$target.slideUp('fast', function(){
|
|
||||||
$target.html('')
|
|
||||||
$target.removeClass (function(index, className){
|
|
||||||
return (className.match (/(^|\s)bg-\S+/g) || []).join(' ');
|
|
||||||
});
|
|
||||||
if(message) return actionMessage(message, $target, type, callback);
|
|
||||||
$target.hide()
|
|
||||||
})
|
|
||||||
}else{
|
|
||||||
if(type) $target.addClass('bg-' + type);
|
|
||||||
|
|
||||||
// Messages that bring their own buttons (actionConfirm) are left
|
|
||||||
// alone; everything else gets the standard dismiss button.
|
|
||||||
if(!message.includes('<button')) message = `
|
|
||||||
<span class="align-middle">${message}</span>
|
|
||||||
<button class="action-close btn btn-sm btn-outline-dark float-end">
|
|
||||||
<i class="fa-solid fa-xmark"></i>
|
|
||||||
</button>
|
|
||||||
`
|
|
||||||
$target.html(message).slideDown('fast');
|
|
||||||
}
|
|
||||||
setTimeout(callback,10)
|
|
||||||
}
|
|
||||||
|
|
||||||
function actionConfirm(message, $target, type, callback){
|
|
||||||
return new Promise((resolve, reject) =>{
|
|
||||||
let id = crypto.randomUUID();
|
|
||||||
message = `
|
|
||||||
<h4 class"align-middle" >
|
|
||||||
<i class="fa-solid fa-triangle-exclamation"></i>
|
|
||||||
<b>${message}</b>
|
|
||||||
<span class="float-end">
|
|
||||||
<button type="button" class="btn btn-success confirm-${id}" data-confirm="true">
|
|
||||||
<i class="fa-solid fa-circle-check"></i>
|
|
||||||
Confirm
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-danger confirm-${id}">
|
|
||||||
<i class="fa-solid fa-circle-stop"></i>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
</h4>
|
|
||||||
`
|
|
||||||
actionMessage(message, $target, type);
|
|
||||||
$("body").on('click', `.confirm-${id}`, function(){
|
|
||||||
actionMessage('', $target, type);
|
|
||||||
resolve(!!$(this).data('confirm'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$.fn.serializeObject = function() {
|
$.fn.serializeObject = function() {
|
||||||
@@ -640,8 +587,7 @@ app.util = (function(app){
|
|||||||
return {
|
return {
|
||||||
downloadFile: downloadFile,
|
downloadFile: downloadFile,
|
||||||
getUrlParameter: getUrlParameter,
|
getUrlParameter: getUrlParameter,
|
||||||
actionMessage: actionMessage,
|
escapeHtml: escapeHtml,
|
||||||
actionConfirm,
|
|
||||||
}
|
}
|
||||||
})(app);
|
})(app);
|
||||||
|
|
||||||
@@ -696,9 +642,9 @@ $( document ).ready(async function(){
|
|||||||
$(this).closest('.card').slideUp('fast');
|
$(this).closest('.card').slideUp('fast');
|
||||||
});
|
});
|
||||||
|
|
||||||
$('.actionMessage').on('click', 'button.action-close', function(event){
|
// action-close click handling is wired by @simpleworkjs/frontend's
|
||||||
app.util.actionMessage(null, $(this));
|
// app.messages.js (delegated on document, so it also covers messages
|
||||||
});
|
// rendered after this ready handler runs).
|
||||||
|
|
||||||
setInterval(()=>{
|
setInterval(()=>{
|
||||||
$('.momentFromNow').each((idx, el)=>{
|
$('.momentFromNow').each((idx, el)=>{
|
||||||
@@ -729,20 +675,17 @@ function formAJAX(btn){
|
|||||||
var method = ($form.attr('method') || 'post').toLowerCase();
|
var method = ($form.attr('method') || 'post').toLowerCase();
|
||||||
|
|
||||||
if($form.validate && !$form.validate()){
|
if($form.validate && !$form.validate()){
|
||||||
app.util.actionMessage('Please fix the form errors.', $form, 'danger')
|
app.messages.action('Please fix the form errors.', $form, 'danger')
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
app.util.actionMessage(
|
// Plain text: app.messages.action HTML-escapes its message (by design,
|
||||||
`<div class="spinner-border" role="status">
|
// see @simpleworkjs/frontend), so raw markup like a spinner <div> would
|
||||||
<span class="visually-hidden">Loading...</span>
|
// render literally instead of as an element.
|
||||||
</div>`,
|
app.messages.action('Saving…', $form, 'info');
|
||||||
$form,
|
|
||||||
'info'
|
|
||||||
);
|
|
||||||
|
|
||||||
app.api[method]($form.attr('action'), formData, function(error, data){
|
app.api[method]($form.attr('action'), formData, function(error, data){
|
||||||
app.util.actionMessage(data.message, $form, error ? 'danger' : 'success'); //re-populate table
|
app.messages.action(data.message, $form, error ? 'danger' : 'success'); //re-populate table
|
||||||
$form.validateClear();
|
$form.validateClear();
|
||||||
if(!error){
|
if(!error){
|
||||||
$form.trigger("reset");
|
$form.trigger("reset");
|
||||||
@@ -750,7 +693,7 @@ function formAJAX(btn){
|
|||||||
}else{
|
}else{
|
||||||
console.log('formAJAX res error', error, data)
|
console.log('formAJAX res error', error, data)
|
||||||
if(data && data.name === 'ObjectValidateError'){
|
if(data && data.name === 'ObjectValidateError'){
|
||||||
app.util.actionMessage('Please fix the form errors', $form, 'danger'); //re-populate table
|
app.messages.action('Please fix the form errors', $form, 'danger'); //re-populate table
|
||||||
}
|
}
|
||||||
if(data && data.keys){
|
if(data && data.keys){
|
||||||
console.log('form key errors', data.keys)
|
console.log('form key errors', data.keys)
|
||||||
|
|||||||
@@ -1,201 +0,0 @@
|
|||||||
( function( $ ) {
|
|
||||||
var settings = {
|
|
||||||
rule: {
|
|
||||||
eq: function(value, options){
|
|
||||||
var compare = $('[name=' + options + ']').val();
|
|
||||||
|
|
||||||
if ( value != compare ) {
|
|
||||||
return "Miss-match";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
$.fn.validate = function(event) {
|
|
||||||
// let thisSettings = $.extend(true, settings, settingsObj);
|
|
||||||
let hasErrors = false;
|
|
||||||
|
|
||||||
if(this.is('[validate]')) return this.validateField(event);
|
|
||||||
|
|
||||||
if(!this.attr('isValid')){
|
|
||||||
console.log('adding reset event')
|
|
||||||
this.on('reset', function(){
|
|
||||||
$(this).attr('isValid', false);
|
|
||||||
$(this).validateClear();
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
this.find('[validate]').each(function(){
|
|
||||||
if(!$(this).validateField()) hasErrors = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
this.attr('isValid', !hasErrors);
|
|
||||||
|
|
||||||
if(hasErrors && event) event.preventDefault();
|
|
||||||
|
|
||||||
return !hasErrors;
|
|
||||||
};
|
|
||||||
|
|
||||||
$.fn.validateClear = function(){
|
|
||||||
$(this).find('input').each(function(){
|
|
||||||
$(this).removeClass('is-invalid');
|
|
||||||
$(this).removeClass('is-valid');
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
$.fn.validateField = function(){
|
|
||||||
var attr = this.attr('validate').split(':'); //array of params
|
|
||||||
var rule = attr[0];
|
|
||||||
var options = attr[1];
|
|
||||||
var value = this.val(); //link to input value
|
|
||||||
var message;
|
|
||||||
|
|
||||||
if(this.prop('disabled')) return true;
|
|
||||||
|
|
||||||
|
|
||||||
//checks if field is required, and length
|
|
||||||
if(!isNaN(options) && value.length < options){
|
|
||||||
message = `Must be ${options} characters`;
|
|
||||||
}
|
|
||||||
|
|
||||||
//checks if empty to stop processing
|
|
||||||
if(!isNaN(options) && value.length === 0) {
|
|
||||||
}else if(rule in settings.rule){
|
|
||||||
message = settings.rule[rule].apply(this, [value, options]);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.validateMessage(message)
|
|
||||||
return !message;
|
|
||||||
}
|
|
||||||
|
|
||||||
$.fn.validateMessage = function(message){
|
|
||||||
if(message && message !== true){
|
|
||||||
this.closest('.form-group').find('b.invalid-feedback').html(message);
|
|
||||||
this.addClass('is-invalid');
|
|
||||||
}else{
|
|
||||||
this.removeClass('is-invalid');
|
|
||||||
this.addClass('is-valid');
|
|
||||||
}
|
|
||||||
return this;
|
|
||||||
};
|
|
||||||
|
|
||||||
jQuery.extend({
|
|
||||||
validateSettings: function( settingsObj ) {
|
|
||||||
$.extend( true, settings, settingsObj );
|
|
||||||
},
|
|
||||||
|
|
||||||
validateInit: function( ettingsObj ) {
|
|
||||||
$( '[action]' ).on( 'submit', function ( event, settingsObj ){
|
|
||||||
$( this ).validate( settingsObj, event );
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
}( jQuery ));
|
|
||||||
|
|
||||||
// Host / target validation, mirrored from the backend (utils/hostname_validate.js):
|
|
||||||
// a bare hostname or IPv4 address, no protocol / "/" / ":" / whitespace. The
|
|
||||||
// incoming host may be a wildcard ("*.example.com"); the target may not.
|
|
||||||
(function(){
|
|
||||||
var LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i;
|
|
||||||
// Either one bare label (Docker service names, /etc/hosts entries) or a
|
|
||||||
// dotted hostname with an alphabetic TLD.
|
|
||||||
var HOSTNAME = /^(?=.{1,253}$)(?:(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}|[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)$/i;
|
|
||||||
var FORBIDDEN = /[\s/:]/;
|
|
||||||
|
|
||||||
function isIPv4( value ) {
|
|
||||||
var parts = value.split( '.' );
|
|
||||||
if ( parts.length !== 4 ) return false;
|
|
||||||
return parts.every( function( p ) {
|
|
||||||
return /^(0|[1-9]\d{0,2})$/.test( p ) && Number( p ) <= 255;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Incoming-host pattern: labels may be normal, "*" (one fragment), or "**"
|
|
||||||
// (any number of fragments, incl. a bare "**" global catch-all).
|
|
||||||
function isHostPattern( value ) {
|
|
||||||
if ( value.length > 253 ) return false;
|
|
||||||
return value.split( '.' ).every( function( l ) {
|
|
||||||
return l === '*' || l === '**' || LABEL.test( l );
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function forbidden( value ) {
|
|
||||||
return FORBIDDEN.test( value ) || value.includes( '://' );
|
|
||||||
}
|
|
||||||
|
|
||||||
// Incoming host: IPv4 or a wildcard host pattern.
|
|
||||||
function checkHost( value ) {
|
|
||||||
if ( typeof value !== 'string' || value.length === 0 ) return "Required";
|
|
||||||
if ( forbidden( value ) ) return 'No protocol, "/", or ":"';
|
|
||||||
if ( isIPv4( value ) || isHostPattern( value ) ) return;
|
|
||||||
return "Enter a valid host or wildcard (*, **)";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Downstream target: IPv4 or a strict hostname, no wildcard.
|
|
||||||
function checkTarget( value ) {
|
|
||||||
if ( typeof value !== 'string' || value.length === 0 ) return "Required";
|
|
||||||
if ( forbidden( value ) ) return 'No protocol, "/", or ":"';
|
|
||||||
if ( isIPv4( value ) || HOSTNAME.test( value ) ) return;
|
|
||||||
return "Enter a valid hostname or IP";
|
|
||||||
}
|
|
||||||
|
|
||||||
$.validateSettings({
|
|
||||||
rule:{
|
|
||||||
ip: function( value ) {
|
|
||||||
value = value.split( '.' );
|
|
||||||
|
|
||||||
if ( value.length != 4 ) {
|
|
||||||
return "Malformed IP";
|
|
||||||
}
|
|
||||||
|
|
||||||
$.each( value, function( key, value ) {
|
|
||||||
if( value > 255 || value < 0 ) {
|
|
||||||
return "Malformed IP";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
// Incoming host name — hostname, IPv4, or wildcard pattern (*, **).
|
|
||||||
host: function( value ) {
|
|
||||||
return checkHost( value );
|
|
||||||
},
|
|
||||||
|
|
||||||
// Downstream target — hostname or IPv4, no wildcard.
|
|
||||||
target: function( value ) {
|
|
||||||
return checkTarget( value );
|
|
||||||
},
|
|
||||||
|
|
||||||
// Back-compat alias (no wildcard).
|
|
||||||
hostname: function( value ) {
|
|
||||||
return checkTarget( value );
|
|
||||||
},
|
|
||||||
|
|
||||||
user: function( value ) {
|
|
||||||
var reg = /^[a-z0-9\_\-\@\.]{1,32}$/;
|
|
||||||
if ( reg.test( value ) === false ) {
|
|
||||||
return "Invalid";
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Mirrors utils/password_policy.js: >= 8 chars, and either 12+ chars
|
|
||||||
// or at least 3 of {lowercase, uppercase, number, symbol}.
|
|
||||||
password: function( value ) {
|
|
||||||
if ( typeof value !== 'string' || value.length < 8 ) {
|
|
||||||
return "Password must be at least 8 characters";
|
|
||||||
}
|
|
||||||
if ( value.length >= 12 ) return;
|
|
||||||
|
|
||||||
var classes = 0;
|
|
||||||
if ( /[a-z]/.test( value ) ) classes++;
|
|
||||||
if ( /[A-Z]/.test( value ) ) classes++;
|
|
||||||
if ( /[0-9]/.test( value ) ) classes++;
|
|
||||||
if ( /[^A-Za-z0-9]/.test( value ) ) classes++;
|
|
||||||
|
|
||||||
if ( classes < 3 ) {
|
|
||||||
return "Use 3 of: lowercase, uppercase, number, symbol (or 12+ chars)";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
@@ -9,6 +9,10 @@ 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'));
|
||||||
|
|
||||||
|
// Self-service API token (PAT) management — any authenticated user, no
|
||||||
|
// admin gate (see routes/api_token.js for why a token can't reach admin routes).
|
||||||
|
router.use('/api-token', middleware.auth, require('./api_token'));
|
||||||
|
|
||||||
// Jump-host data — admin only (audit log, active sessions, metrics).
|
// Jump-host data — admin only (audit log, active sessions, metrics).
|
||||||
router.use('/', middleware.auth, middleware.requireAdmin, require('./jump'));
|
router.use('/', middleware.auth, middleware.requireAdmin, require('./jump'));
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Self-service API token (PAT) management. Every endpoint is owner-scoped: a
|
||||||
|
// user only sees / mutates tokens where created_by === req.user.username.
|
||||||
|
// Mirrors proxy's routes/api_token.js. Mounted under middleware.auth only
|
||||||
|
// (no requireAdmin) — any authenticated user may mint one, but the token
|
||||||
|
// itself carries no group claims (see models/api_token.js), so it can only
|
||||||
|
// reach non-admin routes (e.g. GET /api/user/hosts), never the admin-gated
|
||||||
|
// ones under routes/jump.js.
|
||||||
|
|
||||||
|
const router = require('express').Router();
|
||||||
|
const {ApiToken} = require('../models');
|
||||||
|
|
||||||
|
function forbidden(){
|
||||||
|
let error = new Error('Forbidden');
|
||||||
|
error.name = 'Forbidden';
|
||||||
|
error.message = 'You do not own this API token.';
|
||||||
|
error.status = 403;
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve a token the caller owns. Missing or not-yours both raise 403 (no
|
||||||
|
// existence leak; ids are unguessable random hex anyway).
|
||||||
|
async function getOwned(req, id){
|
||||||
|
let token;
|
||||||
|
try{
|
||||||
|
token = await ApiToken.get(id);
|
||||||
|
}catch(e){
|
||||||
|
throw forbidden();
|
||||||
|
}
|
||||||
|
if(!token || token.created_by !== req.user.username) throw forbidden();
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/', async function(req, res, next){
|
||||||
|
try{
|
||||||
|
return res.json({results: await ApiToken.listDetail({created_by: req.user.username})});
|
||||||
|
}catch(error){
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/', async function(req, res, next){
|
||||||
|
try{
|
||||||
|
const days = req.body.expires_in_days !== '' && req.body.expires_in_days !== undefined
|
||||||
|
? Number(req.body.expires_in_days) : 0;
|
||||||
|
|
||||||
|
const token = await ApiToken.add({
|
||||||
|
name: req.body.name,
|
||||||
|
description: req.body.description || '',
|
||||||
|
created_by: req.user.username,
|
||||||
|
expires_at: days > 0 ? (new Date).getTime() + days * 86400000 : 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
results: token,
|
||||||
|
token: token._raw_token,
|
||||||
|
message: `API token '${token.name}' created. Save it now — it will not be shown again.`,
|
||||||
|
});
|
||||||
|
}catch(error){
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/:id', async function(req, res, next){
|
||||||
|
try{
|
||||||
|
return res.json({results: await getOwned(req, req.params.id)});
|
||||||
|
}catch(error){
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/:id', async function(req, res, next){
|
||||||
|
try{
|
||||||
|
const token = await getOwned(req, req.params.id);
|
||||||
|
|
||||||
|
const update = {};
|
||||||
|
for(const k of ['name', 'description']){
|
||||||
|
if(req.body[k] !== undefined) update[k] = req.body[k];
|
||||||
|
}
|
||||||
|
if(req.body.expires_in_days !== undefined && req.body.expires_in_days !== ''){
|
||||||
|
const days = Number(req.body.expires_in_days);
|
||||||
|
update.expires_at = days > 0 ? (new Date).getTime() + days * 86400000 : 0;
|
||||||
|
}else if(req.body.expires_at !== undefined){
|
||||||
|
update.expires_at = Number(req.body.expires_at) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
results: await token.update(update),
|
||||||
|
message: `API token '${token.name}' updated.`,
|
||||||
|
});
|
||||||
|
}catch(error){
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:id', async function(req, res, next){
|
||||||
|
try{
|
||||||
|
const token = await getOwned(req, req.params.id);
|
||||||
|
await token.remove();
|
||||||
|
return res.json({id: req.params.id, message: `API token '${token.name}' revoked.`});
|
||||||
|
}catch(error){
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/rotate', async function(req, res, next){
|
||||||
|
try{
|
||||||
|
const token = await getOwned(req, req.params.id);
|
||||||
|
const raw = await token.rotate();
|
||||||
|
return res.json({
|
||||||
|
token: raw,
|
||||||
|
message: `API token '${token.name}' rotated. Save it — it will not be shown again.`,
|
||||||
|
});
|
||||||
|
}catch(error){
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -14,6 +14,10 @@ const values = {
|
|||||||
titleIcon: conf.environment !== 'production' ? '<i class="fa-brands fa-dev"></i>' : '',
|
titleIcon: conf.environment !== 'production' ? '<i class="fa-brands fa-dev"></i>' : '',
|
||||||
name: conf.name,
|
name: conf.name,
|
||||||
logo: conf.logo,
|
logo: conf.logo,
|
||||||
|
// The SSH front door's port -- the dashboard's "quick jump" copy buttons
|
||||||
|
// need this to build a real, working `ssh ...` command (the web UI and
|
||||||
|
// SSH front door share a hostname but not a port).
|
||||||
|
sshPort: (conf.ssh && conf.ssh.listenPort) || 22,
|
||||||
...buildInfo,
|
...buildInfo,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -21,7 +25,7 @@ const values = {
|
|||||||
// as the sibling apps), and the app's own JS/CSS/img from public/.
|
// as the sibling apps), and the app's own JS/CSS/img from public/.
|
||||||
mountStaticModules(router, {
|
mountStaticModules(router, {
|
||||||
root: path.join(__dirname, '..'),
|
root: path.join(__dirname, '..'),
|
||||||
deps: ['bootstrap', 'mustache', 'jquery', '@fortawesome', 'moment', 'jq-repeat'],
|
deps: ['bootstrap', 'mustache', 'jquery', '@fortawesome', 'moment', 'jq-repeat', '@simpleworkjs/frontend'],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Liveness probe — no auth.
|
// Liveness probe — no auth.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
const router = require('express').Router();
|
const router = require('express').Router();
|
||||||
const { isAdmin } = require('../middleware/auth');
|
const { isAdmin } = require('../middleware/auth');
|
||||||
|
const access = require('../utils/access');
|
||||||
|
|
||||||
router.get('/me', (req, res) => {
|
router.get('/me', (req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
@@ -14,4 +15,16 @@ router.get('/me', (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The hosts this session can SSH to — every host for an admin, otherwise the
|
||||||
|
// same group-based resolution the SSH front door uses (accessibleHosts),
|
||||||
|
// fed the OIDC session's already-known groups instead of an LDAP lookup.
|
||||||
|
router.get('/hosts', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const hosts = isAdmin(req)
|
||||||
|
? await access.allHosts()
|
||||||
|
: await access.accessibleHosts({ uid: req.user && req.user.username, groups: req.groups || [] });
|
||||||
|
res.json({ results: hosts });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ async function resolveAndConnect(state, record, { onHostKey } = {}) {
|
|||||||
|
|
||||||
let justInjected = false;
|
let justInjected = false;
|
||||||
try { justInjected = await ensureKeyInjected(state.user, JUMP_KEYS.publicLine); }
|
try { justInjected = await ensureKeyInjected(state.user, JUMP_KEYS.publicLine); }
|
||||||
catch (_) { throw fail('key-inject-failed'); }
|
catch (err) { throw fail('key-inject-failed', err.message); }
|
||||||
|
|
||||||
let upstream;
|
let upstream;
|
||||||
try {
|
try {
|
||||||
@@ -139,20 +139,32 @@ async function resolveAndConnect(state, record, { onHostKey } = {}) {
|
|||||||
username: state.uid, privateKey: JUMP_KEYS.clientKey,
|
username: state.uid, privateKey: JUMP_KEYS.clientKey,
|
||||||
uid: state.uid, justInjected, onHostKey,
|
uid: state.uid, justInjected, onHostKey,
|
||||||
});
|
});
|
||||||
} catch (_) { throw fail('upstream-unreachable'); }
|
} catch (err) { throw fail('upstream-unreachable', err.message); }
|
||||||
|
|
||||||
return { upstream, host, endpoint };
|
return { upstream, host, endpoint };
|
||||||
}
|
}
|
||||||
|
|
||||||
function fail(reason) { const e = new Error(reason); e.reason = reason; return e; }
|
// detail carries the real underlying error message (e.g. ECONNREFUSED,
|
||||||
|
// ETIMEDOUT, an ssh2 auth-failure string) so audit records aren't reduced to
|
||||||
|
// just the generic reason code -- without it, a network-layer failure and an
|
||||||
|
// SSH auth failure both looked identical in the audit log.
|
||||||
|
function fail(reason, detail) { const e = new Error(reason); e.reason = reason; e.detail = detail; 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, {
|
||||||
@@ -166,25 +178,47 @@ async function runGrammar(session, client, state) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
const reason = err.reason || 'error';
|
const reason = err.reason || 'error';
|
||||||
rejectUp(new Error(reasonMessage(reason)));
|
rejectUp(new Error(reasonMessage(reason)));
|
||||||
await record.finish({ success: false, failReason: reason });
|
await record.finish({ success: false, failReason: reason, failDetail: err.detail });
|
||||||
await metrics.bump({ uid: state.uid, success: false });
|
await metrics.bump({ uid: state.uid, success: false });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runTuiSession(session, client, state) {
|
async function runTuiSession(session, client, state) {
|
||||||
|
// Register session listeners IMMEDIATELY, before any await — same fix,
|
||||||
|
// same reason, as runGrammar above. The client sends pty-req and shell
|
||||||
|
// requests right after opening the session; awaiting audit.create() and
|
||||||
|
// accessibleHosts() first (both real round-trips: Redis, then the
|
||||||
|
// directory API) left a window where those requests could arrive before
|
||||||
|
// runTui had attached any listener for them, and ssh2 auto-rejects an
|
||||||
|
// unlistened channel request with CHANNEL_FAILURE — surfacing to the
|
||||||
|
// client as "PTY allocation request failed" / "shell request failed",
|
||||||
|
// with the connection then just sitting there (nothing left to drive it).
|
||||||
|
let resolveHosts, rejectHosts;
|
||||||
|
const hostsPromise = new Promise((res, rej) => { resolveHosts = res; rejectHosts = rej; });
|
||||||
|
// A silent catch so a rejection isn't "unhandled" if the client never
|
||||||
|
// sends a shell request at all (exec-only) — runTui's own .catch() below
|
||||||
|
// still runs independently when it does.
|
||||||
|
hostsPromise.catch(() => {});
|
||||||
|
const tuiPromise = runTui(session, state.uid, hostsPromise);
|
||||||
|
|
||||||
const record = await audit.create({ uid: state.uid, authMethod: state.authMethod, clientIp: state.clientIp, mode: 'tui' });
|
const record = await audit.create({ uid: state.uid, authMethod: state.authMethod, clientIp: state.clientIp, mode: 'tui' });
|
||||||
|
|
||||||
const finishFail = async (reason) => {
|
const finishFail = async (reason, detail) => {
|
||||||
await record.finish({ success: false, failReason: reason });
|
await record.finish({ success: false, failReason: reason, failDetail: detail });
|
||||||
await metrics.bump({ uid: state.uid, success: false });
|
await metrics.bump({ uid: state.uid, success: false });
|
||||||
try { client.end(); } catch (_) {}
|
try { client.end(); } catch (_) {}
|
||||||
};
|
};
|
||||||
|
|
||||||
let hosts;
|
let hosts;
|
||||||
try { hosts = await accessibleHosts(state.user); }
|
try {
|
||||||
catch (_) { return finishFail('directory-unreachable'); }
|
hosts = await accessibleHosts(state.user);
|
||||||
|
resolveHosts(hosts);
|
||||||
|
} catch (_) {
|
||||||
|
rejectHosts(new Error('directory-unreachable'));
|
||||||
|
return finishFail('directory-unreachable');
|
||||||
|
}
|
||||||
|
|
||||||
const tui = await runTui(session, state.uid, hosts);
|
const tui = await tuiPromise;
|
||||||
if (!tui.host) return finishFail('cancelled');
|
if (!tui.host) return finishFail('cancelled');
|
||||||
state.target = tui.host.slug;
|
state.target = tui.host.slug;
|
||||||
|
|
||||||
@@ -193,7 +227,7 @@ async function runTuiSession(session, client, state) {
|
|||||||
|
|
||||||
let justInjected = false;
|
let justInjected = false;
|
||||||
try { justInjected = await ensureKeyInjected(state.user, JUMP_KEYS.publicLine); }
|
try { justInjected = await ensureKeyInjected(state.user, JUMP_KEYS.publicLine); }
|
||||||
catch (_) { return finishFail('key-inject-failed'); }
|
catch (err) { return finishFail('key-inject-failed', err.message); }
|
||||||
|
|
||||||
let upstream;
|
let upstream;
|
||||||
try {
|
try {
|
||||||
@@ -202,9 +236,9 @@ async function runTuiSession(session, client, state) {
|
|||||||
username: state.uid, privateKey: JUMP_KEYS.clientKey,
|
username: state.uid, privateKey: JUMP_KEYS.clientKey,
|
||||||
uid: state.uid, justInjected, onHostKey: (fp) => record.patch({ hostKeyFp: fp }),
|
uid: state.uid, justInjected, onHostKey: (fp) => record.patch({ hostKeyFp: fp }),
|
||||||
});
|
});
|
||||||
} catch (_) {
|
} catch (err) {
|
||||||
try { tui.channel.write(`\r\n Could not reach ${endpoint.address}.\r\n`); tui.channel.close(); } catch (_) {}
|
try { tui.channel.write(`\r\n Could not reach ${endpoint.address}.\r\n`); tui.channel.close(); } catch (_) {}
|
||||||
return finishFail('upstream-unreachable');
|
return finishFail('upstream-unreachable', err.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
registry.add(record.id, { uid: state.uid, target: endpoint.address, slug: tui.host.slug });
|
registry.add(record.id, { uid: state.uid, target: endpoint.address, slug: tui.host.slug });
|
||||||
@@ -245,7 +279,10 @@ function reasonMessage(reason) {
|
|||||||
|
|
||||||
// Run the TUI picker over a shell channel; returns { host, channel, ptyInfo }.
|
// Run the TUI picker over a shell channel; returns { host, channel, ptyInfo }.
|
||||||
// host is null if the user quit. exec/subsystem in picker mode are rejected.
|
// host is null if the user quit. exec/subsystem in picker mode are rejected.
|
||||||
function runTui(session, uid, hosts) {
|
// Takes a Promise for the accessible-hosts list (not the resolved list)
|
||||||
|
// so the caller can register these listeners before that lookup completes
|
||||||
|
// — see the comment in runTuiSession for why that ordering matters.
|
||||||
|
function runTui(session, uid, hostsPromise) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
let ptyInfo = null;
|
let ptyInfo = null;
|
||||||
let settled = false;
|
let settled = false;
|
||||||
@@ -254,9 +291,14 @@ function runTui(session, uid, hosts) {
|
|||||||
session.on('pty', (accept, _reject, info) => { ptyInfo = info; accept && accept(); });
|
session.on('pty', (accept, _reject, info) => { ptyInfo = info; accept && accept(); });
|
||||||
session.on('shell', (accept) => {
|
session.on('shell', (accept) => {
|
||||||
const channel = accept();
|
const channel = accept();
|
||||||
pickHost(channel, uid, hosts).then((host) => {
|
hostsPromise.then((hosts) => {
|
||||||
if (!host) { try { channel.write('\r\n Bye.\r\n'); channel.close(); } catch (_) {} }
|
pickHost(channel, uid, hosts).then((host) => {
|
||||||
finish({ host, channel, ptyInfo });
|
if (!host) { try { channel.write('\r\n Bye.\r\n'); channel.close(); } catch (_) {} }
|
||||||
|
finish({ host, channel, ptyInfo });
|
||||||
|
});
|
||||||
|
}).catch(() => {
|
||||||
|
try { channel.write('\r\n Could not reach the directory.\r\n'); channel.close(); } catch (_) {}
|
||||||
|
finish({ host: null });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
session.on('exec', (accept) => {
|
session.on('exec', (accept) => {
|
||||||
@@ -280,7 +322,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}`);
|
||||||
|
|||||||
@@ -156,6 +156,29 @@ test('shell bridges and echoes', async () => {
|
|||||||
assert.match(out, /echo:ping/);
|
assert.match(out, /echo:ping/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('connectUpstream rejects with a specific, non-generic error when the target refuses the connection', async () => {
|
||||||
|
// Regression coverage for ssh_server.js's resolveAndConnect: it used to
|
||||||
|
// discard this error entirely (catch (_) { throw fail('upstream-unreachable') }),
|
||||||
|
// so the audit log recorded the same generic reason for a refused port, a
|
||||||
|
// timeout, or a bad key alike. Now the real message is threaded through as
|
||||||
|
// failDetail, so this must stay meaningful.
|
||||||
|
// Bind a server just to reserve a free port, then close it immediately so
|
||||||
|
// nothing is listening there — guarantees ECONNREFUSED rather than relying
|
||||||
|
// on a hardcoded port number that might be in use.
|
||||||
|
const closedPort = await new Promise((resolve) => {
|
||||||
|
const probe = require('net').createServer();
|
||||||
|
probe.listen(0, '127.0.0.1', () => { const p = probe.address().port; probe.close(() => resolve(p)); });
|
||||||
|
});
|
||||||
|
await assert.rejects(
|
||||||
|
connectUpstream({ host: '127.0.0.1', port: closedPort, username: 'test', privateKey: jumpKey, uid: 'test', justInjected: false }),
|
||||||
|
(err) => {
|
||||||
|
assert.ok(err.message && err.message.length > 0);
|
||||||
|
assert.notStrictEqual(err.message, 'upstream-unreachable');
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('sftp subsystem bytes pass through', async () => {
|
test('sftp subsystem bytes pass through', async () => {
|
||||||
const { conn, ready } = connectJump();
|
const { conn, ready } = connectJump();
|
||||||
await ready;
|
await ready;
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
const { test } = require('node:test');
|
const { test } = require('node:test');
|
||||||
const assert = require('node:assert');
|
const assert = require('node:assert');
|
||||||
const { accessibleHosts, clearCache } = require('../../utils/access');
|
const { accessibleHosts, allHosts, clearCache } = require('../../utils/access');
|
||||||
|
|
||||||
function stubLdap(groups) {
|
function stubLdap(groups) {
|
||||||
return { getGroups: async () => groups };
|
return { getGroups: async () => groups };
|
||||||
@@ -54,6 +54,32 @@ test('caches per uid', async () => {
|
|||||||
assert.strictEqual(calls, 1);
|
assert.strictEqual(calls, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('accepts pre-resolved groups (web UI/OIDC session) without calling ldap.getGroups', async () => {
|
||||||
|
clearCache();
|
||||||
|
let ldapCalled = false;
|
||||||
|
const user = { uid: 'erin', groups: ['host_web01_access'] };
|
||||||
|
const fetchImpl = stubFetch({
|
||||||
|
host_web01_access: [{ id: '5', kind: 'host', slug: 'host_web01' }],
|
||||||
|
});
|
||||||
|
const ldap = { getGroups: async () => { ldapCalled = true; return []; } };
|
||||||
|
const hosts = await accessibleHosts(user, { fetchImpl, ldap });
|
||||||
|
assert.deepStrictEqual(hosts.map((h) => h.id), ['5']);
|
||||||
|
assert.strictEqual(ldapCalled, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('allHosts fetches the whole host inventory with no group filter', async () => {
|
||||||
|
const fetchImpl = async (url) => {
|
||||||
|
assert.ok(!url.includes('group='), 'must not filter by group');
|
||||||
|
assert.ok(url.includes('kind=host'));
|
||||||
|
return { ok: true, json: async () => ({ results: [
|
||||||
|
{ id: '1', kind: 'host', slug: 'host_a' },
|
||||||
|
{ id: '2', kind: 'host', slug: 'host_b' },
|
||||||
|
] }) };
|
||||||
|
};
|
||||||
|
const hosts = await allHosts({ fetchImpl });
|
||||||
|
assert.deepStrictEqual(hosts.map((h) => h.id).sort(), ['1', '2']);
|
||||||
|
});
|
||||||
|
|
||||||
test('a bare-array response (envelope drift) is treated as a failed group, not silently []', async () => {
|
test('a bare-array response (envelope drift) is treated as a failed group, not silently []', async () => {
|
||||||
clearCache();
|
clearCache();
|
||||||
const user = { uid: 'dave', dn: 'd' };
|
const user = { uid: 'dave', dn: 'd' };
|
||||||
|
|||||||
@@ -0,0 +1,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,43 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Regression guard: native alert()/confirm()/prompt() calls block all further
|
||||||
|
// browser events on the page (found live, mid browser-automation testing, on
|
||||||
|
// sso-manager-node's equivalent secret-rotate flow) and are visually
|
||||||
|
// inconsistent with the rest of the UI. This app has no such call sites;
|
||||||
|
// keep it that way.
|
||||||
|
|
||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const ROOTS = ['views', 'public/js', 'public/lib/js'].map((d) => path.join(__dirname, '..', '..', d));
|
||||||
|
|
||||||
|
const NATIVE_DIALOG_RE = /(^|[^.\w$])(alert|confirm|prompt)\s*\(/g;
|
||||||
|
|
||||||
|
function walk(dir) {
|
||||||
|
let files = [];
|
||||||
|
if (!fs.existsSync(dir)) return files;
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const full = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) files = files.concat(walk(full));
|
||||||
|
else if (/\.(ejs|js)$/.test(entry.name)) files.push(full);
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('no view or client-side script calls native alert()/confirm()/prompt()', () => {
|
||||||
|
const offenders = [];
|
||||||
|
for (const root of ROOTS) {
|
||||||
|
for (const file of walk(root)) {
|
||||||
|
const src = fs.readFileSync(file, 'utf8');
|
||||||
|
let m;
|
||||||
|
NATIVE_DIALOG_RE.lastIndex = 0;
|
||||||
|
while ((m = NATIVE_DIALOG_RE.exec(src))) {
|
||||||
|
const line = src.slice(0, m.index).split('\n').length;
|
||||||
|
offenders.push(`${path.relative(path.join(__dirname, '..', '..'), file)}:${line} — ${m[2]}(`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.deepStrictEqual(offenders, []);
|
||||||
|
});
|
||||||
@@ -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...');
|
||||||
|
});
|
||||||
+82
-57
@@ -1,70 +1,95 @@
|
|||||||
'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 { createDirectoryClient } = require('@simpleworkjs/directory-schema');
|
|
||||||
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. Every host is
|
||||||
|
// accessible to every user, so allHosts and accessibleHosts coincide.
|
||||||
|
const { accessibleHosts } = require('./hosts_file');
|
||||||
|
module.exports = { accessibleHosts, allHosts: () => accessibleHosts(), clearCache: () => {} };
|
||||||
|
} else {
|
||||||
|
// Production mode: LDAP groups + SSO API (unchanged).
|
||||||
|
|
||||||
// Build a directory client bound to conf.sso. fetchImpl is injectable so the
|
// Which directory hosts may a user reach, and how do we dial them?
|
||||||
// unit tests can stub the transport; the shared client validates the
|
//
|
||||||
// `{ results }` envelope on every call (turns the old bare-array drift into a
|
// v1 resolution (see directory_spec.md §9.2 in sso-manager-node): the SSO's
|
||||||
// thrown error instead of a silent `[]`).
|
// /api/discovery/me only answers for the API token's own user, and /graph
|
||||||
function directoryClient({ fetchImpl = fetch } = {}) {
|
// omits ResourceGroup links — so we combine the user's LDAP groups (queried
|
||||||
const sso = conf.sso || {};
|
// directly) with per-group resource lookups:
|
||||||
return createDirectoryClient({ baseUrl: sso.url, apiToken: sso.apiToken, fetch: fetchImpl });
|
//
|
||||||
}
|
// 1. LDAP: groups the user's DN is a member of
|
||||||
|
// 2. SSO: GET /api/discovery/resources?group=<cn> per group (ApiToken)
|
||||||
|
// 3. union, keep kind === 'host'
|
||||||
|
//
|
||||||
|
// Results are cached per-uid for a short TTL — the TUI picker and the
|
||||||
|
// username-grammar path share the cache. Dependency-injected fetch/ldap for
|
||||||
|
// unit testing.
|
||||||
|
|
||||||
async function fetchResourcesByGroup(group, { fetchImpl = fetch } = {}) {
|
const { createDirectoryClient } = require('@simpleworkjs/directory-schema');
|
||||||
return directoryClient({ fetchImpl }).getResourcesByGroup(group);
|
const userLdap = require('../models/user_ldap');
|
||||||
}
|
|
||||||
|
|
||||||
async function accessibleHosts(user, { fetchImpl = fetch, ldap = userLdap } = {}) {
|
const CACHE_TTL_MS = 30 * 1000;
|
||||||
const hit = cache.get(user.uid);
|
const cache = new Map(); // uid -> {at, hosts}
|
||||||
if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.hosts;
|
|
||||||
|
|
||||||
const groups = await ldap.getGroups(user.dn);
|
// Build a directory client bound to conf.sso. fetchImpl is injectable so the
|
||||||
|
// unit tests can stub the transport; the shared client validates the
|
||||||
const seen = new Map();
|
// `{ results }` envelope on every call (turns the old bare-array drift into a
|
||||||
for (const cn of groups) {
|
// thrown error instead of a silent `[]`).
|
||||||
let resources;
|
function directoryClient({ fetchImpl = fetch } = {}) {
|
||||||
try {
|
const sso = conf.sso || {};
|
||||||
resources = await fetchResourcesByGroup(cn, { fetchImpl });
|
return createDirectoryClient({ baseUrl: sso.url, apiToken: sso.apiToken, fetch: 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()];
|
async function fetchResourcesByGroup(group, { fetchImpl = fetch } = {}) {
|
||||||
cache.set(user.uid, { at: Date.now(), hosts });
|
return directoryClient({ fetchImpl }).getResourcesByGroup(group);
|
||||||
return hosts;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function clearCache(uid) {
|
// Every host in the inventory, unfiltered — for admins (the web UI's own
|
||||||
if (uid) cache.delete(uid);
|
// account is already gated by requireAdmin before this is ever called).
|
||||||
else cache.clear();
|
async function allHosts({ fetchImpl = fetch } = {}) {
|
||||||
}
|
const resources = await directoryClient({ fetchImpl }).getResourcesByGroup(undefined, { kind: 'host' });
|
||||||
|
return resources.filter(r => r.kind === 'host');
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = { accessibleHosts, clearCache, fetchResourcesByGroup };
|
async function accessibleHosts(user, { fetchImpl = fetch, ldap = userLdap } = {}) {
|
||||||
|
const hit = cache.get(user.uid);
|
||||||
|
if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.hosts;
|
||||||
|
|
||||||
|
// The SSH path passes an LDAP user ({dn, uid, ...}) with no .groups, so we
|
||||||
|
// look them up; the web UI already has the session's OIDC groups claim
|
||||||
|
// and passes it directly, skipping a redundant LDAP round-trip.
|
||||||
|
const groups = user.groups || await ldap.getGroups(user.dn);
|
||||||
|
|
||||||
|
const seen = new Map();
|
||||||
|
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, allHosts, clearCache, fetchResourcesByGroup };
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
+179
-2
@@ -28,7 +28,37 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row g-3">
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header"><i class="fa-solid fa-terminal me-1"></i> Quick Jump</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted small mb-2">
|
||||||
|
Skip the picker: <code>ssh <your-username>_-_<host-slug>@<this-jump-host></code>
|
||||||
|
connects straight to a host. Or just <code>ssh <your-username>@<this-jump-host></code>
|
||||||
|
for the interactive picker.
|
||||||
|
</p>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" class="form-control font-monospace" id="quick-jump-cmd" readonly>
|
||||||
|
<button class="btn btn-outline-secondary" onclick="copySshCommand('#quick-jump-cmd')" title="Copy">
|
||||||
|
<i class="fa-solid fa-copy"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header"><i class="fa-solid fa-network-wired me-1"></i> <span id="my-hosts-title">Hosts you can reach</span></div>
|
||||||
|
<table class="table table-sm mb-0"><tbody id="my-hosts"></tbody></table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="card shadow-sm"><div class="card-header"><i class="fa-solid fa-server me-1"></i> Top hosts</div>
|
<div class="card shadow-sm"><div class="card-header"><i class="fa-solid fa-server me-1"></i> Top hosts</div>
|
||||||
<table class="table table-sm mb-0"><tbody id="top-hosts"></tbody></table>
|
<table class="table table-sm mb-0"><tbody id="top-hosts"></tbody></table>
|
||||||
@@ -41,6 +71,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<span><i class="fa-solid fa-key me-1"></i> API Tokens</span>
|
||||||
|
<button class="btn btn-sm btn-primary" onclick="createApiToken()"><i class="fa-solid fa-plus"></i> New token</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-header actionMessage" style="display:none"></div>
|
||||||
|
<p class="text-muted small px-3 pt-3 mb-0">
|
||||||
|
Personal access tokens authenticate as you against this jump host's own API
|
||||||
|
(e.g. <code>GET /api/user/hosts</code>) — not for SSH login. A token carries
|
||||||
|
no group claims, so it can't reach admin-only endpoints.
|
||||||
|
</p>
|
||||||
|
<table class="table table-sm mb-0">
|
||||||
|
<thead><tr><th>Name</th><th>Created</th><th>Last used</th><th>Expires</th><th></th></tr></thead>
|
||||||
|
<tbody id="api-tokens"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
function rows(sel, list){
|
function rows(sel, list){
|
||||||
var $b = $(sel).empty();
|
var $b = $(sel).empty();
|
||||||
@@ -49,7 +100,125 @@
|
|||||||
$b.append('<tr><td>' + app.jump.esc(x.name) + '</td><td class="text-end">' + x.count + '</td></tr>');
|
$b.append('<tr><td>' + app.jump.esc(x.name) + '</td><td class="text-end">' + x.count + '</td></tr>');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
$(document).ready(function(){
|
// The web UI and the SSH front door share a hostname, just not a port.
|
||||||
|
var SSH_PORT = <%- JSON.stringify(sshPort) %>;
|
||||||
|
function sshCommand(target){
|
||||||
|
var uid = app.auth.user && app.auth.user.username;
|
||||||
|
if(!uid) return '';
|
||||||
|
var portFlag = SSH_PORT === 22 ? '' : ' -p ' + SSH_PORT;
|
||||||
|
return 'ssh ' + uid + (target ? '_-_' + target : '') + '@' + location.hostname + portFlag;
|
||||||
|
}
|
||||||
|
function copySshCommand(sel){
|
||||||
|
var $el = $(sel);
|
||||||
|
var text = $el.val();
|
||||||
|
if(!text) return;
|
||||||
|
navigator.clipboard.writeText(text).then(function(){
|
||||||
|
app.messages.toast('Copied to clipboard', 'success');
|
||||||
|
}, function(){
|
||||||
|
app.messages.toast('Could not copy — select and copy manually', 'danger');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function hostRows(sel, hosts){
|
||||||
|
var $b = $(sel).empty();
|
||||||
|
if(!hosts || !hosts.length){ $b.append('<tr><td class="text-muted">No hosts reachable.</td></tr>'); return; }
|
||||||
|
hosts.forEach(function(h){
|
||||||
|
var addr = (h.metadata && (h.metadata.ip || h.metadata.address)) || '';
|
||||||
|
var rowId = 'host-cmd-' + h.slug.replace(/[^a-zA-Z0-9_-]/g, '');
|
||||||
|
$b.append('<tr><td>' + app.jump.esc(h.displayName || h.name || h.slug) + '</td>'
|
||||||
|
+ '<td class="text-muted small">' + app.jump.esc(h.slug) + '</td>'
|
||||||
|
+ '<td class="text-end text-muted small">' + app.jump.esc(addr) + '</td>'
|
||||||
|
+ '<td class="text-end">'
|
||||||
|
+ '<input type="hidden" id="' + rowId + '" value="' + app.jump.esc(sshCommand(h.slug)) + '">'
|
||||||
|
+ '<button class="btn btn-sm btn-outline-secondary" onclick="copySshCommand(\'#' + rowId + '\')" title="Copy quick-jump command"><i class="fa-solid fa-copy"></i></button>'
|
||||||
|
+ '</td></tr>');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function tokenRows(tokens){
|
||||||
|
var $b = $('#api-tokens').empty();
|
||||||
|
if(!tokens || !tokens.length){ $b.append('<tr><td colspan="5" class="text-muted">No API tokens.</td></tr>'); return; }
|
||||||
|
tokens.forEach(function(t){
|
||||||
|
var expires = t.expires_at ? app.jump.fmtTime(t.expires_at) : 'Never';
|
||||||
|
var lastUsed = t.last_used_on ? app.jump.fmtTime(t.last_used_on) : 'Never';
|
||||||
|
$b.append(
|
||||||
|
'<tr>'
|
||||||
|
+ '<td>' + app.jump.esc(t.name) + '</td>'
|
||||||
|
+ '<td class="text-muted small">' + app.jump.fmtTime(t.created_on) + '</td>'
|
||||||
|
+ '<td class="text-muted small">' + lastUsed + '</td>'
|
||||||
|
+ '<td class="text-muted small">' + expires + '</td>'
|
||||||
|
+ '<td class="text-end">'
|
||||||
|
+ '<button class="btn btn-sm btn-outline-secondary" onclick="rotateApiToken(\'' + t.id + '\', this)" title="Rotate"><i class="fa-solid fa-rotate"></i></button> '
|
||||||
|
+ '<button class="btn btn-sm btn-outline-danger" onclick="revokeApiToken(\'' + t.id + '\', this)" title="Revoke"><i class="fa-solid fa-trash"></i></button>'
|
||||||
|
+ '</td>'
|
||||||
|
+ '</tr>'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadApiTokens(){
|
||||||
|
app.apiToken.list(function(error, data){
|
||||||
|
if(error) return tokenRows([]);
|
||||||
|
tokenRows(data && data.results);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToken(title, token){
|
||||||
|
app.modal.open({title: title, bodyHtml:
|
||||||
|
'<p class="text-danger"><i class="fa-solid fa-triangle-exclamation"></i> Save this token now — it will <strong>not</strong> be shown again.</p>'
|
||||||
|
+ '<div class="input-group"><input type="text" class="form-control font-monospace" readonly value="' + app.jump.esc(token) + '"></div>'
|
||||||
|
+ '<p class="mt-3 mb-0 text-muted small">Use it as a bearer token:<br><code>Authorization: Bearer ' + app.jump.esc(token) + '</code></p>'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createApiToken(){
|
||||||
|
var $body = app.modal.open({title: 'New API Token', bodyHtml:
|
||||||
|
'<div class="mb-3">'
|
||||||
|
+ '<label class="form-label">Name</label>'
|
||||||
|
+ '<input type="text" class="form-control" id="new-token-name" placeholder="e.g. laptop-cron">'
|
||||||
|
+ '</div>'
|
||||||
|
+ '<div class="mb-3">'
|
||||||
|
+ '<label class="form-label">Expires in (days, blank = never)</label>'
|
||||||
|
+ '<input type="number" class="form-control" id="new-token-days" min="1">'
|
||||||
|
+ '</div>'
|
||||||
|
+ '<button class="btn btn-primary" onclick="submitApiToken()"><i class="fa-solid fa-check"></i> Create</button>'
|
||||||
|
});
|
||||||
|
$body.find('#new-token-name').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitApiToken(){
|
||||||
|
var name = $('#new-token-name').val().trim();
|
||||||
|
var $card = $('#api-tokens').closest('.card');
|
||||||
|
if(!name) return app.messages.action('Name is required', $card, 'danger');
|
||||||
|
app.apiToken.add({
|
||||||
|
name: name,
|
||||||
|
expires_in_days: $('#new-token-days').val(),
|
||||||
|
}, function(error, data){
|
||||||
|
if(error) return app.messages.action((data && data.message) || 'Failed to create token', $card, 'danger');
|
||||||
|
showToken('API Token Created', data.token);
|
||||||
|
loadApiTokens();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function revokeApiToken(id, btn){
|
||||||
|
var $card = $(btn).closest('.card');
|
||||||
|
var ok = await app.messages.confirm('Revoke this API token? It stops working immediately.', $card, 'danger');
|
||||||
|
if(!ok) return;
|
||||||
|
app.apiToken.remove(id, function(error, data){
|
||||||
|
if(error) return app.messages.action((data && data.message) || 'Failed to revoke token', $card, 'danger');
|
||||||
|
loadApiTokens();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function rotateApiToken(id, btn){
|
||||||
|
var $card = $(btn).closest('.card');
|
||||||
|
var ok = await app.messages.confirm('Rotate this API token? The old token stops working immediately.', $card, 'warning');
|
||||||
|
if(!ok) return;
|
||||||
|
app.apiToken.rotate(id, function(error, data){
|
||||||
|
if(error) return app.messages.action((data && data.message) || 'Failed to rotate token', $card, 'danger');
|
||||||
|
showToken('API Token Rotated', data.token);
|
||||||
|
loadApiTokens();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$(document).ready(async function(){
|
||||||
app.jump.metrics(function(error, data){
|
app.jump.metrics(function(error, data){
|
||||||
if(error || !data) return;
|
if(error || !data) return;
|
||||||
$('#stat-active').text(data.active);
|
$('#stat-active').text(data.active);
|
||||||
@@ -59,6 +228,14 @@
|
|||||||
rows('#top-hosts', data.topHosts);
|
rows('#top-hosts', data.topHosts);
|
||||||
rows('#top-users', data.topUsers);
|
rows('#top-users', data.topUsers);
|
||||||
});
|
});
|
||||||
|
await app.auth.loadUser();
|
||||||
|
if(app.auth.isAdmin()) $('#my-hosts-title').text('All hosts');
|
||||||
|
$('#quick-jump-cmd').val(sshCommand());
|
||||||
|
app.jump.hosts(function(error, data){
|
||||||
|
if(error) return hostRows('#my-hosts', []);
|
||||||
|
hostRows('#my-hosts', data && data.results);
|
||||||
|
});
|
||||||
|
loadApiTokens();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<%- include('bottom') %>
|
<%- include('bottom') %>
|
||||||
|
|||||||
@@ -21,9 +21,11 @@
|
|||||||
<script type="text/javascript" src="/static-modules/@fortawesome/fontawesome-free/js/all.min.js"></script>
|
<script type="text/javascript" src="/static-modules/@fortawesome/fontawesome-free/js/all.min.js"></script>
|
||||||
<script type="text/javascript" src='/static-modules/mustache/mustache.min.js'></script>
|
<script type="text/javascript" src='/static-modules/mustache/mustache.min.js'></script>
|
||||||
<script type="text/javascript" src='/static-modules/jq-repeat/dist/js/jq-repeat.js'></script>
|
<script type="text/javascript" src='/static-modules/jq-repeat/dist/js/jq-repeat.js'></script>
|
||||||
<script type="text/javascript" src='/static/lib/js/val.js'></script>
|
|
||||||
<script type="text/javascript" src="/static-modules/moment/moment.js"></script>
|
<script type="text/javascript" src="/static-modules/moment/moment.js"></script>
|
||||||
<script type="text/javascript" src="/static/lib/js/app-base.js"></script>
|
<script type="text/javascript" src="/static/lib/js/app-base.js"></script>
|
||||||
|
<script type="text/javascript" src="/static-modules/@simpleworkjs/frontend/lib/app.messages.js"></script>
|
||||||
|
<script type="text/javascript" src="/static-modules/@simpleworkjs/frontend/lib/app.modal.js"></script>
|
||||||
|
<script type="text/javascript" src="/static-modules/@simpleworkjs/frontend/lib/app.validate.js"></script>
|
||||||
<script type="text/javascript" src="/static/js/app.js"></script>
|
<script type="text/javascript" src="/static/js/app.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -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