Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 16ab12a61c | |||
| 0f6be51c35 | |||
| 463111dfd6 | |||
| 4874544955 | |||
| 256476268f | |||
| a57d579b0e | |||
| 49bf0fa1d3 | |||
| 1b4764e328 | |||
| db3333e26d | |||
| 1092031a9f | |||
| f386a5f9c3 | |||
| 82318da484 | |||
| 14784266b3 | |||
| 362e77f3dd | |||
| dfafffe154 |
@@ -1,9 +1,56 @@
|
|||||||
|
## v1.16.1
|
||||||
|
- fix: remove missing DEPLOYMENT.md from Docker build context
|
||||||
|
|
||||||
|
## v1.16.0
|
||||||
|
- Added OpenBao PKI SSH Certificate Support
|
||||||
|
- Fallback to LDAP Key injection
|
||||||
|
|
||||||
|
# v1.15.0
|
||||||
|
- feat: Rename SSO Manager to Jump in UI
|
||||||
|
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
All notable changes to this project are documented here. Format loosely
|
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.14.1] - 2026-08-01
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Bumped `@simpleworkjs/bao-conf` to 1.0.1** so standalone/no-OpenBao boots
|
||||||
|
don't crash. bao-conf 1.0.0's `init()` threw when `VAULT_TOKEN` was unset,
|
||||||
|
which — combined with `bin/www`'s `.catch(() => process.exit(1))` — made the
|
||||||
|
jump host exit at boot in any deployment without an OpenBao sidecar
|
||||||
|
(standalone Docker, bare metal). 1.0.1 makes `init()` fail-soft on a missing
|
||||||
|
token (warn + continue from `CONF_SECRETS`), matching the documented
|
||||||
|
contract. The theta-env stack is unaffected (it always sets a scoped
|
||||||
|
`VAULT_TOKEN`).
|
||||||
|
|
||||||
|
## [1.14.0] - 2026-08-01
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Secrets now load from OpenBao at boot** via
|
||||||
|
[@simpleworkjs/bao-conf](https://simpleworkjs.github.io/bao-conf/), which
|
||||||
|
deep-merges `secret/jump-host/conf` over the file-loaded config. The jump
|
||||||
|
host authenticates to OpenBao with a scoped `VAULT_TOKEN` (policy
|
||||||
|
`jump-host` — read-only on its own path), never the root token. Because the
|
||||||
|
OIDC `clientSecret` is captured at require time inside `createOidcClient`
|
||||||
|
(during `require('../models')`), `bin/www` now runs `bao-conf.init()`
|
||||||
|
**before** `require('../models')`. Fail-soft: if OpenBao is unreachable,
|
||||||
|
boot continues from `CONF_SECRETS`. The `config/jump-secrets.js` file is now
|
||||||
|
an operator-edit seed artifact (gitignored); OpenBao is authoritative. See
|
||||||
|
theta-env's [Secrets docs](https://theta42.github.io/theta-env/secrets/).
|
||||||
|
- Bumped package version to track the release tag.
|
||||||
|
|
||||||
|
## [1.11.0] - 2026-07-30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **`app_super_admin` (cross-app) and `app_jump_admin` groups**: super admins are full admins here same as `app_sso_admin`; jump admins get audit page/data access without other admin rights. The Audit page/API is now actually admin-gated server-side (previously the page shell rendered for any logged-in user, only its data was gated).
|
||||||
|
- **Host list adds Last connection/Last failed connection columns** and highlights rows green (a session is live right now) or yellow (the most recent attempt failed), backed by new per-host last-success/last-fail timestamps in `models/metrics.js`. `services/ssh_server.js` now attributes grammar/TUI connect failures to the resolved host when one was found, not just aggregate counters.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Dashboard's stat boxes and Top hosts/Top users cards moved to the Audit page** (audit is now the admin-facing metrics home; dashboard stays focused on "hosts I can reach"). "All hosts" renamed to "My hosts".
|
||||||
|
|
||||||
## [1.10.2] - 2026-07-30
|
## [1.10.2] - 2026-07-30
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
# Deployment
|
|
||||||
|
|
||||||
Three ways to run the jump host, in increasing manual effort.
|
|
||||||
|
|
||||||
## 1. Unified theta-env stack
|
|
||||||
|
|
||||||
Set in `theta-env/setup.env`:
|
|
||||||
|
|
||||||
```
|
|
||||||
CFG_JUMP_HOST_ENABLED=true
|
|
||||||
CFG_JUMP_HOST=jump.example.com
|
|
||||||
JUMP_SSH_PORT=2222
|
|
||||||
```
|
|
||||||
|
|
||||||
Re-run `./setup.sh`. It builds the submodule, writes `config/jump-secrets.js`,
|
|
||||||
mints the SSO API token, grants the `sshPublicKey` write-ACL to the shared
|
|
||||||
`cn=ldapclient` bind account, registers `jump.example.com` in the proxy, and
|
|
||||||
seeds a directory entry.
|
|
||||||
|
|
||||||
Expose SSH: forward the public host's `:22` (or `:2222`) to the container's
|
|
||||||
published `JUMP_SSH_PORT`.
|
|
||||||
|
|
||||||
## 2. Standalone Docker
|
|
||||||
|
|
||||||
```
|
|
||||||
cp secrets.js.example config/jump-secrets.js
|
|
||||||
$EDITOR config/jump-secrets.js # LDAP bind (+ sshPublicKey write ACL), SSO url + token
|
|
||||||
docker compose up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
Host keys persist in the `jump-data` volume. The web UI is on `:3002`; front it
|
|
||||||
with your own TLS/proxy.
|
|
||||||
|
|
||||||
## 3. Bare metal
|
|
||||||
|
|
||||||
```
|
|
||||||
curl -fsSL https://raw.githubusercontent.com/theta42/jump-host/master/ops/install.sh | sudo bash
|
|
||||||
sudo $EDITOR /etc/jump-host/secrets.js
|
|
||||||
sudo systemctl restart jump-host
|
|
||||||
journalctl -u jump-host -f
|
|
||||||
```
|
|
||||||
|
|
||||||
`ops/install.sh` installs Node 22 + Redis, hard-resets the checkout at
|
|
||||||
`/opt/theta42/jump-host` to the remote branch, symlinks the systemd unit, and
|
|
||||||
runs `npm ci`. Idempotent — re-run to update. Overridable via `REPO_DIR=`,
|
|
||||||
`BRANCH=`, `SECRETS_FILE=`.
|
|
||||||
|
|
||||||
## The LDAP write-ACL (required)
|
|
||||||
|
|
||||||
The bind account must be able to write the `sshPublicKey` attribute so the jump
|
|
||||||
host can inject its key. In the bundled OpenLDAP (`slapd.conf` / `olc`):
|
|
||||||
|
|
||||||
```
|
|
||||||
access to attrs=sshPublicKey
|
|
||||||
by dn.exact="cn=ldapclient,ou=people,dc=example,dc=com" write
|
|
||||||
by self write
|
|
||||||
by * read
|
|
||||||
```
|
|
||||||
|
|
||||||
Without it, key injection fails and every bridge attempt is audited
|
|
||||||
`key-inject-failed`.
|
|
||||||
|
|
||||||
## Listening on port 22
|
|
||||||
|
|
||||||
Default is 2222 (unprivileged). For 22: set `ssh.listenPort: 22`, and either
|
|
||||||
|
|
||||||
- systemd: uncomment `AmbientCapabilities=CAP_NET_BIND_SERVICE` in the unit; or
|
|
||||||
- Docker: publish `22:22`; or
|
|
||||||
- firewall: DNAT `22 → 2222`.
|
|
||||||
|
|
||||||
## Verifying
|
|
||||||
|
|
||||||
```
|
|
||||||
# from a client whose key is in your LDAP sshPublicKey
|
|
||||||
ssh -p 2222 youruid@jump.example.com # TUI picker
|
|
||||||
ssh -p 2222 youruid_-_somehost@jump.example.com
|
|
||||||
sftp -P 2222 youruid_-_somehost@jump.example.com
|
|
||||||
|
|
||||||
curl -s http://localhost:3002/health
|
|
||||||
```
|
|
||||||
|
|
||||||
Watch `journalctl -u jump-host -f` (or `docker logs -f jump-host`) and the
|
|
||||||
audit log at `/audit` in the web UI.
|
|
||||||
+1
-1
@@ -37,7 +37,7 @@ COPY nodejs/utils ./utils
|
|||||||
COPY nodejs/views ./views
|
COPY nodejs/views ./views
|
||||||
COPY nodejs/public ./public
|
COPY nodejs/public ./public
|
||||||
|
|
||||||
COPY README.md CHANGELOG.md DEPLOYMENT.md /
|
COPY README.md CHANGELOG.md /
|
||||||
COPY --from=gitinfo /commit.txt ./.build_commit
|
COPY --from=gitinfo /commit.txt ./.build_commit
|
||||||
|
|
||||||
COPY docker-entrypoint.sh /usr/local/bin/
|
COPY docker-entrypoint.sh /usr/local/bin/
|
||||||
|
|||||||
@@ -159,6 +159,22 @@ Config layers via [@simpleworkjs/conf](https://www.npmjs.com/package/@simplework
|
|||||||
`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.
|
||||||
See `secrets.js.example` for every key.
|
See `secrets.js.example` for every key.
|
||||||
|
|
||||||
|
## Secrets
|
||||||
|
|
||||||
|
At boot, [@simpleworkjs/bao-conf](https://simpleworkjs.github.io/bao-conf/)
|
||||||
|
deep-merges `secret/jump-host/conf` from **OpenBao** over the file-loaded
|
||||||
|
config. The jump host's OIDC `clientSecret` is captured at require time
|
||||||
|
(inside `createOidcClient` during `require('../models')`), so `bin/www` runs
|
||||||
|
`bao-conf.init()` **before** `require('../models')`. Fail-soft: if OpenBao is
|
||||||
|
unreachable, boot continues from `CONF_SECRETS`. The jump host authenticates to
|
||||||
|
OpenBao with the scoped `VAULT_TOKEN` (env, policy `jump-host` — read only
|
||||||
|
`secret/jump-host/conf`), never the root token.
|
||||||
|
|
||||||
|
The `config/jump-secrets.js` file is an operator-edit seed artifact
|
||||||
|
(gitignored); the bootstrap writes the generated API token + OAuth client
|
||||||
|
into OpenBao, which is authoritative. For the full architecture see
|
||||||
|
theta-env's **[Secrets docs](https://theta42.github.io/theta-env/secrets/)**.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
# Documentation
|
|
||||||
|
|
||||||
This directory is the GitHub Pages documentation site for the Jump Host project.
|
|
||||||
|
|
||||||
**Live site:** https://theta42.github.io/jump-host/
|
|
||||||
|
|
||||||
## Pages
|
|
||||||
|
|
||||||
- `index.md` — overview and quick start
|
|
||||||
- `connecting.md` — usage: the username grammar, the TUI picker, SFTP/WinSCP
|
|
||||||
- `architecture.md` — how auth, access resolution, key injection, and bridging work
|
|
||||||
- `installation.md` — Docker, bare-metal, and theta-env install; the LDAP write-ACL
|
|
||||||
|
|
||||||
## Local preview
|
|
||||||
|
|
||||||
```bash
|
|
||||||
gem install jekyll bundler
|
|
||||||
cd docs && jekyll serve
|
|
||||||
# http://localhost:4000/jump-host/
|
|
||||||
```
|
|
||||||
|
|
||||||
## Updating
|
|
||||||
|
|
||||||
Edit the markdown, push to `master`, and GitHub Pages rebuilds automatically.
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
title: Jump Host
|
|
||||||
description: An SSH jump host for the theta42 stack — directory-driven host bridging with audit and metrics; LDAP + SSO Manager by default, or fully standalone.
|
|
||||||
url: "https://theta42.github.io"
|
|
||||||
baseurl: "/jump-host"
|
|
||||||
logo: /assets/img/theta42.svg
|
|
||||||
lang: en_US
|
|
||||||
|
|
||||||
plugins:
|
|
||||||
- jekyll-seo-tag
|
|
||||||
- jekyll-sitemap
|
|
||||||
|
|
||||||
github:
|
|
||||||
repository_url: https://github.com/theta42/jump-host
|
|
||||||
zip_url: https://github.com/theta42/jump-host/archive/refs/heads/master.zip
|
|
||||||
tar_url: https://github.com/theta42/jump-host/archive/refs/heads/master.tar.gz
|
|
||||||
repository_name: theta42/jump-host
|
|
||||||
|
|
||||||
nav:
|
|
||||||
- title: Home
|
|
||||||
page: /
|
|
||||||
icon: fa-house
|
|
||||||
- title: Connecting
|
|
||||||
page: /connecting.html
|
|
||||||
icon: fa-terminal
|
|
||||||
- title: Architecture
|
|
||||||
page: /architecture.html
|
|
||||||
icon: fa-sitemap
|
|
||||||
- title: Installation
|
|
||||||
page: /installation.html
|
|
||||||
icon: fa-download
|
|
||||||
- title: Changelog
|
|
||||||
url: https://github.com/theta42/jump-host/blob/master/CHANGELOG.md
|
|
||||||
icon: fa-list
|
|
||||||
|
|
||||||
defaults:
|
|
||||||
- scope:
|
|
||||||
path: ""
|
|
||||||
type: "pages"
|
|
||||||
values:
|
|
||||||
layout: default
|
|
||||||
image: /assets/img/theta42.svg
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
|
||||||
<link rel="icon" type="image/svg+xml" href="{{ '/assets/img/favicon.svg' | relative_url }}">
|
|
||||||
|
|
||||||
{% seo title=false %}
|
|
||||||
<title>{% if page.title %}{{ page.title }} · {% endif %}{{ site.title }}</title>
|
|
||||||
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css">
|
|
||||||
<link rel="stylesheet" href="{{ '/assets/css/style.css' | relative_url }}">
|
|
||||||
</head>
|
|
||||||
<body class="d-flex flex-column min-vh-100">
|
|
||||||
|
|
||||||
<nav class="navbar navbar-expand-md navbar-dark bg-dark fixed-top">
|
|
||||||
<div class="container-fluid px-3">
|
|
||||||
<a class="navbar-brand d-flex align-items-center" href="{{ '/' | relative_url }}">
|
|
||||||
<img src="{{ '/assets/img/theta42.svg' | relative_url }}" height="28" class="me-2" alt="">
|
|
||||||
{{ site.title }}
|
|
||||||
</a>
|
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navMain" aria-controls="navMain" aria-expanded="false" aria-label="Toggle navigation">
|
|
||||||
<span class="navbar-toggler-icon"></span>
|
|
||||||
</button>
|
|
||||||
<div class="collapse navbar-collapse justify-content-end" id="navMain">
|
|
||||||
<ul class="navbar-nav">
|
|
||||||
{% for item in site.nav %}
|
|
||||||
<li class="nav-item">
|
|
||||||
{% if item.page %}
|
|
||||||
<a class="nav-link{% if page.url == item.page %} active{% endif %}" href="{{ item.page | relative_url }}">
|
|
||||||
{% if item.icon %}<i class="fa-solid {{ item.icon }}"></i>{% endif %} {{ item.title }}
|
|
||||||
</a>
|
|
||||||
{% else %}
|
|
||||||
<a class="nav-link" href="{{ item.url }}" target="_blank" rel="noopener">
|
|
||||||
{% if item.icon %}<i class="fa-solid {{ item.icon }}"></i>{% endif %} {{ item.title }}
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<main class="flex-grow-1" style="margin-top: 4.5rem;">
|
|
||||||
<div class="container-fluid py-4 py-md-5">
|
|
||||||
<div class="row justify-content-center">
|
|
||||||
<div class="col-12 col-lg-10 col-xl-8">
|
|
||||||
<div class="card shadow-lg">
|
|
||||||
<div class="card-body p-4 p-md-5 site-content">
|
|
||||||
{{ content }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer class="py-3 bg-dark text-light mt-auto">
|
|
||||||
<div class="container-fluid d-flex flex-wrap justify-content-between align-items-center small gap-2 px-3">
|
|
||||||
<span class="d-flex align-items-center gap-2">
|
|
||||||
<a href="https://theta42.com" target="_blank" rel="noopener">
|
|
||||||
<img width="40" src="{{ '/assets/img/theta42.svg' | relative_url }}" alt="theta42">
|
|
||||||
</a>
|
|
||||||
© {{ 'now' | date: '%Y' }} theta42 ·
|
|
||||||
<a href="{{ site.github.repository_url }}/blob/master/LICENSE" target="_blank" rel="noopener" class="text-light">MIT License</a>
|
|
||||||
</span>
|
|
||||||
<span class="d-flex align-items-center gap-3">
|
|
||||||
<a href="{{ site.github.repository_url }}" target="_blank" rel="noopener" class="text-light text-decoration-none">
|
|
||||||
<i class="fa-brands fa-github"></i> GitHub
|
|
||||||
</a>
|
|
||||||
<a href="{{ site.github.repository_url }}/blob/master/CHANGELOG.md" target="_blank" rel="noopener" class="text-light text-decoration-none">
|
|
||||||
<i class="fa-solid fa-list"></i> Changelog
|
|
||||||
</a>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
---
|
|
||||||
layout: default
|
|
||||||
title: Architecture
|
|
||||||
description: How the jump host authenticates users, resolves reachable hosts from the directory, injects per-user keys, and bridges SSH — plus the web UI and audit model.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Architecture
|
|
||||||
|
|
||||||
The jump host is a Node.js service (using [`ssh2`](https://github.com/mscdex/ssh2)
|
|
||||||
as both an SSH **server** and **client**) with two faces: the SSH front door
|
|
||||||
(default `:2222`) and a web UI/API (`:3002`). It holds no user database of its
|
|
||||||
own — identity, authorization, and onward credentials all come from the shared
|
|
||||||
directory.
|
|
||||||
|
|
||||||
```
|
|
||||||
┌────────────────────── jump host ──────────────────────┐
|
|
||||||
ssh │ ssh2 Server (:2222) │ ssh2 Client
|
|
||||||
─────┼─▶ 1. authenticate user ──▶ LDAP (sshPublicKey / bind) │ ───────────▶ downstream
|
|
||||||
user │ 2. resolve target ──▶ SSO /api/discovery │ sshd (as the
|
|
||||||
│ 3. inject key ──▶ LDAP (add sshPublicKey) │ real user)
|
|
||||||
│ 4. bridge channels ◀───────────────────────────────▶ │
|
|
||||||
│ web UI/API (:3002) ──▶ audit + metrics (redis) │
|
|
||||||
└───────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## 1. Inbound authentication
|
|
||||||
|
|
||||||
When a user connects, the jump host authenticates them against LDAP:
|
|
||||||
|
|
||||||
- **Public key** — it looks up the user's `sshPublicKey` values in the directory
|
|
||||||
and matches the offered key (handling ssh2's probe-then-sign two-phase
|
|
||||||
publickey auth). The jump host's *own* injected key (identified by its comment
|
|
||||||
marker) is deliberately excluded from this match — only the jump host may hold
|
|
||||||
that private key, so accepting it inbound would be a bypass.
|
|
||||||
- **Password** — an LDAP simple bind as the user's DN. Policy is configurable:
|
|
||||||
`off` (keys only — recommended for a public host), `local` (passwords only
|
|
||||||
from loopback/RFC1918 clients, keys-only from the internet), or `all`.
|
|
||||||
|
|
||||||
Every attempt — success or failure, with method and reason — is audited.
|
|
||||||
|
|
||||||
## 2. Access & target resolution
|
|
||||||
|
|
||||||
The hosts a user may reach are computed from the directory, not a local list:
|
|
||||||
|
|
||||||
1. The user's LDAP group memberships (`(&(objectClass=groupOfNames)(member=…))`).
|
|
||||||
2. For each group, the SSO's
|
|
||||||
`GET /api/discovery/resources?group=<cn>` (authenticated with an API token),
|
|
||||||
unioned and filtered to `kind: host`.
|
|
||||||
|
|
||||||
Each host's dial address is `metadata.ip` (or the hostname from
|
|
||||||
`metadata.address`) and port `metadata.sshPort` (default 22). Results are cached
|
|
||||||
briefly per user and shared by both the grammar path and the TUI picker.
|
|
||||||
|
|
||||||
Target matching tries, in order: exact slug → `host_`-prefixed slug → display
|
|
||||||
name → IP → address hostname. A raw IP that isn't an accessible directory host
|
|
||||||
is refused unless explicitly allowed.
|
|
||||||
|
|
||||||
> The directory auto-creates `<slug>_access` / `<slug>_admin` groups for every
|
|
||||||
> host and service (see the SSO's
|
|
||||||
> [Directory & Inventory](https://theta42.github.io/sso-manager-node/directory.html)
|
|
||||||
> docs), which is exactly what this authorization reads.
|
|
||||||
|
|
||||||
## 3. Per-user key injection {#per-user-key-injection}
|
|
||||||
|
|
||||||
The jump host holds **one** keypair. To connect downstream *as the user*
|
|
||||||
without asking them for anything, it must present a key the downstream `sshd`
|
|
||||||
will accept for that user. Downstream hosts (joined via
|
|
||||||
[ldap-client](https://github.com/theta42/ldap-client)) serve authorized keys
|
|
||||||
straight from LDAP via `AuthorizedKeysCommand`. So on a user's first connection,
|
|
||||||
the jump host appends its own public key to that user's `sshPublicKey` attribute
|
|
||||||
in LDAP — comment-marked so it's recognizable — then connects downstream with
|
|
||||||
its private key.
|
|
||||||
|
|
||||||
- Idempotent: the key is added once; a redis flag skips the LDAP round-trip
|
|
||||||
afterwards.
|
|
||||||
- The jump host's bind account therefore needs **write access to the
|
|
||||||
`sshPublicKey` attribute** on user entries (an OpenLDAP ACL — see the README).
|
|
||||||
In the bundled theta-env deployment this is handled for you.
|
|
||||||
- Because the marker key is excluded from inbound auth (step 1), it grants only
|
|
||||||
the jump host's onward path, never inbound impersonation.
|
|
||||||
|
|
||||||
## 4. Bridging
|
|
||||||
|
|
||||||
Once the upstream connection is ready, the jump host splices SSH channels
|
|
||||||
between the two connections:
|
|
||||||
|
|
||||||
- **shell / exec** — piped both ways, with window-change and exit-status
|
|
||||||
forwarded.
|
|
||||||
- **SFTP subsystem** — the two subsystem channels are raw-piped as opaque bytes;
|
|
||||||
no SFTP protocol parsing is needed, which is why WinSCP and `sftp` work
|
|
||||||
unchanged.
|
|
||||||
- Channel requests that arrive before the upstream is ready are buffered and
|
|
||||||
replayed, so nothing is dropped during the connect.
|
|
||||||
- The downstream host key's SHA256 fingerprint is recorded in the audit event
|
|
||||||
(trust-on-use in v1).
|
|
||||||
|
|
||||||
Byte counts per direction are tallied cheaply for the audit record.
|
|
||||||
|
|
||||||
## Web UI, API & audit
|
|
||||||
|
|
||||||
An Express + EJS + Bootstrap app on `:3002` — the same front-end stack and
|
|
||||||
look/feel as the SSO Manager and Proxy. Login is OIDC against the SSO plus a
|
|
||||||
local anti-lockout admin (`auth.adminUsers`), with admin access gated by
|
|
||||||
`auth.adminGroups`. It exposes:
|
|
||||||
|
|
||||||
- `GET /health` — open; `{status, activeSessions, version}`
|
|
||||||
- `GET /api/sessions` — active sessions
|
|
||||||
- `GET /api/audit?page=&uid=&target=&status=` — the paged audit log
|
|
||||||
- `GET /api/metrics` — counters (total, failures, top users/hosts)
|
|
||||||
|
|
||||||
Audit events and counters live in redis. Each event captures: user, auth method,
|
|
||||||
mode (grammar/picker), target slug/address/port, channel type, client IP,
|
|
||||||
success + failure reason, downstream host-key fingerprint, timing, and bytes in/out.
|
|
||||||
|
|
||||||
## Standalone mode
|
|
||||||
|
|
||||||
Everything above describes the default backend. Set `standalone.enabled: true`
|
|
||||||
and two modules become conditional facades, swapping their entire
|
|
||||||
implementation at `require` time based on that flag — nothing else in the
|
|
||||||
codebase (`ssh_server.js`, `bridge.js`, `key_inject.js`, `tui_picker.js`, the
|
|
||||||
web UI) changes or even knows which mode it's running in:
|
|
||||||
|
|
||||||
- **`models/user_ldap.js`** — LDAP client, or `models/user_file.js` (an
|
|
||||||
[@simpleworkjs/orm](https://www.npmjs.com/package/@simpleworkjs/orm)-backed
|
|
||||||
store implementing the same `getUser` / `getGroups` / `checkPassword` /
|
|
||||||
`addSshKey` interface).
|
|
||||||
- **`utils/access.js`** — LDAP groups + SSO `/api/discovery`, or
|
|
||||||
`utils/hosts_file.js` (same ORM package, same `accessibleHosts()` interface).
|
|
||||||
In standalone mode there's no group-based authorization: every stored host
|
|
||||||
is accessible to every stored user.
|
|
||||||
|
|
||||||
The ORM is Sequelize underneath, defaulting to a local SQLite file but
|
|
||||||
accepting any Sequelize-supported dialect via `conf.orm`. See
|
|
||||||
[Installation](installation.html#standalone-mode) for config and how to add
|
|
||||||
users/hosts (there's no admin UI for standalone data yet).
|
|
||||||
|
|
||||||
## Where it sits in the stack
|
|
||||||
|
|
||||||
- **[SSO Manager](https://theta42.github.io/sso-manager-node/)** — provides the
|
|
||||||
OpenLDAP directory (users, groups, `sshPublicKey`) and the inventory API this
|
|
||||||
jump host reads.
|
|
||||||
- **[ldap-client](https://github.com/theta42/ldap-client)** — enrolls the
|
|
||||||
downstream Linux hosts (SSSD/PAM + `AuthorizedKeysCommand`) that the jump host
|
|
||||||
connects into.
|
|
||||||
- **[Proxy](https://theta42.github.io/proxy/)** — fronts the jump host's web UI
|
|
||||||
under TLS.
|
|
||||||
- **[theta-env](https://theta42.github.io/theta-env/)** — wires it all together.
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
/* theta42 docs site — shares the in-app dark navbar/footer + card look
|
|
||||||
(Bootstrap 5 + Font Awesome, same as the running apps) rather than a
|
|
||||||
generic Jekyll theme. */
|
|
||||||
|
|
||||||
body {
|
|
||||||
background-color: #f4f5f6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar-brand img {
|
|
||||||
filter: drop-shadow(0 0 2px rgba(0, 0, 0, .4));
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar-nav .nav-link.active {
|
|
||||||
color: #fff;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Markdown content typography, scoped to the card body so it doesn't leak
|
|
||||||
into the nav/footer. */
|
|
||||||
.site-content h1:first-child {
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-content h1,
|
|
||||||
.site-content h2,
|
|
||||||
.site-content h3 {
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-content h2 {
|
|
||||||
margin-top: 2.5rem;
|
|
||||||
padding-bottom: .4rem;
|
|
||||||
border-bottom: 1px solid #e9ecef;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-content h3 {
|
|
||||||
margin-top: 1.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-content a {
|
|
||||||
color: #a3671f;
|
|
||||||
text-decoration-color: rgba(163, 103, 31, .35);
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-content a:hover {
|
|
||||||
color: #8a5a16;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-content pre {
|
|
||||||
background-color: #212529;
|
|
||||||
color: #f8f9fa;
|
|
||||||
padding: 1rem 1.25rem;
|
|
||||||
border-radius: .375rem;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-content code {
|
|
||||||
color: #a3671f;
|
|
||||||
background-color: #f4f0e8;
|
|
||||||
padding: .15em .4em;
|
|
||||||
border-radius: .25rem;
|
|
||||||
font-size: .875em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-content pre code {
|
|
||||||
color: inherit;
|
|
||||||
background: none;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-content table {
|
|
||||||
display: block;
|
|
||||||
overflow-x: auto;
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
margin: 1.25rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-content table th,
|
|
||||||
.site-content table td {
|
|
||||||
border: 1px solid #dee2e6;
|
|
||||||
padding: .5rem .75rem;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-content table th {
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-content blockquote {
|
|
||||||
border-left: 4px solid #C59341;
|
|
||||||
padding: .5rem 1rem;
|
|
||||||
margin: 1.25rem 0;
|
|
||||||
background-color: #f8f6f1;
|
|
||||||
color: #495057;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-content img {
|
|
||||||
max-width: 100%;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Screenshot grids in the markdown use width="49%" inline attrs for a
|
|
||||||
two-up desktop layout -- stack them on narrow screens instead of
|
|
||||||
squeezing to illegibility. */
|
|
||||||
@media (max-width: 576px) {
|
|
||||||
.site-content img[width] {
|
|
||||||
width: 100% !important;
|
|
||||||
margin-bottom: .75rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-content hr {
|
|
||||||
margin: 2rem 0;
|
|
||||||
border-top: 1px solid #e9ecef;
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
|
||||||
<!-- Background circle -->
|
|
||||||
<circle cx="50" cy="50" r="48" fill="#1a1a1a" stroke="#4a9eff" stroke-width="3"/>
|
|
||||||
|
|
||||||
<!-- Network nodes -->
|
|
||||||
<circle cx="30" cy="30" r="8" fill="#4a9eff"/>
|
|
||||||
<circle cx="70" cy="30" r="8" fill="#4a9eff"/>
|
|
||||||
<circle cx="50" cy="50" r="10" fill="#66b3ff"/>
|
|
||||||
<circle cx="30" cy="70" r="8" fill="#4a9eff"/>
|
|
||||||
<circle cx="70" cy="70" r="8" fill="#4a9eff"/>
|
|
||||||
|
|
||||||
<!-- Connection lines -->
|
|
||||||
<line x1="30" y1="30" x2="50" y2="50" stroke="#4a9eff" stroke-width="2"/>
|
|
||||||
<line x1="70" y1="30" x2="50" y2="50" stroke="#4a9eff" stroke-width="2"/>
|
|
||||||
<line x1="30" y1="70" x2="50" y2="50" stroke="#4a9eff" stroke-width="2"/>
|
|
||||||
<line x1="70" y1="70" x2="50" y2="50" stroke="#4a9eff" stroke-width="2"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 788 B |
@@ -1,51 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" width="100%" height="100%">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="gold-grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
||||||
<stop offset="0%" stop-color="#C59341" />
|
|
||||||
<stop offset="20%" stop-color="#E4B869" />
|
|
||||||
<stop offset="40%" stop-color="#FBF0B9" />
|
|
||||||
<stop offset="60%" stop-color="#DFB260" />
|
|
||||||
<stop offset="80%" stop-color="#BC8837" />
|
|
||||||
<stop offset="100%" stop-color="#A36F28" />
|
|
||||||
</linearGradient>
|
|
||||||
|
|
||||||
<linearGradient id="text-grad" x1="0%" y1="100%" x2="100%" y2="0%">
|
|
||||||
<stop offset="0%" stop-color="#FFFFFF" />
|
|
||||||
<stop offset="40%" stop-color="#F5E3B5" />
|
|
||||||
<stop offset="70%" stop-color="#D4A343" />
|
|
||||||
<stop offset="100%" stop-color="#8A5A16" />
|
|
||||||
</linearGradient>
|
|
||||||
|
|
||||||
<filter id="drop-shadow" x="-20%" y="-20%" width="140%" height="140%">
|
|
||||||
<feDropShadow dx="0" dy="8" stdDeviation="6" flood-color="#000000" flood-opacity="0.4"/>
|
|
||||||
</filter>
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
<g filter="url(#drop-shadow)">
|
|
||||||
<g fill="url(#gold-grad)">
|
|
||||||
<path d="M 200,40
|
|
||||||
C 290,40 350,110 350,200
|
|
||||||
C 350,290 290,360 200,360
|
|
||||||
C 110,360 50,290 50,200
|
|
||||||
C 50,110 110,40 200,40 Z
|
|
||||||
M 200,75
|
|
||||||
C 130,75 88,130 88,200
|
|
||||||
C 88,270 130,325 200,325
|
|
||||||
C 270,325 312,270 312,200
|
|
||||||
C 312,130 270,75 200,75 Z"
|
|
||||||
fill-rule="evenodd" />
|
|
||||||
|
|
||||||
<path d="M 88,190 L 140,190 C 140,190 142,210 140,210 L 88,210 Z" />
|
|
||||||
|
|
||||||
<path d="M 260,190 L 312,190 C 312,190 310,210 260,210 Z" />
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<text x="200" y="222"
|
|
||||||
font-family="system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
|
|
||||||
font-size="78"
|
|
||||||
font-weight="900"
|
|
||||||
fill="url(#text-grad)"
|
|
||||||
text-anchor="middle"
|
|
||||||
letter-spacing="-2">42</text>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.9 KiB |
@@ -1,105 +0,0 @@
|
|||||||
---
|
|
||||||
layout: default
|
|
||||||
title: Connecting
|
|
||||||
description: How to reach downstream hosts through the jump host — the username grammar, the interactive picker, SFTP/WinSCP, and what access you get.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Connecting
|
|
||||||
|
|
||||||
You reach a downstream host two ways: name the target in your username, or log
|
|
||||||
in plain and pick it from a menu. Either way you authenticate **once**, to the
|
|
||||||
jump host, with your directory credentials.
|
|
||||||
|
|
||||||
## The username grammar
|
|
||||||
|
|
||||||
```
|
|
||||||
{uid}_-_{target}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `{uid}` — your directory username.
|
|
||||||
- `_-_` — the separator (legal in an SSH username everywhere, including WinSCP).
|
|
||||||
- `{target}` — the host to reach: a directory **slug** (`host_web01` or just
|
|
||||||
`web01`), the host's **display name**, its **IP**, or the hostname in its
|
|
||||||
directory `address`.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ssh alice_-_web01@jump.example.com # by slug (host_ prefix optional)
|
|
||||||
ssh alice_-_10.0.0.10@jump.example.com # by IP (must be a host you can reach)
|
|
||||||
```
|
|
||||||
|
|
||||||
If the target matches a host your directory groups grant, you're bridged
|
|
||||||
straight to its `sshd` — same as if you'd SSH'd directly, but through the
|
|
||||||
audited jump host.
|
|
||||||
|
|
||||||
## SFTP / WinSCP / scp
|
|
||||||
|
|
||||||
Because the whole route is encoded in the username, file transfer tools that
|
|
||||||
only take one connection string work with no extra configuration:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sftp -P 2222 alice_-_web01@jump.example.com
|
|
||||||
scp -P 2222 file.txt alice_-_web01@jump.example.com:/tmp/
|
|
||||||
```
|
|
||||||
|
|
||||||
**WinSCP:** set Host name to `jump.example.com`, Port to `2222`, and User name
|
|
||||||
to `alice_-_web01`. SFTP is bridged as an opaque byte stream, so all operations
|
|
||||||
(browse, upload, download, rename) work normally.
|
|
||||||
|
|
||||||
## The interactive picker
|
|
||||||
|
|
||||||
Log in with just your username and you get a TUI list of every host you can
|
|
||||||
reach:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ssh alice@jump.example.com
|
|
||||||
```
|
|
||||||
|
|
||||||
- **↑ / ↓** move the selection
|
|
||||||
- **type** to filter the list incrementally
|
|
||||||
- **Enter** connect to the highlighted host
|
|
||||||
- **number keys** jump straight to that row
|
|
||||||
- **q** or **Ctrl-C** to quit
|
|
||||||
|
|
||||||
Pick a host and you're bridged into it. The picker only ever lists hosts your
|
|
||||||
directory access allows — it doubles as "what can I reach from here?"
|
|
||||||
|
|
||||||
## What you can reach
|
|
||||||
|
|
||||||
The set of hosts is computed per login: your LDAP group memberships intersected
|
|
||||||
with the SSO directory's hosts (via the `host_<name>_access` groups the
|
|
||||||
directory auto-creates for each machine). To get access to a new host, an admin
|
|
||||||
adds you to that host's access group in the SSO — nothing on the jump host
|
|
||||||
changes.
|
|
||||||
|
|
||||||
Targets that don't resolve to a host you're allowed to reach are refused (and
|
|
||||||
audited). Raw IPs that aren't a known directory host are denied by default.
|
|
||||||
|
|
||||||
> On a [standalone](architecture.html#standalone-mode) jump host (no LDAP/SSO),
|
|
||||||
> every registered host is reachable by every registered user — there's no
|
|
||||||
> group-based restriction to ask an admin about.
|
|
||||||
|
|
||||||
## Authentication
|
|
||||||
|
|
||||||
The jump host authenticates **you** against the directory:
|
|
||||||
|
|
||||||
- **Public key** — matched against your `sshPublicKey` entries in LDAP. Use your
|
|
||||||
normal SSH key; the client picks it automatically.
|
|
||||||
- **Password** — your directory password (LDAP bind). Password auth is often
|
|
||||||
restricted to local networks or disabled entirely on a public jump host
|
|
||||||
(keys-only) — check with your operator.
|
|
||||||
|
|
||||||
You never manage a separate credential for the downstream host: the jump host
|
|
||||||
handles onward authentication for you (see
|
|
||||||
[Architecture](architecture.html#per-user-key-injection)).
|
|
||||||
|
|
||||||
## First connection to a host
|
|
||||||
|
|
||||||
The very first time you reach a given downstream host, the jump host provisions
|
|
||||||
its access key for you behind the scenes. If that first attempt races the
|
|
||||||
directory's key-cache refresh you may see a brief
|
|
||||||
|
|
||||||
```
|
|
||||||
jump-host: first-time key propagation, retrying…
|
|
||||||
```
|
|
||||||
|
|
||||||
and it reconnects automatically. Subsequent connections are immediate.
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 90 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 83 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 78 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 72 KiB |
-121
@@ -1,121 +0,0 @@
|
|||||||
---
|
|
||||||
layout: default
|
|
||||||
title: Home
|
|
||||||
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
|
|
||||||
|
|
||||||
An SSH jump host for the [theta42](https://github.com/theta42) self-hosted
|
|
||||||
stack. Users SSH into **one** public host and land on any downstream host
|
|
||||||
they're entitled to — authenticated against the shared LDAP directory,
|
|
||||||
authorized from the [SSO Manager](https://theta42.github.io/sso-manager-node/)'s
|
|
||||||
inventory graph, and audited end to end.
|
|
||||||
|
|
||||||
No per-host accounts, no distributing keys, no VPN. The same people who log in
|
|
||||||
to your SSO are the people who can reach your machines — and only the machines
|
|
||||||
their directory groups grant.
|
|
||||||
|
|
||||||
Part of the theta42 self-hosted identity stack, alongside
|
|
||||||
[SSO Manager](https://theta42.github.io/sso-manager-node/) and
|
|
||||||
[Proxy](https://theta42.github.io/proxy/), composable with one command via
|
|
||||||
[theta-env](https://theta42.github.io/theta-env/).
|
|
||||||
|
|
||||||
## Screenshots
|
|
||||||
|
|
||||||
<a href="images/login.png" target="_blank"><img src="images/login.png" alt="Login" width="49%"></a>
|
|
||||||
<a href="images/dashboard.png" target="_blank"><img src="images/dashboard.png" alt="Dashboard" width="49%"></a>
|
|
||||||
<a href="images/sessions.png" target="_blank"><img src="images/sessions.png" alt="Active sessions" width="49%"></a>
|
|
||||||
<a href="images/audit.png" target="_blank"><img src="images/audit.png" alt="Audit log" width="49%"></a>
|
|
||||||
|
|
||||||
*(click any screenshot to view full size)*
|
|
||||||
|
|
||||||
Don't want to run LDAP or the SSO Manager? **Standalone mode** stores users
|
|
||||||
and hosts in a local SQL database instead (SQLite by default, any
|
|
||||||
Sequelize-supported dialect if you want something else) — same SSH front door,
|
|
||||||
key injection, and audit trail. See
|
|
||||||
[Installation](installation.html#standalone-mode) to get started.
|
|
||||||
|
|
||||||
## Two ways to connect
|
|
||||||
|
|
||||||
**Direct (WinSCP/SFTP-friendly):**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ssh alice_-_web01@jump.example.com
|
|
||||||
sftp -P 2222 alice_-_web01@jump.example.com
|
|
||||||
```
|
|
||||||
|
|
||||||
The username grammar is `{uid}_-_{target}` — `target` is a directory host slug
|
|
||||||
(with or without the `host_` prefix), a bare hostname, or an IP. One username
|
|
||||||
string, no interactive step, so it works cleanly in WinSCP and scripts.
|
|
||||||
|
|
||||||
**Interactive picker:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ssh alice@jump.example.com
|
|
||||||
```
|
|
||||||
|
|
||||||
A plain login shows a TUI list of the hosts you can reach; arrow-key or type to
|
|
||||||
filter, Enter to connect.
|
|
||||||
|
|
||||||
See **[Connecting](connecting.html)** for the full usage guide.
|
|
||||||
|
|
||||||
## Why a jump host (and why this one)
|
|
||||||
|
|
||||||
A bastion/jump host is the standard way to give SSH access to internal machines
|
|
||||||
through a single audited entry point. What's usually painful is *authorization*
|
|
||||||
and *credentials*: who may reach which host, and how the bastion authenticates
|
|
||||||
onward without you copying keys everywhere.
|
|
||||||
|
|
||||||
This jump host answers both from your directory:
|
|
||||||
|
|
||||||
- **Authorization is your directory graph.** The hosts you can reach are the
|
|
||||||
union of your LDAP groups × the SSO's inventory (the `host_<name>_access`
|
|
||||||
groups the directory already auto-creates). Add someone to a group; they can
|
|
||||||
reach the host. No bastion-side allow-list to maintain.
|
|
||||||
- **Onward auth is automatic.** The jump host holds one key and injects its
|
|
||||||
public half into your `sshPublicKey` on first use, then connects downstream
|
|
||||||
**as you**. Downstream hosts already serve keys from LDAP (via
|
|
||||||
[ldap-client](https://github.com/theta42/ldap-client)'s
|
|
||||||
`AuthorizedKeysCommand`), so nothing downstream needs configuring.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- **Username-grammar routing** (`uid_-_target`) — straight-through to the host,
|
|
||||||
SFTP included (WinSCP works)
|
|
||||||
- **Interactive TUI host picker** on plain login, scoped to your access
|
|
||||||
- **LDAP inbound auth** — public key or password (keys-only policy recommended
|
|
||||||
for a public host)
|
|
||||||
- **Directory-driven access** — reachable hosts come from the SSO inventory, not
|
|
||||||
a static list
|
|
||||||
- **Per-user key injection** — no downstream changes, no key distribution
|
|
||||||
- **Shell, exec, and SFTP** bridging
|
|
||||||
- **Web UI + HTTP API** for auditing and metrics — active sessions, a searchable
|
|
||||||
audit log, per-user/per-host counters
|
|
||||||
- **Full audit trail** — who, target, method, result, bytes, duration, and the
|
|
||||||
downstream host-key fingerprint
|
|
||||||
- **Standalone mode** — no LDAP, no SSO Manager; users and hosts live in a
|
|
||||||
local SQL database (Sequelize, any dialect — SQLite by default)
|
|
||||||
- Packaged like the rest of the stack: one-command Docker, idempotent bare-metal
|
|
||||||
installer, or bundled in theta-env
|
|
||||||
|
|
||||||
## Get it
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/theta42/jump-host.git
|
|
||||||
cd jump-host
|
|
||||||
cp secrets.js.example config/jump-secrets.js # then edit it
|
|
||||||
docker compose up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
That's the standalone quick start. For bare-metal and the bundled theta-env
|
|
||||||
option, see **[Installation](installation.html)**.
|
|
||||||
|
|
||||||
## Related projects
|
|
||||||
|
|
||||||
- **[SSO Manager](https://theta42.github.io/sso-manager-node/)** — the OpenLDAP
|
|
||||||
directory + OIDC provider + the inventory graph this jump host reads.
|
|
||||||
- **[Proxy](https://theta42.github.io/proxy/)** — puts your web apps behind the
|
|
||||||
same identity; fronts this jump host's web UI.
|
|
||||||
- **[theta-env](https://theta42.github.io/theta-env/)** — runs the whole stack,
|
|
||||||
jump host included, with one command.
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
---
|
|
||||||
layout: default
|
|
||||||
title: Installation
|
|
||||||
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
|
|
||||||
|
|
||||||
Three ways to run the jump host, in increasing manual effort. All read their
|
|
||||||
config through [@simpleworkjs/conf](https://www.npmjs.com/package/@simpleworkjs/conf)
|
|
||||||
(`conf/base.js` < `conf/<NODE_ENV>.js` < the `CONF_SECRETS` file < `app_*` env).
|
|
||||||
|
|
||||||
## Standalone mode (no LDAP/SSO) {#standalone-mode}
|
|
||||||
|
|
||||||
Skip LDAP and the SSO Manager entirely. Not to be confused with "Standalone
|
|
||||||
Docker" below, which is still LDAP + SSO, just run outside theta-env. Set in your secrets/config:
|
|
||||||
|
|
||||||
```js
|
|
||||||
standalone: { enabled: true },
|
|
||||||
orm: { dialect: 'sqlite', storage: './data/standalone.sqlite', logging: false },
|
|
||||||
```
|
|
||||||
|
|
||||||
`orm` is passed straight to Sequelize, so any supported dialect works — SQLite
|
|
||||||
is just the zero-dependency default. Everything downstream of auth (bridging,
|
|
||||||
key injection, the web UI, audit) is unchanged.
|
|
||||||
|
|
||||||
There's no admin UI for standalone users/hosts yet, so add them directly with
|
|
||||||
the ORM models:
|
|
||||||
|
|
||||||
```js
|
|
||||||
const StandaloneUser = require('./models/standalone_user');
|
|
||||||
const StandaloneHost = require('./models/standalone_host');
|
|
||||||
const bcrypt = require('bcrypt');
|
|
||||||
|
|
||||||
await StandaloneUser.create({
|
|
||||||
uid: 'alice',
|
|
||||||
passwordHash: await bcrypt.hash('a real password', 10),
|
|
||||||
sshPublicKeys: ['ssh-ed25519 AAAA... alice@laptop'],
|
|
||||||
groups: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
await StandaloneHost.create({
|
|
||||||
slug: 'host_web01',
|
|
||||||
displayName: 'web01',
|
|
||||||
kind: 'host',
|
|
||||||
metadata: { ip: '10.0.0.5', sshPort: 22 },
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
Every host in the standalone inventory is reachable by every standalone user —
|
|
||||||
there's no group-based authorization yet (`groups` on `StandaloneUser` is
|
|
||||||
accepted for interface parity with the LDAP path, not enforced).
|
|
||||||
|
|
||||||
The rest of this page (requirements, the LDAP write-ACL, the three install
|
|
||||||
paths) describes the default LDAP + SSO mode — skip it if you're running
|
|
||||||
standalone.
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
- The [SSO Manager](https://theta42.github.io/sso-manager-node/) (OpenLDAP
|
|
||||||
directory + `/api/discovery`), v1.3.0 or newer.
|
|
||||||
- Downstream hosts joined via
|
|
||||||
[ldap-client](https://github.com/theta42/ldap-client) (SSSD +
|
|
||||||
`AuthorizedKeysCommand`).
|
|
||||||
- An LDAP bind account with **write access to the `sshPublicKey` attribute** on
|
|
||||||
user entries (see below).
|
|
||||||
- An SSO API token (`sso_…`) for the directory queries.
|
|
||||||
|
|
||||||
## 1. Unified theta-env stack (recommended)
|
|
||||||
|
|
||||||
Enable it in `theta-env/setup.env`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
CFG_JUMP_HOST_ENABLED=true
|
|
||||||
CFG_JUMP_HOST=jump.example.com
|
|
||||||
JUMP_SSH_PORT=2222
|
|
||||||
```
|
|
||||||
|
|
||||||
Re-run `./setup.sh`. The stack builds the submodule (behind the `jump-host`
|
|
||||||
compose profile), mints the directory API token, writes
|
|
||||||
`./config/jump-secrets.js`, grants the `sshPublicKey` write-ACL, registers the
|
|
||||||
jump host in the proxy, and seeds a directory entry. Forward the public host's
|
|
||||||
`:22` (or `:2222`) to the container's published `JUMP_SSH_PORT`.
|
|
||||||
|
|
||||||
## 2. Standalone Docker
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp secrets.js.example config/jump-secrets.js
|
|
||||||
$EDITOR config/jump-secrets.js # LDAP bind (+ sshPublicKey write ACL), SSO url + token
|
|
||||||
docker compose up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
Host keys persist in the `jump-data` volume. The web UI is on `:3002`; front it
|
|
||||||
with your own TLS/proxy.
|
|
||||||
|
|
||||||
## 3. Bare metal
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsSL https://raw.githubusercontent.com/theta42/jump-host/master/ops/install.sh | sudo bash
|
|
||||||
sudo $EDITOR /etc/jump-host/secrets.js
|
|
||||||
sudo systemctl restart jump-host
|
|
||||||
journalctl -u jump-host -f
|
|
||||||
```
|
|
||||||
|
|
||||||
`ops/install.sh` installs Node 22 + Redis, hard-resets the checkout at
|
|
||||||
`/opt/theta42/jump-host` to the remote branch, symlinks the systemd unit, and
|
|
||||||
runs `npm ci`. Idempotent — re-run to update. Overridable via `REPO_DIR=`,
|
|
||||||
`BRANCH=`, `SECRETS_FILE=`.
|
|
||||||
|
|
||||||
## The LDAP write-ACL (required)
|
|
||||||
|
|
||||||
The jump host injects its public key into each user's `sshPublicKey`, so its
|
|
||||||
bind account must be able to **write** that attribute. In the bundled OpenLDAP:
|
|
||||||
|
|
||||||
```
|
|
||||||
access to attrs=sshPublicKey
|
|
||||||
by dn.exact="cn=ldapclient,ou=people,dc=example,dc=com" write
|
|
||||||
by self write
|
|
||||||
by * read
|
|
||||||
```
|
|
||||||
|
|
||||||
In the theta-env bundle this is handled for you (the jump host binds as the LDAP
|
|
||||||
admin). For a hardened standalone deployment, use a dedicated bind account with
|
|
||||||
exactly this attribute-scoped ACL. Without write access, key injection fails and
|
|
||||||
every bridge attempt is audited `key-inject-failed`.
|
|
||||||
|
|
||||||
## Listening on port 22
|
|
||||||
|
|
||||||
The default SSH port is **2222** so the service needs no privilege. To listen on
|
|
||||||
22, set `ssh.listenPort: 22` and either:
|
|
||||||
|
|
||||||
- **systemd:** uncomment `AmbientCapabilities=CAP_NET_BIND_SERVICE` in the unit;
|
|
||||||
- **Docker:** publish `22:22`; or
|
|
||||||
- **firewall:** DNAT `22 → 2222`.
|
|
||||||
|
|
||||||
## Configuration reference
|
|
||||||
|
|
||||||
Every key is documented in
|
|
||||||
[`secrets.js.example`](https://github.com/theta42/jump-host/blob/master/secrets.js.example):
|
|
||||||
`ldap` (bind + bases + TLS), `sso` (url + apiToken), `ssh`
|
|
||||||
(`listenPort`, `passwordAuth`, `allowRawIPs`, `keyComment`, timeouts,
|
|
||||||
`maxSessions`), `web.port`, `oidc` (web-UI SSO login), `auth`
|
|
||||||
(`adminGroups` / `adminUsers` / `localAdminPass`), and `redis`.
|
|
||||||
|
|
||||||
## Verifying
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ssh -p 2222 youruid@jump.example.com # TUI picker
|
|
||||||
ssh -p 2222 youruid_-_somehost@jump.example.com # direct
|
|
||||||
sftp -P 2222 youruid_-_somehost@jump.example.com # WinSCP path
|
|
||||||
curl -s http://localhost:3002/health
|
|
||||||
```
|
|
||||||
|
|
||||||
Watch `journalctl -u jump-host -f` (or `docker logs -f jump-host`) and the audit
|
|
||||||
log at `/audit` in the web UI.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
User-agent: *
|
|
||||||
Allow: /
|
|
||||||
|
|
||||||
Sitemap: https://theta42.github.io/proxy/sitemap.xml
|
|
||||||
+34
-23
@@ -9,32 +9,43 @@ const http = require('http');
|
|||||||
const conf = require('@simpleworkjs/conf');
|
const conf = require('@simpleworkjs/conf');
|
||||||
const { Server } = require('socket.io');
|
const { Server } = require('socket.io');
|
||||||
|
|
||||||
require('../models');
|
// @simpleworkjs/conf loads ./config/jump-secrets.js synchronously, then
|
||||||
|
// @simpleworkjs/bao-conf deep-merges secret/jump-host/conf from OpenBao over
|
||||||
|
// it. The OIDC clientSecret is captured at require time inside models (via
|
||||||
|
// createOidcClient), so the fetch MUST resolve before require('../models').
|
||||||
|
// Fail-soft: if OpenBao is unreachable, init() leaves conf as the file-loaded
|
||||||
|
// fallback and boot continues from ./config/jump-secrets.js.
|
||||||
|
require('@simpleworkjs/bao-conf').init({ path: 'jump-host', conf }).then(() => {
|
||||||
|
require('../models');
|
||||||
|
|
||||||
const app = require('../app');
|
const app = require('../app');
|
||||||
const middleware = require('../middleware/auth');
|
const middleware = require('../middleware/auth');
|
||||||
const sshServer = require('../services/ssh_server');
|
const sshServer = require('../services/ssh_server');
|
||||||
|
|
||||||
const webPort = (conf.web && conf.web.port) || 3002;
|
const webPort = (conf.web && conf.web.port) || 3002;
|
||||||
const server = http.createServer(app);
|
const server = http.createServer(app);
|
||||||
|
|
||||||
// Socket.IO — the client framework (app-base.js) opens an authenticated socket.
|
// Socket.IO — the client framework (app-base.js) opens an authenticated socket.
|
||||||
// We don't push anything yet, but serving /socket.io keeps the shared front-end
|
// We don't push anything yet, but serving /socket.io keeps the shared front-end
|
||||||
// working exactly as it does in the sibling apps.
|
// working exactly as it does in the sibling apps.
|
||||||
const io = new Server(server);
|
const io = new Server(server);
|
||||||
io.use(middleware.authIO);
|
io.use(middleware.authIO);
|
||||||
app.io = io;
|
app.io = io;
|
||||||
|
|
||||||
server.listen(webPort, () => {
|
server.listen(webPort, () => {
|
||||||
console.log(`[web] jump-host UI/API on :${server.address().port}`);
|
console.log(`[web] jump-host UI/API on :${server.address().port}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
sshServer.start();
|
sshServer.start();
|
||||||
|
|
||||||
function shutdown() {
|
function shutdown() {
|
||||||
console.log('[jump-host] shutting down');
|
console.log('[jump-host] shutting down');
|
||||||
server.close();
|
server.close();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
process.on('SIGTERM', shutdown);
|
process.on('SIGTERM', shutdown);
|
||||||
process.on('SIGINT', shutdown);
|
process.on('SIGINT', shutdown);
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('boot failed:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
+7
-2
@@ -6,7 +6,7 @@
|
|||||||
// values (LDAP creds, SSO API token) belong in the secrets file.
|
// values (LDAP creds, SSO API token) belong in the secrets file.
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
name: 'Jump Host',
|
name: 'Jump',
|
||||||
logo: '/static/img/theta42.svg',
|
logo: '/static/img/theta42.svg',
|
||||||
|
|
||||||
// LDAP directory the users live in (same directory the SSO manages).
|
// LDAP directory the users live in (same directory the SSO manages).
|
||||||
@@ -80,7 +80,12 @@ module.exports = {
|
|||||||
|
|
||||||
auth: {
|
auth: {
|
||||||
// OIDC group memberships that grant web UI/API admin access.
|
// OIDC group memberships that grant web UI/API admin access.
|
||||||
adminGroups: ['app_sso_admin'],
|
// app_super_admin is the cross-app super admin group (sso, proxy, jump-host).
|
||||||
|
adminGroups: ['app_sso_admin', 'app_super_admin'],
|
||||||
|
// OIDC group memberships that grant jump admin access (the audit page
|
||||||
|
// and its data), without granting other admin-only rights. Full admins
|
||||||
|
// (adminGroups/adminUsers) always have jump admin access too.
|
||||||
|
jumpAdminGroups: ['app_jump_admin'],
|
||||||
// Local anti-lockout admin: the first name here is bootstrapped as a
|
// Local anti-lockout admin: the first name here is bootstrapped as a
|
||||||
// redis-backed user on first boot (password from localAdminPass, or a
|
// redis-backed user on first boot (password from localAdminPass, or a
|
||||||
// random one printed to the log once). Lets you in even with OIDC down.
|
// random one printed to the log once). Lets you in even with OIDC down.
|
||||||
|
|||||||
@@ -56,6 +56,25 @@ async function requireAdmin(req, res, next){
|
|||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Jump admin = access to the audit page/data. A narrower grant than full
|
||||||
|
// jump-host admin: full admins (isAdmin) always qualify, plus anyone in
|
||||||
|
// conf.auth.jumpAdminGroups (e.g. a dedicated app_jump_admin LDAP group) can
|
||||||
|
// be granted audit access without also getting other admin-only rights.
|
||||||
|
function isJumpAdmin(req){
|
||||||
|
if(isAdmin(req)) return true;
|
||||||
|
const jumpAdminGroups = (conf.auth && conf.auth.jumpAdminGroups) || [];
|
||||||
|
return (req.groups || []).some(g => jumpAdminGroups.includes(g));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireJumpAdmin(req, res, next){
|
||||||
|
if(isJumpAdmin(req)) return next();
|
||||||
|
const error = new Error('Forbidden');
|
||||||
|
error.name = 'Forbidden';
|
||||||
|
error.status = 403;
|
||||||
|
error.message = 'Jump admin access required.';
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
|
||||||
// Socket.IO handshake auth (app-base.js connects with the session token).
|
// Socket.IO handshake auth (app-base.js connects with the session token).
|
||||||
async function authIO(socket, next){
|
async function authIO(socket, next){
|
||||||
try{
|
try{
|
||||||
@@ -69,4 +88,4 @@ async function authIO(socket, next){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { auth, requireAdmin, authIO, isAdmin };
|
module.exports = { auth, requireAdmin, authIO, isAdmin, isJumpAdmin, requireJumpAdmin };
|
||||||
|
|||||||
@@ -13,10 +13,34 @@ async function bump({ uid, hostSlug, success }) {
|
|||||||
const ops = [redis.incr(`${P()}total`), redis.incr(`${P()}day_${day}`)];
|
const ops = [redis.incr(`${P()}total`), redis.incr(`${P()}day_${day}`)];
|
||||||
if (!success) ops.push(redis.incr(`${P()}fail`));
|
if (!success) ops.push(redis.incr(`${P()}fail`));
|
||||||
if (uid) ops.push(redis.incr(`${P()}user_${uid}`));
|
if (uid) ops.push(redis.incr(`${P()}user_${uid}`));
|
||||||
if (hostSlug) ops.push(redis.incr(`${P()}host_${hostSlug}`));
|
if (hostSlug) {
|
||||||
|
ops.push(redis.incr(`${P()}host_${hostSlug}`));
|
||||||
|
// Last-attempt timestamp per host, split by outcome -- drives the
|
||||||
|
// dashboard's "Last connection"/"Last failed connection" columns and
|
||||||
|
// row highlighting (see lastForHosts below).
|
||||||
|
ops.push(redis.set(`${P()}host_last_${success ? 'success' : 'fail'}_${hostSlug}`, Date.now()));
|
||||||
|
}
|
||||||
await Promise.all(ops);
|
await Promise.all(ops);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-host last-success/last-fail timestamps for a given list of slugs (e.g.
|
||||||
|
// the hosts a session can reach), for the dashboard's host list.
|
||||||
|
async function lastForHosts(slugs) {
|
||||||
|
const redis = await getRedis();
|
||||||
|
const result = {};
|
||||||
|
await Promise.all((slugs || []).map(async (slug) => {
|
||||||
|
const [lastSuccess, lastFail] = await Promise.all([
|
||||||
|
redis.get(`${P()}host_last_success_${slug}`),
|
||||||
|
redis.get(`${P()}host_last_fail_${slug}`),
|
||||||
|
]);
|
||||||
|
result[slug] = {
|
||||||
|
lastConnected: lastSuccess ? Number(lastSuccess) : null,
|
||||||
|
lastFailed: lastFail ? Number(lastFail) : null,
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
async function summary() {
|
async function summary() {
|
||||||
const redis = await getRedis();
|
const redis = await getRedis();
|
||||||
const [total, fail] = await Promise.all([
|
const [total, fail] = await Promise.all([
|
||||||
@@ -37,4 +61,4 @@ async function summary() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { bump, summary };
|
module.exports = { bump, summary, lastForHosts };
|
||||||
|
|||||||
Generated
+15
-2
@@ -1,16 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-jump-host",
|
"name": "t42-jump-host",
|
||||||
"version": "1.9.0",
|
"version": "1.16.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "t42-jump-host",
|
"name": "t42-jump-host",
|
||||||
"version": "1.9.0",
|
"version": "1.16.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||||
"@simpleworkjs/app-stack": "^1.0.0",
|
"@simpleworkjs/app-stack": "^1.0.0",
|
||||||
|
"@simpleworkjs/bao-conf": "^1.0.0",
|
||||||
"@simpleworkjs/conf": "^1.2.0",
|
"@simpleworkjs/conf": "^1.2.0",
|
||||||
"@simpleworkjs/directory-schema": "^1.0.0",
|
"@simpleworkjs/directory-schema": "^1.0.0",
|
||||||
"@simpleworkjs/frontend": "^0.2.6",
|
"@simpleworkjs/frontend": "^0.2.6",
|
||||||
@@ -156,6 +157,18 @@
|
|||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@simpleworkjs/bao-conf": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@simpleworkjs/bao-conf/-/bao-conf-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-mcay5NQ/w9ShpIAolMP/3f9TfXSLE+d5jrA4dTPOUHDjTkdsP7pe4hMmQUmwnniR59U1bGoRIVdXjvDbX3I5nw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"extend": "^3.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@simpleworkjs/conf": {
|
"node_modules/@simpleworkjs/conf": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@simpleworkjs/conf/-/conf-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@simpleworkjs/conf/-/conf-1.2.0.tgz",
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-jump-host",
|
"name": "t42-jump-host",
|
||||||
"version": "1.10.2",
|
"version": "1.16.1",
|
||||||
"description": "SSH jump host for the theta42 stack — LDAP-authenticated, directory-driven host bridging with audit and metrics",
|
"description": "SSH jump host for the theta42 stack — LDAP-authenticated, directory-driven host bridging with audit and metrics",
|
||||||
"author": [
|
"author": [
|
||||||
{
|
{
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||||
"@simpleworkjs/app-stack": "^1.0.0",
|
"@simpleworkjs/app-stack": "^1.0.0",
|
||||||
|
"@simpleworkjs/bao-conf": "^1.0.0",
|
||||||
"@simpleworkjs/conf": "^1.2.0",
|
"@simpleworkjs/conf": "^1.2.0",
|
||||||
"@simpleworkjs/directory-schema": "^1.0.0",
|
"@simpleworkjs/directory-schema": "^1.0.0",
|
||||||
"@simpleworkjs/frontend": "^0.2.6",
|
"@simpleworkjs/frontend": "^0.2.6",
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ router.use('/user', middleware.auth, require('./user'));
|
|||||||
// admin gate (see routes/api_token.js for why a token can't reach admin routes).
|
// 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'));
|
router.use('/api-token', middleware.auth, require('./api_token'));
|
||||||
|
|
||||||
// Jump-host data — admin only (audit log, active sessions, metrics).
|
// Jump-host data — jump admin only (audit log, active sessions, metrics).
|
||||||
router.use('/', middleware.auth, middleware.requireAdmin, require('./jump'));
|
router.use('/', middleware.auth, middleware.requireJumpAdmin, require('./jump'));
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
+18
-2
@@ -4,14 +4,17 @@
|
|||||||
// browser who it is and whether it's an admin (drives login state + nav).
|
// browser who it is and whether it's an admin (drives login state + nav).
|
||||||
|
|
||||||
const router = require('express').Router();
|
const router = require('express').Router();
|
||||||
const { isAdmin } = require('../middleware/auth');
|
const { isAdmin, isJumpAdmin } = require('../middleware/auth');
|
||||||
const access = require('../utils/access');
|
const access = require('../utils/access');
|
||||||
|
const metrics = require('../models/metrics');
|
||||||
|
const registry = require('../services/session_registry');
|
||||||
|
|
||||||
router.get('/me', (req, res) => {
|
router.get('/me', (req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
username: req.user && req.user.username,
|
username: req.user && req.user.username,
|
||||||
groups: req.groups || [],
|
groups: req.groups || [],
|
||||||
isAdmin: isAdmin(req),
|
isAdmin: isAdmin(req),
|
||||||
|
isJumpAdmin: isJumpAdmin(req),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -23,7 +26,20 @@ router.get('/hosts', async (req, res, next) => {
|
|||||||
const hosts = isAdmin(req)
|
const hosts = isAdmin(req)
|
||||||
? await access.allHosts()
|
? await access.allHosts()
|
||||||
: await access.accessibleHosts({ uid: req.user && req.user.username, groups: req.groups || [] });
|
: await access.accessibleHosts({ uid: req.user && req.user.username, groups: req.groups || [] });
|
||||||
res.json({ results: hosts });
|
|
||||||
|
// Enrich with connection state for the dashboard's host list: whether a
|
||||||
|
// session is live right now (active bridges, session_registry), plus the
|
||||||
|
// last successful/failed connection times (models/metrics).
|
||||||
|
const connectedSlugs = new Set(registry.list().map((s) => s.slug));
|
||||||
|
const last = await metrics.lastForHosts(hosts.map((h) => h.slug));
|
||||||
|
const enriched = hosts.map((h) => ({
|
||||||
|
...h,
|
||||||
|
connected: connectedSlugs.has(h.slug),
|
||||||
|
lastConnected: (last[h.slug] && last[h.slug].lastConnected) || null,
|
||||||
|
lastFailed: (last[h.slug] && last[h.slug].lastFailed) || null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json({ results: enriched });
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ function counter(onBytes) {
|
|||||||
// Connect the upstream ssh2.Client, retrying once after a short pause if the
|
// Connect the upstream ssh2.Client, retrying once after a short pause if the
|
||||||
// first attempt fails auth (SSSD/AuthorizedKeysCommand cache lag right after a
|
// first attempt fails auth (SSSD/AuthorizedKeysCommand cache lag right after a
|
||||||
// first-time key injection).
|
// first-time key injection).
|
||||||
function connectUpstream({ host, port, username, privateKey, onHostKey, uid, justInjected }) {
|
function connectUpstream({ host, port, username, privateKey, cert, onHostKey, uid, justInjected, expectedHostKeyFp }) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let attempted = false;
|
let attempted = false;
|
||||||
const dial = (allowRetry) => {
|
const dial = (allowRetry) => {
|
||||||
@@ -42,12 +42,16 @@ function connectUpstream({ host, port, username, privateKey, onHostKey, uid, jus
|
|||||||
})
|
})
|
||||||
.connect({
|
.connect({
|
||||||
host, port, username, privateKey,
|
host, port, username, privateKey,
|
||||||
|
certificates: cert ? [cert] : undefined,
|
||||||
readyTimeout: (conf.ssh && conf.ssh.connectTimeoutMs) || 10000,
|
readyTimeout: (conf.ssh && conf.ssh.connectTimeoutMs) || 10000,
|
||||||
keepaliveInterval: 15000,
|
keepaliveInterval: 15000,
|
||||||
hostVerifier: (key) => {
|
hostVerifier: (key) => {
|
||||||
const fp = 'SHA256:' + crypto.createHash('sha256').update(key).digest('base64').replace(/=+$/, '');
|
const fp = 'SHA256:' + crypto.createHash('sha256').update(key).digest('base64').replace(/=+$/, '');
|
||||||
if (onHostKey) onHostKey(fp);
|
if (onHostKey) onHostKey(fp);
|
||||||
return true; // v1: trust-on-use, fingerprint audited. Pinning = follow-up.
|
if (expectedHostKeyFp && expectedHostKeyFp !== fp) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true; // v1: trust-on-use if not pinned, fingerprint audited.
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -129,17 +129,30 @@ async function resolveAndConnect(state, record, { onHostKey } = {}) {
|
|||||||
await record.patch({ targetSlug: host ? host.slug : 'raw-ip', targetAddr: endpoint.address, targetPort: endpoint.port });
|
await record.patch({ targetSlug: host ? host.slug : 'raw-ip', targetAddr: endpoint.address, targetPort: endpoint.port });
|
||||||
|
|
||||||
let justInjected = false;
|
let justInjected = false;
|
||||||
try { justInjected = await ensureKeyInjected(state.user, JUMP_KEYS.publicLine); }
|
let cert;
|
||||||
catch (err) { throw fail('key-inject-failed', err.message); }
|
const usePki = conf.ssh && conf.ssh.pki && conf.ssh.pki.enabled;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (usePki) {
|
||||||
|
const { getSignedCert } = require('../utils/vault_cert');
|
||||||
|
cert = await getSignedCert(JUMP_KEYS.publicLine, state.uid);
|
||||||
|
} else {
|
||||||
|
justInjected = await ensureKeyInjected(state.user, JUMP_KEYS.publicLine);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const failType = usePki ? 'pki-cert-failed' : 'key-inject-failed';
|
||||||
|
throw fail(failType, err.message, host ? host.slug : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
let upstream;
|
let upstream;
|
||||||
try {
|
try {
|
||||||
upstream = await connectUpstream({
|
upstream = await connectUpstream({
|
||||||
host: endpoint.address, port: endpoint.port,
|
host: endpoint.address, port: endpoint.port,
|
||||||
username: state.uid, privateKey: JUMP_KEYS.clientKey,
|
username: state.uid, privateKey: JUMP_KEYS.clientKey, cert,
|
||||||
uid: state.uid, justInjected, onHostKey,
|
uid: state.uid, justInjected, onHostKey,
|
||||||
|
expectedHostKeyFp: host && host.metadata && host.metadata.sshHostKeyFp,
|
||||||
});
|
});
|
||||||
} catch (err) { throw fail('upstream-unreachable', err.message); }
|
} catch (err) { throw fail('upstream-unreachable', err.message, host ? host.slug : undefined); }
|
||||||
|
|
||||||
return { upstream, host, endpoint };
|
return { upstream, host, endpoint };
|
||||||
}
|
}
|
||||||
@@ -147,8 +160,10 @@ async function resolveAndConnect(state, record, { onHostKey } = {}) {
|
|||||||
// detail carries the real underlying error message (e.g. ECONNREFUSED,
|
// detail carries the real underlying error message (e.g. ECONNREFUSED,
|
||||||
// ETIMEDOUT, an ssh2 auth-failure string) so audit records aren't reduced to
|
// 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
|
// just the generic reason code -- without it, a network-layer failure and an
|
||||||
// SSH auth failure both looked identical in the audit log.
|
// SSH auth failure both looked identical in the audit log. hostSlug (when the
|
||||||
function fail(reason, detail) { const e = new Error(reason); e.reason = reason; e.detail = detail; return e; }
|
// target was already resolved to a known host) lets callers attribute the
|
||||||
|
// failure to that host for per-host "last failed connection" tracking.
|
||||||
|
function fail(reason, detail, hostSlug) { const e = new Error(reason); e.reason = reason; e.detail = detail; e.hostSlug = hostSlug; return e; }
|
||||||
|
|
||||||
async function runGrammar(session, client, state) {
|
async function runGrammar(session, client, state) {
|
||||||
// Register session listeners IMMEDIATELY — before any async work.
|
// Register session listeners IMMEDIATELY — before any async work.
|
||||||
@@ -179,7 +194,7 @@ async function runGrammar(session, client, state) {
|
|||||||
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, failDetail: err.detail });
|
await record.finish({ success: false, failReason: reason, failDetail: err.detail });
|
||||||
await metrics.bump({ uid: state.uid, success: false });
|
await metrics.bump({ uid: state.uid, hostSlug: err.hostSlug, success: false });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,9 +218,9 @@ async function runTuiSession(session, client, state) {
|
|||||||
|
|
||||||
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, detail) => {
|
const finishFail = async (reason, detail, hostSlug) => {
|
||||||
await record.finish({ success: false, failReason: reason, failDetail: detail });
|
await record.finish({ success: false, failReason: reason, failDetail: detail });
|
||||||
await metrics.bump({ uid: state.uid, success: false });
|
await metrics.bump({ uid: state.uid, hostSlug, success: false });
|
||||||
try { client.end(); } catch (_) {}
|
try { client.end(); } catch (_) {}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -226,19 +241,32 @@ async function runTuiSession(session, client, state) {
|
|||||||
await record.patch({ targetSlug: tui.host.slug, targetAddr: endpoint.address, targetPort: endpoint.port });
|
await record.patch({ targetSlug: tui.host.slug, targetAddr: endpoint.address, targetPort: endpoint.port });
|
||||||
|
|
||||||
let justInjected = false;
|
let justInjected = false;
|
||||||
try { justInjected = await ensureKeyInjected(state.user, JUMP_KEYS.publicLine); }
|
let cert;
|
||||||
catch (err) { return finishFail('key-inject-failed', err.message); }
|
const usePki = conf.ssh && conf.ssh.pki && conf.ssh.pki.enabled;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (usePki) {
|
||||||
|
const { getSignedCert } = require('../utils/vault_cert');
|
||||||
|
cert = await getSignedCert(JUMP_KEYS.publicLine, state.uid);
|
||||||
|
} else {
|
||||||
|
justInjected = await ensureKeyInjected(state.user, JUMP_KEYS.publicLine);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const failType = usePki ? 'pki-cert-failed' : 'key-inject-failed';
|
||||||
|
return finishFail(failType, err.message, tui.host.slug);
|
||||||
|
}
|
||||||
|
|
||||||
let upstream;
|
let upstream;
|
||||||
try {
|
try {
|
||||||
upstream = await connectUpstream({
|
upstream = await connectUpstream({
|
||||||
host: endpoint.address, port: endpoint.port,
|
host: endpoint.address, port: endpoint.port,
|
||||||
username: state.uid, privateKey: JUMP_KEYS.clientKey,
|
username: state.uid, privateKey: JUMP_KEYS.clientKey, cert,
|
||||||
uid: state.uid, justInjected, onHostKey: (fp) => record.patch({ hostKeyFp: fp }),
|
uid: state.uid, justInjected, onHostKey: (fp) => record.patch({ hostKeyFp: fp }),
|
||||||
|
expectedHostKeyFp: tui.host && tui.host.metadata && tui.host.metadata.sshHostKeyFp,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} 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', err.message);
|
return finishFail('upstream-unreachable', err.message, tui.host.slug);
|
||||||
}
|
}
|
||||||
|
|
||||||
registry.add(record.id, { uid: state.uid, target: endpoint.address, slug: tui.host.slug });
|
registry.add(record.id, { uid: state.uid, target: endpoint.address, slug: tui.host.slug });
|
||||||
|
|||||||
@@ -9,10 +9,29 @@ const ESC = '\x1b';
|
|||||||
const CLEAR = `${ESC}[2J${ESC}[H`;
|
const CLEAR = `${ESC}[2J${ESC}[H`;
|
||||||
const HIDE_CUR = `${ESC}[?25l`;
|
const HIDE_CUR = `${ESC}[?25l`;
|
||||||
const SHOW_CUR = `${ESC}[?25h`;
|
const SHOW_CUR = `${ESC}[?25h`;
|
||||||
const INV = `${ESC}[7m`;
|
|
||||||
|
// Basic styles
|
||||||
const RST = `${ESC}[0m`;
|
const RST = `${ESC}[0m`;
|
||||||
const DIM = `${ESC}[2m`;
|
|
||||||
const BOLD = `${ESC}[1m`;
|
const BOLD = `${ESC}[1m`;
|
||||||
|
const DIM = `${ESC}[2m`;
|
||||||
|
|
||||||
|
// Colors (30-37: standard, 90-97: bright)
|
||||||
|
const RED = `${ESC}[31m`;
|
||||||
|
const BRIGHT_RED = `${ESC}[91m`;
|
||||||
|
const CYAN = `${ESC}[36m`;
|
||||||
|
const BRIGHT_CYAN = `${ESC}[96m`;
|
||||||
|
const GREEN = `${ESC}[32m`;
|
||||||
|
const BRIGHT_GREEN = `${ESC}[92m`;
|
||||||
|
const YELLOW = `${ESC}[33m`;
|
||||||
|
const BRIGHT_YELLOW = `${ESC}[93m`;
|
||||||
|
const MAGENTA = `${ESC}[35m`;
|
||||||
|
const BRIGHT_MAGENTA = `${ESC}[95m`;
|
||||||
|
const BLUE = `${ESC}[34m`;
|
||||||
|
const BRIGHT_BLUE = `${ESC}[94m`;
|
||||||
|
|
||||||
|
// Inverted selection with color
|
||||||
|
const INV_GREEN = `${ESC}[42m${ESC}[30m`; // Green bg, black text
|
||||||
|
const INV = `${ESC}[7m`;
|
||||||
|
|
||||||
function pickHost(channel, uid, hosts) {
|
function pickHost(channel, uid, hosts) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
@@ -35,18 +54,43 @@ function pickHost(channel, uid, hosts) {
|
|||||||
const list = visible();
|
const list = visible();
|
||||||
if (selected >= list.length) selected = Math.max(0, list.length - 1);
|
if (selected >= list.length) selected = Math.max(0, list.length - 1);
|
||||||
let out = CLEAR + HIDE_CUR;
|
let out = CLEAR + HIDE_CUR;
|
||||||
out += `${BOLD} Theta42 Jump — hosts for ${uid}${RST}\r\n`;
|
|
||||||
out += `${DIM} ↑/↓ move · Enter connect · type to filter · q quit${RST}\r\n\r\n`;
|
// Header with gradient-style color
|
||||||
|
out += `\r\n ${BOLD}${BRIGHT_CYAN}╔════════════════════════════════════════════════════════╗${RST}\r\n`;
|
||||||
|
out += ` ${BOLD}${BRIGHT_CYAN}║${RST} ${BOLD}${BRIGHT_MAGENTA}Theta42 Jump${RST} ${DIM}·${RST} ${BRIGHT_GREEN}hosts for ${uid}${RST} ${BOLD}${BRIGHT_CYAN}║${RST}\r\n`;
|
||||||
|
out += ` ${BOLD}${BRIGHT_CYAN}╚════════════════════════════════════════════════════════╝${RST}\r\n`;
|
||||||
|
out += `\r\n`;
|
||||||
|
out += ` ${DIM}↑/↓ move · Enter connect · type to filter · q quit${RST}\r\n`;
|
||||||
|
out += `\r\n`;
|
||||||
|
|
||||||
if (!list.length) {
|
if (!list.length) {
|
||||||
out += ` ${DIM}(no match for "${filter}")${RST}\r\n`;
|
out += ` ${YELLOW}⚠${RST} ${DIM}(no match for "${filter}")${RST}\r\n`;
|
||||||
} else {
|
} else {
|
||||||
list.forEach((h, i) => {
|
list.forEach((h, i) => {
|
||||||
const ip = (h.metadata && h.metadata.ip) || (h.metadata && h.metadata.address) || '';
|
const ip = (h.metadata && h.metadata.ip) || (h.metadata && h.metadata.address) || '';
|
||||||
const row = ` ${h.name} ${DIM}(${h.slug})${RST}${ip ? ` ${ip}` : ''}`;
|
const isProd = h.metadata && h.metadata.isProduction;
|
||||||
out += (i === selected ? `${INV}> ${h.name} (${h.slug})${ip ? ` ${ip}` : ''}${RST}` : row) + '\r\n';
|
const envBadge = isProd ? `${BOLD}${RED}PROD${RST} ` : `${DIM}DEV${RST} `;
|
||||||
|
|
||||||
|
if (i === selected) {
|
||||||
|
// Selected row with green inverse background
|
||||||
|
const selRow = `${INV_GREEN} ${h.name} ${DIM}(${h.slug})${RST}${ip ? ` ${CYAN}${ip}${RST}` : ''} ${envBadge} ${BOLD}${BRIGHT_GREEN}◄ SELECTED ►${RST}${INV_GREEN}${RST}`;
|
||||||
|
out += selRow + '\r\n';
|
||||||
|
} else {
|
||||||
|
// Normal row with subtle coloring
|
||||||
|
const nameColor = i % 2 === 0 ? BRIGHT_CYAN : CYAN;
|
||||||
|
out += ` ${nameColor}${h.name}${RST} ${DIM}(${h.slug})${RST}${ip ? ` ${BLUE}${ip}${RST}` : ''} ${envBadge}\r\n`;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (filter) out += `\r\n ${DIM}filter:${RST} ${filter}`;
|
|
||||||
|
if (filter) {
|
||||||
|
out += `\r\n ${DIM}filter: ${BRIGHT_YELLOW}${filter}${RST}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
out += `\r\n\r\n ${DIM}────────────────────────────────────────────────────────${RST}\r\n`;
|
||||||
|
out += ` ${DIM}Press${RST} ${BOLD}1-9${RST} ${DIM}to quick-select · ${BOLD}q${RST} ${DIM}to quit${RST}\r\n`;
|
||||||
|
|
||||||
channel.write(out);
|
channel.write(out);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -8,39 +8,33 @@ function stubLdap(groups) {
|
|||||||
return { getGroups: async () => groups };
|
return { getGroups: async () => groups };
|
||||||
}
|
}
|
||||||
|
|
||||||
function stubFetch(byGroup) {
|
function stubFetch(byUid) {
|
||||||
return async (url) => {
|
return async (url) => {
|
||||||
const cn = decodeURIComponent(url.split('group=')[1]);
|
const uid = url.split('/access/')[1];
|
||||||
return { ok: true, json: async () => ({ results: byGroup[cn] || [] }) };
|
return { ok: true, json: async () => ({ results: byUid[uid] || [] }) };
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
test('unions hosts across groups, dedupes, drops non-hosts', async () => {
|
test('drops non-hosts from access projection', async () => {
|
||||||
clearCache();
|
clearCache();
|
||||||
const user = { uid: 'alice', dn: 'uid=alice,ou=people,dc=x' };
|
const user = { uid: 'alice', dn: 'uid=alice,ou=people,dc=x' };
|
||||||
const fetchImpl = stubFetch({
|
const fetchImpl = stubFetch({
|
||||||
host_web01_access: [
|
alice: [
|
||||||
{ id: '1', kind: 'host', slug: 'host_web01' },
|
{ id: '1', kind: 'host', slug: 'host_web01' },
|
||||||
|
{ id: '2', kind: 'host', slug: 'host_db' },
|
||||||
{ id: '9', kind: 'service', slug: 'app_gitea' }, // dropped: not a host
|
{ id: '9', kind: 'service', slug: 'app_gitea' }, // dropped: not a host
|
||||||
],
|
],
|
||||||
host_db_access: [
|
|
||||||
{ id: '1', kind: 'host', slug: 'host_web01' }, // dupe by id
|
|
||||||
{ id: '2', kind: 'host', slug: 'host_db' },
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
const hosts = await accessibleHosts(user, { fetchImpl, ldap: stubLdap(['host_web01_access', 'host_db_access']) });
|
const hosts = await accessibleHosts(user, { fetchImpl });
|
||||||
assert.deepStrictEqual(hosts.map((h) => h.id).sort(), ['1', '2']);
|
assert.deepStrictEqual(hosts.map((h) => h.id).sort(), ['1', '2']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a failing group query does not sink the rest', async () => {
|
test('a failing access query returns empty list without throwing', async () => {
|
||||||
clearCache();
|
clearCache();
|
||||||
const user = { uid: 'bob', dn: 'uid=bob,ou=people,dc=x' };
|
const user = { uid: 'bob', dn: 'uid=bob,ou=people,dc=x' };
|
||||||
const fetchImpl = async (url) => {
|
const fetchImpl = async () => ({ ok: false, status: 500 });
|
||||||
if (url.includes('bad')) return { ok: false, status: 500 };
|
const hosts = await accessibleHosts(user, { fetchImpl });
|
||||||
return { ok: true, json: async () => ({ results: [{ id: '3', kind: 'host', slug: 'host_ok' }] }) };
|
assert.deepStrictEqual(hosts, []);
|
||||||
};
|
|
||||||
const hosts = await accessibleHosts(user, { fetchImpl, ldap: stubLdap(['bad_access', 'good_access']) });
|
|
||||||
assert.deepStrictEqual(hosts.map((h) => h.id), ['3']);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('caches per uid', async () => {
|
test('caches per uid', async () => {
|
||||||
@@ -48,23 +42,19 @@ test('caches per uid', async () => {
|
|||||||
let calls = 0;
|
let calls = 0;
|
||||||
const user = { uid: 'cara', dn: 'd' };
|
const user = { uid: 'cara', dn: 'd' };
|
||||||
const fetchImpl = async () => { calls++; return { ok: true, json: async () => ({ results: [] }) }; };
|
const fetchImpl = async () => { calls++; return { ok: true, json: async () => ({ results: [] }) }; };
|
||||||
const ldap = { getGroups: async () => ['g1'] };
|
await accessibleHosts(user, { fetchImpl });
|
||||||
await accessibleHosts(user, { fetchImpl, ldap });
|
await accessibleHosts(user, { fetchImpl });
|
||||||
await accessibleHosts(user, { fetchImpl, ldap });
|
|
||||||
assert.strictEqual(calls, 1);
|
assert.strictEqual(calls, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('accepts pre-resolved groups (web UI/OIDC session) without calling ldap.getGroups', async () => {
|
test('does not depend on user.groups or ldap.getGroups', async () => {
|
||||||
clearCache();
|
clearCache();
|
||||||
let ldapCalled = false;
|
const user = { uid: 'erin' }; // no dn, no groups
|
||||||
const user = { uid: 'erin', groups: ['host_web01_access'] };
|
|
||||||
const fetchImpl = stubFetch({
|
const fetchImpl = stubFetch({
|
||||||
host_web01_access: [{ id: '5', kind: 'host', slug: 'host_web01' }],
|
erin: [{ id: '5', kind: 'host', slug: 'host_web01' }],
|
||||||
});
|
});
|
||||||
const ldap = { getGroups: async () => { ldapCalled = true; return []; } };
|
const hosts = await accessibleHosts(user, { fetchImpl });
|
||||||
const hosts = await accessibleHosts(user, { fetchImpl, ldap });
|
|
||||||
assert.deepStrictEqual(hosts.map((h) => h.id), ['5']);
|
assert.deepStrictEqual(hosts.map((h) => h.id), ['5']);
|
||||||
assert.strictEqual(ldapCalled, false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('allHosts fetches the whole host inventory with no group filter', async () => {
|
test('allHosts fetches the whole host inventory with no group filter', async () => {
|
||||||
@@ -80,16 +70,14 @@ test('allHosts fetches the whole host inventory with no group filter', async ()
|
|||||||
assert.deepStrictEqual(hosts.map((h) => h.id).sort(), ['1', '2']);
|
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) returns empty list', async () => {
|
||||||
clearCache();
|
clearCache();
|
||||||
const user = { uid: 'dave', dn: 'd' };
|
const user = { uid: 'dave', dn: 'd' };
|
||||||
// drift shape: a bare array instead of { results: [...] }. The shared client
|
// drift shape: a bare array instead of { results: [...] }. The shared client
|
||||||
// throws DirectoryEnvelopeViolation; access.js must catch + continue, so a
|
// throws DirectoryEnvelopeViolation; access.js must catch + continue.
|
||||||
// good group alongside still yields its hosts.
|
const fetchImpl = async () => {
|
||||||
const fetchImpl = async (url) => {
|
return { ok: true, json: async () => [{ id: '7', kind: 'host' }] };
|
||||||
if (url.includes('drift')) return { ok: true, json: async () => [{ id: '7', kind: 'host' }] };
|
|
||||||
return { ok: true, json: async () => ({ results: [{ id: '8', kind: 'host' }] }) };
|
|
||||||
};
|
};
|
||||||
const hosts = await accessibleHosts(user, { fetchImpl, ldap: stubLdap(['drift_access', 'good_access']) });
|
const hosts = await accessibleHosts(user, { fetchImpl });
|
||||||
assert.deepStrictEqual(hosts.map((h) => h.id), ['8']);
|
assert.deepStrictEqual(hosts, []);
|
||||||
});
|
});
|
||||||
|
|||||||
+10
-29
@@ -17,14 +17,9 @@ if (conf.standalone && conf.standalone.enabled) {
|
|||||||
|
|
||||||
// Which directory hosts may a user reach, and how do we dial them?
|
// Which directory hosts may a user reach, and how do we dial them?
|
||||||
//
|
//
|
||||||
// v1 resolution (see directory_spec.md §9.2 in sso-manager-node): the SSO's
|
// We use the SSO's machine-aware /api/discovery/access/:uid endpoint,
|
||||||
// /api/discovery/me only answers for the API token's own user, and /graph
|
// which evaluates the user's groups server-side and returns their complete
|
||||||
// omits ResourceGroup links — so we combine the user's LDAP groups (queried
|
// access projection in one call.
|
||||||
// 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
|
// 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
|
// username-grammar path share the cache. Dependency-injected fetch/ldap for
|
||||||
@@ -56,32 +51,18 @@ if (conf.standalone && conf.standalone.enabled) {
|
|||||||
return resources.filter(r => r.kind === 'host');
|
return resources.filter(r => r.kind === 'host');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function accessibleHosts(user, { fetchImpl = fetch, ldap = userLdap } = {}) {
|
async function accessibleHosts(user, { fetchImpl = fetch } = {}) {
|
||||||
const hit = cache.get(user.uid);
|
const hit = cache.get(user.uid);
|
||||||
if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.hosts;
|
if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.hosts;
|
||||||
|
|
||||||
// The SSH path passes an LDAP user ({dn, uid, ...}) with no .groups, so we
|
let resources = [];
|
||||||
// look them up; the web UI already has the session's OIDC groups claim
|
try {
|
||||||
// and passes it directly, skipping a redundant LDAP round-trip.
|
resources = await directoryClient({ fetchImpl }).getAccess(user.uid);
|
||||||
const groups = user.groups || await ldap.getGroups(user.dn);
|
} catch (error) {
|
||||||
|
console.error(`[access] ${error.message}`);
|
||||||
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()];
|
const hosts = resources.filter(r => r.kind === 'host');
|
||||||
cache.set(user.uid, { at: Date.now(), hosts });
|
cache.set(user.uid, { at: Date.now(), hosts });
|
||||||
return hosts;
|
return hosts;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -37,6 +37,6 @@ module.exports = {
|
|||||||
nav: [
|
nav: [
|
||||||
{href: '/dashboard', icon: 'fa-solid fa-gauge-high', label: 'Dashboard', groups: []},
|
{href: '/dashboard', icon: 'fa-solid fa-gauge-high', label: 'Dashboard', groups: []},
|
||||||
{href: '/sessions', icon: 'fa-solid fa-plug-circle-bolt', label: 'Sessions', groups: []},
|
{href: '/sessions', icon: 'fa-solid fa-plug-circle-bolt', label: 'Sessions', groups: []},
|
||||||
{href: '/audit', icon: 'fa-solid fa-clipboard-list', label: 'Audit', groups: ['admin']},
|
{href: '/audit', icon: 'fa-solid fa-clipboard-list', label: 'Audit', groups: ['admin', 'app_jump_admin']},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const conf = require('@simpleworkjs/conf');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requests a signed SSH certificate from the SSO Manager's OpenBao/Vault proxy.
|
||||||
|
*
|
||||||
|
* @param {string} publicKey - The jump host's public key (e.g. 'ssh-rsa AAAAB3...')
|
||||||
|
* @param {string} targetUid - The username the cert should be valid for
|
||||||
|
* @returns {Promise<string>} - The signed SSH certificate
|
||||||
|
*/
|
||||||
|
async function getSignedCert(publicKey, targetUid) {
|
||||||
|
const sso = conf.sso || {};
|
||||||
|
const pkiConfig = conf.ssh?.pki || {};
|
||||||
|
|
||||||
|
const vaultRole = pkiConfig.role || 'jump-host-role';
|
||||||
|
const endpoint = `${sso.url}/api/vault/ssh/sign/${vaultRole}`;
|
||||||
|
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${sso.apiToken}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
public_key: publicKey,
|
||||||
|
valid_principals: targetUid
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errText = await response.text().catch(() => '');
|
||||||
|
throw new Error(`Failed to sign SSH cert (status ${response.status}): ${errText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (!data.data || !data.data.signed_key) {
|
||||||
|
throw new Error('Vault response missing signed_key');
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.data.signed_key;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getSignedCert };
|
||||||
+64
-2
@@ -1,7 +1,47 @@
|
|||||||
<%- include('top') %>
|
<%- include('top') %>
|
||||||
<script type="text/javascript">app.auth.forceLogin();</script>
|
<script type="text/javascript">app.auth.forceLogin(['admin', 'app_jump_admin']);</script>
|
||||||
|
|
||||||
<div class="container mt-4">
|
<div class="container mt-4">
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<div class="card shadow-sm text-center"><div class="card-body">
|
||||||
|
<div class="display-6" id="stat-active">–</div>
|
||||||
|
<div class="text-muted small text-uppercase">Active sessions</div>
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<div class="card shadow-sm text-center"><div class="card-body">
|
||||||
|
<div class="display-6" id="stat-total">–</div>
|
||||||
|
<div class="text-muted small text-uppercase">Total connections</div>
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<div class="card shadow-sm text-center"><div class="card-body">
|
||||||
|
<div class="display-6 text-danger" id="stat-fail">–</div>
|
||||||
|
<div class="text-muted small text-uppercase">Failed</div>
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<div class="card shadow-sm text-center"><div class="card-body">
|
||||||
|
<div class="display-6" id="stat-users">–</div>
|
||||||
|
<div class="text-muted small text-uppercase">Users seen</div>
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card shadow-sm"><div class="card-header"><i class="fa-solid fa-server me-1"></i> Top hosts</div>
|
||||||
|
<table class="table table-sm mb-0"><tbody id="top-hosts"></tbody></table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card shadow-sm"><div class="card-header"><i class="fa-solid fa-user me-1"></i> Top users</div>
|
||||||
|
<table class="table table-sm mb-0"><tbody id="top-users"></tbody></table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm">
|
||||||
<div class="card-header"><i class="fa-solid fa-clipboard-list me-1"></i> Audit log</div>
|
<div class="card-header"><i class="fa-solid fa-clipboard-list me-1"></i> Audit log</div>
|
||||||
<div class="card-body pb-0">
|
<div class="card-body pb-0">
|
||||||
@@ -33,6 +73,25 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
|
function rows(sel, list){
|
||||||
|
var $b = $(sel).empty();
|
||||||
|
if(!list || !list.length){ $b.append('<tr><td class="text-muted">No data.</td></tr>'); return; }
|
||||||
|
list.forEach(function(x){
|
||||||
|
$b.append('<tr><td>' + app.jump.esc(x.name) + '</td><td class="text-end">' + x.count + '</td></tr>');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function loadMetrics(){
|
||||||
|
app.jump.metrics(function(error, data){
|
||||||
|
if(error || !data) return;
|
||||||
|
$('#stat-active').text(data.active);
|
||||||
|
$('#stat-total').text(data.total);
|
||||||
|
$('#stat-fail').text(data.fail);
|
||||||
|
$('#stat-users').text((data.topUsers || []).length);
|
||||||
|
rows('#top-hosts', data.topHosts);
|
||||||
|
rows('#top-users', data.topUsers);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
var page = 0;
|
var page = 0;
|
||||||
function filters(){ return {page: page, uid: $('#f-uid').val(), target: $('#f-target').val(), status: $('#f-status').val()}; }
|
function filters(){ return {page: page, uid: $('#f-uid').val(), target: $('#f-target').val(), status: $('#f-status').val()}; }
|
||||||
function applyFilters(){ page = 0; load(); }
|
function applyFilters(){ page = 0; load(); }
|
||||||
@@ -59,6 +118,9 @@
|
|||||||
$('#next').prop('disabled', (page + 1) * size >= total);
|
$('#next').prop('disabled', (page + 1) * size >= total);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
$(document).ready(load);
|
$(document).ready(function(){
|
||||||
|
loadMetrics();
|
||||||
|
load();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
<%- include('bottom') %>
|
<%- include('bottom') %>
|
||||||
|
|||||||
+15
-59
@@ -2,33 +2,6 @@
|
|||||||
<script type="text/javascript">app.auth.forceLogin();</script>
|
<script type="text/javascript">app.auth.forceLogin();</script>
|
||||||
|
|
||||||
<div class="container mt-4">
|
<div class="container mt-4">
|
||||||
<div class="row g-3 mb-4">
|
|
||||||
<div class="col-6 col-md-3">
|
|
||||||
<div class="card shadow-sm text-center"><div class="card-body">
|
|
||||||
<div class="display-6" id="stat-active">–</div>
|
|
||||||
<div class="text-muted small text-uppercase">Active sessions</div>
|
|
||||||
</div></div>
|
|
||||||
</div>
|
|
||||||
<div class="col-6 col-md-3">
|
|
||||||
<div class="card shadow-sm text-center"><div class="card-body">
|
|
||||||
<div class="display-6" id="stat-total">–</div>
|
|
||||||
<div class="text-muted small text-uppercase">Total connections</div>
|
|
||||||
</div></div>
|
|
||||||
</div>
|
|
||||||
<div class="col-6 col-md-3">
|
|
||||||
<div class="card shadow-sm text-center"><div class="card-body">
|
|
||||||
<div class="display-6 text-danger" id="stat-fail">–</div>
|
|
||||||
<div class="text-muted small text-uppercase">Failed</div>
|
|
||||||
</div></div>
|
|
||||||
</div>
|
|
||||||
<div class="col-6 col-md-3">
|
|
||||||
<div class="card shadow-sm text-center"><div class="card-body">
|
|
||||||
<div class="display-6" id="stat-users">–</div>
|
|
||||||
<div class="text-muted small text-uppercase">Users seen</div>
|
|
||||||
</div></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row g-3 mb-4">
|
<div class="row g-3 mb-4">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm">
|
||||||
@@ -54,20 +27,12 @@
|
|||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="card shadow-sm">
|
<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>
|
<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 class="table-responsive">
|
||||||
</div>
|
<table class="table table-sm mb-0">
|
||||||
</div>
|
<thead><tr><th>Host</th><th>Slug</th><th class="text-end">Address</th><th>Last connection</th><th>Last failed connection</th><th></th></tr></thead>
|
||||||
</div>
|
<tbody id="my-hosts"></tbody>
|
||||||
|
</table>
|
||||||
<div class="row g-3 mb-4">
|
</div>
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="card shadow-sm"><div class="card-header"><i class="fa-solid fa-server me-1"></i> Top hosts</div>
|
|
||||||
<table class="table table-sm mb-0"><tbody id="top-hosts"></tbody></table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="card shadow-sm"><div class="card-header"><i class="fa-solid fa-user me-1"></i> Top users</div>
|
|
||||||
<table class="table table-sm mb-0"><tbody id="top-users"></tbody></table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -121,13 +86,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
function rows(sel, list){
|
|
||||||
var $b = $(sel).empty();
|
|
||||||
if(!list || !list.length){ $b.append('<tr><td class="text-muted">No data.</td></tr>'); return; }
|
|
||||||
list.forEach(function(x){
|
|
||||||
$b.append('<tr><td>' + app.jump.esc(x.name) + '</td><td class="text-end">' + x.count + '</td></tr>');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// The web UI and the SSH front door share a hostname, just not a port.
|
// The web UI and the SSH front door share a hostname, just not a port.
|
||||||
var SSH_PORT = <%- JSON.stringify(sshPort) %>;
|
var SSH_PORT = <%- JSON.stringify(sshPort) %>;
|
||||||
function sshCommand(target){
|
function sshCommand(target){
|
||||||
@@ -153,9 +111,16 @@
|
|||||||
hosts.forEach(function(h){
|
hosts.forEach(function(h){
|
||||||
var addr = (h.metadata && (h.metadata.ip || h.metadata.address)) || '';
|
var addr = (h.metadata && (h.metadata.ip || h.metadata.address)) || '';
|
||||||
var rowId = 'host-cmd-' + h.slug.replace(/[^a-zA-Z0-9_-]/g, '');
|
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>'
|
// Green: a session to this host is live right now. Yellow: the most
|
||||||
|
// recent attempt to this host failed (and none is currently live).
|
||||||
|
var rowClass = h.connected ? 'table-success'
|
||||||
|
: (h.lastFailed && (!h.lastConnected || h.lastFailed > h.lastConnected)) ? 'table-warning'
|
||||||
|
: '';
|
||||||
|
$b.append('<tr class="' + rowClass + '"><td>' + app.jump.esc(h.displayName || h.name || h.slug) + '</td>'
|
||||||
+ '<td class="text-muted small">' + app.jump.esc(h.slug) + '</td>'
|
+ '<td class="text-muted small">' + app.jump.esc(h.slug) + '</td>'
|
||||||
+ '<td class="text-end text-muted small">' + app.jump.esc(addr) + '</td>'
|
+ '<td class="text-end text-muted small">' + app.jump.esc(addr) + '</td>'
|
||||||
|
+ '<td class="small">' + (h.lastConnected ? app.jump.fmtTime(h.lastConnected) : '—') + '</td>'
|
||||||
|
+ '<td class="small">' + (h.lastFailed ? app.jump.fmtTime(h.lastFailed) : '—') + '</td>'
|
||||||
+ '<td class="text-end">'
|
+ '<td class="text-end">'
|
||||||
+ '<input type="hidden" id="' + rowId + '" value="' + app.jump.esc(sshCommand(h.slug)) + '">'
|
+ '<input type="hidden" id="' + rowId + '" value="' + app.jump.esc(sshCommand(h.slug)) + '">'
|
||||||
+ '<button class="btn btn-sm btn-outline-secondary" onclick="copyFieldValue(\'#' + rowId + '\')" title="Copy quick-jump command"><i class="fa-solid fa-copy"></i></button>'
|
+ '<button class="btn btn-sm btn-outline-secondary" onclick="copyFieldValue(\'#' + rowId + '\')" title="Copy quick-jump command"><i class="fa-solid fa-copy"></i></button>'
|
||||||
@@ -304,17 +269,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
$(document).ready(async function(){
|
$(document).ready(async function(){
|
||||||
app.jump.metrics(function(error, data){
|
|
||||||
if(error || !data) return;
|
|
||||||
$('#stat-active').text(data.active);
|
|
||||||
$('#stat-total').text(data.total);
|
|
||||||
$('#stat-fail').text(data.fail);
|
|
||||||
$('#stat-users').text((data.topUsers || []).length);
|
|
||||||
rows('#top-hosts', data.topHosts);
|
|
||||||
rows('#top-users', data.topUsers);
|
|
||||||
});
|
|
||||||
await app.auth.loadUser();
|
await app.auth.loadUser();
|
||||||
if(app.auth.isAdmin()) $('#my-hosts-title').text('All hosts');
|
if(app.auth.isAdmin()) $('#my-hosts-title').text('My hosts');
|
||||||
$('#quick-jump-cmd').val(sshCommand());
|
$('#quick-jump-cmd').val(sshCommand());
|
||||||
app.jump.hosts(function(error, data){
|
app.jump.hosts(function(error, data){
|
||||||
if(error) return hostRows('#my-hosts', []);
|
if(error) return hostRows('#my-hosts', []);
|
||||||
|
|||||||
@@ -90,7 +90,7 @@
|
|||||||
<hr />
|
<hr />
|
||||||
<div class="d-grid">
|
<div class="d-grid">
|
||||||
<a href="/api/auth/oidc/start" class="btn btn-outline-primary">
|
<a href="/api/auth/oidc/start" class="btn btn-outline-primary">
|
||||||
<i class="fa-solid fa-id-badge"></i> Log in with SSO
|
<i class="fa-solid fa-id-badge"></i> Log in with Jump
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<% } %>
|
<% } %>
|
||||||
|
|||||||
Reference in New Issue
Block a user